Merge branch 'main' into rust
This commit is contained in:
commit
d50f34529c
|
@ -0,0 +1,44 @@
|
||||||
|
# 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=
|
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/u1f320/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/u1f320/pronouns.cc/backend/server.Tag=`git describe --tags --long`" .
|
go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long`" .
|
||||||
|
|
||||||
.PHONY: generate
|
.PHONY: generate
|
||||||
generate:
|
generate:
|
||||||
|
|
10
README.md
10
README.md
|
@ -25,18 +25,20 @@ 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 or data exports** (_not_ required otherwise)
|
- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise)
|
||||||
|
|
||||||
### 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. Create a `.env` file in the repository root containing at least `HMAC_KEY`, `DATABASE_URL`, `REDIS`, `PORT`, and `MINIO_ENDPOINT` keys.
|
2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options.
|
||||||
3. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
|
3. 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.
|
4. Run `go run -v . web` to run the backend.
|
||||||
5. Create `frontend/.env` with the following content: `PUBLIC_BASE_URL=http://localhost:5173`
|
5. Copy `frontend/.env.example` into `frontend/.env` and tweak as necessary.
|
||||||
6. cd into the `frontend` directory and run `pnpm dev` to run the frontend.
|
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.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (C) 2022 Sam <u1f320>
|
Copyright (C) 2022 Sam <u1f320>
|
||||||
|
|
|
@ -6,23 +6,20 @@ import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"image"
|
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
"github.com/disintegration/imaging"
|
"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"
|
||||||
|
|
||||||
"github.com/chai2010/webp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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) (
|
||||||
|
@ -30,6 +27,8 @@ func (db *DB) ConvertAvatar(data string) (
|
||||||
jpgOut *bytes.Buffer,
|
jpgOut *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
|
||||||
|
@ -41,28 +40,31 @@ func (db *DB) ConvertAvatar(data string) (
|
||||||
return nil, nil, errors.Wrap(err, "invalid base64 data")
|
return nil, nil, errors.Wrap(err, "invalid base64 data")
|
||||||
}
|
}
|
||||||
|
|
||||||
img, _, err := image.Decode(bytes.NewReader(rawData))
|
image, err := vips.LoadImageFromBuffer(rawData, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "decodign image")
|
return nil, nil, errors.Wrap(err, "decoding image")
|
||||||
}
|
}
|
||||||
|
|
||||||
resized := imaging.Fill(img, 512, 512, imaging.Center, imaging.Linear)
|
err = image.ThumbnailWithSize(512, 512, vips.InterestingCentre, vips.SizeBoth)
|
||||||
|
|
||||||
webpOut = new(bytes.Buffer)
|
|
||||||
err = webp.Encode(webpOut, resized, &webp.Options{
|
|
||||||
Quality: 90,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "encoding WebP image")
|
return nil, nil, errors.Wrap(err, "resizing image")
|
||||||
}
|
}
|
||||||
|
|
||||||
jpgOut = new(bytes.Buffer)
|
webpExport := vips.NewWebpExportParams()
|
||||||
err = jpeg.Encode(jpgOut, resized, &jpeg.Options{
|
webpExport.Quality = 90
|
||||||
Quality: 80,
|
webpB, _, err := image.ExportWebp(webpExport)
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "encoding JPEG image")
|
return nil, nil, errors.Wrap(err, "exporting webp image")
|
||||||
}
|
}
|
||||||
|
webpOut = bytes.NewBuffer(webpB)
|
||||||
|
|
||||||
|
jpegExport := vips.NewJpegExportParams()
|
||||||
|
jpegExport.Quality = 80
|
||||||
|
jpegB, _, err := image.ExportJpeg(jpegExport)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "exporting jpeg image")
|
||||||
|
}
|
||||||
|
jpgOut = bytes.NewBuffer(jpegB)
|
||||||
|
|
||||||
return webpOut, jpgOut, nil
|
return webpOut, jpgOut, nil
|
||||||
}
|
}
|
||||||
|
@ -79,15 +81,17 @@ 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")
|
||||||
|
@ -108,15 +112,17 @@ 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")
|
||||||
|
@ -126,12 +132,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")
|
||||||
}
|
}
|
||||||
|
@ -140,12 +146,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")
|
||||||
}
|
}
|
||||||
|
@ -154,7 +160,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")
|
||||||
}
|
}
|
||||||
|
@ -162,7 +168,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,7 +6,9 @@ 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/pgx/v5/pgconn"
|
||||||
|
@ -14,12 +16,18 @@ import (
|
||||||
"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)
|
||||||
}
|
}
|
||||||
|
@ -32,19 +40,28 @@ 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.New(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",
|
||||||
|
@ -67,6 +84,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,30 +147,6 @@ 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 {
|
||||||
|
|
|
@ -7,15 +7,6 @@ import (
|
||||||
|
|
||||||
type WordStatus string
|
type WordStatus string
|
||||||
|
|
||||||
const (
|
|
||||||
StatusUnknown WordStatus = ""
|
|
||||||
StatusFavourite WordStatus = "favourite"
|
|
||||||
StatusOkay WordStatus = "okay"
|
|
||||||
StatusJokingly WordStatus = "jokingly"
|
|
||||||
StatusFriendsOnly WordStatus = "friends_only"
|
|
||||||
StatusAvoid WordStatus = "avoid"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (w *WordStatus) UnmarshalJSON(src []byte) error {
|
func (w *WordStatus) UnmarshalJSON(src []byte) error {
|
||||||
if string(src) == "null" {
|
if string(src) == "null" {
|
||||||
return nil
|
return nil
|
||||||
|
@ -40,13 +31,13 @@ func (w *WordStatus) UnmarshalJSON(src []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w WordStatus) Valid(extra ...WordStatus) bool {
|
func (w WordStatus) Valid(extra CustomPreferences) bool {
|
||||||
if w == StatusFavourite || w == StatusOkay || w == StatusJokingly || w == StatusFriendsOnly || w == StatusAvoid {
|
if w == "favourite" || w == "okay" || w == "jokingly" || w == "friends_only" || w == "avoid" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range extra {
|
for k := range extra {
|
||||||
if w == extra[i] {
|
if string(w) == k {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -58,7 +49,7 @@ type FieldEntry struct {
|
||||||
Status WordStatus `json:"status"`
|
Status WordStatus `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fe FieldEntry) Validate() string {
|
func (fe FieldEntry) Validate(custom CustomPreferences) string {
|
||||||
if fe.Value == "" {
|
if fe.Value == "" {
|
||||||
return "value cannot be empty"
|
return "value cannot be empty"
|
||||||
}
|
}
|
||||||
|
@ -67,7 +58,7 @@ func (fe FieldEntry) Validate() 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() {
|
if !fe.Status.Valid(custom) {
|
||||||
return "status is invalid"
|
return "status is invalid"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,7 +71,7 @@ type PronounEntry struct {
|
||||||
Status WordStatus `json:"status"`
|
Status WordStatus `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p PronounEntry) Validate() string {
|
func (p PronounEntry) Validate(custom CustomPreferences) string {
|
||||||
if p.Pronouns == "" {
|
if p.Pronouns == "" {
|
||||||
return "pronouns cannot be empty"
|
return "pronouns cannot be empty"
|
||||||
}
|
}
|
||||||
|
@ -95,7 +86,7 @@ func (p PronounEntry) Validate() 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() {
|
if !p.Status.Valid(custom) {
|
||||||
return "status is invalid"
|
return "status is invalid"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
@ -67,7 +67,8 @@ 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")
|
||||||
|
|
|
@ -48,7 +48,7 @@ func (f FediverseApp) ClientConfig() *oauth2.Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f FediverseApp) MastodonCompatible() bool {
|
func (f FediverseApp) MastodonCompatible() bool {
|
||||||
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed"
|
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed" || f.InstanceType == "gotosocial"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f FediverseApp) Misskey() bool {
|
func (f FediverseApp) Misskey() bool {
|
||||||
|
|
|
@ -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() string {
|
func (f Field) Validate(custom CustomPreferences) string {
|
||||||
if f.Name == "" {
|
if f.Name == "" {
|
||||||
return "name cannot be empty"
|
return "name cannot be empty"
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ func (f Field) Validate() 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() {
|
if !entry.Status.Valid(custom) {
|
||||||
return fmt.Sprintf("entries.%d: status is invalid", i)
|
return fmt.Sprintf("entries.%d: status is invalid", i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,323 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"`
|
||||||
|
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(),
|
||||||
|
"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
|
||||||
|
}
|
|
@ -3,8 +3,10 @@ package db
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/georgysavva/scany/v2/pgxscan"
|
"github.com/georgysavva/scany/v2/pgxscan"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
@ -19,6 +21,7 @@ const (
|
||||||
type Member struct {
|
type Member struct {
|
||||||
ID xid.ID
|
ID xid.ID
|
||||||
UserID xid.ID
|
UserID xid.ID
|
||||||
|
SID string `db:"sid"`
|
||||||
Name string
|
Name string
|
||||||
DisplayName *string
|
DisplayName *string
|
||||||
Bio *string
|
Bio *string
|
||||||
|
@ -35,9 +38,14 @@ 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}$")
|
||||||
|
|
||||||
func MemberNameValid(name string) bool {
|
func MemberNameValid(name string) bool {
|
||||||
|
// These two names will break routing, but periods should still be allowed in names otherwise.
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return memberNameRegex.MatchString(name)
|
return memberNameRegex.MatchString(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,6 +76,25 @@ 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, showHidden bool) (ms []Member, err error) {
|
||||||
builder := sq.Select("*").
|
builder := sq.Select("*").
|
||||||
|
@ -99,8 +126,8 @@ func (db *DB) CreateMember(
|
||||||
name string, displayName *string, bio string, links []string,
|
name string, displayName *string, bio string, links []string,
|
||||||
) (m Member, err error) {
|
) (m Member, err error) {
|
||||||
sql, args, err := sq.Insert("members").
|
sql, args, err := sq.Insert("members").
|
||||||
Columns("user_id", "id", "name", "display_name", "bio", "links").
|
Columns("user_id", "id", "sid", "name", "display_name", "bio", "links").
|
||||||
Values(userID, xid.New(), name, displayName, bio, links).
|
Values(userID, xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
|
||||||
Suffix("RETURNING *").ToSql()
|
Suffix("RETURNING *").ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return m, errors.Wrap(err, "building sql")
|
return m, errors.Wrap(err, "building sql")
|
||||||
|
@ -111,7 +138,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 == "23505" {
|
if pge.Code == uniqueViolation {
|
||||||
return m, ErrMemberNameInUse
|
return m, ErrMemberNameInUse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -218,7 +245,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 == "23505" {
|
if pge.Code == uniqueViolation {
|
||||||
return m, ErrMemberNameInUse
|
return m, ErrMemberNameInUse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -227,3 +254,43 @@ 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 tx.Rollback(ctx)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,198 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -59,7 +59,13 @@ 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("*").From("reports").Where("user_id = ?", userID).Limit(ReportPageSize).OrderBy("id DESC")
|
builder := sq.Select("*",
|
||||||
|
"(SELECT username FROM users WHERE id = reports.user_id) AS user_name",
|
||||||
|
"(SELECT name FROM members WHERE id = reports.member_id) AS member_name").
|
||||||
|
From("reports").
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Limit(ReportPageSize).
|
||||||
|
OrderBy("id DESC")
|
||||||
if before != 0 {
|
if before != 0 {
|
||||||
builder = builder.Where("id < ?", before)
|
builder = builder.Where("id < ?", before)
|
||||||
}
|
}
|
||||||
|
@ -79,7 +85,13 @@ 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("*").From("reports").Where("reporter_id = ?", reporterID).Limit(ReportPageSize).OrderBy("id DESC")
|
builder := sq.Select("*",
|
||||||
|
"(SELECT username FROM users WHERE id = reports.user_id) AS user_name",
|
||||||
|
"(SELECT name FROM members WHERE id = reports.member_id) AS member_name").
|
||||||
|
From("reports").
|
||||||
|
Where("reporter_id = ?", reporterID).
|
||||||
|
Limit(ReportPageSize).
|
||||||
|
OrderBy("id DESC")
|
||||||
if before != 0 {
|
if before != 0 {
|
||||||
builder = builder.Where("id < ?", before)
|
builder = builder.Where("id < ?", before)
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,7 +61,7 @@ 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 ExpiryTime = 3 * 30 * 24 * time.Hour
|
const TokenExpiryTime = 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, apiOnly, readOnly bool) (t Token, err error) {
|
||||||
|
@ -69,7 +69,7 @@ func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID, apiO
|
||||||
SetMap(map[string]any{
|
SetMap(map[string]any{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"token_id": tokenID,
|
"token_id": tokenID,
|
||||||
"expires": time.Now().Add(ExpiryTime),
|
"expires": time.Now().Add(TokenExpiryTime),
|
||||||
"api_only": apiOnly,
|
"api_only": apiOnly,
|
||||||
"read_only": readOnly,
|
"read_only": readOnly,
|
||||||
}).
|
}).
|
||||||
|
|
|
@ -4,10 +4,14 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"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/v2/pgxscan"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
@ -17,10 +21,12 @@ import (
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID xid.ID
|
ID xid.ID
|
||||||
|
SID string `db:"sid"`
|
||||||
Username string
|
Username string
|
||||||
DisplayName *string
|
DisplayName *string
|
||||||
Bio *string
|
Bio *string
|
||||||
MemberTitle *string
|
MemberTitle *string
|
||||||
|
LastActive time.Time
|
||||||
|
|
||||||
Avatar *string
|
Avatar *string
|
||||||
Links []string
|
Links []string
|
||||||
|
@ -36,15 +42,61 @@ type User struct {
|
||||||
FediverseAppID *int64
|
FediverseAppID *int64
|
||||||
FediverseInstance *string
|
FediverseInstance *string
|
||||||
|
|
||||||
MaxInvites int
|
Tumblr *string
|
||||||
IsAdmin bool
|
TumblrUsername *string
|
||||||
ListPrivate bool
|
|
||||||
|
Google *string
|
||||||
|
GoogleUsername *string
|
||||||
|
|
||||||
|
MaxInvites int
|
||||||
|
IsAdmin bool
|
||||||
|
ListPrivate bool
|
||||||
|
LastSIDReroll time.Time `db:"last_sid_reroll"`
|
||||||
|
|
||||||
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++
|
||||||
|
@ -52,12 +104,42 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}$`)
|
||||||
|
|
||||||
|
func UsernameValid(username string) (err error) {
|
||||||
|
// This name would break routing, but periods should still be allowed in names otherwise.
|
||||||
|
if username == ".." {
|
||||||
|
return ErrInvalidUsername
|
||||||
|
}
|
||||||
|
|
||||||
|
if !usernameRegex.MatchString(username) {
|
||||||
|
if len(username) < 2 {
|
||||||
|
return ErrUsernameTooShort
|
||||||
|
} else if len(username) > 40 {
|
||||||
|
return ErrUsernameTooLong
|
||||||
|
}
|
||||||
|
|
||||||
|
return ErrInvalidUsername
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ErrUserNotFound = errors.Sentinel("user not found")
|
ErrUserNotFound = errors.Sentinel("user not found")
|
||||||
|
|
||||||
|
@ -84,17 +166,11 @@ 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 !usernameRegex.MatchString(username) {
|
if err := UsernameValid(username); err != nil {
|
||||||
if len(username) < 2 {
|
return u, err
|
||||||
return u, ErrUsernameTooShort
|
|
||||||
} else if len(username) > 40 {
|
|
||||||
return u, ErrUsernameTooLong
|
|
||||||
}
|
|
||||||
|
|
||||||
return u, ErrInvalidUsername
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sql, args, err := sq.Insert("users").Columns("id", "username").Values(xid.New(), username).Suffix("RETURNING *").ToSql()
|
sql, args, err := sq.Insert("users").Columns("id", "username", "sid").Values(xid.New(), username, squirrel.Expr("find_free_user_sid()")).Suffix("RETURNING *").ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, errors.Wrap(err, "building sql")
|
return u, errors.Wrap(err, "building sql")
|
||||||
}
|
}
|
||||||
|
@ -104,7 +180,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 == "23505" {
|
if pge.Code == uniqueViolation {
|
||||||
return u, ErrUsernameTaken
|
return u, ErrUsernameTaken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -240,6 +316,128 @@ 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").
|
||||||
|
@ -279,9 +477,28 @@ 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 !usernameRegex.MatchString(username) {
|
if err := UsernameValid(username); err != nil {
|
||||||
return false, false, nil
|
return false, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -291,8 +508,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 !usernameRegex.MatchString(newName) {
|
if err := UsernameValid(newName); err != nil {
|
||||||
return ErrInvalidUsername
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
@ -305,7 +522,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 == "23505" {
|
if pge.Code == uniqueViolation {
|
||||||
return ErrUsernameTaken
|
return ErrUsernameTaken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -322,8 +539,9 @@ func (db *DB) UpdateUser(
|
||||||
memberTitle *string, listPrivate *bool,
|
memberTitle *string, listPrivate *bool,
|
||||||
links *[]string,
|
links *[]string,
|
||||||
avatar *string,
|
avatar *string,
|
||||||
|
customPreferences *CustomPreferences,
|
||||||
) (u User, err error) {
|
) (u User, err error) {
|
||||||
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil {
|
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && customPreferences == nil {
|
||||||
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
|
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")
|
||||||
|
@ -365,6 +583,9 @@ func (db *DB) UpdateUser(
|
||||||
if listPrivate != nil {
|
if listPrivate != nil {
|
||||||
builder = builder.Set("list_private", *listPrivate)
|
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 == "" {
|
||||||
|
@ -403,6 +624,23 @@ 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).
|
||||||
|
|
|
@ -13,8 +13,8 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"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"
|
||||||
"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"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package exporter
|
package exporter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -24,6 +24,12 @@ type userExport 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"`
|
||||||
|
|
||||||
MaxInvites int `json:"max_invites"`
|
MaxInvites int `json:"max_invites"`
|
||||||
|
|
||||||
Warnings []db.Warning `json:"warnings"`
|
Warnings []db.Warning `json:"warnings"`
|
||||||
|
@ -41,6 +47,10 @@ func dbUserToExport(u db.User, fields []db.Field, warnings []db.Warning) userExp
|
||||||
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,
|
||||||
MaxInvites: u.MaxInvites,
|
MaxInvites: u.MaxInvites,
|
||||||
Fediverse: u.Fediverse,
|
Fediverse: u.Fediverse,
|
||||||
FediverseUsername: u.FediverseUsername,
|
FediverseUsername: u.FediverseUsername,
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -7,9 +7,10 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
|
||||||
"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"
|
||||||
|
|
||||||
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
"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"
|
||||||
|
@ -22,6 +23,12 @@ var Command = &cli.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(c *cli.Context) error {
|
func run(c *cli.Context) error {
|
||||||
|
// 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()
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,99 @@
|
||||||
|
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,16 +1,24 @@
|
||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/routes/auth"
|
"net/http"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/routes/bot"
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/routes/member"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/auth"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/routes/meta"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/bot"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/routes/mod"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/member"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/routes/user"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/meta"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/mod"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/routes/user"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed openapi.html
|
||||||
|
var openapi string
|
||||||
|
|
||||||
// mountRoutes mounts all API routes on the server's router.
|
// mountRoutes mounts all API routes on the server's router.
|
||||||
// they are all mounted under /v1/
|
// they are all mounted under /v1/
|
||||||
func mountRoutes(s *server.Server) {
|
func mountRoutes(s *server.Server) {
|
||||||
|
@ -23,4 +31,9 @@ func mountRoutes(s *server.Server) {
|
||||||
meta.Mount(s, r)
|
meta.Mount(s, r)
|
||||||
mod.Mount(s, r)
|
mod.Mount(s, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// API docs
|
||||||
|
s.Router.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
render.HTML(w, r, openapi)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -5,9 +5,9 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
@ -27,7 +27,7 @@ var discordOAuthConfig = oauth2.Config{
|
||||||
Scopes: []string{"identify"},
|
Scopes: []string{"identify"},
|
||||||
}
|
}
|
||||||
|
|
||||||
type discordOauthCallbackRequest struct {
|
type oauthCallbackRequest 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"`
|
||||||
|
@ -39,9 +39,10 @@ type discordCallbackResponse struct {
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
User *userResponse `json:"user,omitempty"`
|
User *userResponse `json:"user,omitempty"`
|
||||||
|
|
||||||
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"`
|
||||||
|
@ -52,7 +53,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[discordOauthCallbackRequest](r)
|
decoded, err := Decode[oauthCallbackRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
}
|
}
|
||||||
|
@ -148,10 +149,11 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, discordCallbackResponse{
|
render.JSON(w, r, discordCallbackResponse{
|
||||||
HasAccount: false,
|
HasAccount: false,
|
||||||
Discord: du.String(),
|
Discord: du.String(),
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RequireInvite: s.RequireInvite,
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -193,6 +195,11 @@ 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")
|
||||||
|
@ -245,10 +252,11 @@ func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type discordSignupRequest struct {
|
type signupRequest 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 {
|
||||||
|
@ -259,7 +267,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[discordSignupRequest](r)
|
req, err := Decode[signupRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return server.APIError{Code: server.ErrBadRequest}
|
return server.APIError{Code: server.ErrBadRequest}
|
||||||
}
|
}
|
||||||
|
@ -293,6 +301,19 @@ 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 {
|
||||||
|
@ -302,6 +323,11 @@ 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")
|
||||||
|
|
|
@ -6,9 +6,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
@ -27,9 +27,10 @@ type fediCallbackResponse struct {
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
User *userResponse `json:"user,omitempty"`
|
User *userResponse `json:"user,omitempty"`
|
||||||
|
|
||||||
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"`
|
||||||
|
@ -169,10 +170,11 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
HasAccount: false,
|
HasAccount: false,
|
||||||
Fediverse: mu.Username,
|
Fediverse: mu.Username,
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RequireInvite: s.RequireInvite,
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -220,6 +222,11 @@ 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")
|
||||||
|
@ -273,10 +280,11 @@ func (s *Server) mastodonUnlink(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type fediSignupRequest struct {
|
type fediSignupRequest struct {
|
||||||
Instance string `json:"instance"`
|
Instance string `json:"instance"`
|
||||||
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 {
|
||||||
|
@ -321,6 +329,19 @@ 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 {
|
||||||
|
@ -330,6 +351,11 @@ 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")
|
||||||
|
|
|
@ -7,9 +7,9 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"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/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
@ -149,10 +149,11 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, fediCallbackResponse{
|
render.JSON(w, r, fediCallbackResponse{
|
||||||
HasAccount: false,
|
HasAccount: false,
|
||||||
Fediverse: mu.User.Username,
|
Fediverse: mu.User.Username,
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RequireInvite: s.RequireInvite,
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -195,6 +196,11 @@ func (s *Server) misskeyLink(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: "Misskey 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 misskey")
|
return errors.Wrap(err, "updating user from misskey")
|
||||||
|
@ -251,6 +257,19 @@ func (s *Server) misskeySignup(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 {
|
||||||
|
@ -260,6 +279,11 @@ func (s *Server) misskeySignup(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: "Misskey 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 misskey")
|
return errors.Wrap(err, "updating user from misskey")
|
||||||
|
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
"emperror.dev/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,8 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
@ -25,6 +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)
|
||||||
|
@ -62,9 +67,12 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r
|
||||||
switch softwareName {
|
switch softwareName {
|
||||||
case "misskey", "foundkey", "calckey":
|
case "misskey", "foundkey", "calckey":
|
||||||
return s.noAppMisskeyURL(ctx, w, r, softwareName, instance)
|
return s.noAppMisskeyURL(ctx, w, r, softwareName, instance)
|
||||||
case "mastodon", "pleroma", "akkoma", "pixelfed":
|
case "mastodon", "pleroma", "akkoma", "pixelfed", "gotosocial":
|
||||||
|
case "glitchcafe":
|
||||||
|
// plural.cafe (potentially other instances too?) runs Mastodon but changes the software name
|
||||||
|
// changing it back to mastodon here for consistency
|
||||||
|
softwareName = "mastodon"
|
||||||
default:
|
default:
|
||||||
// sorry, misskey :( TODO: support misskey
|
|
||||||
return server.APIError{Code: server.ErrUnsupportedInstance}
|
return server.APIError{Code: server.ErrUnsupportedInstance}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,386 @@
|
||||||
|
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/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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 err
|
||||||
|
}
|
||||||
|
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 tx.Rollback(ctx)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -4,8 +4,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,9 +4,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"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"
|
||||||
|
@ -18,6 +18,9 @@ type Server struct {
|
||||||
|
|
||||||
RequireInvite bool
|
RequireInvite bool
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
|
||||||
|
hcaptchaSitekey string
|
||||||
|
hcaptchaSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
type userResponse struct {
|
type userResponse struct {
|
||||||
|
@ -34,6 +37,12 @@ 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"`
|
||||||
|
@ -52,6 +61,10 @@ 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,
|
||||||
|
@ -60,9 +73,11 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
|
||||||
|
|
||||||
func Mount(srv *server.Server, r chi.Router) {
|
func Mount(srv *server.Server, r chi.Router) {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
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) {
|
||||||
|
@ -84,6 +99,20 @@ 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))
|
||||||
|
@ -120,7 +149,9 @@ type oauthURLsRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type oauthURLsResponse struct {
|
type oauthURLsResponse struct {
|
||||||
Discord string `json:"discord"`
|
Discord string `json:"discord,omitempty"`
|
||||||
|
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 {
|
||||||
|
@ -136,14 +167,25 @@ 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
|
||||||
|
|
||||||
// copy Discord config and set redirect url
|
if discordOAuthConfig.ClientID != "" {
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
||||||
render.JSON(w, r, oauthURLsResponse{
|
render.JSON(w, r, resp)
|
||||||
Discord: discordCfg.AuthCodeURL(state) + "&prompt=none",
|
|
||||||
})
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"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/rs/xid"
|
"github.com/rs/xid"
|
||||||
|
@ -96,9 +96,14 @@ func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
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"
|
readOnly := r.FormValue("read_only") == "true"
|
||||||
tokenID := xid.New()
|
tokenID := xid.New()
|
||||||
tokenStr, err := s.Auth.CreateToken(claims.UserID, tokenID, false, true, !readOnly)
|
tokenStr, err := s.Auth.CreateToken(claims.UserID, tokenID, u.IsAdmin, true, !readOnly)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "creating token")
|
return errors.Wrap(err, "creating token")
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,419 @@
|
||||||
|
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/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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 err
|
||||||
|
}
|
||||||
|
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 tx.Rollback(ctx)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"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/mediocregopher/radix/v4"
|
"github.com/mediocregopher/radix/v4"
|
||||||
|
@ -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, "GETDEL", "undelete:"+token))
|
err = s.DB.Redis.Do(ctx, radix.Cmd(&idString, "GET", "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,6 +76,11 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,9 +8,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"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"
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
@ -144,7 +144,7 @@ func (bot *Bot) userPronouns(w http.ResponseWriter, r *http.Request, ev *discord
|
||||||
var favs []db.FieldEntry
|
var favs []db.FieldEntry
|
||||||
|
|
||||||
for _, e := range field.Entries {
|
for _, e := range field.Entries {
|
||||||
if e.Status == db.StatusFavourite {
|
if e.Status == "favourite" {
|
||||||
favs = append(favs, e)
|
favs = append(favs, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,10 +5,10 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
"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"
|
||||||
)
|
)
|
||||||
|
@ -80,7 +80,7 @@ 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: @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, ,",
|
Details: "Member name cannot contain any of the following: @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,15 +103,15 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("name", &cmr.Names); err != nil {
|
if err := validateSlicePtr("name", &cmr.Names, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("pronoun", &cmr.Pronouns); err != nil {
|
if err := validateSlicePtr("pronoun", &cmr.Pronouns, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("field", &cmr.Fields); err != nil {
|
if err := validateSlicePtr("field", &cmr.Fields, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,22 +176,29 @@ 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 err
|
||||||
|
}
|
||||||
|
|
||||||
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, true))
|
render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, nil, true))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type validator interface {
|
type validator interface {
|
||||||
Validate() string
|
Validate(custom db.CustomPreferences) 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) *server.APIError {
|
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
|
||||||
if slice == nil {
|
if slice == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -211,7 +218,7 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
|
||||||
|
|
||||||
// validate all fields
|
// validate all fields
|
||||||
for i, pronouns := range *slice {
|
for i, pronouns := range *slice {
|
||||||
if s := pronouns.Validate(); s != "" {
|
if s := pronouns.Validate(custom); 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),
|
||||||
|
|
|
@ -8,8 +8,9 @@ import (
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
@ -51,6 +52,13 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 err
|
||||||
|
}
|
||||||
|
|
||||||
render.NoContent(w, r)
|
render.NoContent(w, r)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
|
@ -13,6 +14,7 @@ import (
|
||||||
|
|
||||||
type GetMemberResponse struct {
|
type GetMemberResponse struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
|
SID string `json:"sid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Bio *string `json:"bio"`
|
Bio *string `json:"bio"`
|
||||||
|
@ -22,15 +24,17 @@ type GetMemberResponse 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"`
|
||||||
|
Flags []db.MemberFlag `json:"flags"`
|
||||||
|
|
||||||
User PartialUser `json:"user"`
|
User PartialUser `json:"user"`
|
||||||
|
|
||||||
Unlisted *bool `json:"unlisted,omitempty"`
|
Unlisted *bool `json:"unlisted,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember bool) GetMemberResponse {
|
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse {
|
||||||
r := GetMemberResponse{
|
r := GetMemberResponse{
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
|
SID: m.SID,
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
DisplayName: m.DisplayName,
|
DisplayName: m.DisplayName,
|
||||||
Bio: m.Bio,
|
Bio: m.Bio,
|
||||||
|
@ -40,12 +44,14 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo
|
||||||
Names: db.NotNull(m.Names),
|
Names: db.NotNull(m.Names),
|
||||||
Pronouns: db.NotNull(m.Pronouns),
|
Pronouns: db.NotNull(m.Pronouns),
|
||||||
Fields: db.NotNull(fields),
|
Fields: db.NotNull(fields),
|
||||||
|
Flags: flags,
|
||||||
|
|
||||||
User: PartialUser{
|
User: PartialUser{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName,
|
||||||
Avatar: u.Avatar,
|
Avatar: u.Avatar,
|
||||||
|
CustomPreferences: u.CustomPreferences,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,10 +63,11 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember boo
|
||||||
}
|
}
|
||||||
|
|
||||||
type PartialUser struct {
|
type PartialUser struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
Username string `json:"name"`
|
Username string `json:"name"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
|
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
@ -99,7 +106,12 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember))
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,7 +146,42 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember))
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.MemberFlags(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,8 +3,8 @@ package member
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
type memberListResponse struct {
|
type memberListResponse struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
|
SID string `json:"sid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Bio *string `json:"bio"`
|
Bio *string `json:"bio"`
|
||||||
|
@ -26,13 +27,15 @@ func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse {
|
||||||
resps := make([]memberListResponse, len(ms))
|
resps := make([]memberListResponse, len(ms))
|
||||||
for i := range ms {
|
for i := range ms {
|
||||||
resps[i] = memberListResponse{
|
resps[i] = memberListResponse{
|
||||||
ID: ms[i].ID,
|
ID: ms[i].ID,
|
||||||
Name: ms[i].Name,
|
SID: ms[i].SID,
|
||||||
Bio: ms[i].Bio,
|
Name: ms[i].Name,
|
||||||
Avatar: ms[i].Avatar,
|
DisplayName: ms[i].DisplayName,
|
||||||
Links: db.NotNull(ms[i].Links),
|
Bio: ms[i].Bio,
|
||||||
Names: db.NotNull(ms[i].Names),
|
Avatar: ms[i].Avatar,
|
||||||
Pronouns: db.NotNull(ms[i].Pronouns),
|
Links: db.NotNull(ms[i].Links),
|
||||||
|
Names: db.NotNull(ms[i].Names),
|
||||||
|
Pronouns: db.NotNull(ms[i].Pronouns),
|
||||||
}
|
}
|
||||||
|
|
||||||
if isSelf {
|
if isSelf {
|
||||||
|
|
|
@ -4,11 +4,12 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
"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"
|
||||||
|
@ -25,6 +26,7 @@ type PatchMemberRequest struct {
|
||||||
Fields *[]db.Field `json:"fields"`
|
Fields *[]db.Field `json:"fields"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Unlisted *bool `json:"unlisted"`
|
Unlisted *bool `json:"unlisted"`
|
||||||
|
Flags *[]xid.ID `json:"flags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
@ -41,6 +43,11 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrMemberNotFound}
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u, err := s.DB.User(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
m, err := s.DB.Member(ctx, id)
|
m, err := s.DB.Member(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrMemberNotFound {
|
if err == db.ErrMemberNotFound {
|
||||||
|
@ -69,7 +76,8 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
req.Fields == nil &&
|
req.Fields == nil &&
|
||||||
req.Names == nil &&
|
req.Names == nil &&
|
||||||
req.Pronouns == nil &&
|
req.Pronouns == nil &&
|
||||||
req.Avatar == nil {
|
req.Avatar == nil &&
|
||||||
|
req.Flags == nil {
|
||||||
return server.APIError{
|
return server.APIError{
|
||||||
Code: server.ErrBadRequest,
|
Code: server.ErrBadRequest,
|
||||||
Details: "Data must not be empty",
|
Details: "Data must not be empty",
|
||||||
|
@ -104,7 +112,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
if !db.MemberNameValid(*req.Name) {
|
if !db.MemberNameValid(*req.Name) {
|
||||||
return server.APIError{
|
return server.APIError{
|
||||||
Code: server.ErrBadRequest,
|
Code: server.ErrBadRequest,
|
||||||
Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, ,",
|
Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -148,15 +156,25 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("name", req.Names); err != nil {
|
// 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
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("pronoun", req.Pronouns); err != nil {
|
if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("field", req.Fields); err != nil {
|
if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -265,18 +283,86 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 err
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.Commit(ctx)
|
err = tx.Commit(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("committing transaction: %v", err)
|
log.Errorf("committing transaction: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
// echo the updated member back on success
|
||||||
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
|
if !claims.TokenWrite {
|
||||||
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// echo the updated member back on success
|
m, err := s.DB.Member(ctx, id)
|
||||||
render.JSON(w, r, dbMemberToMember(u, m, fields, true))
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ package member
|
||||||
import (
|
import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
@ -19,6 +19,7 @@ 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
|
||||||
|
@ -28,5 +29,8 @@ 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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"emperror.dev/errors"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
@ -21,31 +20,34 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MetaResponse struct {
|
type MetaResponse struct {
|
||||||
GitRepository string `json:"git_repository"`
|
GitRepository string `json:"git_repository"`
|
||||||
GitCommit string `json:"git_commit"`
|
GitCommit string `json:"git_commit"`
|
||||||
Users int64 `json:"users"`
|
Users MetaUsers `json:"users"`
|
||||||
Members int64 `json:"members"`
|
Members int64 `json:"members"`
|
||||||
RequireInvite bool `json:"require_invite"`
|
RequireInvite bool `json:"require_invite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
var numUsers, numMembers int64
|
numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx)
|
||||||
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{
|
render.JSON(w, r, MetaResponse{
|
||||||
GitRepository: server.Repository,
|
GitRepository: server.Repository,
|
||||||
GitCommit: server.Revision,
|
GitCommit: server.Revision,
|
||||||
Users: numUsers,
|
Users: MetaUsers{
|
||||||
|
Total: numUsers,
|
||||||
|
ActiveMonth: activeMonth,
|
||||||
|
ActiveWeek: activeWeek,
|
||||||
|
ActiveDay: activeDay,
|
||||||
|
},
|
||||||
Members: numMembers,
|
Members: numMembers,
|
||||||
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
||||||
})
|
})
|
||||||
|
|
|
@ -3,9 +3,9 @@ package mod
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"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"
|
||||||
|
|
|
@ -4,8 +4,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"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"
|
||||||
|
|
|
@ -4,9 +4,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"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"
|
||||||
|
|
|
@ -3,9 +3,10 @@ package mod
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
@ -23,6 +24,8 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
|
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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/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"
|
||||||
|
@ -44,7 +44,7 @@ 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 {
|
if claims.APIToken {
|
||||||
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ package user
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,9 +4,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,239 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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/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 tx.Rollback(ctx)
|
||||||
|
|
||||||
|
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) 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"}
|
||||||
|
}
|
||||||
|
|
||||||
|
flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
|
||||||
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting current user flags")
|
||||||
|
}
|
||||||
|
if len(flags) >= db.MaxPrideFlags {
|
||||||
|
return server.APIError{
|
||||||
|
Code: server.ErrFlagLimitReached,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, flag := range flags {
|
||||||
|
if flag.ID == flagID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 tx.Rollback(ctx)
|
||||||
|
|
||||||
|
flag, err := s.DB.EditFlag(ctx, tx, flagID, 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"}
|
||||||
|
}
|
||||||
|
|
||||||
|
flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
|
||||||
|
if err != nil {
|
||||||
|
return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
|
||||||
|
}
|
||||||
|
|
||||||
|
flag, err := s.DB.UserFlag(ctx, flagID)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrFlagNotFound {
|
||||||
|
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "getting flag object")
|
||||||
|
}
|
||||||
|
if flag.UserID != claims.UserID {
|
||||||
|
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
|
||||||
|
}
|
|
@ -2,39 +2,53 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/rs/xid"
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GetUserResponse struct {
|
type GetUserResponse struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
Username string `json:"name"`
|
SID string `json:"sid"`
|
||||||
DisplayName *string `json:"display_name"`
|
Username string `json:"name"`
|
||||||
Bio *string `json:"bio"`
|
DisplayName *string `json:"display_name"`
|
||||||
MemberTitle *string `json:"member_title"`
|
Bio *string `json:"bio"`
|
||||||
Avatar *string `json:"avatar"`
|
MemberTitle *string `json:"member_title"`
|
||||||
Links []string `json:"links"`
|
Avatar *string `json:"avatar"`
|
||||||
Names []db.FieldEntry `json:"names"`
|
Links []string `json:"links"`
|
||||||
Pronouns []db.PronounEntry `json:"pronouns"`
|
Names []db.FieldEntry `json:"names"`
|
||||||
Members []PartialMember `json:"members"`
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
Fields []db.Field `json:"fields"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetMeResponse struct {
|
type GetMeResponse struct {
|
||||||
GetUserResponse
|
GetUserResponse
|
||||||
|
|
||||||
MaxInvites int `json:"max_invites"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
|
||||||
ListPrivate bool `json:"list_private"`
|
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"`
|
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"`
|
||||||
|
@ -42,6 +56,7 @@ type GetMeResponse struct {
|
||||||
|
|
||||||
type PartialMember struct {
|
type PartialMember struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
|
SID string `json:"sid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Bio *string `json:"bio"`
|
Bio *string `json:"bio"`
|
||||||
|
@ -51,24 +66,32 @@ type PartialMember struct {
|
||||||
Pronouns []db.PronounEntry `json:"pronouns"`
|
Pronouns []db.PronounEntry `json:"pronouns"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse {
|
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse {
|
||||||
resp := GetUserResponse{
|
resp := GetUserResponse{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Username: u.Username,
|
SID: u.SID,
|
||||||
DisplayName: u.DisplayName,
|
Username: u.Username,
|
||||||
Bio: u.Bio,
|
DisplayName: u.DisplayName,
|
||||||
MemberTitle: u.MemberTitle,
|
Bio: u.Bio,
|
||||||
Avatar: u.Avatar,
|
MemberTitle: u.MemberTitle,
|
||||||
Links: db.NotNull(u.Links),
|
Avatar: u.Avatar,
|
||||||
Names: db.NotNull(u.Names),
|
Links: db.NotNull(u.Links),
|
||||||
Pronouns: db.NotNull(u.Pronouns),
|
Names: db.NotNull(u.Names),
|
||||||
Fields: db.NotNull(fields),
|
Pronouns: db.NotNull(u.Pronouns),
|
||||||
|
Fields: db.NotNull(fields),
|
||||||
|
CustomPreferences: u.CustomPreferences,
|
||||||
|
Flags: flags,
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.IsAdmin {
|
||||||
|
resp.Badges |= db.BadgeAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.Members = make([]PartialMember, len(members))
|
resp.Members = make([]PartialMember, len(members))
|
||||||
for i := range members {
|
for i := range members {
|
||||||
resp.Members[i] = PartialMember{
|
resp.Members[i] = PartialMember{
|
||||||
ID: members[i].ID,
|
ID: members[i].ID,
|
||||||
|
SID: members[i].SID,
|
||||||
Name: members[i].Name,
|
Name: members[i].Name,
|
||||||
DisplayName: members[i].DisplayName,
|
DisplayName: members[i].DisplayName,
|
||||||
Bio: members[i].Bio,
|
Bio: members[i].Bio,
|
||||||
|
@ -82,56 +105,29 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
userRef := chi.URLParamFromCtx(ctx, "userRef")
|
userRef := chi.URLParamFromCtx(ctx, "userRef")
|
||||||
|
|
||||||
|
var u db.User
|
||||||
if id, err := xid.FromString(userRef); err == nil {
|
if id, err := xid.FromString(userRef); err == nil {
|
||||||
u, err := s.DB.User(ctx, id)
|
u, err = s.DB.User(ctx, id)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
if u.DeletedAt != nil {
|
log.Errorf("getting user by ID: %v", err)
|
||||||
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 err
|
|
||||||
}
|
|
||||||
|
|
||||||
var members []db.Member
|
|
||||||
if !u.ListPrivate || isSelf {
|
|
||||||
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Error getting user members: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 u.ID.IsNil() {
|
||||||
if err == db.ErrUserNotFound {
|
u, err = s.DB.Username(ctx, userRef)
|
||||||
return server.APIError{
|
if err == db.ErrUserNotFound {
|
||||||
Code: server.ErrUserNotFound,
|
return server.APIError{
|
||||||
|
Code: server.ErrUserNotFound,
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
log.Errorf("Error getting user by username: %v", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if err != nil {
|
|
||||||
log.Errorf("Error getting user by username: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.DeletedAt != nil {
|
if u.DeletedAt != nil {
|
||||||
|
@ -149,6 +145,12 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user flags: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var members []db.Member
|
var members []db.Member
|
||||||
if !u.ListPrivate || isSelf {
|
if !u.ListPrivate || isSelf {
|
||||||
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
|
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
|
||||||
|
@ -158,7 +160,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, dbUserToResponse(u, fields, members))
|
render.JSON(w, r, dbUserToResponse(u, fields, members, flags))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,13 +186,25 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user flags: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
render.JSON(w, r, GetMeResponse{
|
render.JSON(w, r, GetMeResponse{
|
||||||
GetUserResponse: dbUserToResponse(u, fields, members),
|
GetUserResponse: dbUserToResponse(u, fields, members, flags),
|
||||||
|
CreatedAt: u.ID.Time(),
|
||||||
MaxInvites: u.MaxInvites,
|
MaxInvites: u.MaxInvites,
|
||||||
IsAdmin: u.IsAdmin,
|
IsAdmin: u.IsAdmin,
|
||||||
ListPrivate: u.ListPrivate,
|
ListPrivate: u.ListPrivate,
|
||||||
|
LastSIDReroll: u.LastSIDReroll,
|
||||||
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,
|
||||||
|
|
|
@ -3,26 +3,31 @@ package user
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
"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/google/uuid"
|
||||||
|
"github.com/rs/xid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PatchUserRequest struct {
|
type PatchUserRequest struct {
|
||||||
Username *string `json:"username"`
|
Username *string `json:"name"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Bio *string `json:"bio"`
|
Bio *string `json:"bio"`
|
||||||
MemberTitle *string `json:"member_title"`
|
MemberTitle *string `json:"member_title"`
|
||||||
Links *[]string `json:"links"`
|
Links *[]string `json:"links"`
|
||||||
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"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
ListPrivate *bool `json:"list_private"`
|
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.
|
// patchUser parses a PatchUserRequest and updates the user with the given ID.
|
||||||
|
@ -57,7 +62,9 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
req.Fields == nil &&
|
req.Fields == nil &&
|
||||||
req.Names == nil &&
|
req.Names == nil &&
|
||||||
req.Pronouns == nil &&
|
req.Pronouns == nil &&
|
||||||
req.Avatar == nil {
|
req.Avatar == nil &&
|
||||||
|
req.CustomPreferences == nil &&
|
||||||
|
req.Flags == nil {
|
||||||
return server.APIError{
|
return server.APIError{
|
||||||
Code: server.ErrBadRequest,
|
Code: server.ErrBadRequest,
|
||||||
Details: "Data must not be empty",
|
Details: "Data must not be empty",
|
||||||
|
@ -103,15 +110,46 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("name", req.Names); err != nil {
|
// 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
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("pronoun", req.Pronouns); err != nil {
|
if err := validateSlicePtr("pronoun", req.Pronouns, customPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateSlicePtr("field", req.Fields); err != nil {
|
if err := validateSlicePtr("field", req.Fields, customPreferences); err != nil {
|
||||||
return *err
|
return *err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -186,7 +224,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash)
|
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.CustomPreferences)
|
||||||
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
|
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
|
||||||
log.Errorf("updating user: %v", err)
|
log.Errorf("updating user: %v", err)
|
||||||
return err
|
return err
|
||||||
|
@ -228,6 +266,26 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 err
|
||||||
|
}
|
||||||
|
|
||||||
err = tx.Commit(ctx)
|
err = tx.Commit(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("committing transaction: %v", err)
|
log.Errorf("committing transaction: %v", err)
|
||||||
|
@ -243,14 +301,26 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
|
||||||
|
flags, err := s.DB.UserFlags(ctx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("getting user flags: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// echo the updated user back on success
|
// echo the updated user back on success
|
||||||
render.JSON(w, r, GetMeResponse{
|
render.JSON(w, r, GetMeResponse{
|
||||||
GetUserResponse: dbUserToResponse(u, fields, nil),
|
GetUserResponse: dbUserToResponse(u, fields, nil, flags),
|
||||||
MaxInvites: u.MaxInvites,
|
MaxInvites: u.MaxInvites,
|
||||||
IsAdmin: u.IsAdmin,
|
IsAdmin: u.IsAdmin,
|
||||||
ListPrivate: u.ListPrivate,
|
ListPrivate: u.ListPrivate,
|
||||||
|
LastSIDReroll: u.LastSIDReroll,
|
||||||
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: fediInstance,
|
FediverseInstance: fediInstance,
|
||||||
|
@ -259,12 +329,12 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type validator interface {
|
type validator interface {
|
||||||
Validate() string
|
Validate(custom db.CustomPreferences) 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) *server.APIError {
|
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
|
||||||
if slice == nil {
|
if slice == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -284,7 +354,7 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
|
||||||
|
|
||||||
// validate all fields
|
// validate all fields
|
||||||
for i, pronouns := range *slice {
|
for i, pronouns := range *slice {
|
||||||
if s := pronouns.Validate(); s != "" {
|
if s := pronouns.Validate(custom); 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),
|
||||||
|
@ -294,3 +364,31 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
|
||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ package user
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -29,6 +29,13 @@ 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))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server/auth"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
|
"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"
|
||||||
|
@ -46,14 +47,11 @@ 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 30 days.
|
// It expires after three months.
|
||||||
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(ExpireDays * 24 * time.Hour)
|
expires := now.Add(db.TokenExpiryTime)
|
||||||
|
|
||||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/log"
|
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -97,10 +97,13 @@ 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
|
ErrMemberListPrivate = 2002
|
||||||
|
ErrFlagLimitReached = 2003
|
||||||
|
ErrRerollingTooQuickly = 2004
|
||||||
|
|
||||||
// Member-related error codes
|
// Member-related error codes
|
||||||
ErrMemberNotFound = 3001
|
ErrMemberNotFound = 3001
|
||||||
|
@ -141,9 +144,12 @@ 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.",
|
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",
|
||||||
|
@ -181,9 +187,12 @@ 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,
|
ErrMemberListPrivate: http.StatusForbidden,
|
||||||
|
ErrFlagLimitReached: http.StatusBadRequest,
|
||||||
|
ErrRerollingTooQuickly: http.StatusForbidden,
|
||||||
|
|
||||||
ErrMemberNotFound: http.StatusNotFound,
|
ErrMemberNotFound: http.StatusNotFound,
|
||||||
ErrMemberLimitReached: http.StatusBadRequest,
|
ErrMemberLimitReached: http.StatusBadRequest,
|
||||||
|
|
|
@ -6,13 +6,15 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server/auth"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server/rate"
|
"codeberg.org/pronounscc/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
|
||||||
|
@ -22,7 +24,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository is the URL of the git repository
|
// Repository is the URL of the git repository
|
||||||
const Repository = "https://codeberg.org/u1f320/pronouns.cc"
|
const Repository = "https://codeberg.org/pronounscc/pronouns.cc"
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Router *chi.Mux
|
Router *chi.Mux
|
||||||
|
@ -48,6 +50,22 @@ 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 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)
|
||||||
|
|
||||||
|
@ -97,12 +115,17 @@ func New() (*Server, error) {
|
||||||
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])
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -6,12 +6,11 @@ You might have to change paths and ports, but they should work fine as-is.
|
||||||
## Building pronouns.cc
|
## Building pronouns.cc
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://codeberg.org/u1f320/pronouns.cc.git pronouns
|
git clone https://codeberg.org/pronounscc/pronouns.cc.git pronouns
|
||||||
cd pronouns
|
cd pronouns
|
||||||
|
git checkout stable
|
||||||
make all
|
make all
|
||||||
|
|
||||||
# if required fonts have not been downloaded yet
|
|
||||||
./download-fonts.sh
|
|
||||||
# if running for the first time
|
# if running for the first time
|
||||||
./pronouns database migrate
|
./pronouns database migrate
|
||||||
```
|
```
|
||||||
|
@ -23,7 +22,7 @@ one in the repository root (for the backend) and one in the frontend directory.
|
||||||
|
|
||||||
### Backend keys
|
### Backend keys
|
||||||
|
|
||||||
- `HMAC_KEY`: the key used to sign tokens. This should be a base64 string, you can generate one with `scripts/genkey`.
|
- `HMAC_KEY`: the key used to sign tokens. This should be a base64 string, you can generate one with `go run -v . generate key` (or `./pronouns generate key` after building).
|
||||||
- `DATABASE_URL`: the URL for the PostgreSQL database.
|
- `DATABASE_URL`: the URL for the PostgreSQL database.
|
||||||
- `REDIS`: the URL for the Redis database.
|
- `REDIS`: the URL for the Redis database.
|
||||||
- `PORT` (int): the port the backend will listen on.
|
- `PORT` (int): the port the backend will listen on.
|
||||||
|
@ -45,6 +44,8 @@ one in the repository root (for the backend) and one in the frontend directory.
|
||||||
|
|
||||||
- `PUBLIC_BASE_URL`: the base URL for the frontend.
|
- `PUBLIC_BASE_URL`: the base URL for the frontend.
|
||||||
- `PRIVATE_SENTRY_DSN`: your Sentry DSN.
|
- `PRIVATE_SENTRY_DSN`: your Sentry DSN.
|
||||||
|
- `PUBLIC_MEDIA_URL`: the base URL for media.
|
||||||
|
If you're proxying your media through nginx as in `pronounscc.nginx`, set this to `$PUBLIC_BASE_URL/media`.
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
|
@ -62,9 +63,6 @@ systemctl start pronouns-exporter # if the exporter was stopped
|
||||||
Both the backend and frontend are expected to run behind a reverse proxy such as Caddy or nginx.
|
Both the backend and frontend are expected to run behind a reverse proxy such as Caddy or nginx.
|
||||||
This directory contains a sample configuration file for nginx.
|
This directory contains a sample configuration file for nginx.
|
||||||
|
|
||||||
Every path should be proxied to the frontend, except:
|
Every path should be proxied to the frontend, except for `/api/`:
|
||||||
|
this should be proxied to the backend, with the URL being rewritten to remove `/api`
|
||||||
- `/api/`: this should be proxied to the backend, with the URL being rewritten to remove `/api`
|
(for example, a request to `$DOMAIN/api/v1/users/@me` should be proxied to `localhost:8080/v1/users/@me`)
|
||||||
(for example, a request to `$DOMAIN/api/v1/users/@me` should be proxied to `localhost:8080/v1/users/@me`)
|
|
||||||
- `/media/`: this should be proxied to your object storage.
|
|
||||||
Make sure to rewrite `/media` into your storage bucket's name.
|
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
curl "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=400" -o frontend/static/fonts/FiraGO-400.woff "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=400i" -o frontend/static/fonts/FiraGO-400i.woff "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=700" -o frontend/static/fonts/FiraGO-700.woff "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=700i" -o frontend/static/fonts/FiraGO-700i.woff
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Base of frontend URLs
|
||||||
|
PUBLIC_BASE_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Base of media URLs, required for avatars, pride flags, and data exports
|
||||||
|
# If using the provided nginx reverse proxy config, use `$PUBLIC_BASE_URL/media`
|
||||||
|
PUBLIC_MEDIA_URL=
|
||||||
|
|
||||||
|
# Base of shortened profile URLs (leave empty to disable)
|
||||||
|
PUBLIC_SHORT_BASE=
|
||||||
|
|
||||||
|
# hCaptcha configuration (leave empty to disable)
|
||||||
|
PUBLIC_HCAPTCHA_SITEKEY=
|
||||||
|
|
||||||
|
# Sentry configuration (unused in dev, required in production)
|
||||||
|
PRIVATE_SENTRY_DSN=
|
|
@ -0,0 +1,44 @@
|
||||||
|
// This script regenerates the list of icons for the frontend (frontend/src/icons.json)
|
||||||
|
// and the backend (backend/icons/icons.go) from the currently installed version of Bootstrap Icons.
|
||||||
|
// Run with `pnpm node icons.js` in the frontend directory.
|
||||||
|
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
import icons from "bootstrap-icons/font/bootstrap-icons.json" assert { type: "json" };
|
||||||
|
|
||||||
|
const keys = Object.keys(icons);
|
||||||
|
|
||||||
|
console.log(`Found ${keys.length} icons`);
|
||||||
|
const output = JSON.stringify(keys);
|
||||||
|
console.log(`Saving file as src/icons.ts`);
|
||||||
|
|
||||||
|
writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`);
|
||||||
|
|
||||||
|
const goCode1 = `// Generated code. DO NOT EDIT
|
||||||
|
package icons
|
||||||
|
|
||||||
|
var icons = [...]string{
|
||||||
|
`;
|
||||||
|
|
||||||
|
const goCode2 = `}
|
||||||
|
|
||||||
|
// IsValid returns true if the input is the name of a Bootstrap icon.
|
||||||
|
func IsValid(name string) bool {
|
||||||
|
for i := range icons {
|
||||||
|
if icons[i] == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let goOutput = goCode1;
|
||||||
|
|
||||||
|
keys.forEach((element) => {
|
||||||
|
goOutput += ` "${element}",\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
goOutput += goCode2;
|
||||||
|
|
||||||
|
console.log("Writing Go code");
|
||||||
|
writeFileSync("../backend/icons/icons.go", goOutput);
|
|
@ -28,6 +28,7 @@
|
||||||
"prettier-plugin-svelte": "^2.10.0",
|
"prettier-plugin-svelte": "^2.10.0",
|
||||||
"svelte": "^3.58.0",
|
"svelte": "^3.58.0",
|
||||||
"svelte-check": "^3.1.4",
|
"svelte-check": "^3.1.4",
|
||||||
|
"svelte-hcaptcha": "^0.1.1",
|
||||||
"sveltestrap": "^5.10.0",
|
"sveltestrap": "^5.10.0",
|
||||||
"tslib": "^2.5.0",
|
"tslib": "^2.5.0",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
|
@ -36,6 +37,7 @@
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/firago": "^4.5.3",
|
||||||
"@popperjs/core": "^2.11.7",
|
"@popperjs/core": "^2.11.7",
|
||||||
"@sentry/node": "^7.46.0",
|
"@sentry/node": "^7.46.0",
|
||||||
"base64-arraybuffer": "^1.0.2",
|
"base64-arraybuffer": "^1.0.2",
|
||||||
|
@ -44,6 +46,7 @@
|
||||||
"jose": "^4.13.1",
|
"jose": "^4.13.1",
|
||||||
"luxon": "^3.3.0",
|
"luxon": "^3.3.0",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
|
"pretty-bytes": "^6.1.0",
|
||||||
"sanitize-html": "^2.10.0"
|
"sanitize-html": "^2.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,12 +1,31 @@
|
||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "svelte-hcaptcha" {
|
||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
|
||||||
|
export interface HCaptchaProps {
|
||||||
|
sitekey?: string;
|
||||||
|
apihost?: string;
|
||||||
|
hl?: string;
|
||||||
|
reCaptchaCompat?: boolean;
|
||||||
|
theme?: CaptchaTheme;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare class HCaptcha extends SvelteComponent {
|
||||||
|
$$prop_def: HCaptchaProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HCaptcha;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-bs-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Internal error occurred</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-family: sans-serif;
|
||||||
|
margin: 40px auto;
|
||||||
|
max-width: 650px;
|
||||||
|
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:link,
|
||||||
|
a:visited {
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
color: rgba(33, 37, 41, 0.75);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #212529;
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:link,
|
||||||
|
a:visited {
|
||||||
|
color: #6ea8fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
color: rgba(173, 181, 189, 0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<p class="logo">
|
||||||
|
<img src="/logo.svg" alt="pronouns.cc logo" width="50%" />
|
||||||
|
</p>
|
||||||
|
<h1>Internal error occurred</h1>
|
||||||
|
<p>An internal error has occurred. Don't worry, it's (probably) not your fault.</p>
|
||||||
|
<p>
|
||||||
|
If this is the first time this is happening, try reloading the page. Otherwise, check the
|
||||||
|
<a href="https://status.pronouns.cc/" target="_blank">status page</a> for updates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="info">
|
||||||
|
<strong>Status:</strong> %sveltekit.status%<br />
|
||||||
|
<strong>Error message:</strong> %sveltekit.error.message%
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -7,11 +7,11 @@ Sentry.init({ dsn: PRIVATE_SENTRY_DSN });
|
||||||
export const handleError = (({ error, event }) => {
|
export const handleError = (({ error, event }) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
console.log(event);
|
console.log(event);
|
||||||
const id = Sentry.captureException(error);
|
// const id = Sentry.captureException(error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: "Internal server error",
|
message: "Internal server error",
|
||||||
code: 500,
|
code: 500,
|
||||||
id,
|
//id,
|
||||||
};
|
};
|
||||||
}) satisfies HandleServerError;
|
}) satisfies HandleServerError;
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,48 @@
|
||||||
|
import { type CustomPreferences, PreferenceSize } from "./entities";
|
||||||
|
|
||||||
|
const defaultPreferences: CustomPreferences = {
|
||||||
|
favourite: {
|
||||||
|
icon: "heart-fill",
|
||||||
|
tooltip: "Favourite",
|
||||||
|
size: PreferenceSize.Large,
|
||||||
|
muted: false,
|
||||||
|
favourite: true,
|
||||||
|
},
|
||||||
|
okay: {
|
||||||
|
icon: "hand-thumbs-up",
|
||||||
|
tooltip: "Okay",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
jokingly: {
|
||||||
|
icon: "emoji-laughing",
|
||||||
|
tooltip: "Jokingly",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
friends_only: {
|
||||||
|
icon: "people",
|
||||||
|
tooltip: "Friends only",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
avoid: {
|
||||||
|
icon: "hand-thumbs-down",
|
||||||
|
tooltip: "Avoid",
|
||||||
|
size: PreferenceSize.Small,
|
||||||
|
muted: true,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
missing: {
|
||||||
|
icon: "question-lg",
|
||||||
|
tooltip: "Unknown (missing)",
|
||||||
|
size: PreferenceSize.Normal,
|
||||||
|
muted: false,
|
||||||
|
favourite: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defaultPreferences;
|
|
@ -1,31 +1,61 @@
|
||||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
import { PUBLIC_BASE_URL, PUBLIC_MEDIA_URL } from "$env/static/public";
|
||||||
|
|
||||||
export const MAX_MEMBERS = 500;
|
export const MAX_MEMBERS = 500;
|
||||||
|
export const MAX_FIELDS = 25;
|
||||||
export const MAX_DESCRIPTION_LENGTH = 1000;
|
export const MAX_DESCRIPTION_LENGTH = 1000;
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
sid: string;
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
links: string[];
|
links: string[];
|
||||||
member_title: string | null;
|
member_title: string | null;
|
||||||
|
badges: number;
|
||||||
|
|
||||||
names: FieldEntry[];
|
names: FieldEntry[];
|
||||||
pronouns: Pronoun[];
|
pronouns: Pronoun[];
|
||||||
members: PartialMember[];
|
members: PartialMember[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
flags: PrideFlag[];
|
||||||
|
custom_preferences: CustomPreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomPreferences {
|
||||||
|
[key: string]: CustomPreference;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomPreference {
|
||||||
|
icon: string;
|
||||||
|
tooltip: string;
|
||||||
|
size: PreferenceSize;
|
||||||
|
muted: boolean;
|
||||||
|
favourite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PreferenceSize {
|
||||||
|
Large = "large",
|
||||||
|
Normal = "normal",
|
||||||
|
Small = "small",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeUser extends User {
|
export interface MeUser extends User {
|
||||||
|
created_at: string;
|
||||||
max_invites: number;
|
max_invites: number;
|
||||||
|
is_admin: boolean;
|
||||||
discord: string | null;
|
discord: string | null;
|
||||||
discord_username: string | null;
|
discord_username: string | null;
|
||||||
|
tumblr: string | null;
|
||||||
|
tumblr_username: string | null;
|
||||||
|
google: string | null;
|
||||||
|
google_username: string | null;
|
||||||
fediverse: string | null;
|
fediverse: string | null;
|
||||||
fediverse_username: string | null;
|
fediverse_username: string | null;
|
||||||
fediverse_instance: string | null;
|
fediverse_instance: string | null;
|
||||||
list_private: boolean;
|
list_private: boolean;
|
||||||
|
last_sid_reroll: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Field {
|
export interface Field {
|
||||||
|
@ -35,26 +65,18 @@ export interface Field {
|
||||||
|
|
||||||
export interface FieldEntry {
|
export interface FieldEntry {
|
||||||
value: string;
|
value: string;
|
||||||
status: WordStatus;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pronoun {
|
export interface Pronoun {
|
||||||
pronouns: string;
|
pronouns: string;
|
||||||
display_text: string | null;
|
display_text: string | null;
|
||||||
status: WordStatus;
|
status: string;
|
||||||
}
|
|
||||||
|
|
||||||
export enum WordStatus {
|
|
||||||
Unknown = "",
|
|
||||||
Favourite = "favourite",
|
|
||||||
Okay = "okay",
|
|
||||||
Jokingly = "jokingly",
|
|
||||||
FriendsOnly = "friends_only",
|
|
||||||
Avoid = "avoid",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartialMember {
|
export interface PartialMember {
|
||||||
id: string;
|
id: string;
|
||||||
|
sid: string;
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
|
@ -66,6 +88,7 @@ export interface PartialMember {
|
||||||
|
|
||||||
export interface Member extends PartialMember {
|
export interface Member extends PartialMember {
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
flags: PrideFlag[];
|
||||||
|
|
||||||
user: MemberPartialUser;
|
user: MemberPartialUser;
|
||||||
unlisted?: boolean;
|
unlisted?: boolean;
|
||||||
|
@ -76,6 +99,14 @@ export interface MemberPartialUser {
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
custom_preferences: CustomPreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrideFlag {
|
||||||
|
id: string;
|
||||||
|
hash: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Invite {
|
export interface Invite {
|
||||||
|
@ -135,6 +166,7 @@ export enum ErrorCode {
|
||||||
AlreadyLinked = 1014,
|
AlreadyLinked = 1014,
|
||||||
NotLinked = 1015,
|
NotLinked = 1015,
|
||||||
LastProvider = 1016,
|
LastProvider = 1016,
|
||||||
|
InvalidCaptcha = 1017,
|
||||||
|
|
||||||
UserNotFound = 2001,
|
UserNotFound = 2001,
|
||||||
|
|
||||||
|
@ -159,8 +191,8 @@ export const userAvatars = (user: User | MeUser | MemberPartialUser) => {
|
||||||
if (!user.avatar) return defaultAvatars;
|
if (!user.avatar) return defaultAvatars;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`${PUBLIC_BASE_URL}/media/users/${user.id}/${user.avatar}.webp`,
|
`${PUBLIC_MEDIA_URL}/users/${user.id}/${user.avatar}.webp`,
|
||||||
`${PUBLIC_BASE_URL}/media/users/${user.id}/${user.avatar}.jpg`,
|
`${PUBLIC_MEDIA_URL}/users/${user.id}/${user.avatar}.jpg`,
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -168,11 +200,13 @@ export const memberAvatars = (member: Member | PartialMember) => {
|
||||||
if (!member.avatar) return defaultAvatars;
|
if (!member.avatar) return defaultAvatars;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`${PUBLIC_BASE_URL}/media/members/${member.id}/${member.avatar}.webp`,
|
`${PUBLIC_MEDIA_URL}/members/${member.id}/${member.avatar}.webp`,
|
||||||
`${PUBLIC_BASE_URL}/media/members/${member.id}/${member.avatar}.jpg`,
|
`${PUBLIC_MEDIA_URL}/members/${member.id}/${member.avatar}.jpg`,
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const flagURL = ({ hash }: PrideFlag) => `${PUBLIC_MEDIA_URL}/flags/${hash}.webp`;
|
||||||
|
|
||||||
export const defaultAvatars = [
|
export const defaultAvatars = [
|
||||||
`${PUBLIC_BASE_URL}/default/512.webp`,
|
`${PUBLIC_BASE_URL}/default/512.webp`,
|
||||||
`${PUBLIC_BASE_URL}/default/512.jpg`,
|
`${PUBLIC_BASE_URL}/default/512.jpg`,
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import type { APIError } from "./entities";
|
import { ErrorCode, type APIError } from "./entities";
|
||||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||||
|
import { addToast } from "$lib/toast";
|
||||||
|
import { userStore } from "$lib/store";
|
||||||
|
|
||||||
export async function apiFetch<T>(
|
export async function apiFetch<T>(
|
||||||
path: string,
|
path: string,
|
||||||
|
@ -26,8 +28,24 @@ export async function apiFetch<T>(
|
||||||
return data as T;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiFetchClient = async <T>(path: string, method = "GET", body: any = null) =>
|
export const apiFetchClient = async <T>(path: string, method = "GET", body: any = null) => {
|
||||||
apiFetch<T>(path, { method, body, token: localStorage.getItem("pronouns-token") || undefined });
|
try {
|
||||||
|
const data = await apiFetch<T>(path, {
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
token: localStorage.getItem("pronouns-token") || undefined,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as APIError).code === ErrorCode.InvalidToken) {
|
||||||
|
addToast({ header: "Token expired", body: "Your token has expired, please log in again." });
|
||||||
|
userStore.set(null);
|
||||||
|
localStorage.removeItem("pronouns-token");
|
||||||
|
localStorage.removeItem("pronouns-user");
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Fetches the specified path without parsing the response body. */
|
/** Fetches the specified path without parsing the response body. */
|
||||||
export async function fastFetch(
|
export async function fastFetch(
|
||||||
|
@ -53,5 +71,20 @@ export async function fastFetch(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetches the specified path without parsing the response body. */
|
/** Fetches the specified path without parsing the response body. */
|
||||||
export const fastFetchClient = async (path: string, method = "GET", body: any = null) =>
|
export const fastFetchClient = async (path: string, method = "GET", body: any = null) => {
|
||||||
fastFetch(path, { method, body, token: localStorage.getItem("pronouns-token") || undefined });
|
try {
|
||||||
|
await fastFetch(path, {
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
token: localStorage.getItem("pronouns-token") || undefined,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as APIError).code === ErrorCode.InvalidToken) {
|
||||||
|
addToast({ header: "Token expired", body: "Your token has expired, please log in again." });
|
||||||
|
userStore.set(null);
|
||||||
|
localStorage.removeItem("pronouns-token");
|
||||||
|
localStorage.removeItem("pronouns-user");
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
export const memberNameRegex = /^[^@\\?!#/\\\\[\]"'$%&()+<=>^|~`,]{1,100}$/;
|
export const memberNameRegex = /^[^@\\?!#/\\\\[\]"\\{\\}'$%&()+<=>^|~`,]{1,100}$/;
|
||||||
export const usernameRegex = /^[\w-.]{2,40}$/;
|
export const usernameRegex = /^[\w-.]{2,40}$/;
|
||||||
|
|
|
@ -8,13 +8,22 @@ export interface SignupResponse {
|
||||||
export interface MetaResponse {
|
export interface MetaResponse {
|
||||||
git_repository: string;
|
git_repository: string;
|
||||||
git_commit: string;
|
git_commit: string;
|
||||||
users: number;
|
users: MetaUsers;
|
||||||
members: number;
|
members: number;
|
||||||
require_invite: boolean;
|
require_invite: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MetaUsers {
|
||||||
|
total: number;
|
||||||
|
active_month: number;
|
||||||
|
active_week: number;
|
||||||
|
active_day: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UrlsResponse {
|
export interface UrlsResponse {
|
||||||
discord: string;
|
discord?: string;
|
||||||
|
tumblr?: string;
|
||||||
|
google?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportResponse {
|
export interface ExportResponse {
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Field } from "$lib/api/entities";
|
import type { CustomPreferences, Field } from "$lib/api/entities";
|
||||||
|
|
||||||
import StatusLine from "./StatusLine.svelte";
|
import StatusLine from "./StatusLine.svelte";
|
||||||
|
|
||||||
export let field: Field;
|
export let field: Field;
|
||||||
|
export let preferences: CustomPreferences;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3>{field.name}</h3>
|
<h3>{field.name}</h3>
|
||||||
<ul class="list-unstyled fs-5">
|
<ul class="list-unstyled fs-5">
|
||||||
{#each field.entries as entry}
|
{#each field.entries as entry}
|
||||||
<li><StatusLine status={entry.status}>{entry.value}</StatusLine></li>
|
<li><StatusLine {preferences} status={entry.status}>{entry.value}</StatusLine></li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,10 +4,12 @@
|
||||||
export let icon: string;
|
export let icon: string;
|
||||||
export let color: "primary" | "secondary" | "success" | "danger";
|
export let color: "primary" | "secondary" | "success" | "danger";
|
||||||
export let tooltip: string;
|
export let tooltip: string;
|
||||||
export let active: boolean = false;
|
export let active = false;
|
||||||
export let disabled: boolean = false;
|
export let disabled = false;
|
||||||
export let type: string | undefined = undefined;
|
export let type: string | undefined = undefined;
|
||||||
export let id: string | undefined = undefined;
|
export let id: string | undefined = undefined;
|
||||||
|
export let outline: boolean | undefined = undefined;
|
||||||
|
export let border = true;
|
||||||
|
|
||||||
export let click: ((e: MouseEvent) => void) | undefined = undefined;
|
export let click: ((e: MouseEvent) => void) | undefined = undefined;
|
||||||
export let href: string | undefined = undefined;
|
export let href: string | undefined = undefined;
|
||||||
|
@ -23,6 +25,8 @@
|
||||||
{active}
|
{active}
|
||||||
{disabled}
|
{disabled}
|
||||||
{href}
|
{href}
|
||||||
|
{outline}
|
||||||
|
class={border ? undefined : "border-0"}
|
||||||
aria-label={tooltip}
|
aria-label={tooltip}
|
||||||
on:click={click}
|
on:click={click}
|
||||||
bind:inner={button}
|
bind:inner={button}
|
||||||
|
|
|
@ -1,15 +1,27 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { memberAvatars, WordStatus, type PartialMember, type User } from "$lib/api/entities";
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
import {
|
||||||
|
memberAvatars,
|
||||||
|
type PartialMember,
|
||||||
|
type User,
|
||||||
|
type CustomPreferences,
|
||||||
|
} from "$lib/api/entities";
|
||||||
|
import { Icon, Tooltip } from "sveltestrap";
|
||||||
import FallbackImage from "./FallbackImage.svelte";
|
import FallbackImage from "./FallbackImage.svelte";
|
||||||
|
|
||||||
export let user: User;
|
export let user: User;
|
||||||
export let member: PartialMember;
|
export let member: PartialMember & {
|
||||||
|
unlisted?: boolean
|
||||||
|
};
|
||||||
|
|
||||||
let pronouns: string | undefined;
|
let pronouns: string | undefined;
|
||||||
|
|
||||||
|
let mergedPreferences: CustomPreferences;
|
||||||
|
$: mergedPreferences = Object.assign({}, defaultPreferences, user.custom_preferences);
|
||||||
|
|
||||||
const getPronouns = (member: PartialMember) => {
|
const getPronouns = (member: PartialMember) => {
|
||||||
const filteredPronouns = member.pronouns.filter(
|
const filteredPronouns = member.pronouns.filter(
|
||||||
(pronouns) => pronouns.status === WordStatus.Favourite,
|
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
|
||||||
);
|
);
|
||||||
if (filteredPronouns.length === 0) {
|
if (filteredPronouns.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -20,14 +32,16 @@
|
||||||
return pronouns.display_text;
|
return pronouns.display_text;
|
||||||
} else {
|
} else {
|
||||||
const split = pronouns.pronouns.split("/");
|
const split = pronouns.pronouns.split("/");
|
||||||
if (split.length < 2) return split.join("/");
|
if (split.length === 5) return split.splice(0, 2).join("/");
|
||||||
else return split.slice(0, 2).join("/");
|
return pronouns.pronouns;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
};
|
};
|
||||||
|
|
||||||
$: pronouns = getPronouns(member);
|
$: pronouns = getPronouns(member);
|
||||||
|
|
||||||
|
let iconElement: HTMLElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -37,6 +51,10 @@
|
||||||
<p class="m-2">
|
<p class="m-2">
|
||||||
<a class="text-reset fs-5 text-break" href="/@{user.name}/{member.name}">
|
<a class="text-reset fs-5 text-break" href="/@{user.name}/{member.name}">
|
||||||
{member.display_name ?? member.name}
|
{member.display_name ?? member.name}
|
||||||
|
{#if member.unlisted === true}
|
||||||
|
<span bind:this={iconElement} tabindex={0}><Icon name="lock"/></span>
|
||||||
|
<Tooltip target={iconElement} placement="top">This member is hidden</Tooltip>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{#if pronouns}
|
{#if pronouns}
|
||||||
<br />
|
<br />
|
||||||
|
|
|
@ -11,18 +11,28 @@
|
||||||
return pronouns.display_text;
|
return pronouns.display_text;
|
||||||
} else {
|
} else {
|
||||||
const split = pronouns.pronouns.split("/");
|
const split = pronouns.pronouns.split("/");
|
||||||
if (split.length < 2) return split.join("/");
|
if (split.length === 5) return split.splice(0, 2).join("/");
|
||||||
else return split.slice(0, 2).join("/");
|
return pronouns.pronouns;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let link: string;
|
let link: string;
|
||||||
let shouldLink: boolean;
|
let shouldLink: boolean;
|
||||||
|
|
||||||
$: link = pronouns.display_text
|
$: link = linkPronouns(pronouns);
|
||||||
? `${pronouns.pronouns},${pronouns.display_text}`
|
|
||||||
: pronouns.pronouns;
|
|
||||||
$: shouldLink = pronouns.pronouns.split("/").length === 5;
|
$: shouldLink = pronouns.pronouns.split("/").length === 5;
|
||||||
|
|
||||||
|
const linkPronouns = (pronouns: Pronoun) => {
|
||||||
|
const linkBase = pronouns.pronouns
|
||||||
|
.split("/")
|
||||||
|
.map((snippet) => encodeURIComponent(snippet))
|
||||||
|
.join("/");
|
||||||
|
|
||||||
|
if (pronouns.display_text) {
|
||||||
|
return `${linkBase},${encodeURIComponent(pronouns.display_text)}`;
|
||||||
|
}
|
||||||
|
return linkBase;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if shouldLink}
|
{#if shouldLink}
|
||||||
|
|
|
@ -1,53 +1,24 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Icon, Tooltip } from "sveltestrap";
|
import { Icon, Tooltip } from "sveltestrap";
|
||||||
|
|
||||||
import { WordStatus } from "$lib/api/entities";
|
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
|
||||||
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
|
||||||
export let status: WordStatus;
|
export let preferences: CustomPreferences;
|
||||||
|
export let status: string;
|
||||||
export let className: string | null = null;
|
export let className: string | null = null;
|
||||||
|
|
||||||
const iconFor = (wordStatus: WordStatus) => {
|
let mergedPreferences: CustomPreferences;
|
||||||
switch (wordStatus) {
|
$: mergedPreferences = Object.assign({}, defaultPreferences, preferences);
|
||||||
case WordStatus.Favourite:
|
|
||||||
return "heart-fill";
|
|
||||||
case WordStatus.Okay:
|
|
||||||
return "hand-thumbs-up";
|
|
||||||
case WordStatus.Jokingly:
|
|
||||||
return "emoji-laughing";
|
|
||||||
case WordStatus.FriendsOnly:
|
|
||||||
return "people";
|
|
||||||
case WordStatus.Avoid:
|
|
||||||
return "hand-thumbs-down";
|
|
||||||
default:
|
|
||||||
return "hand-thumbs-up";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const textFor = (wordStatus: WordStatus) => {
|
let currentPreference: CustomPreference;
|
||||||
switch (wordStatus) {
|
$: currentPreference =
|
||||||
case WordStatus.Favourite:
|
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
|
||||||
return "Favourite";
|
|
||||||
case WordStatus.Okay:
|
|
||||||
return "Okay";
|
|
||||||
case WordStatus.Jokingly:
|
|
||||||
return "Jokingly";
|
|
||||||
case WordStatus.FriendsOnly:
|
|
||||||
return "Friends only";
|
|
||||||
case WordStatus.Avoid:
|
|
||||||
return "Avoid";
|
|
||||||
default:
|
|
||||||
return "Okay";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let statusIcon: string;
|
|
||||||
$: statusIcon = iconFor(status);
|
|
||||||
|
|
||||||
let statusText: string;
|
|
||||||
$: statusText = textFor(status);
|
|
||||||
|
|
||||||
let iconElement: HTMLElement;
|
let iconElement: HTMLElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span bind:this={iconElement} tabindex={0}><Icon name={statusIcon} class={className} /></span>
|
<span bind:this={iconElement} tabindex={0}
|
||||||
<Tooltip target={iconElement} placement="top">{statusText}</Tooltip>
|
><Icon name={currentPreference.icon} class={className} /></span
|
||||||
|
>
|
||||||
|
<Tooltip target={iconElement} placement="top">{currentPreference.tooltip}</Tooltip>
|
||||||
|
|
|
@ -1,14 +1,44 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { WordStatus } from "$lib/api/entities";
|
import { PreferenceSize } from "$lib/api/entities";
|
||||||
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
||||||
|
|
||||||
export let status: WordStatus;
|
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
|
||||||
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
|
||||||
|
export let preferences: CustomPreferences;
|
||||||
|
export let status: string;
|
||||||
|
|
||||||
|
let mergedPreferences: CustomPreferences;
|
||||||
|
$: mergedPreferences = Object.assign({}, defaultPreferences, preferences);
|
||||||
|
|
||||||
|
let currentPreference: CustomPreference;
|
||||||
|
$: currentPreference =
|
||||||
|
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
|
||||||
|
|
||||||
|
let classes: string;
|
||||||
|
$: classes = setClasses(currentPreference);
|
||||||
|
|
||||||
|
const setClasses = (pref: CustomPreference) => {
|
||||||
|
let classes = "";
|
||||||
|
if (pref.muted) {
|
||||||
|
classes += "text-muted ";
|
||||||
|
}
|
||||||
|
switch (pref.size) {
|
||||||
|
case PreferenceSize.Large:
|
||||||
|
classes += "fs-5";
|
||||||
|
break;
|
||||||
|
case PreferenceSize.Normal:
|
||||||
|
break;
|
||||||
|
case PreferenceSize.Small:
|
||||||
|
classes += "fs-6";
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes.trim();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if status === WordStatus.Favourite}
|
{#if currentPreference.size === PreferenceSize.Large}
|
||||||
<strong class="fs-5"><StatusIcon {status} /> <slot /></strong>
|
<strong class={classes}><StatusIcon {preferences} {status} /> <slot /></strong>
|
||||||
{:else if status === WordStatus.Avoid}
|
|
||||||
<span class="fs-6 text-muted"><StatusIcon {status} /> <slot /></span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<StatusIcon {status} /> <slot />
|
<span class={classes}><StatusIcon {preferences} {status} /> <slot /></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -6,9 +6,11 @@ import type { MeUser } from "./api/entities";
|
||||||
const initialUserValue = null;
|
const initialUserValue = null;
|
||||||
export const userStore = writable<MeUser | null>(initialUserValue);
|
export const userStore = writable<MeUser | null>(initialUserValue);
|
||||||
|
|
||||||
let defaultThemeValue = "dark";
|
const defaultThemeValue = "dark";
|
||||||
const initialThemeValue = browser
|
const initialThemeValue = browser
|
||||||
? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue
|
? window.localStorage.getItem("pronouns-theme") ?? defaultThemeValue
|
||||||
: defaultThemeValue;
|
: defaultThemeValue;
|
||||||
|
|
||||||
export const themeStore = writable<string>(initialThemeValue);
|
export const themeStore = writable<string>(initialThemeValue);
|
||||||
|
|
||||||
|
export const CURRENT_CHANGELOG = "0.5.3";
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
<p>An internal error occurred. Please try again later.</p>
|
<p>An internal error occurred. Please try again later.</p>
|
||||||
<p>
|
<p>
|
||||||
If this error keeps happening, please <a
|
If this error keeps happening, please <a
|
||||||
href="https://codeberg.org/u1f320/pronouns.cc/issues"
|
href="https://codeberg.org/pronounscc/pronouns.cc/issues"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer">file a bug report</a
|
rel="noreferrer">file a bug report</a
|
||||||
> with an explanation of what you did to cause the error.
|
> with an explanation of what you did to cause the error.
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { error } from "@sveltejs/kit";
|
import { building } from "$app/environment";
|
||||||
import type { LayoutServerLoad } from "./$types";
|
import type { LayoutServerLoad } from "./$types";
|
||||||
import type { APIError } from "$lib/api/entities";
|
|
||||||
import { apiFetch } from "$lib/api/fetch";
|
import { apiFetch } from "$lib/api/fetch";
|
||||||
import type { MetaResponse } from "$lib/api/responses";
|
import type { MetaResponse } from "$lib/api/responses";
|
||||||
|
|
||||||
|
@ -8,6 +7,24 @@ export const load = (async () => {
|
||||||
try {
|
try {
|
||||||
return await apiFetch<MetaResponse>("/meta", {});
|
return await apiFetch<MetaResponse>("/meta", {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw error(500, (e as APIError).message);
|
console.warn("error fetching meta endpoint:", e);
|
||||||
|
|
||||||
|
if (building) {
|
||||||
|
// just return an empty object--this only affects the three static pages, nothing else, so it's fine
|
||||||
|
return {
|
||||||
|
git_repository: "",
|
||||||
|
git_commit: "",
|
||||||
|
users: {
|
||||||
|
total: 0,
|
||||||
|
active_month: 0,
|
||||||
|
active_week: 0,
|
||||||
|
active_day: 0,
|
||||||
|
},
|
||||||
|
members: 0,
|
||||||
|
require_invite: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}) satisfies LayoutServerLoad;
|
}) satisfies LayoutServerLoad;
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
|
import "@fontsource/firago/400.css";
|
||||||
|
import "@fontsource/firago/400-italic.css";
|
||||||
|
import "@fontsource/firago/700.css";
|
||||||
|
import "@fontsource/firago/700-italic.css";
|
||||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||||
import "./main.css";
|
import "./main.css";
|
||||||
|
|
||||||
|
@ -8,6 +12,7 @@
|
||||||
import { version } from "$app/environment";
|
import { version } from "$app/environment";
|
||||||
import { toastStore } from "$lib/toast";
|
import { toastStore } from "$lib/toast";
|
||||||
import Toast from "$lib/components/Toast.svelte";
|
import Toast from "$lib/components/Toast.svelte";
|
||||||
|
import { Icon } from "sveltestrap";
|
||||||
|
|
||||||
export let data: LayoutData;
|
export let data: LayoutData;
|
||||||
|
|
||||||
|
@ -22,6 +27,7 @@
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta name="theme-color" content="#aa8ed6" />
|
<meta name="theme-color" content="#aa8ed6" />
|
||||||
<meta property="og:site_name" content="pronouns.cc" />
|
<meta property="og:site_name" content="pronouns.cc" />
|
||||||
|
<script defer data-domain="pronouns.cc" src="https://plausible.pronouns.cc/js/script.js"></script>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="d-flex flex-column min-vh-100">
|
<div class="d-flex flex-column min-vh-100">
|
||||||
|
@ -39,16 +45,28 @@
|
||||||
|
|
||||||
<footer class="container">
|
<footer class="container">
|
||||||
<hr />
|
<hr />
|
||||||
<p>
|
<div class="d-flex flex-column flex-md-row">
|
||||||
pronouns.cc <a href="https://codeberg.org/u1f320/pronouns.cc/commit/{commit}">{version}</a>
|
<p>
|
||||||
{#if versionMismatch}
|
pronouns.cc <a href="https://codeberg.org/pronounscc/pronouns.cc/commit/{commit}"
|
||||||
(backend: <a href="https://codeberg.org/u1f320/pronouns.cc/commit/{data.git_commit}"
|
>{version}</a
|
||||||
>{data.git_commit}</a
|
>
|
||||||
>)
|
{#if versionMismatch}
|
||||||
{/if} ·
|
(backend: <a href="https://codeberg.org/pronounscc/pronouns.cc/commit/{data.git_commit}"
|
||||||
<a href="/page/about">About & contact</a> ·
|
>{data.git_commit}</a
|
||||||
<a href="/page/terms">Terms of service</a> ·
|
>)
|
||||||
<a href="/page/privacy">Privacy policy</a>
|
{/if} ·
|
||||||
</p>
|
<a href="https://status.pronouns.cc/" target="_blank">Status</a> ·
|
||||||
|
<a href="/page/about">About & contact</a> ·
|
||||||
|
<a href="/page/changelog">Changelog</a> ·
|
||||||
|
<Icon name="cash" aria-hidden />
|
||||||
|
<a href="https://liberapay.com/u1f320/" target="_blank">Donate</a>
|
||||||
|
·
|
||||||
|
<a href="/page/terms">Terms of service</a> ·
|
||||||
|
<a href="/page/privacy">Privacy policy</a>
|
||||||
|
</p>
|
||||||
|
<p class="ms-auto">
|
||||||
|
Users: <strong>{data.users.total}</strong> · Members: <strong>{data.members}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -49,7 +49,7 @@
|
||||||
<p>
|
<p>
|
||||||
pronouns.cc is currently <strong>in beta</strong>. There might be issues and some
|
pronouns.cc is currently <strong>in beta</strong>. There might be issues and some
|
||||||
functionality is not available or unfinished. Issue reports and pull requests
|
functionality is not available or unfinished. Issue reports and pull requests
|
||||||
<a href="https://codeberg.org/u1f320/pronouns.cc" rel="noreferrer" target="_blank"
|
<a href="https://codeberg.org/pronounscc/pronouns.cc" rel="noreferrer" target="_blank"
|
||||||
>in the repository</a
|
>in the repository</a
|
||||||
> are welcome!
|
> are welcome!
|
||||||
</p>
|
</p>
|
||||||
|
@ -67,7 +67,7 @@
|
||||||
<h2 class="fs-5">Open source</h2>
|
<h2 class="fs-5">Open source</h2>
|
||||||
<p>
|
<p>
|
||||||
pronouns.cc is
|
pronouns.cc is
|
||||||
<a href="https://codeberg.org/u1f320/pronouns.cc" rel="noreferrer" target="_blank"
|
<a href="https://codeberg.org/pronounscc/pronouns.cc" rel="noreferrer" target="_blank"
|
||||||
>open source</a
|
>open source</a
|
||||||
>, and licensed under the GNU Affero General Public License. Feel free to contribute!
|
>, and licensed under the GNU Affero General Public License. Feel free to contribute!
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<p>An internal error occurred. Please try again later.</p>
|
<p>An internal error occurred. Please try again later.</p>
|
||||||
<p>
|
<p>
|
||||||
If this error keeps happening, please <a
|
If this error keeps happening, please <a
|
||||||
href="https://codeberg.org/u1f320/pronouns.cc/issues"
|
href="https://codeberg.org/pronounscc/pronouns.cc/issues"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer">file a bug report</a
|
rel="noreferrer">file a bug report</a
|
||||||
> with an explanation of what you did to cause the error.
|
> with an explanation of what you did to cause the error.
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Icon,
|
Icon,
|
||||||
Input,
|
Input,
|
||||||
|
InputGroup,
|
||||||
Modal,
|
Modal,
|
||||||
ModalBody,
|
ModalBody,
|
||||||
ModalFooter,
|
ModalFooter,
|
||||||
|
@ -20,12 +23,14 @@
|
||||||
MAX_MEMBERS,
|
MAX_MEMBERS,
|
||||||
pronounDisplay,
|
pronounDisplay,
|
||||||
userAvatars,
|
userAvatars,
|
||||||
WordStatus,
|
|
||||||
type APIError,
|
type APIError,
|
||||||
type Member,
|
type Member,
|
||||||
type PartialMember,
|
type PartialMember,
|
||||||
|
type CustomPreferences,
|
||||||
|
type FieldEntry,
|
||||||
|
type Pronoun,
|
||||||
} from "$lib/api/entities";
|
} from "$lib/api/entities";
|
||||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
import { PUBLIC_BASE_URL, PUBLIC_SHORT_BASE } from "$env/static/public";
|
||||||
import { apiFetchClient } from "$lib/api/fetch";
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
@ -34,16 +39,22 @@
|
||||||
import ProfileLink from "./ProfileLink.svelte";
|
import ProfileLink from "./ProfileLink.svelte";
|
||||||
import { memberNameRegex } from "$lib/api/regex";
|
import { memberNameRegex } from "$lib/api/regex";
|
||||||
import StatusLine from "$lib/components/StatusLine.svelte";
|
import StatusLine from "$lib/components/StatusLine.svelte";
|
||||||
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
import { addToast } from "$lib/toast";
|
||||||
|
import ProfileFlag from "./ProfileFlag.svelte";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
import Badges from "./badges/Badges.svelte";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
let bio: string | null;
|
let bio: string | null;
|
||||||
$: bio = renderMarkdown(data.bio);
|
$: bio = renderMarkdown(data.bio);
|
||||||
|
|
||||||
let memberPage: number = 0;
|
let memberPage = 0;
|
||||||
let memberSlice: PartialMember[] = [];
|
let memberSlice: PartialMember[] = [];
|
||||||
$: memberSlice = data.members.slice(memberPage * 20, (memberPage + 1) * 20);
|
$: memberSlice = data.members.slice(memberPage * 20, (memberPage + 1) * 20);
|
||||||
const totalPages = Math.ceil(data.members.length / 20);
|
let totalPages: number;
|
||||||
|
$: totalPages = Math.ceil(data.members.length / 20);
|
||||||
|
|
||||||
const prevPage = () => {
|
const prevPage = () => {
|
||||||
if (memberPage === 0) {
|
if (memberPage === 0) {
|
||||||
|
@ -84,8 +95,17 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const favNames = data.names.filter((entry) => entry.status === WordStatus.Favourite);
|
let mergedPreferences: CustomPreferences;
|
||||||
const favPronouns = data.pronouns.filter((entry) => entry.status === WordStatus.Favourite);
|
$: mergedPreferences = Object.assign({}, defaultPreferences, data.custom_preferences);
|
||||||
|
|
||||||
|
let favNames: FieldEntry[];
|
||||||
|
$: favNames = data.names.filter(
|
||||||
|
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
|
||||||
|
);
|
||||||
|
let favPronouns: Pronoun[];
|
||||||
|
$: favPronouns = data.pronouns.filter(
|
||||||
|
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
|
||||||
|
);
|
||||||
|
|
||||||
let profileEmpty = false;
|
let profileEmpty = false;
|
||||||
$: profileEmpty =
|
$: profileEmpty =
|
||||||
|
@ -93,6 +113,31 @@
|
||||||
data.pronouns.length === 0 &&
|
data.pronouns.length === 0 &&
|
||||||
data.fields.length === 0 &&
|
data.fields.length === 0 &&
|
||||||
(!data.bio || data.bio.length === 0);
|
(!data.bio || data.bio.length === 0);
|
||||||
|
|
||||||
|
const copyURL = async () => {
|
||||||
|
const url = `${PUBLIC_BASE_URL}/@${data.name}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
addToast({ body: "Copied the link to your clipboard!", duration: 2000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShortURL = async () => {
|
||||||
|
const url = `${PUBLIC_SHORT_BASE}/${data.sid}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
addToast({ body: "Copied the short link to your clipboard!", duration: 2000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if ($userStore && $userStore.id === data.id) {
|
||||||
|
console.log("User is current user, fetching members");
|
||||||
|
try {
|
||||||
|
const members = await apiFetchClient<PartialMember[]>("/users/@me/members");
|
||||||
|
data.members = members;
|
||||||
|
} catch (e) {
|
||||||
|
// If it fails, we fail silently but log to console anyway
|
||||||
|
console.error("Fetching members:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
@ -106,15 +151,22 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 text-center">
|
<div class="col-md-4 text-center">
|
||||||
<FallbackImage width={200} urls={userAvatars(data)} alt="Avatar for @{data.name}" />
|
<FallbackImage width={200} urls={userAvatars(data)} alt="Avatar for @{data.name}" />
|
||||||
|
{#if data.flags && data.bio}
|
||||||
|
<div class="d-flex flex-wrap m-4">
|
||||||
|
{#each data.flags as flag}
|
||||||
|
<ProfileFlag {flag} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
{#if data.display_name}
|
{#if data.display_name}
|
||||||
<div>
|
<div>
|
||||||
<h2>{data.display_name}</h2>
|
<h2>{data.display_name} <Badges userBadges={data.badges} /></h2>
|
||||||
<p class="fs-5 text-body-secondary">@{data.name}</p>
|
<p class="fs-5 text-body-secondary">@{data.name}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<h2>@{data.name}</h2>
|
<h2>@{data.name} <Badges userBadges={data.badges} /></h2>
|
||||||
{/if}
|
{/if}
|
||||||
{#if profileEmpty && $userStore?.id === data.id}
|
{#if profileEmpty && $userStore?.id === data.id}
|
||||||
<hr />
|
<hr />
|
||||||
|
@ -140,13 +192,24 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if data.flags && !data.bio}
|
||||||
|
<div class="d-flex flex-wrap m-4">
|
||||||
|
{#each data.flags as flag}
|
||||||
|
<ProfileFlag {flag} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
||||||
{#if data.names.length > 0}
|
{#if data.names.length > 0}
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<h3>Names</h3>
|
<h3>Names</h3>
|
||||||
<ul class="list-unstyled fs-5">
|
<ul class="list-unstyled fs-5">
|
||||||
{#each data.names as name}
|
{#each data.names as name}
|
||||||
<li><StatusLine status={name.status}>{name.value}</StatusLine></li>
|
<li>
|
||||||
|
<StatusLine preferences={data.custom_preferences} status={name.status}
|
||||||
|
>{name.value}</StatusLine
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -156,22 +219,43 @@
|
||||||
<h3>Pronouns</h3>
|
<h3>Pronouns</h3>
|
||||||
<ul class="list-unstyled fs-5">
|
<ul class="list-unstyled fs-5">
|
||||||
{#each data.pronouns as pronouns}
|
{#each data.pronouns as pronouns}
|
||||||
<li><StatusLine status={pronouns.status}><PronounLink {pronouns} /></StatusLine></li>
|
<li>
|
||||||
|
<StatusLine preferences={data.custom_preferences} status={pronouns.status}
|
||||||
|
><PronounLink {pronouns} /></StatusLine
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each data.fields as field}
|
{#each data.fields as field}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<FieldCard {field} />
|
<FieldCard preferences={data.custom_preferences} {field} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if $userStore && $userStore.id !== data.id}
|
<div class="row">
|
||||||
<div class="row">
|
<div class="col-md-6">
|
||||||
<ReportButton subject="user" reportUrl="/users/{data.id}/reports" />
|
<InputGroup>
|
||||||
|
<Button color="secondary" outline on:click={copyURL}>
|
||||||
|
<Icon name="clipboard" /> Copy link
|
||||||
|
</Button>
|
||||||
|
{#if PUBLIC_SHORT_BASE}
|
||||||
|
<IconButton
|
||||||
|
outline
|
||||||
|
icon="link-45deg"
|
||||||
|
tooltip="Copy short link"
|
||||||
|
color="secondary"
|
||||||
|
click={copyShortURL}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if $userStore && $userStore.id !== data.id}
|
||||||
|
<ReportButton subject="user" reportUrl="/users/{data.id}/reports" />
|
||||||
|
{/if}
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="col-md-6" />
|
||||||
|
</div>
|
||||||
{#if data.members.length > 0 || ($userStore && $userStore.id === data.id)}
|
{#if data.members.length > 0 || ($userStore && $userStore.id === data.id)}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
@ -200,11 +284,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if data.members.length > 0}
|
{#if data.members.length > 0}
|
||||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-4 text-center">
|
<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 text-center">
|
||||||
{#each memberSlice as member}
|
{#each memberSlice as member}
|
||||||
<PartialMemberCard user={data} {member} />
|
<PartialMemberCard user={data} {member} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="text-center">
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button on:click={prevPage} disabled={memberPage === 0}
|
||||||
|
><Icon name="chevron-left" /> Previous page</Button
|
||||||
|
>
|
||||||
|
<Button disabled>Page {memberPage + 1}/{totalPages}</Button>
|
||||||
|
<Button on:click={nextPage} disabled={memberPage === totalPages - 1}
|
||||||
|
>Next page <Icon name="chevron-right" /></Button
|
||||||
|
>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
|
@ -225,7 +322,7 @@
|
||||||
<p class="text-muted my-2">
|
<p class="text-muted my-2">
|
||||||
<Icon name="info-circle-fill" aria-label="Info" /> Your members must have distinct names. Member
|
<Icon name="info-circle-fill" aria-label="Info" /> Your members must have distinct names. Member
|
||||||
names must be 100 characters long at most, and cannot contain the following characters: @ ?
|
names must be 100 characters long at most, and cannot contain the following characters: @ ?
|
||||||
! # / \ [ ] " ' $ % & ( ) + < = > ^ | ~ ` and ,
|
! # / \ [ ] " ' $ % & ( ) { } + < = > ^ | ~ ` and ,
|
||||||
</p>
|
</p>
|
||||||
{#if newMemberError}
|
{#if newMemberError}
|
||||||
<ErrorAlert error={newMemberError} />
|
<ErrorAlert error={newMemberError} />
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { flagURL, type PrideFlag } from "$lib/api/entities";
|
||||||
|
import { Tooltip } from "sveltestrap";
|
||||||
|
|
||||||
|
export let flag: PrideFlag;
|
||||||
|
|
||||||
|
let elem: HTMLElement;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="mx-2 my-1">
|
||||||
|
<Tooltip target={elem} aria-hidden placement="top">{flag.description ?? flag.name}</Tooltip>
|
||||||
|
<img bind:this={elem} class="flag" src={flagURL(flag)} alt={flag.description ?? flag.name} />
|
||||||
|
{flag.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flag {
|
||||||
|
height: 1.5rem;
|
||||||
|
max-width: 200px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -28,11 +28,9 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<Button color="danger" outline on:click={toggle}
|
||||||
<Button color="danger" outline on:click={toggle}
|
><Icon name="exclamation-triangle-fill" /> Report {subject}</Button
|
||||||
><Icon name="exclamation-triangle-fill" /> Report {subject}</Button
|
>
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal header="Report {subject}" {isOpen} {toggle}>
|
<Modal header="Report {subject}" {isOpen} {toggle}>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
|
@ -47,6 +45,9 @@
|
||||||
<a class="text-reset" href="/page/terms" target="_blank" rel="noopener noreferrer"
|
<a class="text-reset" href="/page/terms" target="_blank" rel="noopener noreferrer"
|
||||||
>terms of service</a
|
>terms of service</a
|
||||||
>.
|
>.
|
||||||
|
<br />
|
||||||
|
Note that we <strong>cannot</strong> take action on any reports that cannot be proven with just
|
||||||
|
the reported profile and any external pages linked on it.
|
||||||
</p>
|
</p>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|
|
@ -2,25 +2,43 @@
|
||||||
import FieldCard from "$lib/components/FieldCard.svelte";
|
import FieldCard from "$lib/components/FieldCard.svelte";
|
||||||
|
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import StatusIcon from "$lib/components/StatusIcon.svelte";
|
|
||||||
import PronounLink from "$lib/components/PronounLink.svelte";
|
import PronounLink from "$lib/components/PronounLink.svelte";
|
||||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||||
import { Alert, Button, Icon } from "sveltestrap";
|
import { Alert, Button, Icon, InputGroup } from "sveltestrap";
|
||||||
import { memberAvatars, pronounDisplay, WordStatus } from "$lib/api/entities";
|
import {
|
||||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
memberAvatars,
|
||||||
|
pronounDisplay,
|
||||||
|
type CustomPreferences,
|
||||||
|
type FieldEntry,
|
||||||
|
type Pronoun,
|
||||||
|
} from "$lib/api/entities";
|
||||||
|
import { PUBLIC_BASE_URL, PUBLIC_SHORT_BASE } from "$env/static/public";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
import { renderMarkdown } from "$lib/utils";
|
import { renderMarkdown } from "$lib/utils";
|
||||||
import ReportButton from "../ReportButton.svelte";
|
import ReportButton from "../ReportButton.svelte";
|
||||||
import ProfileLink from "../ProfileLink.svelte";
|
import ProfileLink from "../ProfileLink.svelte";
|
||||||
import StatusLine from "$lib/components/StatusLine.svelte";
|
import StatusLine from "$lib/components/StatusLine.svelte";
|
||||||
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
|
import { addToast } from "$lib/toast";
|
||||||
|
import ProfileFlag from "../ProfileFlag.svelte";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
let bio: string | null;
|
let bio: string | null;
|
||||||
$: bio = renderMarkdown(data.bio);
|
$: bio = renderMarkdown(data.bio);
|
||||||
|
|
||||||
const favNames = data.names.filter((entry) => entry.status === WordStatus.Favourite);
|
let mergedPreferences: CustomPreferences;
|
||||||
const favPronouns = data.pronouns.filter((entry) => entry.status === WordStatus.Favourite);
|
$: mergedPreferences = Object.assign({}, defaultPreferences, data.user.custom_preferences);
|
||||||
|
|
||||||
|
let favNames: FieldEntry[];
|
||||||
|
$: favNames = data.names.filter(
|
||||||
|
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
|
||||||
|
);
|
||||||
|
let favPronouns: Pronoun[];
|
||||||
|
$: favPronouns = data.pronouns.filter(
|
||||||
|
(entry) => (mergedPreferences[entry.status] || { favourite: false }).favourite,
|
||||||
|
);
|
||||||
|
|
||||||
let profileEmpty = false;
|
let profileEmpty = false;
|
||||||
$: profileEmpty =
|
$: profileEmpty =
|
||||||
|
@ -28,6 +46,18 @@
|
||||||
data.pronouns.length === 0 &&
|
data.pronouns.length === 0 &&
|
||||||
data.fields.length === 0 &&
|
data.fields.length === 0 &&
|
||||||
(!data.bio || data.bio.length === 0);
|
(!data.bio || data.bio.length === 0);
|
||||||
|
|
||||||
|
const copyURL = async () => {
|
||||||
|
const url = `${PUBLIC_BASE_URL}/@${data.user.name}/${data.name}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
addToast({ body: "Copied the link to your clipboard!", duration: 2000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShortURL = async () => {
|
||||||
|
const url = `${PUBLIC_SHORT_BASE}/${data.sid}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
addToast({ body: "Copied the short link to your clipboard!", duration: 2000 });
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
@ -47,6 +77,13 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 text-center">
|
<div class="col-md-4 text-center">
|
||||||
<FallbackImage width={200} urls={memberAvatars(data)} alt="Avatar for @{data.name}" />
|
<FallbackImage width={200} urls={memberAvatars(data)} alt="Avatar for @{data.name}" />
|
||||||
|
{#if data.flags && data.bio}
|
||||||
|
<div class="d-flex flex-wrap m-4">
|
||||||
|
{#each data.flags as flag}
|
||||||
|
<ProfileFlag {flag} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<h2>{data.display_name ?? data.name}</h2>
|
<h2>{data.display_name ?? data.name}</h2>
|
||||||
|
@ -75,13 +112,24 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if data.flags && !data.bio}
|
||||||
|
<div class="d-flex flex-wrap m-4">
|
||||||
|
{#each data.flags as flag}
|
||||||
|
<ProfileFlag {flag} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
|
||||||
{#if data.names.length > 0}
|
{#if data.names.length > 0}
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<h3>Names</h3>
|
<h3>Names</h3>
|
||||||
<ul class="list-unstyled fs-5">
|
<ul class="list-unstyled fs-5">
|
||||||
{#each data.names as name}
|
{#each data.names as name}
|
||||||
<li><StatusLine status={name.status}>{name.value}</StatusLine></li>
|
<li>
|
||||||
|
<StatusLine preferences={data.user.custom_preferences} status={name.status}
|
||||||
|
>{name.value}</StatusLine
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -91,22 +139,43 @@
|
||||||
<h3>Pronouns</h3>
|
<h3>Pronouns</h3>
|
||||||
<ul class="list-unstyled fs-5">
|
<ul class="list-unstyled fs-5">
|
||||||
{#each data.pronouns as pronouns}
|
{#each data.pronouns as pronouns}
|
||||||
<li><StatusLine status={pronouns.status}><PronounLink {pronouns} /></StatusLine></li>
|
<li>
|
||||||
|
<StatusLine preferences={data.user.custom_preferences} status={pronouns.status}
|
||||||
|
><PronounLink {pronouns} /></StatusLine
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each data.fields as field}
|
{#each data.fields as field}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<FieldCard {field} />
|
<FieldCard preferences={data.user.custom_preferences} {field} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if $userStore && $userStore.id !== data.user.id}
|
<div class="row">
|
||||||
<div class="row">
|
<div class="col-md-6">
|
||||||
<ReportButton subject="member" reportUrl="/members/{data.id}/reports" />
|
<InputGroup>
|
||||||
|
<Button color="secondary" outline on:click={copyURL}>
|
||||||
|
<Icon name="clipboard" /> Copy link
|
||||||
|
</Button>
|
||||||
|
{#if PUBLIC_SHORT_BASE}
|
||||||
|
<IconButton
|
||||||
|
outline
|
||||||
|
icon="link-45deg"
|
||||||
|
tooltip="Copy short link"
|
||||||
|
color="secondary"
|
||||||
|
click={copyShortURL}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if $userStore && $userStore.id !== data.user.id}
|
||||||
|
<ReportButton subject="member" reportUrl="/members/{data.id}/reports" />
|
||||||
|
{/if}
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="col-md-6" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip } from "sveltestrap";
|
||||||
|
|
||||||
|
let icon: HTMLElement;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Tooltip target={icon} placement="top">This user is an admin</Tooltip>
|
||||||
|
|
||||||
|
<div class="profile-badge" bind:this={icon}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="1 1 15.767 14.761"
|
||||||
|
><path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M3.829.497A2.823 2.823 0 0 0 1 3.326v10.11a2.822 2.822 0 0 0 2.829 2.828h10.11a2.822 2.822 0 0 0 2.829-2.828V3.326a2.823 2.823 0 0 0-2.83-2.83zm6.103 2.427a.33.33 0 0 1 .316.233l1.099 3.603 2.943 1.09a.33.33 0 0 1 0 .62l-2.942 1.088-1.099 3.712a.33.33 0 0 1-.635 0L8.515 9.558 5.573 8.47a.33.33 0 0 1 0-.62l2.943-1.09 1.1-3.601a.33.33 0 0 1 .316-.234zm-5.073.111a.33.33 0 0 1 .31.216l.29.782.782.29a.331.331 0 0 1 0 .62l-.783.29-.289.782a.33.33 0 0 1-.62 0l-.29-.782-.782-.29a.33.33 0 0 1 0-.62l.782-.29.29-.782a.33.33 0 0 1 .31-.216zM6.294 9.87c.149 0 .28.1.319.244l.297 1.091.765.283a.33.33 0 0 1 0 .62l-.76.283-.3 1.197a.331.331 0 0 1-.642 0l-.3-1.197-.762-.282a.33.33 0 0 1 0-.62l.766-.284.297-1.091a.33.33 0 0 1 .32-.244z"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.profile-badge {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Admin from "./Admin.svelte";
|
||||||
|
|
||||||
|
export let userBadges: number;
|
||||||
|
|
||||||
|
let isAdmin: boolean;
|
||||||
|
$: isAdmin = (userBadges & (1 << 0)) === 1 << 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="profile-badges">
|
||||||
|
{#if isAdmin}
|
||||||
|
<Admin />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.profile-badges {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -17,7 +17,6 @@
|
||||||
ModalFooter,
|
ModalFooter,
|
||||||
} from "sveltestrap";
|
} from "sveltestrap";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
import fediverse from "./fediverse.svg";
|
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
|
@ -60,12 +59,16 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 mb-1">
|
<div class="col-md-4 mb-1">
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<ListGroupItem tag="button" on:click={toggleModal}>
|
<ListGroupItem tag="button" on:click={toggleModal}>Log in with the Fediverse</ListGroupItem>
|
||||||
<img height="16px" src={fediverse} alt="Fediverse logo" aria-hidden /> Log in with the Fediverse
|
{#if data.discord}
|
||||||
</ListGroupItem>
|
<ListGroupItem tag="a" href={data.discord}>Log in with Discord</ListGroupItem>
|
||||||
<ListGroupItem tag="a" href={data.discord}>
|
{/if}
|
||||||
<Icon name="discord" /> Log in with Discord
|
{#if data.tumblr}
|
||||||
</ListGroupItem>
|
<ListGroupItem tag="a" href={data.tumblr}>Log in with Tumblr</ListGroupItem>
|
||||||
|
{/if}
|
||||||
|
{#if data.google}
|
||||||
|
<ListGroupItem tag="a" href={data.google}>Log in with Google</ListGroupItem>
|
||||||
|
{/if}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Modal header="Pick an instance" isOpen={modalOpen} toggle={toggleModal}>
|
<Modal header="Pick an instance" isOpen={modalOpen} toggle={toggleModal}>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
import { fastFetch } from "$lib/api/fetch";
|
import { fastFetch } from "$lib/api/fetch";
|
||||||
import { usernameRegex } from "$lib/api/regex";
|
import { usernameRegex } from "$lib/api/regex";
|
||||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||||
|
import { PUBLIC_HCAPTCHA_SITEKEY } from "$env/static/public";
|
||||||
|
import HCaptcha from "svelte-hcaptcha";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
import { addToast } from "$lib/toast";
|
import { addToast } from "$lib/toast";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
@ -23,6 +25,7 @@
|
||||||
export let remoteName: string | undefined;
|
export let remoteName: string | undefined;
|
||||||
export let error: APIError | undefined;
|
export let error: APIError | undefined;
|
||||||
export let requireInvite: boolean | undefined;
|
export let requireInvite: boolean | undefined;
|
||||||
|
export let requireCaptcha: boolean | undefined;
|
||||||
export let isDeleted: boolean | undefined;
|
export let isDeleted: boolean | undefined;
|
||||||
export let ticket: string | undefined;
|
export let ticket: string | undefined;
|
||||||
export let token: string | undefined;
|
export let token: string | undefined;
|
||||||
|
@ -54,7 +57,31 @@
|
||||||
let toggleForceDeleteModal = () => (forceDeleteModalOpen = !forceDeleteModalOpen);
|
let toggleForceDeleteModal = () => (forceDeleteModalOpen = !forceDeleteModalOpen);
|
||||||
|
|
||||||
export let linkAccount: () => Promise<void>;
|
export let linkAccount: () => Promise<void>;
|
||||||
export let signupForm: (username: string, inviteCode: string) => Promise<void>;
|
export let signupForm: (
|
||||||
|
username: string,
|
||||||
|
inviteCode: string,
|
||||||
|
captchaToken: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
let captchaToken = "";
|
||||||
|
let captcha: any;
|
||||||
|
|
||||||
|
const captchaSuccess = (token: any) => {
|
||||||
|
captchaToken = token.detail.token;
|
||||||
|
};
|
||||||
|
|
||||||
|
let canSubmit = false;
|
||||||
|
$: canSubmit = usernameValid && (!!captchaToken || !requireCaptcha);
|
||||||
|
|
||||||
|
const captchaError = () => {
|
||||||
|
addToast({
|
||||||
|
header: "Captcha failed",
|
||||||
|
body: "There was an error verifying the captcha, please try again.",
|
||||||
|
});
|
||||||
|
captcha.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetCaptcha = (): void => captcha.reset();
|
||||||
|
|
||||||
const forceDeleteAccount = async () => {
|
const forceDeleteAccount = async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -116,7 +143,7 @@
|
||||||
<Button color="secondary" href="/settings/auth">Cancel</Button>
|
<Button color="secondary" href="/settings/auth">Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if ticket}
|
{:else if ticket}
|
||||||
<form on:submit|preventDefault={() => signupForm(username, inviteCode)}>
|
<form on:submit|preventDefault={() => signupForm(username, inviteCode, captchaToken)}>
|
||||||
<div>
|
<div>
|
||||||
<FormGroup floating label="{authType} username">
|
<FormGroup floating label="{authType} username">
|
||||||
<Input readonly value={remoteName} />
|
<Input readonly value={remoteName} />
|
||||||
|
@ -144,12 +171,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if requireCaptcha}
|
||||||
|
<div class="mt-2 mx-2 mb-1">
|
||||||
|
<HCaptcha
|
||||||
|
bind:this={captcha}
|
||||||
|
sitekey={PUBLIC_HCAPTCHA_SITEKEY}
|
||||||
|
on:success={captchaSuccess}
|
||||||
|
on:error={captchaError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="text-muted my-1">
|
<div class="text-muted my-1">
|
||||||
By signing up, you agree to the <a href="/page/terms">terms of service</a> and the
|
By signing up, you agree to the <a href="/page/terms">terms of service</a> and the
|
||||||
<a href="/page/privacy">privacy policy</a>.
|
<a href="/page/privacy">privacy policy</a>.
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<Button type="submit" color="primary" disabled={!usernameValid}>Sign up</Button>
|
<Button type="submit" color="primary" disabled={!canSubmit}>Sign up</Button>
|
||||||
{#if !usernameValid && username.length > 0}
|
{#if !usernameValid && username.length > 0}
|
||||||
<span class="text-danger-emphasis mb-2">That username is not valid.</span>
|
<span class="text-danger-emphasis mb-2">That username is not valid.</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -30,6 +30,7 @@ interface CallbackResponse {
|
||||||
discord?: string;
|
discord?: string;
|
||||||
ticket?: string;
|
ticket?: string;
|
||||||
require_invite: boolean;
|
require_invite: boolean;
|
||||||
|
require_captcha: boolean;
|
||||||
|
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { APIError, MeUser } from "$lib/api/entities";
|
import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities";
|
||||||
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
@ -10,7 +10,9 @@
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
const signupForm = async (username: string, invite: string) => {
|
let callbackPage: any;
|
||||||
|
|
||||||
|
const signupForm = async (username: string, invite: string, captchaToken: string) => {
|
||||||
try {
|
try {
|
||||||
const resp = await apiFetch<SignupResponse>("/auth/discord/signup", {
|
const resp = await apiFetch<SignupResponse>("/auth/discord/signup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -18,6 +20,7 @@
|
||||||
ticket: data.ticket,
|
ticket: data.ticket,
|
||||||
username: username,
|
username: username,
|
||||||
invite_code: invite,
|
invite_code: invite,
|
||||||
|
captcha_response: captchaToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -27,6 +30,10 @@
|
||||||
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
||||||
goto(`/@${resp.user.name}`);
|
goto(`/@${resp.user.name}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if ((e as APIError).code === ErrorCode.InvalidCaptcha) {
|
||||||
|
callbackPage.resetCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
data.error = e as APIError;
|
data.error = e as APIError;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -48,10 +55,12 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CallbackPage
|
<CallbackPage
|
||||||
|
bind:this={callbackPage}
|
||||||
authType="Discord"
|
authType="Discord"
|
||||||
remoteName={data.discord}
|
remoteName={data.discord}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
requireInvite={data.require_invite}
|
requireInvite={data.require_invite}
|
||||||
|
requireCaptcha={data.require_captcha}
|
||||||
isDeleted={data.is_deleted}
|
isDeleted={data.is_deleted}
|
||||||
ticket={data.ticket}
|
ticket={data.ticket}
|
||||||
token={data.token}
|
token={data.token}
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { APIError, MeUser } from "$lib/api/entities";
|
||||||
|
import { apiFetch } from "$lib/api/fetch";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||||
|
|
||||||
|
export const load = (async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const resp = await apiFetch<CallbackResponse>("/auth/google/callback", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
callback_domain: PUBLIC_BASE_URL,
|
||||||
|
code: url.searchParams.get("code"),
|
||||||
|
state: url.searchParams.get("state"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...resp,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e as APIError };
|
||||||
|
}
|
||||||
|
}) satisfies PageServerLoad;
|
||||||
|
|
||||||
|
interface CallbackResponse {
|
||||||
|
has_account: boolean;
|
||||||
|
token?: string;
|
||||||
|
user?: MeUser;
|
||||||
|
|
||||||
|
google?: string;
|
||||||
|
ticket?: string;
|
||||||
|
require_invite: boolean;
|
||||||
|
require_captcha: boolean;
|
||||||
|
|
||||||
|
is_deleted: boolean;
|
||||||
|
deleted_at?: string;
|
||||||
|
self_delete?: boolean;
|
||||||
|
delete_reason?: string;
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities";
|
||||||
|
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
||||||
|
import { userStore } from "$lib/store";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
import { addToast } from "$lib/toast";
|
||||||
|
import CallbackPage from "../CallbackPage.svelte";
|
||||||
|
import type { SignupResponse } from "$lib/api/responses";
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
let callbackPage: any;
|
||||||
|
|
||||||
|
const signupForm = async (username: string, invite: string, captchaToken: string) => {
|
||||||
|
try {
|
||||||
|
const resp = await apiFetch<SignupResponse>("/auth/google/signup", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
ticket: data.ticket,
|
||||||
|
username: username,
|
||||||
|
invite_code: invite,
|
||||||
|
captcha_response: captchaToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem("pronouns-token", resp.token);
|
||||||
|
localStorage.setItem("pronouns-user", JSON.stringify(resp.user));
|
||||||
|
userStore.set(resp.user);
|
||||||
|
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
||||||
|
goto(`/@${resp.user.name}`);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as APIError).code === ErrorCode.InvalidCaptcha) {
|
||||||
|
callbackPage.resetCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
|
data.error = e as APIError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkAccount = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiFetchClient<MeUser>("/auth/google/add-provider", "POST", {
|
||||||
|
ticket: data.ticket,
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem("pronouns-user", JSON.stringify(resp));
|
||||||
|
userStore.set(resp);
|
||||||
|
addToast({ header: "Linked account", body: "Successfully linked account!" });
|
||||||
|
await goto("/settings/auth");
|
||||||
|
} catch (e) {
|
||||||
|
data.error = e as APIError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CallbackPage
|
||||||
|
bind:this={callbackPage}
|
||||||
|
authType="Google"
|
||||||
|
remoteName={data.google}
|
||||||
|
error={data.error}
|
||||||
|
requireInvite={data.require_invite}
|
||||||
|
requireCaptcha={data.require_captcha}
|
||||||
|
isDeleted={data.is_deleted}
|
||||||
|
ticket={data.ticket}
|
||||||
|
token={data.token}
|
||||||
|
user={data.user}
|
||||||
|
deletedAt={data.deleted_at}
|
||||||
|
selfDelete={data.self_delete}
|
||||||
|
deleteReason={data.delete_reason}
|
||||||
|
{linkAccount}
|
||||||
|
{signupForm}
|
||||||
|
/>
|
|
@ -30,6 +30,7 @@ interface CallbackResponse {
|
||||||
fediverse?: string;
|
fediverse?: string;
|
||||||
ticket?: string;
|
ticket?: string;
|
||||||
require_invite: boolean;
|
require_invite: boolean;
|
||||||
|
require_captcha: boolean;
|
||||||
|
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue