feat: add tumblr oauth
This commit is contained in:
		
							parent
							
								
									6131884ba7
								
							
						
					
					
						commit
						716c1283e7
					
				
					 13 changed files with 641 additions and 7 deletions
				
			
		|  | @ -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"). | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
							
								
								
									
										394
									
								
								backend/routes/auth/tumblr.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										394
									
								
								backend/routes/auth/tumblr.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| } | ||||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -15,6 +15,7 @@ export interface MetaResponse { | |||
| 
 | ||||
| export interface UrlsResponse { | ||||
|   discord: string; | ||||
|   tumblr: string; | ||||
| } | ||||
| 
 | ||||
| export interface ExportResponse { | ||||
|  |  | |||
|  | @ -60,12 +60,9 @@ | |||
|   <div class="row"> | ||||
|     <div class="col-md-4 mb-1"> | ||||
|       <ListGroup> | ||||
|         <ListGroupItem tag="button" on:click={toggleModal}> | ||||
|           <img height="16px" src={fediverse} alt="Fediverse logo" aria-hidden /> Log in with the Fediverse | ||||
|         </ListGroupItem> | ||||
|         <ListGroupItem tag="a" href={data.discord}> | ||||
|           <Icon name="discord" /> Log in with Discord | ||||
|         </ListGroupItem> | ||||
|         <ListGroupItem tag="button" on:click={toggleModal}>Log in with the Fediverse</ListGroupItem> | ||||
|         <ListGroupItem tag="a" href={data.discord}>Log in with Discord</ListGroupItem> | ||||
|         <ListGroupItem tag="a" href={data.tumblr}>Log in with Tumblr</ListGroupItem> | ||||
|       </ListGroup> | ||||
|       <Modal header="Pick an instance" isOpen={modalOpen} toggle={toggleModal}> | ||||
|         <ModalBody> | ||||
|  |  | |||
							
								
								
									
										38
									
								
								frontend/src/routes/auth/login/tumblr/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								frontend/src/routes/auth/login/tumblr/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<CallbackResponse>("/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; | ||||
| } | ||||
							
								
								
									
										64
									
								
								frontend/src/routes/auth/login/tumblr/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								frontend/src/routes/auth/login/tumblr/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,64 @@ | |||
| <script lang="ts"> | ||||
|   import { goto } from "$app/navigation"; | ||||
|   import type { APIError, MeUser } from "$lib/api/entities"; | ||||
|   import { apiFetch, apiFetchClient } from "$lib/api/fetch"; | ||||
|   import { userStore } from "$lib/store"; | ||||
|   import type { PageData } from "./$types"; | ||||
|   import { addToast } from "$lib/toast"; | ||||
|   import CallbackPage from "../CallbackPage.svelte"; | ||||
|   import type { SignupResponse } from "$lib/api/responses"; | ||||
| 
 | ||||
|   export let data: PageData; | ||||
| 
 | ||||
|   const signupForm = async (username: string, invite: string) => { | ||||
|     try { | ||||
|       const resp = await apiFetch<SignupResponse>("/auth/tumblr/signup", { | ||||
|         method: "POST", | ||||
|         body: { | ||||
|           ticket: data.ticket, | ||||
|           username: username, | ||||
|           invite_code: invite, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       localStorage.setItem("pronouns-token", resp.token); | ||||
|       localStorage.setItem("pronouns-user", JSON.stringify(resp.user)); | ||||
|       userStore.set(resp.user); | ||||
|       addToast({ header: "Welcome!", body: "Signed up successfully!" }); | ||||
|       goto(`/@${resp.user.name}`); | ||||
|     } catch (e) { | ||||
|       data.error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const linkAccount = async () => { | ||||
|     try { | ||||
|       const resp = await apiFetchClient<MeUser>("/auth/tumblr/add-provider", "POST", { | ||||
|         ticket: data.ticket, | ||||
|       }); | ||||
| 
 | ||||
|       localStorage.setItem("pronouns-user", JSON.stringify(resp)); | ||||
|       userStore.set(resp); | ||||
|       addToast({ header: "Linked account", body: "Successfully linked account!" }); | ||||
|       await goto("/settings/auth"); | ||||
|     } catch (e) { | ||||
|       data.error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <CallbackPage | ||||
|   authType="Tumblr" | ||||
|   remoteName={data.tumblr} | ||||
|   error={data.error} | ||||
|   requireInvite={data.require_invite} | ||||
|   isDeleted={data.is_deleted} | ||||
|   ticket={data.ticket} | ||||
|   token={data.token} | ||||
|   user={data.user} | ||||
|   deletedAt={data.deleted_at} | ||||
|   selfDelete={data.self_delete} | ||||
|   deleteReason={data.delete_reason} | ||||
|   {linkAccount} | ||||
|   {signupForm} | ||||
| /> | ||||
|  | @ -21,7 +21,7 @@ | |||
|   let canUnlink = false; | ||||
| 
 | ||||
|   $: canUnlink = | ||||
|     [data.user.discord, data.user.fediverse] | ||||
|     [data.user.discord, data.user.fediverse, data.user.tumblr] | ||||
|       .map<number>((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<MeUser>("/auth/tumblr/remove-provider", "POST"); | ||||
|       data.user = resp; | ||||
|       addToast({ header: "Unlinked account", body: "Successfully unlinked Tumblr account!" }); | ||||
|       toggleTumblrUnlinkModal(); | ||||
|     } catch (e) { | ||||
|       error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <div> | ||||
|  | @ -126,6 +140,28 @@ | |||
|         </CardBody> | ||||
|       </Card> | ||||
|     </div> | ||||
|     <div class="my-2"> | ||||
|       <Card> | ||||
|         <CardBody> | ||||
|           <CardTitle>Tumblr</CardTitle> | ||||
|           <CardText> | ||||
|             {#if data.user.tumblr} | ||||
|               Your currently linked Tumblr account is <b>{data.user.tumblr_username}</b> | ||||
|               (<code>{data.user.tumblr}</code>). | ||||
|             {:else} | ||||
|               You do not have a linked Tumblr account. | ||||
|             {/if} | ||||
|           </CardText> | ||||
|           {#if data.user.tumblr} | ||||
|             <Button color="danger" disabled={!canUnlink} on:click={toggleTumblrUnlinkModal} | ||||
|               >Unlink account</Button | ||||
|             > | ||||
|           {:else} | ||||
|             <Button color="secondary" href={data.urls.tumblr}>Link account</Button> | ||||
|           {/if} | ||||
|         </CardBody> | ||||
|       </Card> | ||||
|     </div> | ||||
|     <Modal header="Pick an instance" isOpen={fediLinkModalOpen} toggle={toggleFediLinkModal}> | ||||
|       <ModalBody> | ||||
|         <Input placeholder="Instance (e.g. mastodon.social)" bind:value={instance} /> | ||||
|  |  | |||
							
								
								
									
										6
									
								
								scripts/migrate/013_tumblr_oauth.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								scripts/migrate/013_tumblr_oauth.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -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; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue