From 10dc59d3d4bac86b8124e8be793a3591914fc1b4 Mon Sep 17 00:00:00 2001
From: Sam
Date: Sat, 3 Jun 2023 03:06:26 +0200
Subject: [PATCH] feat: add short IDs + link shortener
---
backend/db/member.go | 66 ++++++++++++-
backend/db/user.go | 47 ++++++++-
backend/prns/main.go | 99 +++++++++++++++++++
backend/routes/member/get_member.go | 2 +
backend/routes/member/get_members.go | 2 +
backend/routes/member/patch_member.go | 47 +++++++++
backend/routes/member/routes.go | 3 +
backend/routes/user/get_user.go | 12 ++-
backend/routes/user/patch_user.go | 30 ++++++
backend/routes/user/routes.go | 2 +
backend/server/errors.go | 21 ++--
frontend/src/lib/api/entities.ts | 3 +
frontend/src/routes/@[username]/+page.svelte | 17 ++++
.../@[username]/[memberName]/+page.svelte | 17 ++++
.../src/routes/edit/member/[id]/+page.svelte | 74 +++++++++++---
frontend/src/routes/edit/profile/+page.svelte | 47 +++++++++
main.go | 2 +
scripts/migrate/018_short_ids.sql | 50 ++++++++++
18 files changed, 510 insertions(+), 31 deletions(-)
create mode 100644 backend/prns/main.go
create mode 100644 scripts/migrate/018_short_ids.sql
diff --git a/backend/db/member.go b/backend/db/member.go
index 43682c5..e893348 100644
--- a/backend/db/member.go
+++ b/backend/db/member.go
@@ -3,8 +3,10 @@ package db
import (
"context"
"regexp"
+ "time"
"emperror.dev/errors"
+ "github.com/Masterminds/squirrel"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
@@ -19,6 +21,7 @@ const (
type Member struct {
ID xid.ID
UserID xid.ID
+ SID string `db:"sid"`
Name string
DisplayName *string
Bio *string
@@ -73,6 +76,25 @@ func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (
return m, nil
}
+// MemberBySID gets a user by their short ID.
+func (db *DB) MemberBySID(ctx context.Context, sid string) (u Member, err error) {
+ sql, args, err := sq.Select("*").From("members").Where("sid = ?", sid).ToSql()
+ if err != nil {
+ return u, errors.Wrap(err, "building sql")
+ }
+
+ err = pgxscan.Get(ctx, db, &u, sql, args...)
+ if err != nil {
+ if errors.Cause(err) == pgx.ErrNoRows {
+ return u, ErrMemberNotFound
+ }
+
+ return u, errors.Wrap(err, "getting members from db")
+ }
+
+ return u, nil
+}
+
// UserMembers returns all of a user's members, sorted by name.
func (db *DB) UserMembers(ctx context.Context, userID xid.ID, showHidden bool) (ms []Member, err error) {
builder := sq.Select("*").
@@ -104,8 +126,8 @@ func (db *DB) CreateMember(
name string, displayName *string, bio string, links []string,
) (m Member, err error) {
sql, args, err := sq.Insert("members").
- Columns("user_id", "id", "name", "display_name", "bio", "links").
- Values(userID, xid.New(), name, displayName, bio, links).
+ Columns("user_id", "id", "sid", "name", "display_name", "bio", "links").
+ Values(userID, xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
Suffix("RETURNING *").ToSql()
if err != nil {
return m, errors.Wrap(err, "building sql")
@@ -232,3 +254,43 @@ func (db *DB) UpdateMember(
}
return m, nil
}
+
+func (db *DB) RerollMemberSID(ctx context.Context, userID, memberID xid.ID) (newID string, err error) {
+ tx, err := db.Begin(ctx)
+ if err != nil {
+ return "", errors.Wrap(err, "beginning transaction")
+ }
+ defer 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
+}
diff --git a/backend/db/user.go b/backend/db/user.go
index 0370088..47c7c61 100644
--- a/backend/db/user.go
+++ b/backend/db/user.go
@@ -11,6 +11,7 @@ import (
"codeberg.org/u1f320/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/icons"
"emperror.dev/errors"
+ "github.com/Masterminds/squirrel"
"github.com/bwmarrin/discordgo"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
@@ -20,6 +21,7 @@ import (
type User struct {
ID xid.ID
+ SID string `db:"sid"`
Username string
DisplayName *string
Bio *string
@@ -46,9 +48,10 @@ type User struct {
Google *string
GoogleUsername *string
- MaxInvites int
- IsAdmin bool
- ListPrivate bool
+ MaxInvites int
+ IsAdmin bool
+ ListPrivate bool
+ LastSIDReroll time.Time `db:"last_sid_reroll"`
DeletedAt *time.Time
SelfDelete *bool
@@ -161,7 +164,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
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 {
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
}
+// UserBySID gets a user by their short ID.
+func (db *DB) UserBySID(ctx context.Context, sid string) (u User, err error) {
+ sql, args, err := sq.Select("*").From("users").Where("sid = ?", sid).ToSql()
+ if err != nil {
+ return u, errors.Wrap(err, "building sql")
+ }
+
+ err = pgxscan.Get(ctx, db, &u, sql, args...)
+ if err != nil {
+ if errors.Cause(err) == pgx.ErrNoRows {
+ return u, ErrUserNotFound
+ }
+
+ return u, errors.Wrap(err, "getting user from db")
+ }
+
+ return u, nil
+}
+
// UsernameTaken checks if the given username is already taken.
func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken bool, err error) {
if err := UsernameValid(username); err != nil {
@@ -596,6 +618,23 @@ func (db *DB) DeleteUser(ctx context.Context, tx pgx.Tx, id xid.ID, selfDelete b
return nil
}
+func (db *DB) RerollUserSID(ctx context.Context, id xid.ID) (newID string, err error) {
+ sql, args, err := sq.Update("users").
+ Set("sid", squirrel.Expr("find_free_user_sid()")).
+ Set("last_sid_reroll", time.Now()).
+ Where("id = ?", id).
+ Suffix("RETURNING sid").ToSql()
+ if err != nil {
+ return "", errors.Wrap(err, "building sql")
+ }
+
+ err = db.QueryRow(ctx, sql, args...).Scan(&newID)
+ if err != nil {
+ return "", errors.Wrap(err, "executing query")
+ }
+ return newID, nil
+}
+
func (db *DB) UndoDeleteUser(ctx context.Context, id xid.ID) error {
sql, args, err := sq.Update("users").
Set("deleted_at", nil).
diff --git a/backend/prns/main.go b/backend/prns/main.go
new file mode 100644
index 0000000..691e279
--- /dev/null
+++ b/backend/prns/main.go
@@ -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
+}
diff --git a/backend/routes/member/get_member.go b/backend/routes/member/get_member.go
index a248a63..449c23f 100644
--- a/backend/routes/member/get_member.go
+++ b/backend/routes/member/get_member.go
@@ -14,6 +14,7 @@ import (
type GetMemberResponse struct {
ID xid.ID `json:"id"`
+ SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
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 {
r := GetMemberResponse{
ID: m.ID,
+ SID: m.SID,
Name: m.Name,
DisplayName: m.DisplayName,
Bio: m.Bio,
diff --git a/backend/routes/member/get_members.go b/backend/routes/member/get_members.go
index 12afd6e..c5065cd 100644
--- a/backend/routes/member/get_members.go
+++ b/backend/routes/member/get_members.go
@@ -12,6 +12,7 @@ import (
type memberListResponse struct {
ID xid.ID `json:"id"`
+ SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
@@ -27,6 +28,7 @@ func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse {
for i := range ms {
resps[i] = memberListResponse{
ID: ms[i].ID,
+ SID: ms[i].SID,
Name: ms[i].Name,
DisplayName: ms[i].DisplayName,
Bio: ms[i].Bio,
diff --git a/backend/routes/member/patch_member.go b/backend/routes/member/patch_member.go
index 7e3409d..95166af 100644
--- a/backend/routes/member/patch_member.go
+++ b/backend/routes/member/patch_member.go
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strings"
+ "time"
"codeberg.org/u1f320/pronouns.cc/backend/common"
"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))
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
+}
diff --git a/backend/routes/member/routes.go b/backend/routes/member/routes.go
index fbf885f..60da078 100644
--- a/backend/routes/member/routes.go
+++ b/backend/routes/member/routes.go
@@ -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).Patch("/{memberRef}", server.WrapHandler(s.patchMember))
r.With(server.MustAuth).Delete("/{memberRef}", server.WrapHandler(s.deleteMember))
+
+ // reroll member SID
+ r.With(server.MustAuth).Get("/{memberRef}/reroll", server.WrapHandler(s.rerollMemberSID))
})
}
diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go
index c04f48d..6b94ed9 100644
--- a/backend/routes/user/get_user.go
+++ b/backend/routes/user/get_user.go
@@ -14,6 +14,7 @@ import (
type GetUserResponse struct {
ID xid.ID `json:"id"`
+ SID string `json:"sid"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
@@ -33,9 +34,10 @@ type GetMeResponse struct {
CreatedAt time.Time `json:"created_at"`
- MaxInvites int `json:"max_invites"`
- 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"`
DiscordUsername *string `json:"discord_username"`
@@ -53,6 +55,7 @@ type GetMeResponse struct {
type PartialMember struct {
ID xid.ID `json:"id"`
+ SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
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 {
resp := GetUserResponse{
ID: u.ID,
+ SID: u.SID,
Username: u.Username,
DisplayName: u.DisplayName,
Bio: u.Bio,
@@ -82,6 +86,7 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags [
for i := range members {
resp.Members[i] = PartialMember{
ID: members[i].ID,
+ SID: members[i].SID,
Name: members[i].Name,
DisplayName: members[i].DisplayName,
Bio: members[i].Bio,
@@ -188,6 +193,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate,
+ LastSIDReroll: u.LastSIDReroll,
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go
index 19ab3e1..3a3c2fd 100644
--- a/backend/routes/user/patch_user.go
+++ b/backend/routes/user/patch_user.go
@@ -3,6 +3,7 @@ package user
import (
"fmt"
"net/http"
+ "time"
"codeberg.org/u1f320/pronouns.cc/backend/common"
"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,
IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate,
+ LastSIDReroll: u.LastSIDReroll,
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
@@ -362,3 +364,31 @@ func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPrefe
return nil
}
+
+func (s *Server) rerollUserSID(w http.ResponseWriter, r *http.Request) error {
+ ctx := r.Context()
+
+ claims, _ := server.ClaimsFromContext(ctx)
+
+ if !claims.TokenWrite {
+ return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
+ }
+
+ u, err := s.DB.User(ctx, claims.UserID)
+ if err != nil {
+ return errors.Wrap(err, "getting existing user")
+ }
+
+ if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) {
+ return server.APIError{Code: server.ErrRerollingTooQuickly}
+ }
+
+ newID, err := s.DB.RerollUserSID(ctx, u.ID)
+ if err != nil {
+ return errors.Wrap(err, "updating user SID")
+ }
+
+ u.SID = newID
+ render.JSON(w, r, dbUserToResponse(u, nil, nil, nil))
+ return nil
+}
diff --git a/backend/routes/user/routes.go b/backend/routes/user/routes.go
index fd6e9a2..6bc7c14 100644
--- a/backend/routes/user/routes.go
+++ b/backend/routes/user/routes.go
@@ -34,6 +34,8 @@ func Mount(srv *server.Server, r chi.Router) {
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))
})
})
}
diff --git a/backend/server/errors.go b/backend/server/errors.go
index deb901b..c902b7e 100644
--- a/backend/server/errors.go
+++ b/backend/server/errors.go
@@ -100,9 +100,10 @@ const (
ErrInvalidCaptcha = 1017 // invalid or missing captcha response
// User-related error codes
- ErrUserNotFound = 2001
- ErrMemberListPrivate = 2002
- ErrFlagLimitReached = 2003
+ ErrUserNotFound = 2001
+ ErrMemberListPrivate = 2002
+ ErrFlagLimitReached = 2003
+ ErrRerollingTooQuickly = 2004
// Member-related error codes
ErrMemberNotFound = 3001
@@ -145,9 +146,10 @@ var errCodeMessages = map[int]string{
ErrLastProvider: "This is your account's only authentication provider",
ErrInvalidCaptcha: "Invalid or missing captcha response",
- ErrUserNotFound: "User not found",
- ErrMemberListPrivate: "This user's member list is private",
- ErrFlagLimitReached: "Maximum number of pride flags reached",
+ ErrUserNotFound: "User not found",
+ ErrMemberListPrivate: "This user's member list is private",
+ ErrFlagLimitReached: "Maximum number of pride flags reached",
+ ErrRerollingTooQuickly: "You can only reroll one short ID per hour.",
ErrMemberNotFound: "Member not found",
ErrMemberLimitReached: "Member limit reached",
@@ -187,9 +189,10 @@ var errCodeStatuses = map[int]int{
ErrLastProvider: http.StatusBadRequest,
ErrInvalidCaptcha: http.StatusBadRequest,
- ErrUserNotFound: http.StatusNotFound,
- ErrMemberListPrivate: http.StatusForbidden,
- ErrFlagLimitReached: http.StatusBadRequest,
+ ErrUserNotFound: http.StatusNotFound,
+ ErrMemberListPrivate: http.StatusForbidden,
+ ErrFlagLimitReached: http.StatusBadRequest,
+ ErrRerollingTooQuickly: http.StatusForbidden,
ErrMemberNotFound: http.StatusNotFound,
ErrMemberLimitReached: http.StatusBadRequest,
diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts
index 37bcd87..4086f5b 100644
--- a/frontend/src/lib/api/entities.ts
+++ b/frontend/src/lib/api/entities.ts
@@ -6,6 +6,7 @@ export const MAX_DESCRIPTION_LENGTH = 1000;
export interface User {
id: string;
+ sid: string;
name: string;
display_name: string | null;
bio: string | null;
@@ -53,6 +54,7 @@ export interface MeUser extends User {
fediverse_username: string | null;
fediverse_instance: string | null;
list_private: boolean;
+ last_sid_reroll: string;
}
export interface Field {
@@ -73,6 +75,7 @@ export interface Pronoun {
export interface PartialMember {
id: string;
+ sid: string;
name: string;
display_name: string | null;
bio: string | null;
diff --git a/frontend/src/routes/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte
index d4204d4..15169a5 100644
--- a/frontend/src/routes/@[username]/+page.svelte
+++ b/frontend/src/routes/@[username]/+page.svelte
@@ -30,6 +30,7 @@
type Pronoun,
} from "$lib/api/entities";
import { PUBLIC_BASE_URL } from "$env/static/public";
+ import { env } from "$env/dynamic/public";
import { apiFetchClient } from "$lib/api/fetch";
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import { goto } from "$app/navigation";
@@ -41,6 +42,7 @@
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;
@@ -117,6 +119,12 @@
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 () => {
if ($userStore && $userStore.id === data.id) {
console.log("User is current user, fetching members");
@@ -231,6 +239,15 @@
Copy link
+ {#if env.PUBLIC_SHORT_BASE}
+
+ {/if}
{#if $userStore && $userStore.id !== data.id}
{/if}
diff --git a/frontend/src/routes/@[username]/[memberName]/+page.svelte b/frontend/src/routes/@[username]/[memberName]/+page.svelte
index b281905..c421bc8 100644
--- a/frontend/src/routes/@[username]/[memberName]/+page.svelte
+++ b/frontend/src/routes/@[username]/[memberName]/+page.svelte
@@ -13,6 +13,7 @@
type Pronoun,
} from "$lib/api/entities";
import { PUBLIC_BASE_URL } from "$env/static/public";
+ import { env } from "$env/dynamic/public";
import { userStore } from "$lib/store";
import { renderMarkdown } from "$lib/utils";
import ReportButton from "../ReportButton.svelte";
@@ -21,6 +22,7 @@
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;
@@ -51,6 +53,12 @@
await navigator.clipboard.writeText(url);
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 });
+ };
@@ -153,6 +161,15 @@
Copy link
+ {#if env.PUBLIC_SHORT_BASE}
+
+ {/if}
{#if $userStore && $userStore.id !== data.user.id}
{/if}
diff --git a/frontend/src/routes/edit/member/[id]/+page.svelte b/frontend/src/routes/edit/member/[id]/+page.svelte
index 533c1be..cfabcf6 100644
--- a/frontend/src/routes/edit/member/[id]/+page.svelte
+++ b/frontend/src/routes/edit/member/[id]/+page.svelte
@@ -28,8 +28,10 @@
CardHeader,
Alert,
} from "sveltestrap";
+ import { DateTime } from "luxon";
import { encode } from "base64-arraybuffer";
import prettyBytes from "pretty-bytes";
+ import { env } from "$env/dynamic/public";
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
import IconButton from "$lib/components/IconButton.svelte";
import EditableField from "../../EditableField.svelte";
@@ -373,6 +375,28 @@
let deleteName = "";
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(`/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 {
bio: string;
name: string;
@@ -407,19 +431,19 @@
newLink,
}),
restore: (value) => {
- bio = value.bio
- name = value.name
- display_name = value.display_name
- links = value.links
- names = value.names
- pronouns = value.pronouns
- fields = value.fields
- flags = value.flags
- unlisted = value.unlisted
- avatar = value.avatar
- newName = value.newName
- newPronouns = value.newPronouns
- newLink = value.newLink
+ bio = value.bio;
+ name = value.name;
+ display_name = value.display_name;
+ links = value.links;
+ names = value.names;
+ pronouns = value.pronouns;
+ fields = value.fields;
+ flags = value.flags;
+ unlisted = value.unlisted;
+ avatar = value.avatar;
+ newName = value.newName;
+ newPronouns = value.newPronouns;
+ newLink = value.newLink;
},
};
@@ -755,6 +779,30 @@
+ {#if env.PUBLIC_SHORT_BASE}
+
+
+ Current short ID: {data.member.sid}
+
+ rerollSid()}
+ >Reroll short ID
+
+
+
+
+
+ This ID is used in prns.cc
links. You can reroll one short ID every hour (shared
+ between your main profile and all members) by pressing the button above.
+
+
+
+ {/if}
diff --git a/frontend/src/routes/edit/profile/+page.svelte b/frontend/src/routes/edit/profile/+page.svelte
index d0a480e..8536898 100644
--- a/frontend/src/routes/edit/profile/+page.svelte
+++ b/frontend/src/routes/edit/profile/+page.svelte
@@ -28,7 +28,9 @@
TabPane,
} from "sveltestrap";
import { encode } from "base64-arraybuffer";
+ import { DateTime } from "luxon";
import { apiFetchClient } from "$lib/api/fetch";
+ import { env } from "$env/dynamic/public";
import IconButton from "$lib/components/IconButton.svelte";
import EditableField from "../EditableField.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("/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 {
bio: string;
display_name: string;
@@ -721,6 +745,29 @@
will be used.
+ {#if env.PUBLIC_SHORT_BASE}
+
+
+ Current short ID: {data.user.sid}
+
+ rerollSid()}
+ >Reroll short ID
+
+
+
+
+
+ This ID is used in prns.cc
links. You can reroll one short ID every hour (shared
+ between your main profile and all members) by pressing the button above.
+
+
+ {/if}
diff --git a/main.go b/main.go
index 14e9b03..0121272 100644
--- a/main.go
+++ b/main.go
@@ -6,6 +6,7 @@ import (
"codeberg.org/u1f320/pronouns.cc/backend"
"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/scripts/cleandb"
"codeberg.org/u1f320/pronouns.cc/scripts/genid"
@@ -22,6 +23,7 @@ var app = &cli.App{
Commands: []*cli.Command{
backend.Command,
exporter.Command,
+ prns.Command,
{
Name: "database",
Aliases: []string{"db"},
diff --git a/scripts/migrate/018_short_ids.sql b/scripts/migrate/018_short_ids.sql
new file mode 100644
index 0000000..09b9af6
--- /dev/null
+++ b/scripts/migrate/018_short_ids.sql
@@ -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;