feat: add short IDs + link shortener
This commit is contained in:
parent
7c94c088e0
commit
10dc59d3d4
|
@ -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
|
||||||
|
@ -73,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("*").
|
||||||
|
@ -104,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")
|
||||||
|
@ -232,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
|
||||||
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/icons"
|
"codeberg.org/u1f320/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"
|
||||||
|
@ -20,6 +21,7 @@ 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
|
||||||
|
@ -46,9 +48,10 @@ type User struct {
|
||||||
Google *string
|
Google *string
|
||||||
GoogleUsername *string
|
GoogleUsername *string
|
||||||
|
|
||||||
MaxInvites int
|
MaxInvites int
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
ListPrivate bool
|
ListPrivate bool
|
||||||
|
LastSIDReroll time.Time `db:"last_sid_reroll"`
|
||||||
|
|
||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
SelfDelete *bool
|
SelfDelete *bool
|
||||||
|
@ -161,7 +164,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sql, args, err := sq.Insert("users").Columns("id", "username").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")
|
||||||
}
|
}
|
||||||
|
@ -468,6 +471,25 @@ func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserBySID gets a user by their short ID.
|
||||||
|
func (db *DB) UserBySID(ctx context.Context, sid string) (u User, err error) {
|
||||||
|
sql, args, err := sq.Select("*").From("users").Where("sid = ?", sid).ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return u, errors.Wrap(err, "building sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = pgxscan.Get(ctx, db, &u, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Cause(err) == pgx.ErrNoRows {
|
||||||
|
return u, ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, errors.Wrap(err, "getting user from db")
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UsernameTaken checks if the given username is already taken.
|
// UsernameTaken checks if the given username is already taken.
|
||||||
func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken bool, err error) {
|
func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken bool, err error) {
|
||||||
if err := UsernameValid(username); err != nil {
|
if err := UsernameValid(username); err != nil {
|
||||||
|
@ -596,6 +618,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).
|
||||||
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
package prns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
dbpkg "codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
|
"codeberg.org/u1f320/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
|
||||||
|
}
|
|
@ -14,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"`
|
||||||
|
@ -33,6 +34,7 @@ type GetMemberResponse struct {
|
||||||
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse {
|
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse {
|
||||||
r := GetMemberResponse{
|
r := GetMemberResponse{
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
|
SID: m.SID,
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
DisplayName: m.DisplayName,
|
DisplayName: m.DisplayName,
|
||||||
Bio: m.Bio,
|
Bio: m.Bio,
|
||||||
|
|
|
@ -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"`
|
||||||
|
@ -27,6 +28,7 @@ func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse {
|
||||||
for i := range ms {
|
for i := range ms {
|
||||||
resps[i] = memberListResponse{
|
resps[i] = memberListResponse{
|
||||||
ID: ms[i].ID,
|
ID: ms[i].ID,
|
||||||
|
SID: ms[i].SID,
|
||||||
Name: ms[i].Name,
|
Name: ms[i].Name,
|
||||||
DisplayName: ms[i].DisplayName,
|
DisplayName: ms[i].DisplayName,
|
||||||
Bio: ms[i].Bio,
|
Bio: ms[i].Bio,
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
|
@ -319,3 +320,49 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
||||||
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
|
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
|
||||||
return nil
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getting user")
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.DB.Member(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == db.ErrMemberNotFound {
|
||||||
|
return server.APIError{Code: server.ErrMemberNotFound}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Wrap(err, "getting member")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.UserID != claims.UserID {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -29,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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
|
|
||||||
type GetUserResponse struct {
|
type GetUserResponse struct {
|
||||||
ID xid.ID `json:"id"`
|
ID xid.ID `json:"id"`
|
||||||
|
SID string `json:"sid"`
|
||||||
Username string `json:"name"`
|
Username string `json:"name"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Bio *string `json:"bio"`
|
Bio *string `json:"bio"`
|
||||||
|
@ -33,9 +34,10 @@ type GetMeResponse struct {
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
MaxInvites int `json:"max_invites"`
|
MaxInvites int `json:"max_invites"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
ListPrivate bool `json:"list_private"`
|
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"`
|
||||||
|
@ -53,6 +55,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"`
|
||||||
|
@ -65,6 +68,7 @@ type PartialMember struct {
|
||||||
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse {
|
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse {
|
||||||
resp := GetUserResponse{
|
resp := GetUserResponse{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
|
SID: u.SID,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName,
|
||||||
Bio: u.Bio,
|
Bio: u.Bio,
|
||||||
|
@ -82,6 +86,7 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags [
|
||||||
for i := range members {
|
for i := range members {
|
||||||
resp.Members[i] = PartialMember{
|
resp.Members[i] = PartialMember{
|
||||||
ID: members[i].ID,
|
ID: members[i].ID,
|
||||||
|
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,
|
||||||
|
@ -188,6 +193,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
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,
|
Tumblr: u.Tumblr,
|
||||||
|
|
|
@ -3,6 +3,7 @@ package user
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
"codeberg.org/u1f320/pronouns.cc/backend/common"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
"codeberg.org/u1f320/pronouns.cc/backend/db"
|
||||||
|
@ -313,6 +314,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
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,
|
Tumblr: u.Tumblr,
|
||||||
|
@ -362,3 +364,31 @@ func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPrefe
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
|
@ -34,6 +34,8 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
r.Post("/@me/flags", server.WrapHandler(s.postUserFlag))
|
r.Post("/@me/flags", server.WrapHandler(s.postUserFlag))
|
||||||
r.Patch("/@me/flags/{flagID}", server.WrapHandler(s.patchUserFlag))
|
r.Patch("/@me/flags/{flagID}", server.WrapHandler(s.patchUserFlag))
|
||||||
r.Delete("/@me/flags/{flagID}", server.WrapHandler(s.deleteUserFlag))
|
r.Delete("/@me/flags/{flagID}", server.WrapHandler(s.deleteUserFlag))
|
||||||
|
|
||||||
|
r.Get("/@me/reroll", server.WrapHandler(s.rerollUserSID))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -100,9 +100,10 @@ const (
|
||||||
ErrInvalidCaptcha = 1017 // invalid or missing captcha response
|
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
|
ErrFlagLimitReached = 2003
|
||||||
|
ErrRerollingTooQuickly = 2004
|
||||||
|
|
||||||
// Member-related error codes
|
// Member-related error codes
|
||||||
ErrMemberNotFound = 3001
|
ErrMemberNotFound = 3001
|
||||||
|
@ -145,9 +146,10 @@ var errCodeMessages = map[int]string{
|
||||||
ErrLastProvider: "This is your account's only authentication provider",
|
ErrLastProvider: "This is your account's only authentication provider",
|
||||||
ErrInvalidCaptcha: "Invalid or missing captcha response",
|
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",
|
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",
|
||||||
|
@ -187,9 +189,10 @@ var errCodeStatuses = map[int]int{
|
||||||
ErrLastProvider: http.StatusBadRequest,
|
ErrLastProvider: http.StatusBadRequest,
|
||||||
ErrInvalidCaptcha: http.StatusBadRequest,
|
ErrInvalidCaptcha: http.StatusBadRequest,
|
||||||
|
|
||||||
ErrUserNotFound: http.StatusNotFound,
|
ErrUserNotFound: http.StatusNotFound,
|
||||||
ErrMemberListPrivate: http.StatusForbidden,
|
ErrMemberListPrivate: http.StatusForbidden,
|
||||||
ErrFlagLimitReached: http.StatusBadRequest,
|
ErrFlagLimitReached: http.StatusBadRequest,
|
||||||
|
ErrRerollingTooQuickly: http.StatusForbidden,
|
||||||
|
|
||||||
ErrMemberNotFound: http.StatusNotFound,
|
ErrMemberNotFound: http.StatusNotFound,
|
||||||
ErrMemberLimitReached: http.StatusBadRequest,
|
ErrMemberLimitReached: http.StatusBadRequest,
|
||||||
|
|
|
@ -6,6 +6,7 @@ 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;
|
||||||
|
@ -53,6 +54,7 @@ export interface MeUser extends User {
|
||||||
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 {
|
||||||
|
@ -73,6 +75,7 @@ export interface Pronoun {
|
||||||
|
|
||||||
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;
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
type Pronoun,
|
type Pronoun,
|
||||||
} from "$lib/api/entities";
|
} from "$lib/api/entities";
|
||||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||||
|
import { env } from "$env/dynamic/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";
|
||||||
|
@ -41,6 +42,7 @@
|
||||||
import defaultPreferences from "$lib/api/default_preferences";
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
import { addToast } from "$lib/toast";
|
import { addToast } from "$lib/toast";
|
||||||
import ProfileFlag from "./ProfileFlag.svelte";
|
import ProfileFlag from "./ProfileFlag.svelte";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
|
@ -117,6 +119,12 @@
|
||||||
addToast({ body: "Copied the link to your clipboard!", duration: 2000 });
|
addToast({ body: "Copied the link to your clipboard!", duration: 2000 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyShortURL = async () => {
|
||||||
|
const url = `${env.PUBLIC_SHORT_BASE}/${data.sid}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
addToast({ body: "Copied the short link to your clipboard!", duration: 2000 });
|
||||||
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if ($userStore && $userStore.id === data.id) {
|
if ($userStore && $userStore.id === data.id) {
|
||||||
console.log("User is current user, fetching members");
|
console.log("User is current user, fetching members");
|
||||||
|
@ -231,6 +239,15 @@
|
||||||
<Button color="secondary" outline on:click={copyURL}>
|
<Button color="secondary" outline on:click={copyURL}>
|
||||||
<Icon name="clipboard" /> Copy link
|
<Icon name="clipboard" /> Copy link
|
||||||
</Button>
|
</Button>
|
||||||
|
{#if env.PUBLIC_SHORT_BASE}
|
||||||
|
<IconButton
|
||||||
|
outline
|
||||||
|
icon="link-45deg"
|
||||||
|
tooltip="Copy short link"
|
||||||
|
color="secondary"
|
||||||
|
click={copyShortURL}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if $userStore && $userStore.id !== data.id}
|
{#if $userStore && $userStore.id !== data.id}
|
||||||
<ReportButton subject="user" reportUrl="/users/{data.id}/reports" />
|
<ReportButton subject="user" reportUrl="/users/{data.id}/reports" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
type Pronoun,
|
type Pronoun,
|
||||||
} from "$lib/api/entities";
|
} from "$lib/api/entities";
|
||||||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||||
|
import { env } from "$env/dynamic/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";
|
||||||
|
@ -21,6 +22,7 @@
|
||||||
import defaultPreferences from "$lib/api/default_preferences";
|
import defaultPreferences from "$lib/api/default_preferences";
|
||||||
import { addToast } from "$lib/toast";
|
import { addToast } from "$lib/toast";
|
||||||
import ProfileFlag from "../ProfileFlag.svelte";
|
import ProfileFlag from "../ProfileFlag.svelte";
|
||||||
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
|
@ -51,6 +53,12 @@
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
addToast({ body: "Copied the link to your clipboard!", duration: 2000 });
|
addToast({ body: "Copied the link to your clipboard!", duration: 2000 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyShortURL = async () => {
|
||||||
|
const url = `${env.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">
|
||||||
|
@ -153,6 +161,15 @@
|
||||||
<Button color="secondary" outline on:click={copyURL}>
|
<Button color="secondary" outline on:click={copyURL}>
|
||||||
<Icon name="clipboard" /> Copy link
|
<Icon name="clipboard" /> Copy link
|
||||||
</Button>
|
</Button>
|
||||||
|
{#if env.PUBLIC_SHORT_BASE}
|
||||||
|
<IconButton
|
||||||
|
outline
|
||||||
|
icon="link-45deg"
|
||||||
|
tooltip="Copy short link"
|
||||||
|
color="secondary"
|
||||||
|
click={copyShortURL}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if $userStore && $userStore.id !== data.user.id}
|
{#if $userStore && $userStore.id !== data.user.id}
|
||||||
<ReportButton subject="member" reportUrl="/members/{data.id}/reports" />
|
<ReportButton subject="member" reportUrl="/members/{data.id}/reports" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -28,8 +28,10 @@
|
||||||
CardHeader,
|
CardHeader,
|
||||||
Alert,
|
Alert,
|
||||||
} from "sveltestrap";
|
} from "sveltestrap";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
import { encode } from "base64-arraybuffer";
|
import { encode } from "base64-arraybuffer";
|
||||||
import prettyBytes from "pretty-bytes";
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import { env } from "$env/dynamic/public";
|
||||||
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
|
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
|
||||||
import IconButton from "$lib/components/IconButton.svelte";
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
import EditableField from "../../EditableField.svelte";
|
import EditableField from "../../EditableField.svelte";
|
||||||
|
@ -373,6 +375,28 @@
|
||||||
let deleteName = "";
|
let deleteName = "";
|
||||||
let deleteError: APIError | null = null;
|
let deleteError: APIError | null = null;
|
||||||
|
|
||||||
|
const now = DateTime.now().toLocal();
|
||||||
|
let canRerollSid: boolean;
|
||||||
|
$: canRerollSid =
|
||||||
|
now.diff(DateTime.fromISO(data.user.last_sid_reroll).toLocal(), "hours").hours >= 1;
|
||||||
|
|
||||||
|
const rerollSid = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiFetchClient<Member>(`/members/${data.member.id}/reroll`);
|
||||||
|
addToast({ header: "Success", body: "Rerolled short ID!" });
|
||||||
|
error = null;
|
||||||
|
data.member.sid = resp.sid;
|
||||||
|
} catch (e) {
|
||||||
|
error = e as APIError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShortURL = async () => {
|
||||||
|
const url = `${env.PUBLIC_SHORT_BASE}/${data.member.sid}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
addToast({ body: "Copied the short link to your clipboard!", duration: 2000 });
|
||||||
|
};
|
||||||
|
|
||||||
interface SnapshotData {
|
interface SnapshotData {
|
||||||
bio: string;
|
bio: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -407,19 +431,19 @@
|
||||||
newLink,
|
newLink,
|
||||||
}),
|
}),
|
||||||
restore: (value) => {
|
restore: (value) => {
|
||||||
bio = value.bio
|
bio = value.bio;
|
||||||
name = value.name
|
name = value.name;
|
||||||
display_name = value.display_name
|
display_name = value.display_name;
|
||||||
links = value.links
|
links = value.links;
|
||||||
names = value.names
|
names = value.names;
|
||||||
pronouns = value.pronouns
|
pronouns = value.pronouns;
|
||||||
fields = value.fields
|
fields = value.fields;
|
||||||
flags = value.flags
|
flags = value.flags;
|
||||||
unlisted = value.unlisted
|
unlisted = value.unlisted;
|
||||||
avatar = value.avatar
|
avatar = value.avatar;
|
||||||
newName = value.newName
|
newName = value.newName;
|
||||||
newPronouns = value.newPronouns
|
newPronouns = value.newPronouns;
|
||||||
newLink = value.newLink
|
newLink = value.newLink;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -755,6 +779,30 @@
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{#if env.PUBLIC_SHORT_BASE}
|
||||||
|
<div class="col-md">
|
||||||
|
<p>
|
||||||
|
Current short ID: <code>{data.member.sid}</code>
|
||||||
|
<ButtonGroup class="mb-1">
|
||||||
|
<Button color="secondary" disabled={!canRerollSid} on:click={() => rerollSid()}
|
||||||
|
>Reroll short ID</Button
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="link-45deg"
|
||||||
|
tooltip="Copy short link"
|
||||||
|
color="secondary"
|
||||||
|
click={copyShortURL}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
<br />
|
||||||
|
<span class="text-muted">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
This ID is used in <code>prns.cc</code> links. You can reroll one short ID every hour (shared
|
||||||
|
between your main profile and all members) by pressing the button above.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
|
|
@ -28,7 +28,9 @@
|
||||||
TabPane,
|
TabPane,
|
||||||
} from "sveltestrap";
|
} from "sveltestrap";
|
||||||
import { encode } from "base64-arraybuffer";
|
import { encode } from "base64-arraybuffer";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
import { apiFetchClient } from "$lib/api/fetch";
|
import { apiFetchClient } from "$lib/api/fetch";
|
||||||
|
import { env } from "$env/dynamic/public";
|
||||||
import IconButton from "$lib/components/IconButton.svelte";
|
import IconButton from "$lib/components/IconButton.svelte";
|
||||||
import EditableField from "../EditableField.svelte";
|
import EditableField from "../EditableField.svelte";
|
||||||
import EditableName from "../EditableName.svelte";
|
import EditableName from "../EditableName.svelte";
|
||||||
|
@ -379,6 +381,28 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const now = DateTime.now().toLocal();
|
||||||
|
let canRerollSid: boolean;
|
||||||
|
$: canRerollSid =
|
||||||
|
now.diff(DateTime.fromISO(data.user.last_sid_reroll).toLocal(), "hours").hours >= 1;
|
||||||
|
|
||||||
|
const rerollSid = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiFetchClient<MeUser>("/users/@me/reroll");
|
||||||
|
addToast({ header: "Success", body: "Rerolled short ID!" });
|
||||||
|
error = null;
|
||||||
|
data.user.sid = resp.sid;
|
||||||
|
} catch (e) {
|
||||||
|
error = e as APIError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShortURL = async () => {
|
||||||
|
const url = `${env.PUBLIC_SHORT_BASE}/${data.user.sid}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
addToast({ body: "Copied the short link to your clipboard!", duration: 2000 });
|
||||||
|
};
|
||||||
|
|
||||||
interface SnapshotData {
|
interface SnapshotData {
|
||||||
bio: string;
|
bio: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
@ -721,6 +745,29 @@
|
||||||
will be used.
|
will be used.
|
||||||
</p>
|
</p>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
{#if env.PUBLIC_SHORT_BASE}
|
||||||
|
<hr />
|
||||||
|
<p>
|
||||||
|
Current short ID: <code>{data.user.sid}</code>
|
||||||
|
<ButtonGroup class="mb-1">
|
||||||
|
<Button color="secondary" disabled={!canRerollSid} on:click={() => rerollSid()}
|
||||||
|
>Reroll short ID</Button
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="link-45deg"
|
||||||
|
tooltip="Copy short link"
|
||||||
|
color="secondary"
|
||||||
|
click={copyShortURL}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
<br />
|
||||||
|
<span class="text-muted">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
This ID is used in <code>prns.cc</code> links. You can reroll one short ID every hour (shared
|
||||||
|
between your main profile and all members) by pressing the button above.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
<div class="col-md">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|
2
main.go
2
main.go
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend"
|
"codeberg.org/u1f320/pronouns.cc/backend"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/exporter"
|
"codeberg.org/u1f320/pronouns.cc/backend/exporter"
|
||||||
|
"codeberg.org/u1f320/pronouns.cc/backend/prns"
|
||||||
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
"codeberg.org/u1f320/pronouns.cc/scripts/cleandb"
|
"codeberg.org/u1f320/pronouns.cc/scripts/cleandb"
|
||||||
"codeberg.org/u1f320/pronouns.cc/scripts/genid"
|
"codeberg.org/u1f320/pronouns.cc/scripts/genid"
|
||||||
|
@ -22,6 +23,7 @@ var app = &cli.App{
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
backend.Command,
|
backend.Command,
|
||||||
exporter.Command,
|
exporter.Command,
|
||||||
|
prns.Command,
|
||||||
{
|
{
|
||||||
Name: "database",
|
Name: "database",
|
||||||
Aliases: []string{"db"},
|
Aliases: []string{"db"},
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
-- +migrate Up
|
||||||
|
|
||||||
|
-- 2023-06-03: Add short IDs for the prns.cc domain.
|
||||||
|
|
||||||
|
-- add the columns
|
||||||
|
alter table users add column sid text unique check(length(sid)=5);
|
||||||
|
alter table members add column sid text unique check(length(sid)=6);
|
||||||
|
alter table users add column last_sid_reroll timestamptz not null default now() - '1 hour'::interval;
|
||||||
|
|
||||||
|
-- create the generate short ID functions
|
||||||
|
-- these are copied from PluralKit's HID functions:
|
||||||
|
-- https://github.com/PluralKit/PluralKit/blob/e4a2930bf353af9406e48934569677d7de6dd90d/PluralKit.Core/Database/Functions/functions.sql#L118-L152
|
||||||
|
|
||||||
|
-- +migrate StatementBegin
|
||||||
|
create function generate_sid(len int) returns text as $$
|
||||||
|
select string_agg(substr('abcdefghijklmnopqrstuvwxyz', ceil(random() * 26)::integer, 1), '') from generate_series(1, len)
|
||||||
|
$$ language sql volatile;
|
||||||
|
-- +migrate StatementEnd
|
||||||
|
|
||||||
|
-- +migrate StatementBegin
|
||||||
|
create function find_free_user_sid() returns text as $$
|
||||||
|
declare new_sid text;
|
||||||
|
begin
|
||||||
|
loop
|
||||||
|
new_sid := generate_sid(5);
|
||||||
|
if not exists (select 1 from users where sid = new_sid) then return new_sid; end if;
|
||||||
|
end loop;
|
||||||
|
end
|
||||||
|
$$ language plpgsql volatile;
|
||||||
|
-- +migrate StatementEnd
|
||||||
|
|
||||||
|
-- +migrate StatementBegin
|
||||||
|
create function find_free_member_sid() returns text as $$
|
||||||
|
declare new_sid text;
|
||||||
|
begin
|
||||||
|
loop
|
||||||
|
new_sid := generate_sid(6);
|
||||||
|
if not exists (select 1 from members where sid = new_sid) then return new_sid; end if;
|
||||||
|
end loop;
|
||||||
|
end
|
||||||
|
$$ language plpgsql volatile;
|
||||||
|
-- +migrate StatementEnd
|
||||||
|
|
||||||
|
-- give all users and members short IDs
|
||||||
|
update users set sid = find_free_user_sid();
|
||||||
|
update members set sid = find_free_member_sid();
|
||||||
|
|
||||||
|
-- finally, make the values non-nullable
|
||||||
|
alter table users alter column sid set not null;
|
||||||
|
alter table members alter column sid set not null;
|
Loading…
Reference in New Issue