feat(backend): start on fediverse auth support
This commit is contained in:
		
							parent
							
								
									bfa810fbb2
								
							
						
					
					
						commit
						17f6ac4d23
					
				
					 7 changed files with 354 additions and 36 deletions
				
			
		
							
								
								
									
										76
									
								
								backend/db/fediverse.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								backend/db/fediverse.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,76 @@ | |||
| package db | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"os" | ||||
| 
 | ||||
| 	"emperror.dev/errors" | ||||
| 	"github.com/georgysavva/scany/pgxscan" | ||||
| 	"github.com/jackc/pgx/v4" | ||||
| 	"golang.org/x/oauth2" | ||||
| ) | ||||
| 
 | ||||
| type FediverseApp struct { | ||||
| 	ID int64 | ||||
| 	// Instance is the instance's base API url, excluding schema | ||||
| 	Instance     string | ||||
| 	ClientID     string | ||||
| 	ClientSecret string | ||||
| 	InstanceType string | ||||
| } | ||||
| 
 | ||||
| func (f FediverseApp) ClientConfig() *oauth2.Config { | ||||
| 	// if f.MastodonCompatible() { | ||||
| 	return &oauth2.Config{ | ||||
| 		ClientID:     f.ClientID, | ||||
| 		ClientSecret: f.ClientSecret, | ||||
| 		Endpoint: oauth2.Endpoint{ | ||||
| 			AuthURL:   "https://" + f.Instance + "/oauth/authorize", | ||||
| 			TokenURL:  "https://" + f.Instance + "/oauth/token", | ||||
| 			AuthStyle: oauth2.AuthStyleInParams, | ||||
| 		}, | ||||
| 		Scopes:      []string{"read:accounts"}, | ||||
| 		RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon", | ||||
| 	} | ||||
| 	// } | ||||
| 
 | ||||
| 	// TODO: misskey, assuming i can even find english API documentation, that is | ||||
| } | ||||
| 
 | ||||
| func (f FediverseApp) MastodonCompatible() bool { | ||||
| 	return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed" | ||||
| } | ||||
| 
 | ||||
| const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app") | ||||
| 
 | ||||
| func (db *DB) FediverseApp(ctx context.Context, instance string) (fa FediverseApp, err error) { | ||||
| 	sql, args, err := sq.Select("*").From("fediverse_apps").Where("instance = ?", instance).ToSql() | ||||
| 	if err != nil { | ||||
| 		return fa, errors.Wrap(err, "building sql") | ||||
| 	} | ||||
| 
 | ||||
| 	err = pgxscan.Get(ctx, db, &fa, sql, args...) | ||||
| 	if err != nil { | ||||
| 		if errors.Cause(err) == pgx.ErrNoRows { | ||||
| 			return fa, ErrNoInstanceApp | ||||
| 		} | ||||
| 		return fa, errors.Wrap(err, "executing query") | ||||
| 	} | ||||
| 	return fa, nil | ||||
| } | ||||
| 
 | ||||
| func (db *DB) CreateFediverseApp(ctx context.Context, instance, instanceType, clientID, clientSecret string) (fa FediverseApp, err error) { | ||||
| 	sql, args, err := sq.Insert("fediverse_apps"). | ||||
| 		Columns("instance", "instance_type", "client_id", "client_secret"). | ||||
| 		Values(instance, instanceType, clientID, clientSecret). | ||||
| 		Suffix("RETURNING *").ToSql() | ||||
| 	if err != nil { | ||||
| 		return fa, errors.Wrap(err, "building query") | ||||
| 	} | ||||
| 
 | ||||
| 	err = pgxscan.Get(ctx, db, &fa, sql, args...) | ||||
| 	if err != nil { | ||||
| 		return fa, errors.Wrap(err, "executing query") | ||||
| 	} | ||||
| 	return fa, nil | ||||
| } | ||||
|  | @ -28,6 +28,10 @@ type User struct { | |||
| 	Discord         *string | ||||
| 	DiscordUsername *string | ||||
| 
 | ||||
| 	Fediverse         *string | ||||
| 	FediverseUsername *string | ||||
| 	FediverseAppID    *int64 | ||||
| 
 | ||||
| 	MaxInvites int | ||||
| 
 | ||||
| 	DeletedAt    *time.Time | ||||
|  |  | |||
							
								
								
									
										95
									
								
								backend/routes/auth/fedi_nodeinfo.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								backend/routes/auth/fedi_nodeinfo.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,95 @@ | |||
| package auth | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 
 | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/server" | ||||
| 	"emperror.dev/errors" | ||||
| ) | ||||
| 
 | ||||
| const errNoNodeinfoURL = errors.Sentinel("no valid nodeinfo rel found") | ||||
| 
 | ||||
| // nodeinfo queries an instance's nodeinfo and returns the software name. | ||||
| func nodeinfo(ctx context.Context, instance string) (softwareName string, err error) { | ||||
| 	req, err := http.NewRequestWithContext(ctx, "GET", "https://"+instance+"/.well-known/nodeinfo", nil) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "creating .well-known/nodeinfo request") | ||||
| 	} | ||||
| 	req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) | ||||
| 	req.Header.Set("Accept", "application/json") | ||||
| 
 | ||||
| 	resp, err := http.DefaultClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "sending .well-known/nodeinfo request") | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
| 
 | ||||
| 	jb, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "reading .well-known/nodeinfo response") | ||||
| 	} | ||||
| 
 | ||||
| 	var wkr wellKnownResponse | ||||
| 	err = json.Unmarshal(jb, &wkr) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "unmarshaling .well-known/nodeinfo response") | ||||
| 	} | ||||
| 
 | ||||
| 	var nodeinfoURL string | ||||
| 	for _, link := range wkr.Links { | ||||
| 		if link.Rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" { | ||||
| 			nodeinfoURL = link.Href | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if nodeinfoURL == "" { | ||||
| 		return "", errNoNodeinfoURL | ||||
| 	} | ||||
| 
 | ||||
| 	req, err = http.NewRequestWithContext(ctx, "GET", nodeinfoURL, nil) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "creating nodeinfo request") | ||||
| 	} | ||||
| 	req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) | ||||
| 	req.Header.Set("Accept", "application/json") | ||||
| 
 | ||||
| 	resp, err = http.DefaultClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "sending nodeinfo request") | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
| 
 | ||||
| 	jb, err = io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "reading nodeinfo response") | ||||
| 	} | ||||
| 
 | ||||
| 	var ni partialNodeinfo | ||||
| 	err = json.Unmarshal(jb, &ni) | ||||
| 	if err != nil { | ||||
| 		return "", errors.Wrap(err, "unmarshaling nodeinfo response") | ||||
| 	} | ||||
| 
 | ||||
| 	return ni.Software.Name, nil | ||||
| } | ||||
| 
 | ||||
| type wellKnownResponse struct { | ||||
| 	Links []wellKnownLink `json:"links"` | ||||
| } | ||||
| 
 | ||||
| type wellKnownLink struct { | ||||
| 	Rel  string `json:"rel"` | ||||
| 	Href string `json:"href"` | ||||
| } | ||||
| 
 | ||||
| type partialNodeinfo struct { | ||||
| 	Software nodeinfoSoftware `json:"software"` | ||||
| } | ||||
| 
 | ||||
| type nodeinfoSoftware struct { | ||||
| 	Name string `json:"name"` | ||||
| } | ||||
							
								
								
									
										114
									
								
								backend/routes/auth/fediverse.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								backend/routes/auth/fediverse.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,114 @@ | |||
| package auth | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/log" | ||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/server" | ||||
| 	"emperror.dev/errors" | ||||
| 	"github.com/go-chi/render" | ||||
| ) | ||||
| 
 | ||||
| type FediResponse struct { | ||||
| 	URL string `json:"url"` | ||||
| } | ||||
| 
 | ||||
| func (s *Server) getFediverseURL(w http.ResponseWriter, r *http.Request) error { | ||||
| 	ctx := r.Context() | ||||
| 	instance := r.FormValue("instance") | ||||
| 	if instance == "" { | ||||
| 		return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL is empty"} | ||||
| 	} | ||||
| 
 | ||||
| 	app, err := s.DB.FediverseApp(ctx, instance) | ||||
| 	if err != nil { | ||||
| 		return s.noAppFediverseURL(ctx, w, r, instance) | ||||
| 	} | ||||
| 
 | ||||
| 	state, err := s.setCSRFState(r.Context()) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "setting CSRF state") | ||||
| 	} | ||||
| 
 | ||||
