feat(backend): start on fediverse auth support
This commit is contained in:
parent
bfa810fbb2
commit
17f6ac4d23
|
@ -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
|
Discord *string
|
||||||
DiscordUsername *string
|
DiscordUsername *string
|
||||||
|
|
||||||
|
Fediverse *string
|
||||||
|
FediverseUsername *string
|
||||||
|
FediverseAppID *int64
|
||||||
|
|
||||||
MaxInvites int
|
MaxInvites int
|
||||||
|
|
||||||
DeletedAt *time.Time
|
DeletedAt *time.Time
|
||||||
|
|
|
@ -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"`
|
||||||
|
}
|
|
@ -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
|
*server.Server
|
||||||
|
|
||||||
RequireInvite bool
|
RequireInvite bool
|
||||||
|
BaseURL string
|
||||||
ExporterPath string
|
ExporterPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,6 +56,7 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
Server: srv,
|
Server: srv,
|
||||||
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
||||||
|
BaseURL: os.Getenv("BASE_URL"),
|
||||||
ExporterPath: "http://127.0.0.1:" + os.Getenv("EXPORTER_PORT"),
|
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
|
// generate csrf token, returns all supported OAuth provider URLs
|
||||||
r.Post("/urls", server.WrapHandler(s.oauthURLs))
|
r.Post("/urls", server.WrapHandler(s.oauthURLs))
|
||||||
|
r.Get("/urls/fediverse", server.WrapHandler(s.getFediverseURL))
|
||||||
|
|
||||||
r.Route("/discord", func(r chi.Router) {
|
r.Route("/discord", func(r chi.Router) {
|
||||||
// takes code + state, validates it, returns token OR discord signup ticket
|
// takes code + state, validates it, returns token OR discord signup ticket
|
||||||
r.Post("/callback", server.WrapHandler(s.discordCallback))
|
r.Post("/callback", server.WrapHandler(s.discordCallback))
|
||||||
// takes discord signup ticket to register account
|
// takes discord signup ticket to register account
|
||||||
r.Post("/signup", server.WrapHandler(s.discordSignup))
|
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
|
// invite routes
|
||||||
|
|
|
@ -81,18 +81,19 @@ const (
|
||||||
ErrInternalServerError = 500 // catch-all code for unknown errors
|
ErrInternalServerError = 500 // catch-all code for unknown errors
|
||||||
|
|
||||||
// Login/authorize error codes
|
// Login/authorize error codes
|
||||||
ErrInvalidState = 1001
|
ErrInvalidState = 1001
|
||||||
ErrInvalidOAuthCode = 1002
|
ErrInvalidOAuthCode = 1002
|
||||||
ErrInvalidToken = 1003 // a token was supplied, but it is invalid
|
ErrInvalidToken = 1003 // a token was supplied, but it is invalid
|
||||||
ErrInviteRequired = 1004
|
ErrInviteRequired = 1004
|
||||||
ErrInvalidTicket = 1005 // invalid signup ticket
|
ErrInvalidTicket = 1005 // invalid signup ticket
|
||||||
ErrInvalidUsername = 1006 // invalid username (when signing up)
|
ErrInvalidUsername = 1006 // invalid username (when signing up)
|
||||||
ErrUsernameTaken = 1007 // username taken (when signing up)
|
ErrUsernameTaken = 1007 // username taken (when signing up)
|
||||||
ErrInvitesDisabled = 1008 // invites are disabled (unneeded)
|
ErrInvitesDisabled = 1008 // invites are disabled (unneeded)
|
||||||
ErrInviteLimitReached = 1009 // invite limit reached (when creating invites)
|
ErrInviteLimitReached = 1009 // invite limit reached (when creating invites)
|
||||||
ErrInviteAlreadyUsed = 1010 // invite already used (when signing up)
|
ErrInviteAlreadyUsed = 1010 // invite already used (when signing up)
|
||||||
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
|
||||||
|
|
||||||
// User-related error codes
|
// User-related error codes
|
||||||
ErrUserNotFound = 2001
|
ErrUserNotFound = 2001
|
||||||
|
@ -116,18 +117,19 @@ var errCodeMessages = map[int]string{
|
||||||
ErrTooManyRequests: "Rate limit reached",
|
ErrTooManyRequests: "Rate limit reached",
|
||||||
ErrMethodNotAllowed: "Method not allowed",
|
ErrMethodNotAllowed: "Method not allowed",
|
||||||
|
|
||||||
ErrInvalidState: "Invalid OAuth state",
|
ErrInvalidState: "Invalid OAuth state",
|
||||||
ErrInvalidOAuthCode: "Invalid OAuth code",
|
ErrInvalidOAuthCode: "Invalid OAuth code",
|
||||||
ErrInvalidToken: "Supplied token was invalid",
|
ErrInvalidToken: "Supplied token was invalid",
|
||||||
ErrInviteRequired: "A valid invite code is required",
|
ErrInviteRequired: "A valid invite code is required",
|
||||||
ErrInvalidTicket: "Invalid signup ticket",
|
ErrInvalidTicket: "Invalid signup ticket",
|
||||||
ErrInvalidUsername: "Invalid username",
|
ErrInvalidUsername: "Invalid username",
|
||||||
ErrUsernameTaken: "Username is already taken",
|
ErrUsernameTaken: "Username is already taken",
|
||||||
ErrInvitesDisabled: "Invites are disabled",
|
ErrInvitesDisabled: "Invites are disabled",
|
||||||
ErrInviteLimitReached: "Your account has reached the invite limit",
|
ErrInviteLimitReached: "Your account has reached the invite limit",
|
||||||
ErrInviteAlreadyUsed: "That invite code has already been used",
|
ErrInviteAlreadyUsed: "That invite code has already been used",
|
||||||
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",
|
||||||
|
|
||||||
ErrUserNotFound: "User not found",
|
ErrUserNotFound: "User not found",
|
||||||
|
|
||||||
|
@ -148,18 +150,19 @@ var errCodeStatuses = map[int]int{
|
||||||
ErrTooManyRequests: http.StatusTooManyRequests,
|
ErrTooManyRequests: http.StatusTooManyRequests,
|
||||||
ErrMethodNotAllowed: http.StatusMethodNotAllowed,
|
ErrMethodNotAllowed: http.StatusMethodNotAllowed,
|
||||||
|
|
||||||
ErrInvalidState: http.StatusBadRequest,
|
ErrInvalidState: http.StatusBadRequest,
|
||||||
ErrInvalidOAuthCode: http.StatusForbidden,
|
ErrInvalidOAuthCode: http.StatusForbidden,
|
||||||
ErrInvalidToken: http.StatusUnauthorized,
|
ErrInvalidToken: http.StatusUnauthorized,
|
||||||
ErrInviteRequired: http.StatusBadRequest,
|
ErrInviteRequired: http.StatusBadRequest,
|
||||||
ErrInvalidTicket: http.StatusBadRequest,
|
ErrInvalidTicket: http.StatusBadRequest,
|
||||||
ErrInvalidUsername: http.StatusBadRequest,
|
ErrInvalidUsername: http.StatusBadRequest,
|
||||||
ErrUsernameTaken: http.StatusBadRequest,
|
ErrUsernameTaken: http.StatusBadRequest,
|
||||||
ErrInvitesDisabled: http.StatusForbidden,
|
ErrInvitesDisabled: http.StatusForbidden,
|
||||||
ErrInviteLimitReached: http.StatusForbidden,
|
ErrInviteLimitReached: http.StatusForbidden,
|
||||||
ErrInviteAlreadyUsed: http.StatusBadRequest,
|
ErrInviteAlreadyUsed: http.StatusBadRequest,
|
||||||
ErrDeletionPending: http.StatusBadRequest,
|
ErrDeletionPending: http.StatusBadRequest,
|
||||||
ErrRecentExport: http.StatusBadRequest,
|
ErrRecentExport: http.StatusBadRequest,
|
||||||
|
ErrUnsupportedInstance: http.StatusBadRequest,
|
||||||
|
|
||||||
ErrUserNotFound: http.StatusNotFound,
|
ErrUserNotFound: http.StatusNotFound,
|
||||||
|
|
||||||
|
|
|
@ -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…
Reference in New Issue