diff --git a/backend/db/user.go b/backend/db/user.go index 3ad8196..8c9d00f 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -36,6 +36,9 @@ type User struct { FediverseAppID *int64 FediverseInstance *string + Tumblr *string + TumblrUsername *string + MaxInvites int IsAdmin bool ListPrivate bool @@ -52,6 +55,9 @@ func (u User) NumProviders() (numProviders int) { if u.Fediverse != nil { numProviders++ } + if u.Tumblr != nil { + numProviders++ + } return numProviders } @@ -240,6 +246,67 @@ func (u *User) UnlinkDiscord(ctx context.Context, ex Execer) error { return nil } +// TumblrUser fetches a user by Tumblr user ID. +func (db *DB) TumblrUser(ctx context.Context, tumblrID string) (u User, err error) { + sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). + From("users").Where("tumblr = ?", tumblrID).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, "executing query") + } + return u, nil +} + +func (u *User) UpdateFromTumblr(ctx context.Context, ex Execer, tumblrID, tumblrUsername string) error { + sql, args, err := sq.Update("users"). + Set("tumblr", tumblrID). + Set("tumblr_username", tumblrUsername). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Tumblr = &tumblrID + u.TumblrUsername = &tumblrUsername + + return nil +} + +func (u *User) UnlinkTumblr(ctx context.Context, ex Execer) error { + sql, args, err := sq.Update("users"). + Set("tumblr", nil). + Set("tumblr_username", nil). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Tumblr = nil + u.TumblrUsername = nil + + return nil +} + // 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("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance"). diff --git a/backend/exporter/types.go b/backend/exporter/types.go index 83540f3..48682d0 100644 --- a/backend/exporter/types.go +++ b/backend/exporter/types.go @@ -24,6 +24,9 @@ type userExport struct { Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` + Tumblr *string `json:"tumblr"` + TumblrUsername *string `json:"tumblr_username"` + MaxInvites int `json:"max_invites"` Warnings []db.Warning `json:"warnings"` @@ -41,6 +44,8 @@ func dbUserToExport(u db.User, fields []db.Field, warnings []db.Warning) userExp Fields: db.NotNull(fields), Discord: u.Discord, DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, MaxInvites: u.MaxInvites, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 1283472..18a06e5 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -34,6 +34,9 @@ type userResponse struct { Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` + Tumblr *string `json:"tumblr"` + TumblrUsername *string `json:"tumblr_username"` + Fediverse *string `json:"fediverse"` FediverseUsername *string `json:"fediverse_username"` FediverseInstance *string `json:"fediverse_instance"` @@ -52,6 +55,8 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { Fields: db.NotNull(fields), Discord: u.Discord, DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: u.FediverseInstance, @@ -84,6 +89,13 @@ func Mount(srv *server.Server, r chi.Router) { r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.discordUnlink)) }) + r.Route("/tumblr", func(r chi.Router) { + r.Post("/callback", server.WrapHandler(s.tumblrCallback)) + r.Post("/signup", server.WrapHandler(s.tumblrSignup)) + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.tumblrLink)) + r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.tumblrUnlink)) + }) + r.Route("/mastodon", func(r chi.Router) { r.Post("/callback", server.WrapHandler(s.mastodonCallback)) r.Post("/signup", server.WrapHandler(s.mastodonSignup)) @@ -121,6 +133,7 @@ type oauthURLsRequest struct { type oauthURLsResponse struct { Discord string `json:"discord"` + Tumblr string `json:"tumblr"` } func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error { @@ -140,9 +153,13 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error { // copy Discord config and set redirect url discordCfg := discordOAuthConfig discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord" + // copy tumblr config + tumblrCfg := tumblrOAuthConfig + tumblrCfg.RedirectURL = req.CallbackDomain + "/auth/login/tumblr" render.JSON(w, r, oauthURLsResponse{ Discord: discordCfg.AuthCodeURL(state) + "&prompt=none", + Tumblr: tumblrCfg.AuthCodeURL(state), }) return nil } diff --git a/backend/routes/auth/tumblr.go b/backend/routes/auth/tumblr.go new file mode 100644 index 0000000..d477b31 --- /dev/null +++ b/backend/routes/auth/tumblr.go @@ -0,0 +1,394 @@ +package auth + +import ( + "encoding/json" + "io" + "net/http" + "os" + "time" + + "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" + "golang.org/x/oauth2" +) + +var tumblrOAuthConfig = oauth2.Config{ + ClientID: os.Getenv("TUMBLR_CLIENT_ID"), + ClientSecret: os.Getenv("TUMBLR_CLIENT_SECRET"), + Endpoint: oauth2.Endpoint{ + AuthURL: "https://www.tumblr.com/oauth2/authorize", + TokenURL: "https://api.tumblr.com/v2/oauth2/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"basic"}, +} + +type partialTumblrResponse struct { + Meta struct { + Status int `json:"status"` + Message string `json:"msg"` + } `json:"meta"` + Response struct { + User struct { + Blogs []struct { + Name string `json:"name"` + Primary bool `json:"primary"` + UUID string `json:"uuid"` + } `json:"blogs"` + } `json:"user"` + } `json:"response"` +} + +type tumblrUserInfo struct { + Name string `json:"name"` + ID string `json:"id"` +} + +type tumblrCallbackResponse struct { + HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Tumblr will be set + + Token string `json:"token,omitempty"` + User *userResponse `json:"user,omitempty"` + + Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + SelfDelete *bool `json:"self_delete,omitempty"` + DeleteReason *string `json:"delete_reason,omitempty"` +} + +func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + decoded, err := Decode[discordOauthCallbackRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // if the state can't be validated, return + if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { + if err != nil { + return err + } + + return server.APIError{Code: server.ErrInvalidState} + } + + cfg := tumblrOAuthConfig + cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/tumblr" + token, err := cfg.Exchange(r.Context(), decoded.Code) + if err != nil { + log.Errorf("exchanging oauth code: %v", err) + + return server.APIError{Code: server.ErrInvalidOAuthCode} + } + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.tumblr.com/v2/user/info", nil) + if err != nil { + return errors.Wrap(err, "creating user/info request") + } + + req.Header.Set("Content-Type", "application/json") + token.SetAuthHeader(req) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return errors.Wrap(err, "sending user/info request") + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return errors.New("response had status code < 200 or >= 400") + } + + jb, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrap(err, "reading user/info response") + } + + var tr partialTumblrResponse + err = json.Unmarshal(jb, &tr) + if err != nil { + return errors.Wrap(err, "unmarshaling user/info response") + } + + var tumblrName, tumblrID string + for _, blog := range tr.Response.User.Blogs { + if blog.Primary { + tumblrName = blog.Name + tumblrID = blog.UUID + break + } + } + + if tumblrID == "" { + return server.APIError{Code: server.ErrInternalServerError, Details: "Your Tumblr account doesn't seem to have a primary blog"} + } + + u, err := s.DB.TumblrUser(ctx, tumblrID) + if err == nil { + if u.DeletedAt != nil { + // store cancel delete token + token := undeleteToken() + err = s.saveUndeleteToken(ctx, u.ID, token) + if err != nil { + log.Errorf("saving undelete token: %v", err) + return err + } + + render.JSON(w, r, tumblrCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, + }) + return nil + } + + err = u.UpdateFromTumblr(ctx, s.DB, tumblrName, tumblrID) + if err != nil { + log.Errorf("updating user %v with Tumblr info: %v", u.ID, err) + } + + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) + if err != nil { + return err + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "querying fields") + } + + render.JSON(w, r, tumblrCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, fields), + }) + + return nil + + } else if err != db.ErrUserNotFound { // internal error + return err + } + + // no user found, so save a ticket + save their Tumblr info in Redis + ticket := RandBase64(32) + err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600") + if err != nil { + log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err) + return err + } + + render.JSON(w, r, tumblrCallbackResponse{ + HasAccount: false, + Tumblr: tumblrName, + Ticket: ticket, + RequireInvite: s.RequireInvite, + }) + + return nil +} + +func (s *Server) tumblrLink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + req, err := Decode[linkRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Tumblr != nil { + return server.APIError{Code: server.ErrAlreadyLinked} + } + + tui := new(tumblrUserInfo) + err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui) + if err != nil { + log.Errorf("getting tumblr user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + err = u.UpdateFromTumblr(ctx, s.DB, tui.ID, tui.Name) + if err != nil { + return errors.Wrap(err, "updating user from tumblr") + } + + 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 +} + +func (s *Server) tumblrUnlink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken { + return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Tumblr == nil { + return server.APIError{Code: server.ErrNotLinked} + } + + // cannot unlink last auth provider + if u.NumProviders() <= 1 { + return server.APIError{Code: server.ErrLastProvider} + } + + err = u.UnlinkTumblr(ctx, s.DB) + if err != nil { + return errors.Wrap(err, "updating user in db") + } + + 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 +} + +func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + req, err := Decode[discordSignupRequest](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) + + tui := new(tumblrUserInfo) + err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui) + if err != nil { + log.Errorf("getting tumblr user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + 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.UpdateFromTumblr(ctx, tx, tui.ID, tui.Name) + if err != nil { + return errors.Wrap(err, "updating user from tumblr") + } + + if s.RequireInvite { + valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode) + if err != nil { + return errors.Wrap(err, "checking and invalidating invite") + } + + if !valid { + return server.APIError{Code: server.ErrInviteRequired} + } + + if used { + return server.APIError{Code: server.ErrInviteAlreadyUsed} + } + } + + // delete sign up ticket + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "tumblr:"+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 + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + // return user + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u, nil), + Token: token, + }) + return nil +} diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index b8e3833..9265ce1 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -35,6 +35,9 @@ type GetMeResponse struct { Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` + Tumblr *string `json:"tumblr"` + TumblrUsername *string `json:"tumblr_username"` + Fediverse *string `json:"fediverse"` FediverseUsername *string `json:"fediverse_username"` FediverseInstance *string `json:"fediverse_instance"` @@ -191,6 +194,8 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { ListPrivate: u.ListPrivate, Discord: u.Discord, DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: u.FediverseInstance, diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index 01a32c2..bef275a 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -251,6 +251,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { ListPrivate: u.ListPrivate, Discord: u.Discord, DiscordUsername: u.DiscordUsername, + Tumblr: u.Tumblr, + TumblrUsername: u.TumblrUsername, Fediverse: u.Fediverse, FediverseUsername: u.FediverseUsername, FediverseInstance: fediInstance, diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 133b609..18043e6 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -22,6 +22,8 @@ export interface MeUser extends User { max_invites: number; discord: string | null; discord_username: string | null; + tumblr: string | null; + tumblr_username: string | null; fediverse: string | null; fediverse_username: string | null; fediverse_instance: string | null; diff --git a/frontend/src/lib/api/responses.ts b/frontend/src/lib/api/responses.ts index b7ba0c5..e3cc6cb 100644 --- a/frontend/src/lib/api/responses.ts +++ b/frontend/src/lib/api/responses.ts @@ -15,6 +15,7 @@ export interface MetaResponse { export interface UrlsResponse { discord: string; + tumblr: string; } export interface ExportResponse { diff --git a/frontend/src/routes/auth/login/+page.svelte b/frontend/src/routes/auth/login/+page.svelte index 339db36..4150e4a 100644 --- a/frontend/src/routes/auth/login/+page.svelte +++ b/frontend/src/routes/auth/login/+page.svelte @@ -60,12 +60,9 @@
- - Fediverse logo Log in with the Fediverse - - - Log in with Discord - + Log in with the Fediverse + Log in with Discord + Log in with Tumblr diff --git a/frontend/src/routes/auth/login/tumblr/+page.server.ts b/frontend/src/routes/auth/login/tumblr/+page.server.ts new file mode 100644 index 0000000..48a0b8c --- /dev/null +++ b/frontend/src/routes/auth/login/tumblr/+page.server.ts @@ -0,0 +1,38 @@ +import type { APIError, MeUser } from "$lib/api/entities"; +import { apiFetch } from "$lib/api/fetch"; +import type { PageServerLoad } from "./$types"; +import { PUBLIC_BASE_URL } from "$env/static/public"; + +export const load = (async ({ url }) => { + try { + const resp = await apiFetch("/auth/tumblr/callback", { + method: "POST", + body: { + callback_domain: PUBLIC_BASE_URL, + code: url.searchParams.get("code"), + state: url.searchParams.get("state"), + }, + }); + + return { + ...resp, + }; + } catch (e) { + return { error: e as APIError }; + } +}) satisfies PageServerLoad; + +interface CallbackResponse { + has_account: boolean; + token?: string; + user?: MeUser; + + tumblr?: string; + ticket?: string; + require_invite: boolean; + + is_deleted: boolean; + deleted_at?: string; + self_delete?: boolean; + delete_reason?: string; +} diff --git a/frontend/src/routes/auth/login/tumblr/+page.svelte b/frontend/src/routes/auth/login/tumblr/+page.svelte new file mode 100644 index 0000000..c64dcef --- /dev/null +++ b/frontend/src/routes/auth/login/tumblr/+page.svelte @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/routes/settings/auth/+page.svelte b/frontend/src/routes/settings/auth/+page.svelte index eddd309..27d1217 100644 --- a/frontend/src/routes/settings/auth/+page.svelte +++ b/frontend/src/routes/settings/auth/+page.svelte @@ -21,7 +21,7 @@ let canUnlink = false; $: canUnlink = - [data.user.discord, data.user.fediverse] + [data.user.discord, data.user.fediverse, data.user.tumblr] .map((entry) => (entry === null ? 0 : 1)) .reduce((prev, current) => prev + current) >= 2; @@ -38,6 +38,9 @@ let discordUnlinkModalOpen = false; let toggleDiscordUnlinkModal = () => (discordUnlinkModalOpen = !discordUnlinkModalOpen); + let tumblrUnlinkModalOpen = false; + let toggleTumblrUnlinkModal = () => (tumblrUnlinkModalOpen = !tumblrUnlinkModalOpen); + const fediLogin = async () => { fediDisabled = true; try { @@ -74,6 +77,17 @@ error = e as APIError; } }; + + const tumblrUnlink = async () => { + try { + const resp = await apiFetchClient("/auth/tumblr/remove-provider", "POST"); + data.user = resp; + addToast({ header: "Unlinked account", body: "Successfully unlinked Tumblr account!" }); + toggleTumblrUnlinkModal(); + } catch (e) { + error = e as APIError; + } + };
@@ -126,6 +140,28 @@
+
+ + + Tumblr + + {#if data.user.tumblr} + Your currently linked Tumblr account is {data.user.tumblr_username} + ({data.user.tumblr}). + {:else} + You do not have a linked Tumblr account. + {/if} + + {#if data.user.tumblr} + + {:else} + + {/if} + + +
diff --git a/scripts/migrate/013_tumblr_oauth.sql b/scripts/migrate/013_tumblr_oauth.sql new file mode 100644 index 0000000..0a04c62 --- /dev/null +++ b/scripts/migrate/013_tumblr_oauth.sql @@ -0,0 +1,6 @@ +-- +migrate Up + +-- 2023-04-18: Add tumblr oauth + +alter table users add column tumblr text null; +alter table users add column tumblr_username text null;