| 	render.JSON(w, r, FediResponse{ | ||||
| 		URL: app.ClientConfig().AuthCodeURL(state), | ||||
| 	}) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r *http.Request, instance string) error { | ||||
| 	softwareName, err := nodeinfo(ctx, instance) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("querying instance %q nodeinfo: %v", instance, err) | ||||
| 	} | ||||
| 
 | ||||
| 	switch softwareName { | ||||
| 	case "mastodon", "pleroma", "akkoma", "pixelfed", "calckey": | ||||
| 	default: | ||||
| 		// sorry, misskey :( TODO: support misskey | ||||
| 		return server.APIError{Code: server.ErrUnsupportedInstance} | ||||
| 	} | ||||
| 
 | ||||
| 	log.Debugf("creating application on mastodon-compatible instance %q", instance) | ||||
| 
 | ||||
| 	formData := url.Values{ | ||||
| 		"client_name":   {"pronouns.cc (+" + s.BaseURL + ")"}, | ||||
| 		"redirect_uris": {s.BaseURL + "/auth/login/mastodon"}, | ||||
| 		"scopes":        {"read:accounts"}, | ||||
| 		"website":       {s.BaseURL}, | ||||
| 	} | ||||
| 
 | ||||
| 	req, err := http.NewRequestWithContext(ctx, "POST", "https://"+instance+"/api/v1/apps", strings.NewReader(formData.Encode())) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("creating POST apps request for %q: %v", instance, err) | ||||
| 		return errors.Wrap(err, "creating POST apps request") | ||||
| 	} | ||||
| 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||||
| 	req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) | ||||
| 
 | ||||
| 	resp, err := http.DefaultClient.Do(req) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("sending POST apps request for %q: %v", instance, err) | ||||
| 		return errors.Wrap(err, "sending POST apps request") | ||||
| 	} | ||||
| 	defer resp.Body.Close() | ||||
| 
 | ||||
| 	jb, err := io.ReadAll(resp.Body) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("reading response for request: %v", err) | ||||
| 		return errors.Wrap(err, "reading response") | ||||
| 	} | ||||
| 
 | ||||
| 	var ma mastodonApplication | ||||
| 	err = json.Unmarshal(jb, &ma) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "unmarshaling mastodon app") | ||||
| 	} | ||||
| 
 | ||||
| 	app, err := s.DB.CreateFediverseApp(ctx, instance, softwareName, ma.ClientID, ma.ClientSecret) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("saving app for %q: %v", instance, err) | ||||
| 		return errors.Wrap(err, "creating app") | ||||
| 	} | ||||
| 
 | ||||
| 	state, err := s.setCSRFState(r.Context()) | ||||
| 	if err != nil { | ||||
| 		return errors.Wrap(err, "setting CSRF state") | ||||
| 	} | ||||
| 
 | ||||
| 	render.JSON(w, r, FediResponse{ | ||||
| 		URL: app.ClientConfig().AuthCodeURL(state), | ||||
| 	}) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type mastodonApplication struct { | ||||
| 	Name         string `json:"name"` | ||||
| 	ClientID     string `json:"client_id"` | ||||
| 	ClientSecret string `json:"client_secret"` | ||||
| } | ||||
|  | @ -17,6 +17,7 @@ type Server struct { | |||
| 	*server.Server | ||||
| 
 | ||||
| 	RequireInvite bool | ||||
| 	BaseURL       string | ||||
| 	ExporterPath  string | ||||
| } | ||||
| 
 | ||||
