feat: add short IDs + link shortener

This commit is contained in:
Sam 2023-06-03 03:06:26 +02:00
parent 7c94c088e0
commit 10dc59d3d4
No known key found for this signature in database
GPG Key ID: B4EF20DDE721CAA1
18 changed files with 510 additions and 31 deletions

View File

@ -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
}

View File

@ -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
@ -49,6 +51,7 @@ type User struct {
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).

99
backend/prns/main.go Normal file
View File

@ -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
}

View File

@ -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,

View File

@ -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,

View File

@ -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
}

View File

@ -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))
}) })
} }

View File

@ -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"`
@ -36,6 +37,7 @@ type GetMeResponse struct {
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,

View File

@ -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
}

View File

@ -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))
}) })
}) })
} }

View File

@ -103,6 +103,7 @@ const (
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
@ -148,6 +149,7 @@ var errCodeMessages = map[int]string{
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",
@ -190,6 +192,7 @@ var errCodeStatuses = map[int]int{
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,

View File

@ -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;

View File

@ -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}

View File

@ -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}

View File

@ -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>

View File

@ -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">

View File

@ -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"},

View File

@ -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;