feat: get signup via discord working

This commit is contained in:
Sam 2022-11-18 02:17:27 +01:00
parent 77dea0c5ed
commit 9a3c51459b
6 changed files with 183 additions and 18 deletions

View File

@ -46,7 +46,7 @@ const (
) )
// CreateUser creates a user with the given username. // CreateUser creates a user with the given username.
func (db *DB) CreateUser(ctx context.Context, username string) (u User, err error) { func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) {
// check if the username is valid // check if the username is valid
// if not, return an error depending on what failed // if not, return an error depending on what failed
if !usernameRegex.MatchString(username) { if !usernameRegex.MatchString(username) {
@ -64,7 +64,7 @@ func (db *DB) CreateUser(ctx context.Context, username string) (u User, err erro
return u, errors.Wrap(err, "building sql") return u, errors.Wrap(err, "building sql")
} }
err = pgxscan.Get(ctx, db, &u, sql, args...) err = pgxscan.Get(ctx, tx, &u, sql, args...)
if err != nil { if err != nil {
if v, ok := errors.Cause(err).(*pgconn.PgError); ok { if v, ok := errors.Cause(err).(*pgconn.PgError); ok {
if v.Code == "23505" { // unique constraint violation if v.Code == "23505" { // unique constraint violation

View File

@ -7,8 +7,10 @@ import (
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/mediocregopher/radix/v4"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@ -37,7 +39,7 @@ type discordCallbackResponse struct {
Discord string `json:"discord,omitempty"` // username, for UI purposes Discord string `json:"discord,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"` Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite,omitempty"` // require an invite for signing up RequireInvite bool `json:"require_invite"` // require an invite for signing up
} }
func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
@ -114,6 +116,108 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} }
type signupRequest struct {
Ticket string `json:"ticket"`
Username string `json:"username"`
InviteCode string `json:"invite_code"`
}
type signupResponse struct {
User userResponse `json:"user"`
Token string `json:"token"`
}
func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
req, err := Decode[signupRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if s.RequireInvite && req.InviteCode == "" {
return server.APIError{Code: server.ErrInviteRequired}
}
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return err
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
}
if taken {
return server.APIError{Code: server.ErrUsernameTaken}
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
var du discordgo.User
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)
if err != nil {
log.Errorf("getting discord user for ticket: %v", err)
return server.APIError{Code: server.ErrInvalidTicket}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
return errors.Wrap(err, "creating user")
}
err = u.UpdateFromDiscord(ctx, tx, &du)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
return server.APIError{Code: server.ErrUsernameTaken}
}
return errors.Wrap(err, "updating user from discord")
}
if s.RequireInvite {
// TODO: check invites, invalidate invite when done
inviteValid := true
if !inviteValid {
err = tx.Rollback(ctx)
if err != nil {
return errors.Wrap(err, "rolling back transaction")
}
return server.APIError{Code: server.ErrInviteRequired}
}
}
// delete sign up ticket
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "discord:"+req.Ticket))
if err != nil {
return errors.Wrap(err, "deleting signup ticket")
}
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// create token
token, err := s.Auth.CreateToken(u.ID)
if err != nil {
return errors.Wrap(err, "creating token")
}
// return user
render.JSON(w, r, signupResponse{
User: *dbUserToUserResponse(u),
Token: token,
})
return nil
}
func Decode[T any](r *http.Request) (T, error) { func Decode[T any](r *http.Request) (T, error) {
decoded := *new(T) decoded := *new(T)

View File

@ -61,7 +61,7 @@ func Mount(srv *server.Server, r chi.Router) {
// takes code + state, validates it, returns token OR discord signup ticket // takes code + state, validates it, returns token OR discord signup ticket
r.Post("/callback", server.WrapHandler(s.discordCallback)) r.Post("/callback", server.WrapHandler(s.discordCallback))
// takes discord signup ticket to register account // takes discord signup ticket to register account
r.Post("/signup", nil) r.Post("/signup", server.WrapHandler(s.discordSignup))
}) })
}) })
} }

View File

@ -76,6 +76,10 @@ const (
ErrInvalidState = 1001 ErrInvalidState = 1001
ErrInvalidOAuthCode = 1002 ErrInvalidOAuthCode = 1002
ErrInvalidToken = 1003 // a token was supplied, but it is invalid ErrInvalidToken = 1003 // a token was supplied, but it is invalid
ErrInviteRequired = 1004
ErrInvalidTicket = 1005 // invalid signup ticket
ErrInvalidUsername = 1006 // invalid username (when signing up)
ErrUsernameTaken = 1007 // username taken (when signing up)
// User-related error codes // User-related error codes
ErrUserNotFound = 2001 ErrUserNotFound = 2001
@ -99,6 +103,10 @@ var errCodeMessages = map[int]string{
ErrInvalidState: "Invalid OAuth state", ErrInvalidState: "Invalid OAuth state",
ErrInvalidOAuthCode: "Invalid OAuth code", ErrInvalidOAuthCode: "Invalid OAuth code",
ErrInvalidToken: "Supplied token was invalid", ErrInvalidToken: "Supplied token was invalid",
ErrInviteRequired: "A valid invite code is required",
ErrInvalidTicket: "Invalid signup ticket",
ErrInvalidUsername: "Invalid username",
ErrUsernameTaken: "Username is already taken",
ErrUserNotFound: "User not found", ErrUserNotFound: "User not found",

View File

@ -84,6 +84,11 @@ export interface SignupRequest {
invite_code?: string; invite_code?: string;
} }
export interface SignupResponse {
user: MeUser;
token: string;
}
export interface PartialUser { export interface PartialUser {
id: string; id: string;
username: string; username: string;

View File

@ -3,7 +3,8 @@ import { useRouter } from "next/router";
import { useRecoilState } from "recoil"; import { useRecoilState } from "recoil";
import fetchAPI from "../../lib/fetch"; import fetchAPI from "../../lib/fetch";
import { userState } from "../../lib/state"; import { userState } from "../../lib/state";
import { MeUser } from "../../lib/types"; import { APIError, MeUser, SignupResponse } from "../../lib/types";
import TextInput from "../../components/TextInput";
interface CallbackResponse { interface CallbackResponse {
has_account: boolean; has_account: boolean;
@ -12,7 +13,7 @@ interface CallbackResponse {
discord?: string; discord?: string;
ticket?: string; ticket?: string;
require_invite?: boolean; require_invite: boolean;
} }
interface State { interface State {
@ -23,6 +24,7 @@ interface State {
discord: string | null; discord: string | null;
ticket: string | null; ticket: string | null;
error?: any; error?: any;
requireInvite: boolean;
} }
export default function Discord() { export default function Discord() {
@ -37,7 +39,9 @@ export default function Discord() {
discord: null, discord: null,
ticket: null, ticket: null,
error: null, error: null,
requireInvite: false,
}); });
const [formData, setFormData] = useState<{ username: string, invite: string }>({ username: "", invite: "" });
useEffect(() => { useEffect(() => {
if (!router.query.code || !router.query.state) { return; } if (!router.query.code || !router.query.state) { return; }
@ -58,10 +62,10 @@ export default function Discord() {
user: resp.user || null, user: resp.user || null,
discord: resp.discord || null, discord: resp.discord || null,
ticket: resp.ticket || null, ticket: resp.ticket || null,
requireInvite: resp.require_invite,
}) })
}).catch(e => { }).catch(e => {
return { setState({
props: {
hasAccount: false, hasAccount: false,
isLoading: false, isLoading: false,
error: e, error: e,
@ -69,8 +73,8 @@ export default function Discord() {
user: null, user: null,
discord: null, discord: null,
ticket: null, ticket: null,
}, requireInvite: false,
}; });
}) })
// we got a token + user, save it and return to the home page // we got a token + user, save it and return to the home page
@ -82,5 +86,49 @@ export default function Discord() {
} }
}, [state.token, state.user, setState, router]); }, [state.token, state.user, setState, router]);
return <>wow such login</>; // user needs to create an account
const signup = async () => {
try {
const resp = await fetchAPI<SignupResponse>("/auth/discord/signup", "POST", {
ticket: state.ticket,
username: formData.username,
invite_code: formData.invite,
});
setUser(resp.user);
localStorage.setItem("pronouns-token", resp.token);
} catch (e) {
setState({ ...state, error: e });
}
};
return <>
<h1 className="font-bold text-lg">Get started</h1>
<p>You{"'"}ve logged in with Discord as <strong className="font-bold">{state.discord}</strong>.</p>
{state.error && (
<div className="bg-red-600 dark:bg-red-700 p-2 rounded-md">
<p>Error: {state.error.message ?? state.error}</p>
<p>Try again?</p>
</div>
)}
<label>
<span className="font-bold">Username</span>
<TextInput value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} />
</label>
{state.requireInvite && (
<label>
<span className="font-bold">Invite code</span>
<TextInput value={formData.invite} onChange={(e) => setFormData({ ...formData, invite: e.target.value })} />
</label>
)}
<button
type="button"
onClick={() => signup()}
className="bg-green-600 dark:bg-green-700 hover:bg-green-700 hover:dark:bg-green-800 p-2 rounded-md"
>
<span className="font-bold">Create account</span>
</button>
</>;
} }