feat: add short IDs + link shortener
This commit is contained in:
		
							parent
							
								
									7c94c088e0
								
							
						
					
					
						commit
						10dc59d3d4
					
				
					 18 changed files with 510 additions and 31 deletions
				
			
		|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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). | ||||
|  |  | |||
							
								
								
									
										99
									
								
								backend/prns/main.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								backend/prns/main.go
									
										
									
									
									
										Normal 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 | ||||
| } | ||||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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)) | ||||
| 	}) | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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)) | ||||
| 		}) | ||||
| 	}) | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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 @@ | |||
|           <Button color="secondary" outline on:click={copyURL}> | ||||
|             <Icon name="clipboard" /> Copy link | ||||
|           </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} | ||||
|             <ReportButton subject="user" reportUrl="/users/{data.id}/reports" /> | ||||
|           {/if} | ||||
|  |  | |||
|  | @ -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 }); | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <div class="container"> | ||||
|  | @ -153,6 +161,15 @@ | |||
|           <Button color="secondary" outline on:click={copyURL}> | ||||
|             <Icon name="clipboard" /> Copy link | ||||
|           </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} | ||||
|             <ReportButton subject="member" reportUrl="/members/{data.id}/reports" /> | ||||
|           {/if} | ||||
|  |  | |||
|  | @ -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<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 { | ||||
|     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; | ||||
|     }, | ||||
|   }; | ||||
| </script> | ||||
|  | @ -755,6 +779,30 @@ | |||
|           </strong> | ||||
|         </p> | ||||
|       </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> | ||||
|   </TabPane> | ||||
| </TabContent> | ||||
|  |  | |||
|  | @ -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<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 { | ||||
|     bio: string; | ||||
|     display_name: string; | ||||
|  | @ -721,6 +745,29 @@ | |||
|             will be used. | ||||
|           </p> | ||||
|         </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 class="col-md"> | ||||
|         <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/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"}, | ||||
|  |  | |||
							
								
								
									
										50
									
								
								scripts/migrate/018_short_ids.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								scripts/migrate/018_short_ids.sql
									
										
									
									
									
										Normal 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; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue