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 @@ + {#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 @@ + {#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} + + + + +
+ + + 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} + + + + +
+ + + 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;