|  | @ -55,6 +56,7 @@ func Mount(srv *server.Server, r chi.Router) { | |||
| 	s := &Server{ | ||||
| 		Server:        srv, | ||||
| 		RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", | ||||
| 		BaseURL:       os.Getenv("BASE_URL"), | ||||
| 		ExporterPath:  "http://127.0.0.1:" + os.Getenv("EXPORTER_PORT"), | ||||
| 	} | ||||
| 
 | ||||
|  | @ -64,12 +66,21 @@ func Mount(srv *server.Server, r chi.Router) { | |||
| 
 | ||||
| 		// generate csrf token, returns all supported OAuth provider URLs | ||||
| 		r.Post("/urls", server.WrapHandler(s.oauthURLs)) | ||||
| 		r.Get("/urls/fediverse", server.WrapHandler(s.getFediverseURL)) | ||||
| 
 | ||||
| 		r.Route("/discord", func(r chi.Router) { | ||||
| 			// takes code + state, validates it, returns token OR discord signup ticket | ||||
| 			r.Post("/callback", server.WrapHandler(s.discordCallback)) | ||||
| 			// takes discord signup ticket to register account | ||||
| 			r.Post("/signup", server.WrapHandler(s.discordSignup)) | ||||
| 			// takes discord signup ticket to link to existing account | ||||
| 			r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(nil)) | ||||
| 		}) | ||||
| 
 | ||||
| 		r.Route("/mastodon", func(r chi.Router) { | ||||
| 			r.Post("/callback", server.WrapHandler(nil)) | ||||
| 			r.Post("/signup", server.WrapHandler(nil)) | ||||
| 			r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(nil)) | ||||
| 		}) | ||||
| 
 | ||||
| 		// invite routes | ||||
|  |  | |||
|  | @ -81,18 +81,19 @@ const ( | |||
| 	ErrInternalServerError = 500 // catch-all code for unknown errors | ||||
| 
 | ||||
| 	// Login/authorize error codes | ||||
| 	ErrInvalidState       = 1001 | ||||
| 	ErrInvalidOAuthCode   = 1002 | ||||
| 	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) | ||||
| 	ErrInvitesDisabled    = 1008 // invites are disabled (unneeded) | ||||
| 	ErrInviteLimitReached = 1009 // invite limit reached (when creating invites) | ||||
| 	ErrInviteAlreadyUsed  = 1010 // invite already used (when signing up) | ||||
| 	ErrDeletionPending    = 1011 // own user deletion pending, returned with undo code | ||||
| 	ErrRecentExport       = 1012 // latest export is too recent | ||||
| 	ErrInvalidState        = 1001 | ||||
| 	ErrInvalidOAuthCode    = 1002 | ||||
| 	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) | ||||
| 	ErrInvitesDisabled     = 1008 // invites are disabled (unneeded) | ||||
| 	ErrInviteLimitReached  = 1009 // invite limit reached (when creating invites) | ||||
| 	ErrInviteAlreadyUsed   = 1010 // invite already used (when signing up) | ||||
| 	ErrDeletionPending     = 1011 // own user deletion pending, returned with undo code | ||||
| 	ErrRecentExport        = 1012 // latest export is too recent | ||||
| 	ErrUnsupportedInstance = 1013 // unsupported fediverse software | ||||
| 
 | ||||
