feat: allow linking fediverse account to existing user

This commit is contained in:
Sam 2023-03-18 15:19:53 +01:00
parent d6bb2f7743
commit 97191933cb
No known key found for this signature in database
GPG Key ID: B4EF20DDE721CAA1
14 changed files with 306 additions and 93 deletions

View File

@ -31,6 +31,7 @@ type User struct {
Fediverse *string Fediverse *string
FediverseUsername *string FediverseUsername *string
FediverseAppID *int64 FediverseAppID *int64
FediverseInstance *string
MaxInvites int MaxInvites int
@ -99,7 +100,8 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
} }
func (db *DB) FediverseUser(ctx context.Context, userID string, instanceAppID int64) (u User, err error) { func (db *DB) FediverseUser(ctx context.Context, userID string, instanceAppID int64) (u User, err error) {
sql, args, err := sq.Select("*").From("users"). sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
From("users").
Where("fediverse = ?", userID).Where("fediverse_app_id = ?", instanceAppID). Where("fediverse = ?", userID).Where("fediverse_app_id = ?", instanceAppID).
ToSql() ToSql()
if err != nil { if err != nil {
@ -141,7 +143,8 @@ func (u *User) UpdateFromFedi(ctx context.Context, ex Execer, userID, username s
// DiscordUser fetches a user by Discord user ID. // DiscordUser fetches a user by Discord user ID.
func (db *DB) DiscordUser(ctx context.Context, discordID string) (u User, err error) { func (db *DB) DiscordUser(ctx context.Context, discordID string) (u User, err error) {
sql, args, err := sq.Select("*").From("users").Where("discord = ?", discordID).ToSql() sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
From("users").Where("discord = ?", discordID).ToSql()
if err != nil { if err != nil {
return u, errors.Wrap(err, "building sql") return u, errors.Wrap(err, "building sql")
} }
@ -181,7 +184,8 @@ func (u *User) UpdateFromDiscord(ctx context.Context, ex Execer, du *discordgo.U
// User gets a user by ID. // User gets a user by ID.
func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) { func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql() sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
From("users").Where("id = ?", id).ToSql()
if err != nil { if err != nil {
return u, errors.Wrap(err, "building sql") return u, errors.Wrap(err, "building sql")
} }

View File

@ -203,15 +203,15 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
u, err := s.DB.CreateUser(ctx, tx, req.Username) u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil { if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
return server.APIError{Code: server.ErrUsernameTaken}
}
return errors.Wrap(err, "creating user") return errors.Wrap(err, "creating user")
} }
err = u.UpdateFromDiscord(ctx, tx, du) err = u.UpdateFromDiscord(ctx, tx, du)
if err != nil { if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
return server.APIError{Code: server.ErrUsernameTaken}
}
return errors.Wrap(err, "updating user from discord") return errors.Wrap(err, "updating user from discord")
} }

View File

@ -174,6 +174,57 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
return nil return nil
} }
type fediLinkRequest struct {
Instance string `json:"instance"`
Ticket string `json:"ticket"`
}
func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
req, err := Decode[fediLinkRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
app, err := s.DB.FediverseApp(ctx, req.Instance)
if err != nil {
return errors.Wrap(err, "getting instance application")
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
if u.Fediverse != nil {
return server.APIError{Code: server.ErrAlreadyLinked}
}
mu := new(partialMastodonAccount)
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
if err != nil {
log.Errorf("getting mastoAPI user for ticket: %v", err)
return server.APIError{Code: server.ErrInvalidTicket}
}
err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
if err != nil {
return errors.Wrap(err, "updating user from mastoAPI")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "getting user fields")
}
render.JSON(w, r, dbUserToUserResponse(u, fields))
return nil
}
type fediSignupRequest struct { type fediSignupRequest struct {
Instance string `json:"instance"` Instance string `json:"instance"`
Ticket string `json:"ticket"` Ticket string `json:"ticket"`
@ -225,15 +276,15 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
u, err := s.DB.CreateUser(ctx, tx, req.Username) u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil { if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
return server.APIError{Code: server.ErrUsernameTaken}
}
return errors.Wrap(err, "creating user") return errors.Wrap(err, "creating user")
} }
err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID) err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID)
if err != nil { if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
return server.APIError{Code: server.ErrUsernameTaken}
}
return errors.Wrap(err, "updating user from mastoAPI") return errors.Wrap(err, "updating user from mastoAPI")
} }

View File

@ -33,21 +33,28 @@ type userResponse struct {
Discord *string `json:"discord"` Discord *string `json:"discord"`
DiscordUsername *string `json:"discord_username"` DiscordUsername *string `json:"discord_username"`
Fediverse *string `json:"fediverse"`
FediverseUsername *string `json:"fediverse_username"`
FediverseInstance *string `json:"fediverse_instance"`
} }
func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
return &userResponse{ return &userResponse{
ID: u.ID, ID: u.ID,
Username: u.Username, Username: u.Username,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Bio: u.Bio, Bio: u.Bio,
Avatar: u.Avatar, Avatar: u.Avatar,
Links: db.NotNull(u.Links), Links: db.NotNull(u.Links),
Names: db.NotNull(u.Names), Names: db.NotNull(u.Names),
Pronouns: db.NotNull(u.Pronouns), Pronouns: db.NotNull(u.Pronouns),
Fields: db.NotNull(fields), Fields: db.NotNull(fields),
Discord: u.Discord, Discord: u.Discord,
DiscordUsername: u.DiscordUsername, DiscordUsername: u.DiscordUsername,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: u.FediverseInstance,
} }
} }
@ -78,7 +85,7 @@ func Mount(srv *server.Server, r chi.Router) {
r.Route("/mastodon", func(r chi.Router) { r.Route("/mastodon", func(r chi.Router) {
r.Post("/callback", server.WrapHandler(s.mastodonCallback)) r.Post("/callback", server.WrapHandler(s.mastodonCallback))
r.Post("/signup", server.WrapHandler(s.mastodonSignup)) r.Post("/signup", server.WrapHandler(s.mastodonSignup))
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(nil)) r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.mastodonLink))
}) })
// invite routes // invite routes

View File

@ -159,15 +159,6 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
return err return err
} }
// get fedi instance name if the user has a linked fedi account
var fediInstance *string
if u.FediverseAppID != nil {
app, err := s.DB.FediverseAppByID(ctx, *u.FediverseAppID)
if err == nil {
fediInstance = &app.Instance
}
}
render.JSON(w, r, GetMeResponse{ render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, members), GetUserResponse: dbUserToResponse(u, fields, members),
MaxInvites: u.MaxInvites, MaxInvites: u.MaxInvites,
@ -175,7 +166,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
DiscordUsername: u.DiscordUsername, DiscordUsername: u.DiscordUsername,
Fediverse: u.Fediverse, Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername, FediverseUsername: u.FediverseUsername,
FediverseInstance: fediInstance, FediverseInstance: u.FediverseInstance,
}) })
return nil return nil
} }

View File

