diff --git a/backend/db/user.go b/backend/db/user.go index 5b1e38a..facaf09 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -31,6 +31,7 @@ type User struct { Fediverse *string FediverseUsername *string FediverseAppID *int64 + FediverseInstance *string 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) { - 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). ToSql() 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. 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 { 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. 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 { return u, errors.Wrap(err, "building sql") } diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go index c067653..036bcd5 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -203,15 +203,15 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { u, err := s.DB.CreateUser(ctx, tx, req.Username) if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + 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") } diff --git a/backend/routes/auth/fedi_mastodon.go b/backend/routes/auth/fedi_mastodon.go index 49c1f73..0650a73 100644 --- a/backend/routes/auth/fedi_mastodon.go +++ b/backend/routes/auth/fedi_mastodon.go @@ -174,6 +174,57 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error 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 { Instance string `json:"instance"` 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) if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + return errors.Wrap(err, "creating user") } err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID) if err != nil { - if errors.Cause(err) == db.ErrUsernameTaken { - return server.APIError{Code: server.ErrUsernameTaken} - } - return errors.Wrap(err, "updating user from mastoAPI") } diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 601af27..9f5baba 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -33,21 +33,28 @@ type userResponse struct { Discord *string `json:"discord"` 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 { return &userResponse{ - ID: u.ID, - Username: u.Username, - DisplayName: u.DisplayName, - Bio: u.Bio, - Avatar: u.Avatar, - Links: db.NotNull(u.Links), - Names: db.NotNull(u.Names), - Pronouns: db.NotNull(u.Pronouns), - Fields: db.NotNull(fields), - Discord: u.Discord, - DiscordUsername: u.DiscordUsername, + ID: u.ID, + Username: u.Username, + DisplayName: u.DisplayName, + Bio: u.Bio, + Avatar: u.Avatar, + Links: db.NotNull(u.Links), + Names: db.NotNull(u.Names), + Pronouns: db.NotNull(u.Pronouns), + Fields: db.NotNull(fields), + Discord: u.Discord, + 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.Post("/callback", server.WrapHandler(s.mastodonCallback)) 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 diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index dff8f77..af43134 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -159,15 +159,6 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { 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{ GetUserResponse: dbUserToResponse(u, fields, members), MaxInvites: u.MaxInvites, @@ -175,7 +166,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { DiscordUsername: u.DiscordUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, - FediverseInstance: fediInstance, + FediverseInstance: u.FediverseInstance, }) return nil } diff --git a/backend/server/errors.go b/backend/server/errors.go index 976e7e3..4ae73a9 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -94,6 +94,7 @@ const ( ErrDeletionPending = 1011 // own user deletion pending, returned with undo code ErrRecentExport = 1012 // latest export is too recent ErrUnsupportedInstance = 1013 // unsupported fediverse software + ErrAlreadyLinked = 1014 // user already has linked account of the same type // User-related error codes ErrUserNotFound = 2001 @@ -130,6 +131,7 @@ var errCodeMessages = map[int]string{ ErrDeletionPending: "Your account is pending deletion", ErrRecentExport: "Your latest data export is less than 1 day old", ErrUnsupportedInstance: "Unsupported instance software", + ErrAlreadyLinked: "Your account is already linked to an account of this type", ErrUserNotFound: "User not found", @@ -163,6 +165,7 @@ var errCodeStatuses = map[int]int{ ErrDeletionPending: http.StatusBadRequest, ErrRecentExport: http.StatusBadRequest, ErrUnsupportedInstance: http.StatusBadRequest, + ErrAlreadyLinked: http.StatusBadRequest, ErrUserNotFound: http.StatusNotFound, diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 470bc65..7171ed5 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -21,6 +21,9 @@ export interface MeUser extends User { max_invites: number; discord: string | null; discord_username: string | null; + fediverse: string | null; + fediverse_username: string | null; + fediverse_instance: string | null; } export interface Field { diff --git a/frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte b/frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte index ff89268..971815c 100644 --- a/frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte +++ b/frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte @@ -4,10 +4,11 @@ import { goto } from "$app/navigation"; 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 type { PageData } from "./$types"; import ErrorAlert from "$lib/components/ErrorAlert.svelte"; + import { addToast } from "$lib/toast"; interface SignupResponse { user: MeUser; @@ -67,6 +68,22 @@ deleteError = e as APIError; } }; + + const linkAccount = async () => { + try { + const resp = await apiFetchClient("/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; + } + }; @@ -78,7 +95,32 @@ {#if data.error} {/if} -{#if data.ticket} +{#if data.ticket && $userStore} +
+ + +
+
+ + +
+
+ + +
+{:else if data.ticket}
@@ -86,7 +128,7 @@ id="fediverse" class="form-control" name="fediverse" - disabled + readonly value="{data.fediverse}@{data.instance}" />
diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index 3a94058..b19e652 100644 --- a/frontend/src/routes/settings/+layout.svelte +++ b/frontend/src/routes/settings/+layout.svelte @@ -35,7 +35,14 @@ Your profile - {#if data.require_invite} + + Authentication + + {#if data.invitesEnabled} { + const user = await apiFetchClient("/users/@me"); + const members = await apiFetchClient("/users/@me/members"); + + let invites: Invite[] = []; + let invitesEnabled = true; + try { + invites = await apiFetchClient("/auth/invites"); + } catch (e) { + if ((e as APIError).code === ErrorCode.InvitesDisabled) { + invitesEnabled = false; + } + } + const data = await parent(); - return data; + + return { + ...data, + user, + members, + invites, + invitesEnabled, + }; }) satisfies LayoutLoad; diff --git a/frontend/src/routes/settings/+page.ts b/frontend/src/routes/settings/+page.ts deleted file mode 100644 index dbe5eb7..0000000 --- a/frontend/src/routes/settings/+page.ts +++ /dev/null @@ -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("/users/@me"); - const members = await apiFetchClient("/users/@me/members"); - - let invites: Invite[] = []; - let invitesEnabled = true; - try { - invites = await apiFetchClient("/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); - } -}; diff --git a/frontend/src/routes/settings/auth/+page.svelte b/frontend/src/routes/settings/auth/+page.svelte new file mode 100644 index 0000000..4fa904d --- /dev/null +++ b/frontend/src/routes/settings/auth/+page.svelte @@ -0,0 +1,115 @@ + + +
+

Authentication providers

+ +
+
+ + + Fediverse + + {#if data.user.fediverse} + Your currently linked Fediverse account is {data.user.fediverse_username}@{data.user.fediverse_instance} + ({data.user.fediverse}). + {:else} + You do not have a linked Fediverse account. + {/if} + + {#if data.user.fediverse} + + {:else} + + {/if} + + +
+
+ + + Discord + + {#if data.user.discord} + Your currently linked Discord account is {data.user.discord_username} + ({data.user.discord}). + {:else} + You do not have a linked Discord account. + {/if} + + {#if data.user.discord} + + {:else} + + {/if} + + +
+ + +

+ Note: Misskey (and derivatives) are not supported yet, sorry. +

+ + {#if error} +
+ +
+ {/if} +
+ + + +
+
+
diff --git a/frontend/src/routes/settings/auth/+page.ts b/frontend/src/routes/settings/auth/+page.ts new file mode 100644 index 0000000..d4ba145 --- /dev/null +++ b/frontend/src/routes/settings/auth/+page.ts @@ -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("/auth/urls", { + method: "POST", + body: { + callback_domain: PUBLIC_BASE_URL, + }, + }); + + return { urls: resp }; +}; + +interface UrlsResponse { + discord: string; +} diff --git a/frontend/src/routes/settings/invites/+page.ts b/frontend/src/routes/settings/invites/+page.ts deleted file mode 100644 index 6e98a33..0000000 --- a/frontend/src/routes/settings/invites/+page.ts +++ /dev/null @@ -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("/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;