| 	// User-related error codes | ||||
| 	ErrUserNotFound = 2001 | ||||
|  | @ -116,18 +117,19 @@ var errCodeMessages = map[int]string{ | |||
| 	ErrTooManyRequests:     "Rate limit reached", | ||||
| 	ErrMethodNotAllowed:    "Method not allowed", | ||||
| 
 | ||||
| 	ErrInvalidState:       "Invalid OAuth state", | ||||
| 	ErrInvalidOAuthCode:   "Invalid OAuth code", | ||||
| 	ErrInvalidToken:       "Supplied token was invalid", | ||||
| 	ErrInviteRequired:     "A valid invite code is required", | ||||
| 	ErrInvalidTicket:      "Invalid signup ticket", | ||||
| 	ErrInvalidUsername:    "Invalid username", | ||||
| 	ErrUsernameTaken:      "Username is already taken", | ||||
| 	ErrInvitesDisabled:    "Invites are disabled", | ||||
| 	ErrInviteLimitReached: "Your account has reached the invite limit", | ||||
| 	ErrInviteAlreadyUsed:  "That invite code has already been used", | ||||
| 	ErrDeletionPending:    "Your account is pending deletion", | ||||
| 	ErrRecentExport:       "Your latest data export is less than 1 day old", | ||||
| 	ErrInvalidState:        "Invalid OAuth state", | ||||
| 	ErrInvalidOAuthCode:    "Invalid OAuth code", | ||||
| 	ErrInvalidToken:        "Supplied token was invalid", | ||||
| 	ErrInviteRequired:      "A valid invite code is required", | ||||
| 	ErrInvalidTicket:       "Invalid signup ticket", | ||||
| 	ErrInvalidUsername:     "Invalid username", | ||||
| 	ErrUsernameTaken:       "Username is already taken", | ||||
| 	ErrInvitesDisabled:     "Invites are disabled", | ||||
| 	ErrInviteLimitReached:  "Your account has reached the invite limit", | ||||
| 	ErrInviteAlreadyUsed:   "That invite code has already been used", | ||||
| 	ErrDeletionPending:     "Your account is pending deletion", | ||||
| 	ErrRecentExport:        "Your latest data export is less than 1 day old", | ||||
| 	ErrUnsupportedInstance: "Unsupported instance software", | ||||
| 
 | ||||
| 	ErrUserNotFound: "User not found", | ||||
| 
 | ||||
|  | @ -148,18 +150,19 @@ var errCodeStatuses = map[int]int{ | |||
| 	ErrTooManyRequests:     http.StatusTooManyRequests, | ||||
| 	ErrMethodNotAllowed:    http.StatusMethodNotAllowed, | ||||
| 
 | ||||
| 	ErrInvalidState:       http.StatusBadRequest, | ||||
| 	ErrInvalidOAuthCode:   http.StatusForbidden, | ||||
| 	ErrInvalidToken:       http.StatusUnauthorized, | ||||
| 	ErrInviteRequired:     http.StatusBadRequest, | ||||
| 	ErrInvalidTicket:      http.StatusBadRequest, | ||||
| 	ErrInvalidUsername:    http.StatusBadRequest, | ||||
| 	ErrUsernameTaken:      http.StatusBadRequest, | ||||
| 	ErrInvitesDisabled:    http.StatusForbidden, | ||||
| 	ErrInviteLimitReached: http.StatusForbidden, | ||||
| 	ErrInviteAlreadyUsed:  http.StatusBadRequest, | ||||
| 	ErrDeletionPending:    http.StatusBadRequest, | ||||
| 	ErrRecentExport:       http.StatusBadRequest, | ||||
| 	ErrInvalidState:        http.StatusBadRequest, | ||||
| 	ErrInvalidOAuthCode:    http.StatusForbidden, | ||||
| 	ErrInvalidToken:        http.StatusUnauthorized, | ||||
| 	ErrInviteRequired:      http.StatusBadRequest, | ||||
| 	ErrInvalidTicket:       http.StatusBadRequest, | ||||
| 	ErrInvalidUsername:     http.StatusBadRequest, | ||||
| 	ErrUsernameTaken:       http.StatusBadRequest, | ||||
| 	ErrInvitesDisabled:     http.StatusForbidden, | ||||
| 	ErrInviteLimitReached:  http.StatusForbidden, | ||||
| 	ErrInviteAlreadyUsed:   http.StatusBadRequest, | ||||
| 	ErrDeletionPending:     http.StatusBadRequest, | ||||
| 	ErrRecentExport:        http.StatusBadRequest, | ||||
| 	ErrUnsupportedInstance: http.StatusBadRequest, | ||||
| 
 | ||||
| 	ErrUserNotFound: http.StatusNotFound, | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										15
									
								
								scripts/migrate/009_fediverse_oauth.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								scripts/migrate/009_fediverse_oauth.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | |||
| -- +migrate Up | ||||
| 
 | ||||
| -- 2023-03-16: Add fediverse (Mastodon/Pleroma/Misskey) OAuth | ||||
| 
 | ||||
| create table fediverse_apps ( | ||||
|     id            serial primary key, | ||||
|     instance      text not null unique, | ||||
|     client_id     text not null, | ||||
|     client_secret text not null, | ||||
|     instance_type text not null | ||||
| ); | ||||
| 
 | ||||
| alter table users add column fediverse text null; | ||||
| alter table users add column fediverse_username text null; | ||||
| alter table users add column fediverse_app_id integer null references fediverse_apps (id) on delete set null; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue