diff --git a/components/Login.vue b/components/Login.vue
index c36c7133..f5697492 100644
--- a/components/Login.vue
+++ b/components/Login.vue
@@ -12,9 +12,19 @@
diff --git a/locale/en/translations.suml b/locale/en/translations.suml
index 7921250a..4ab8f40e 100644
--- a/locale/en/translations.suml
+++ b/locale/en/translations.suml
@@ -487,6 +487,7 @@ user:
why: >
Registering lets you manage your cards ({/@example=like this one}).
passwordless: 'The website doesn''t store any passwords. {https://avris.it/blog/passwords-are-passé=More info.}'
+ instancePlaceholder: 'Instance'
code:
action: 'Validate'
invalid: 'Invalid code.'
diff --git a/locale/pl/translations.suml b/locale/pl/translations.suml
index c173297d..aa9c9af9 100644
--- a/locale/pl/translations.suml
+++ b/locale/pl/translations.suml
@@ -1184,6 +1184,7 @@ user:
why: >
Założenie konta pozwala na zarządzanie swoimi wizytówkami ({/@example=takimi jak ta}).
passwordless: 'Strona nie zapisuje żadnych haseł. {https://avris.it/blog/passwords-are-passé=Więcej info.}'
+ instancePlaceholder: 'Instancja'
code:
action: 'Sprawdź'
invalid: 'Kod nieprawidłowy.'
diff --git a/migrations/041-mastodon-oauth.sql b/migrations/041-mastodon-oauth.sql
new file mode 100644
index 00000000..91588917
--- /dev/null
+++ b/migrations/041-mastodon-oauth.sql
@@ -0,0 +1,11 @@
+-- Up
+
+CREATE TABLE mastodon_oauth (
+ instance TEXT NOT NULL PRIMARY KEY,
+ client_id TEXT NOT NULL,
+ client_secret TEXT NOT NULL
+);
+
+-- Down
+
+DROP TABLE mastodon_oauth;
diff --git a/server/index.js b/server/index.js
index 270c3c8f..23e8b2ed 100644
--- a/server/index.js
+++ b/server/index.js
@@ -82,6 +82,7 @@ app.use(async function (req, res, next) {
}
});
+app.use(require('./routes/grantOverrides').default);
router.use(grant.express()(require('./social').config));
app.use(require('./routes/home').default);
diff --git a/server/routes/grantOverrides.js b/server/routes/grantOverrides.js
new file mode 100644
index 00000000..19cb94be
--- /dev/null
+++ b/server/routes/grantOverrides.js
@@ -0,0 +1,101 @@
+// grant doesn't care about the specifics of some services,
+// so for some services we don't care about grant :))))
+
+import { Router } from 'express';
+import SQL from 'sql-template-strings';
+import fetch from 'node-fetch';
+import assert from 'assert';
+import { handleErrorAsync } from "../../src/helpers";
+
+const normalizeDomainName = (domain) => {
+ const url = new URL('https://' + domain);
+ assert(url.port === '');
+ return url.hostname;
+}
+
+const config = {
+ mastodon: {
+ scopes: ['read:accounts'],
+ redirect_uri: `${process.env.HOME_URL || 'https://pronouns.page'}/api/user/social/mastodon`,
+ },
+};
+
+const router = Router();
+
+const mastodonGetOAuthKeys = async (db, instance, dbOnly = false) => {
+ const existingKeys = await db.get(
+ SQL`SELECT client_id, client_secret FROM mastodon_oauth WHERE instance = ${instance}`);
+ if (existingKeys) {
+ return existingKeys;
+ }
+ const keys = await fetch(`https://${instance}/api/v1/apps`, {
+ method: 'POST',
+ body: new URLSearchParams({
+ client_name: 'pronouns.page',
+ redirect_uris: config.mastodon.redirect_uri,
+ scopes: config.mastodon.scopes.join(' '),
+ website: process.env.HOME_URL,
+ }).toString(),
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'User-Agent': 'pronouns.page',
+ },
+ }).then(res => res.json());
+ assert(keys.client_id && keys.client_secret && !keys.error);
+ db.get(SQL`
+ INSERT INTO mastodon_oauth (instance, client_id, client_secret)
+ VALUES (${instance}, ${keys.client_id}, ${keys.client_secret})
+ `);
+ return keys;
+};
+router.get('/connect/mastodon', handleErrorAsync(async (req, res) => {
+ assert(req.query.instance);
+ const instance = normalizeDomainName(req.query.instance);
+ const { client_id, client_secret } = await mastodonGetOAuthKeys(req.db, instance);
+ req.session.grant = { instance, client_id, client_secret };
+ res.redirect(`https://${instance}/oauth/authorize?` + new URLSearchParams({
+ client_id,
+ scope: config.mastodon.scopes.join(' '),
+ redirect_uri: config.mastodon.redirect_uri,
+ response_type: 'code',
+ }));
+}));
+router.get('/user/social/mastodon', handleErrorAsync(async (req, res, next) => {
+ if (!req.session.grant || !req.session.grant.instance || !req.query.code) {
+ next();
+ return;
+ }
+ const { instance, client_id, client_secret } = req.session.grant;
+ const response = await fetch(`https://${instance}/oauth/token`, {
+ method: 'POST',
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id,
+ client_secret,
+ redirect_uri: config.mastodon.redirect_uri,
+ scope: config.mastodon.scopes.join(' '),
+ code: req.query.code,
+ }).toString(),
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'User-Agent': 'pronouns.page',
+ },
+ }).then(res => res.json());
+ if (!response.access_token || response.error) {
+ next();
+ return;
+ }
+ const profile = await fetch(`https://${instance}/api/v1/accounts/verify_credentials`, {
+ headers: {
+ Authorization: `Bearer ${response.access_token}`,
+ 'User-Agent': 'pronouns.page',
+ },
+ }).then(res => res.json());
+ response.profile = profile;
+ response.instance = instance;
+ req.session.grant.response = response;
+ next();
+ return;
+}));
+
+export default router;
diff --git a/server/routes/user.js b/server/routes/user.js
index 861e987d..b262629b 100644
--- a/server/routes/user.js
+++ b/server/routes/user.js
@@ -388,7 +388,9 @@ router.post('/user/:id/set-roles', handleErrorAsync(async (req, res) => {
// happens on home
router.get('/user/social-redirect/:provider/:locale', handleErrorAsync(async (req, res) => {
req.session.socialRedirect = req.params.locale;
- return res.redirect(`/api/connect/${req.params.provider}`);
+ return res.redirect(`/api/connect/${req.params.provider}?${new URLSearchParams({
+ instance: req.query.instance || undefined,
+ })}`);
}));
// happens on home
diff --git a/server/social.js b/server/social.js
index 3a22555d..5b9fb5d4 100644
--- a/server/social.js
+++ b/server/social.js
@@ -30,6 +30,8 @@ export const config = {
callback: '/api/user/social/discord',
scope: ['identify', 'email'],
},
+ // non-grant, but things break if it's not there
+ mastodon: {},
}
export const handlers = {
@@ -73,4 +75,13 @@ export const handlers = {
access_secret: r.access_secret,
}
},
+ mastodon(r) {
+ const acct = `${r.profile.username}@${r.instance}`;
+ return {
+ id: acct,
+ name: acct,
+ avatar: r.profile.avatar,
+ access_token: r.access_token,
+ };
+ },
};
diff --git a/src/data.js b/src/data.js
index 769ce596..8e86cd59 100644
--- a/src/data.js
+++ b/src/data.js
@@ -7,6 +7,7 @@ export const socialProviders = {
facebook: { name: 'Facebook' },
google: { name: 'Google' },
discord: { name: 'Discord' },
+ mastodon: { name: 'Mastodon', instanceRequired: true },
}
import pronounsRaw from '../data/pronouns/pronouns.tsv';