@ -94,6 +94,7 @@ const (
ErrDeletionPending = 1011 // own user deletion pending, returned with undo code ErrDeletionPending = 1011 // own user deletion pending, returned with undo code
ErrRecentExport = 1012 // latest export is too recent ErrRecentExport = 1012 // latest export is too recent
ErrUnsupportedInstance = 1013 // unsupported fediverse software ErrUnsupportedInstance = 1013 // unsupported fediverse software
ErrAlreadyLinked = 1014 // user already has linked account of the same type
// User-related error codes // User-related error codes
ErrUserNotFound = 2001 ErrUserNotFound = 2001
@ -130,6 +131,7 @@ var errCodeMessages = map[int]string{
ErrDeletionPending: "Your account is pending deletion", ErrDeletionPending: "Your account is pending deletion",
ErrRecentExport: "Your latest data export is less than 1 day old", ErrRecentExport: "Your latest data export is less than 1 day old",
ErrUnsupportedInstance: "Unsupported instance software", ErrUnsupportedInstance: "Unsupported instance software",
ErrAlreadyLinked: "Your account is already linked to an account of this type",
ErrUserNotFound: "User not found", ErrUserNotFound: "User not found",
@ -163,6 +165,7 @@ var errCodeStatuses = map[int]int{
ErrDeletionPending: http.StatusBadRequest, ErrDeletionPending: http.StatusBadRequest,
ErrRecentExport: http.StatusBadRequest, ErrRecentExport: http.StatusBadRequest,
ErrUnsupportedInstance: http.StatusBadRequest, ErrUnsupportedInstance: http.StatusBadRequest,
ErrAlreadyLinked: http.StatusBadRequest,
ErrUserNotFound: http.StatusNotFound, ErrUserNotFound: http.StatusNotFound,

View File

@ -21,6 +21,9 @@ export interface MeUser extends User {
max_invites: number; max_invites: number;
discord: string | null; discord: string | null;
discord_username: string | null; discord_username: string | null;
fediverse: string | null;
fediverse_username: string | null;
fediverse_instance: string | null;
} }
export interface Field { export interface Field {

View File

@ -4,10 +4,11 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { APIError, MeUser } from "$lib/api/entities"; import type { APIError, MeUser } from "$lib/api/entities";
import { apiFetch } from "$lib/api/fetch"; import { apiFetch, apiFetchClient } from "$lib/api/fetch";
import { userStore } from "$lib/store"; import { userStore } from "$lib/store";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import ErrorAlert from "$lib/components/ErrorAlert.svelte"; import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import { addToast } from "$lib/toast";
interface SignupResponse { interface SignupResponse {
user: MeUser; user: MeUser;
@ -67,6 +68,22 @@
deleteError = e as APIError; deleteError = e as APIError;
} }
}; };
const linkAccount = async () => {
try {
const resp = await apiFetchClient<MeUser>("/auth/mastodon/add-provider", "POST", {
instance: data.instance,
ticket: data.ticket,
});
localStorage.setItem("pronouns-user", JSON.stringify(resp));
userStore.set(resp);
addToast({ header: "Linked account", body: "Successfully linked account!" });
goto("/settings/auth");
} catch (e) {
data.error = e as APIError;
}
};
</script> </script>
<svelte:head> <svelte:head>
@ -78,7 +95,32 @@
{#if data.error} {#if data.error}
<ErrorAlert error={data.error} /> <ErrorAlert error={data.error} />
{/if} {/if}
{#if data.ticket} {#if data.ticket && $userStore}
<div>
<label for="fediverse">Fediverse username</label>
<input
id="fediverse"
class="form-control"
name="fediverse"
readonly
value="{data.fediverse}@{data.instance}"
/>
</div>
<div>
<label for="fediverse">pronouns.cc username</label>
<input
id="pronounscc"
class="form-control"
name="pronounscc"
readonly
value={$userStore.name}
/>
</div>
<div>
<Button on:click={linkAccount}>Link account</Button>
<Button color="secondary" href="/settings/auth">Cancel</Button>
</div>
{:else if data.ticket}
<form on:submit|preventDefault={signupForm}> <form on:submit|preventDefault={signupForm}>
<div> <div>
<label for="fediverse">Fediverse username</label> <label for="fediverse">Fediverse username</label>
@ -86,7 +128,7 @@
id="fediverse" id="fediverse"
class="form-control" class="form-control"
name="fediverse" name="fediverse"
disabled readonly
value="{data.fediverse}@{data.instance}" value="{data.fediverse}@{data.instance}"
/> />
</div> </div>

View File

@ -35,7 +35,14 @@
<ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings"> <ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings">
Your profile Your profile
</ListGroupItem> </ListGroupItem>
{#if data.require_invite} <ListGroupItem
tag="a"
active={$page.url.pathname === "/settings/auth"}
href="/settings/auth"
>
Authentication
</ListGroupItem>
{#if data.invitesEnabled}
<ListGroupItem <ListGroupItem
tag="a" tag="a"
active={$page.url.pathname === "/settings/invites"} active={$page.url.pathname === "/settings/invites"}

View File

@ -1,8 +1,36 @@
import {
ErrorCode,
type APIError,
type Invite,
type MeUser,
type PartialMember,
} from "$lib/api/entities";
import { apiFetchClient } from "$lib/api/fetch";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
export const ssr = false; export const ssr = false;
export const load = (async ({ parent }) => { export const load = (async ({ parent }) => {
const user = await apiFetchClient<MeUser>("/users/@me");
const members = await apiFetchClient<PartialMember[]>("/users/@me/members");
let invites: Invite[] = [];
let invitesEnabled = true;
try {
invites = await apiFetchClient<Invite[]>("/auth/invites");
} catch (e) {
if ((e as APIError).code === ErrorCode.InvitesDisabled) {
invitesEnabled = false;
}
}
const data = await parent(); const data = await parent();
return data;
return {
...data,
user,
members,
invites,
invitesEnabled,
};
}) satisfies LayoutLoad; }) satisfies LayoutLoad;

View File

@ -1,30 +0,0 @@
import {
type Invite,
type APIError,
type MeUser,
type PartialMember,
ErrorCode,
} from "$lib/api/entities";
import { apiFetchClient } from "$lib/api/fetch";
import { error } from "@sveltejs/kit";
export const load = async () => {
try {
const user = await apiFetchClient<MeUser>("/users/@me");
const members = await apiFetchClient<PartialMember[]>("/users/@me/members");
let invites: Invite[] = [];
let invitesEnabled = true;
try {
invites = await apiFetchClient<Invite[]>("/auth/invites");
} catch (e) {
if ((e as APIError).code === ErrorCode.InvitesDisabled) {
invitesEnabled = false;
}
}
return { user, members, invites, invitesEnabled };
} catch (e) {
throw error(500, (e as APIError).message);
}
};

View File

@ -0,0 +1,115 @@
<script lang="ts">
import type { APIError } from "$lib/api/entities";
import { apiFetch } from "$lib/api/fetch";
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import {
Button,
Card,
CardBody,
CardText,
CardTitle,
Input,
Modal,
ModalBody,
ModalFooter,
} from "sveltestrap";
import type { PageData } from "./$types";
export let data: PageData;
let canUnlink = false;
$: canUnlink =
[data.user.discord, data.user.fediverse]
.map<number>((entry) => (entry === null ? 0 : 1))
.reduce((prev, current) => prev + current) >= 2;
let error: APIError | null = null;
let instance = "";
let fediDisabled = false;
let fediLinkModalOpen = false;
let toggleFediLinkModal = () => (fediLinkModalOpen = !fediLinkModalOpen);
const fediLogin = async () => {
fediDisabled = true;
try {
const resp = await apiFetch<{ url: string }>(
`/auth/urls/fediverse?instance=${encodeURIComponent(instance)}`,
{},
);
window.location.assign(resp.url);
} catch (e) {
error = e as APIError;
} finally {
fediDisabled = false;
}
};
</script>
<div>
<h1>Authentication providers</h1>
<div>
<div class="my-2">
<Card>
<CardBody>
<CardTitle>Fediverse</CardTitle>
<CardText>
{#if data.user.fediverse}
Your currently linked Fediverse account is <b
>{data.user.fediverse_username}@{data.user.fediverse_instance}</b
>
(<code>{data.user.fediverse}</code>).
{:else}
You do not have a linked Fediverse account.
{/if}
</CardText>
{#if data.user.fediverse}
<Button color="danger" disabled={!canUnlink}>Unlink account</Button>
{:else}
<Button color="secondary" on:click={toggleFediLinkModal}>Link account</Button>
{/if}
</CardBody>
</Card>
</div>
<div class="my-2">
<Card>
<CardBody>
<CardTitle>Discord</CardTitle>
<CardText>
{#if data.user.discord}
Your currently linked Discord account is <b>{data.user.discord_username}</b>
(<code>{data.user.discord}</code>).
{:else}
You do not have a linked Discord account.
{/if}
</CardText>
{#if data.user.discord}
<Button color="danger" disabled={!canUnlink}>Unlink account</Button>
{:else}
<Button color="secondary" href={data.urls.discord}>Link account</Button>
{/if}
</CardBody>
</Card>
</div>
<Modal header="Pick an instance" isOpen={fediLinkModalOpen} toggle={toggleFediLinkModal}>
<ModalBody>
<p>
<strong>Note:</strong> Misskey (and derivatives) are not supported yet, sorry.
</p>
<Input placeholder="Instance (e.g. mastodon.social)" bind:value={instance} />
{#if error}
<div class="mt-2">
<ErrorAlert {error} />
</div>
{/if}
</ModalBody>
<ModalFooter>
<Button color="primary" disabled={fediDisabled || instance === ""} on:click={fediLogin}
>Log in</Button
>
</ModalFooter>
</Modal>
</div>
</div>

View File

@ -0,0 +1,17 @@
import { PUBLIC_BASE_URL } from "$env/static/public";
import { apiFetch } from "$lib/api/fetch";
export const load = async () => {
const resp = await apiFetch<UrlsResponse>("/auth/urls", {
method: "POST",
body: {
callback_domain: PUBLIC_BASE_URL,
},
});
return { urls: resp };
};
interface UrlsResponse {
discord: string;
}

View File

@ -1,25 +0,0 @@
import { ErrorCode, type APIError, type Invite } from "$lib/api/entities";
import { apiFetchClient } from "$lib/api/fetch";
import { error } from "@sveltejs/kit";
import type { PageLoad } from "../$types";
export const load = (async () => {
const data = {
invitesEnabled: true,
invites: [] as Invite[],
};
try {
const invites = await apiFetchClient<Invite[]>("/auth/invites");
data.invites = invites;
} catch (e) {
if ((e as APIError).code === ErrorCode.InvitesDisabled) {
data.invitesEnabled = false;
data.invites = [];
} else {
throw error((e as APIError).code, (e as APIError).message);
}
}
return data;
}) satisfies PageLoad;