Fediverse Open on Main Server

Open Users or Notes on services that supports ActivityPub on your main Misskey server. You can also open Users or Notes on your main server on remote Misskey servers. Open the home page of this script and execute the user script command to set the main server.

  1. // ==UserScript==
  2. // @name Fediverse Open on Main Server
  3. // @name:en Fediverse Open on Remote Servers
  4. // @name:ja Fediverse リモートサーバーで開く
  5. // @description Open Users or Notes on services that supports ActivityPub on your main Misskey server. You can also open Users or Notes on your main server on remote Misskey servers. Open the home page of this script and execute the user script command to set the main server.
  6. // @description:en Open Users or Notes on services that supports ActivityPub on your main Misskey server. You can also open Users or Notes on your main server on remote Misskey servers. Open the home page of this script and execute the user script command to set the main server.
  7. // @description:ja ActivityPubに対応しているサービスのUser、またはNoteを、メインで利用しているMisskeyサーバーで開きます。また、メインで利用しているサーバーのUser、Noteを、リモートとして登録した複数のMisskeyサーバーで開けるようにします。このスクリプトのホームページを開いて、ユーザースクリプトコマンドを実行して、設定を行ってください。
  8. // @namespace https://greasyfork.org/users/137
  9. // @version 3.0.1
  10. // @match https://greasyfork.org/*/scripts/474630-*
  11. // @match https://mastodon.social/*
  12. // @match https://pawoo.net/*
  13. // @match https://mstdn.jp/*
  14. // @match https://misskey.io/*
  15. // @match https://mastodon.cloud/*
  16. // @match https://fedibird.com/*
  17. // @match https://nijimiss.moe/*
  18. // @match https://buicha.social/*
  19. // @match https://misskey.niri.la/*
  20. // @match https://vcasskey.net/*
  21. // @match https://bsky.app/*
  22. // @require https://greasyfork.org/scripts/19616/code/utilities.js?version=895049
  23. // @license MPL-2.0
  24. // @contributionURL https://github.com/sponsors/esperecyan
  25. // @compatible Edge
  26. // @compatible Firefox Firefoxを推奨 / Firefox is recommended
  27. // @compatible Opera
  28. // @compatible Chrome
  29. // @grant GM.registerMenuCommand
  30. // @grant GM.setValue
  31. // @grant GM.getValue
  32. // @grant GM.deleteValue
  33. // @grant GM.openInTab
  34. // @grant GM_xmlhttpRequest
  35. // @run-at document-start
  36. // @noframes
  37. // @icon https://codeberg.org/fediverse/distributopia/raw/branch/main/all-logos-in-one-basket/public/basket/Fediverse_logo_proposal-1-min.svg
  38. // @author 100の人
  39. // @homepageURL https://greasyfork.org/scripts/474630
  40. // ==/UserScript==
  41.  
  42. /*global Gettext, _, h */
  43.  
  44. 'use strict';
  45.  
  46. // L10N
  47. Gettext.setLocalizedTexts({
  48. /*eslint-disable quote-props, max-len */
  49. 'ja': {
  50. 'Fediverse Open on Remote Servers': 'Fediverse リモートサーバーで開く',
  51. 'Settings of “Fediverse Open on Remote Servers”': '「Fediverse リモートサーバーで開く」の設定',
  52. 'Server URLs': 'サーバーのURL',
  53. '* Specify the URL of your main server on the first line.': '※1行目に、メインサーバーのURLを指定します。',
  54. 'Add the URLs entered above and the server to which you want to add the user script command to the “User @match” in the user script settings in the format like “https://example.com/*”.':
  55. '上で入力したURL、およびユーザースクリプトコマンドを追加したいサーバーのURLを、「https://example.com/*」のような形式で、ユーザースクリプト設定の「ユーザー @match」へ追加してください。',
  56. 'Cancel': 'キャンセル',
  57. 'OK': 'OK',
  58. 'Fediverse Open on $SERVER_URL$': 'Fediverse $SERVER_URL$ で開く',
  59. 'Failed to look up.': '照会に失敗しました。',
  60. 'This account is not bridged. Would you like to request Bridgy Fed to send a DM guiding the bridge?':
  61. 'このアカウントはブリッジされていません。ブリッジを案内するDMの送信を、Bridgy Fedへ要求しますか?',
  62. 'The request to Bridgy Fed to send a DM guiding the bridge has been successfully completed.':
  63. 'ブリッジを案内するDMの送信を、Bridgy Fedへ要求するリクエストが正常に完了しました。',
  64. 'An unexplained HTTP error occurred.': '原因不明のHTTPエラーが発生しました。',
  65. },
  66. /*eslint-enable quote-props, max-len */
  67. });
  68. Gettext.originalLocale = 'en';
  69. Gettext.setLocale(navigator.language);
  70.  
  71. /**
  72. * @param {string} serverURL
  73. * @param {string} url
  74. * @returns {Promise.<string>}
  75. */
  76. async function miAuth(serverURL, url)
  77. {
  78. const sessionId = crypto.randomUUID();
  79. await Promise.all([ GM.setValue('miAuthSessionId', sessionId), GM.setValue('urlWaitingMiAuth', url) ]);
  80. GM.openInTab(`${serverURL}/miauth/${sessionId}?${new URLSearchParams({
  81. name: _('Fediverse Open on Remote Servers'),
  82. callback: serverURL,
  83. permission: 'read:account,write:notes',
  84. })}`, false);
  85. }
  86.  
  87. /**
  88. * 通信がContent Security PolicyによってブロックされるViolemntmonkeyの不具合を回避して、{@link fetch}します。
  89. * @param {string} input
  90. * @param {RequestInit} init
  91. * @returns {Promise<Response>}
  92. */
  93. async function fetchBypassCSP(input, init = null)
  94. {
  95. if (typeof GM_xmlhttpRequest !== 'undefined') { //eslint-disable-line camelcase
  96. // Violemntmonkey
  97. const response = await new Promise(function (resolve, reject) {
  98. GM_xmlhttpRequest(Object.assign({ //eslint-disable-line new-cap
  99. method: init?.method,
  100. url: input,
  101. headers: init?.headers,
  102. data: init?.body,
  103. onload: resolve,
  104. onerror: reject,
  105. ontimeout: reject,
  106. }));
  107. });
  108.  
  109. return new Response(response.responseText, {
  110. status: response.status,
  111. statusText: response.statusText,
  112. headers: response.responseHeaders?.trim()?.split(/[\r\n]+/).map(function (line) {
  113. const nameValue = /([^:]+): (.*)/.exec(line);
  114. return [ nameValue[1], nameValue[2] ];
  115. }),
  116. });
  117. } else {
  118. return fetch(input, init);
  119. }
  120. }
  121.  
  122. /**
  123. * @param {string} accessToken
  124. * @param {string} url
  125. * @returns {Promise.<?string>}
  126. */
  127. async function lookUpOnMisskey(serverURL, accessToken, url)
  128. {
  129. let response = await fetchBypassCSP(`${serverURL}/api/ap/show`, {
  130. method: 'POST',
  131. headers: { 'content-type': 'application/json' },
  132. body: JSON.stringify({ i: accessToken, uri: url }),
  133. });
  134.  
  135. if (!response.ok) {
  136. if (response.status === 401) {
  137. await miAuth(serverURL, url);
  138. return;
  139. }
  140.  
  141. let responseText;
  142. let message = _('An unexplained HTTP error occurred.');
  143. switch (response.status) {
  144. case 401:
  145. await miAuth(serverURL, url);
  146. return;
  147. case 500: {
  148. responseText = await response.text();
  149. if (![ 'invalid Actor: wrong inbox', '404 Not Found' ].includes(
  150. JSON.parse(responseText)?.error?.info?.message,
  151. )) {
  152. message = _('Failed to look up.');
  153. break;
  154. }
  155.  
  156. //eslint-disable-next-line max-len
  157. const match = /^https:\/\/web\.brid\.gy\/r\/https:\/\/bsky\.app\/profile\/(?:(?<handle>(?:[-0-9A-Za-z]+\.)+[A-Za-z]{2,})|(?<did>did:plc:[-0-9A-Za-z]+))$/.exec(url);
  158. if (!match) {
  159. message = _('Failed to look up.');
  160. break;
  161. }
  162.  
  163. responseText = null;
  164.  
  165. //eslint-disable-next-line no-alert, max-len
  166. if (!confirm(_('This account is not bridged. Would you like to request Bridgy Fed to send a DM guiding the bridge?'))) {
  167. return;
  168. }
  169.  
  170. let handle = match.groups.handle;
  171. if (!handle) {
  172. response = await fetchBypassCSP('https://bsky.social/xrpc/com.atproto.repo.describeRepo?'
  173. + new URLSearchParams({ repo: match.groups.did }));
  174. if (!response.ok) {
  175. break;
  176. }
  177. handle = (await response.json()).handle;
  178. }
  179.  
  180. response = await fetchBypassCSP(`${serverURL}/api/users/show`, {
  181. method: 'POST',
  182. headers: { 'content-type': 'application/json' },
  183. body: JSON.stringify({
  184. i: accessToken,
  185. username: 'bsky.brid.gy',
  186. host: 'bsky.brid.gy',
  187. detailed: false,
  188. }),
  189. });
  190. if (!response.ok) {
  191. break;
  192. }
  193.  
  194. response = await fetchBypassCSP(`${serverURL}/api/notes/create`, {
  195. method: 'POST',
  196. headers: { 'content-type': 'application/json' },
  197. body: JSON.stringify({
  198. i: accessToken,
  199. visibility: 'specified',
  200. visibleUserIds: [ (await response.json()).id ],
  201. text: handle,
  202. }),
  203. });
  204. if (!response.ok) {
  205. break;
  206. }
  207.  
  208. //eslint-disable-next-line no-alert
  209. alert(_('The request to Bridgy Fed to send a DM guiding the bridge has been successfully completed.'));
  210. return;
  211. }
  212. }
  213.  
  214. //eslint-disable-next-line no-alert
  215. alert(message + '\n\n'
  216. + response.url + '\n'
  217. + `HTTP Status: ${response.status} ${response.statusText}\n`
  218. + 'HTTP Response Body:\n' + (responseText ?? await response.text()));
  219. return;
  220. }
  221.  
  222. const { type, object: { username, host, id } } = await response.json();
  223. switch (type) {
  224. case 'User':
  225. return serverURL + '/@' + username + (host ? '@' + host : '');
  226. case 'Note':
  227. return serverURL + '/notes/' + id;
  228. }
  229. }
  230.  
  231. /**
  232. * @typedef {Object} Server
  233. * @property {'Misskey'} application
  234. * @property {string} url
  235. * @property {string} [accessToken]
  236. */
  237.  
  238. /**
  239. * @typedef {Server[]} Servers 先頭がメインサーバー。
  240. */
  241.  
  242. /**
  243. * 設定した各サーバーの情報を取得します。
  244. * @returns {Promise.<Servers>}
  245. */
  246. async function getServers()
  247. {
  248. const [ serversJSON, url, accessToken ]
  249. = await Promise.all([ 'servers', 'url', 'accessToken' ].map(name => GM.getValue(name)));
  250. if (url) {
  251. // バージョン 1.0.0
  252. const servers = [ { application: 'Misskey', url, accessToken } ];
  253. await Promise.all([ GM.setValue('servers', JSON.stringify(servers)) ]
  254. .concat([ 'application', 'url', 'accessToken' ].map(name => GM.deleteValue(name))));
  255. return servers;
  256. }
  257.  
  258. if (!serversJSON) {
  259. return [ ];
  260. }
  261.  
  262. return JSON.parse(serversJSON);
  263. }
  264.  
  265.  
  266. switch (location.host) {
  267. case 'greasyfork.org': {
  268. /** @type {HTMLDialogElement} */
  269. let dialog, form;
  270. GM.registerMenuCommand(_('Settings of “Fediverse Open on Remote Servers”'), async function () {
  271. const servers = await getServers();
  272. if (!dialog) {
  273. document.body.insertAdjacentHTML('beforeend', h`<dialog>
  274. <form method="dialog">
  275. <p><label>
  276. ${_('Server URLs')}
  277. <textarea name="server-urls" cols="50" rows="10"
  278. placeholder="https://example.com\nhttps://example.net\nhttps://example.org"
  279. pattern="(https?://[^\\/\n]+)(\nhttps?://[^\\/\r\n]+)*"></textarea>
  280. </label><small>${_('* Specify the URL of your main server on the first line.')}</small></p>
  281. <p>${_('Add the URLs entered above and the server to which you want to add the user script command to the “User @match” in the user script settings in the format like “https://example.com/*”.' /* eslint-disable-line max-len */)}</p>
  282. <button name="cancel">${_('Cancel')}</button> <button>${_('OK')}</button>
  283. </form>
  284. </dialog>`);
  285.  
  286. dialog = document.body.lastElementChild;
  287. form = dialog.getElementsByTagName('form')[0];
  288. form['server-urls'].addEventListener('change', function (event) {
  289. event.target.value = event.target.value.split('\n').map(function (line) {
  290. try {
  291. return new URL(line.trim()).origin;
  292. } catch (exception) {
  293. if (exception.name !== 'TypeError') {
  294. throw exception;
  295. }
  296. return line;
  297. }
  298. }).join('\n');
  299. });
  300. let chromium = false;
  301. form.addEventListener('submit', function (event) {
  302. if (event.submitter?.name === 'cancel') {
  303. event.preventDefault();
  304. dialog.close();
  305. }
  306. chromium = true;
  307. });
  308. form.addEventListener('formdata', function (event) {
  309. chromium = false;
  310. GM.setValue('servers', JSON.stringify(
  311. event.formData.get('server-urls').trim().split('\n')
  312. .filter(function (line) {
  313. try {
  314. new URL(line);
  315. } catch (exception) {
  316. if (exception.name !== 'TypeError') {
  317. throw exception;
  318. }
  319. return false;
  320. }
  321. return true;
  322. })
  323. .map(url => servers.find(server => server.url === url) ?? { application: 'Misskey', url }),
  324. ));
  325.  
  326. });
  327. // Chromiumでformdataイベントが発生しない不具合の回避
  328. dialog.addEventListener('close', function () {
  329. if (!chromium) {
  330. return;
  331. }
  332. form.dispatchEvent(new FormDataEvent('formdata', { formData: new FormData(form) }));
  333. });
  334. }
  335. form['server-urls'].value = servers.map(server => server.url).join('\n');
  336.  
  337. dialog.showModal();
  338. });
  339. break;
  340. }
  341. default:
  342. if (location.search.startsWith('?session=')) {
  343. // MiAuthで認可が終わった後のリダイレクトの可能性があれば
  344. Promise.all(
  345. [ getServers() ].concat([ 'miAuthSessionId', 'urlWaitingMiAuth' ].map(name => GM.getValue(name))),
  346. ).then(async function ([ servers, miAuthSessionId, urlWaitingMiAuth ]) {
  347. const server = servers.find(server => server.url === location.origin);
  348. if (!server) {
  349. return;
  350. }
  351.  
  352. const session = new URLSearchParams(location.search).get('session');
  353. if (session !== miAuthSessionId) {
  354. return;
  355. }
  356.  
  357. await Promise.all([ 'miAuthSessionId', 'urlWaitingMiAuth' ].map(name => GM.deleteValue(name)));
  358.  
  359. // アクセストークンを取得
  360. const response
  361. = await fetch(`${server.url}/api/miauth/${miAuthSessionId}/check`, { method: 'POST' });
  362. if (!response.ok) {
  363. console.error(response);
  364. return;
  365. }
  366. const { ok, token } = await response.json();
  367. if (!ok) {
  368. console.error(response);
  369. return;
  370. }
  371.  
  372. server.accessToken = token;
  373. await GM.setValue('servers', JSON.stringify(servers));
  374.  
  375. // 照会
  376. const lookedUpURL = await lookUpOnMisskey(server.url, token, urlWaitingMiAuth);
  377. if (!lookedUpURL) {
  378. return;
  379. }
  380. location.replace(lookedUpURL);
  381. });
  382. } else {
  383. getServers().then(function (servers) {
  384. if (servers.length === 0) {
  385. return;
  386. }
  387.  
  388. for (const server of location.origin === servers[0].url ? servers.slice(1) : [ servers[0] ]) {
  389. GM.registerMenuCommand(
  390. _('Fediverse Open on $SERVER_URL$').replace('$SERVER_URL$', server.url),
  391. async function () {
  392. const url = location.origin === 'https://bsky.app'
  393. ? ('https://web.brid.gy/r/' + location.href)
  394. : (document.querySelector(
  395. '.ti-alert-triangle + [rel="nofollow noopener"][target="_blank"]',
  396. )?.href ?? location.href);
  397. const { accessToken } = (await getServers()).find(({ url }) => url === server.url);
  398. if (!accessToken) {
  399. await miAuth(server.url, url);
  400. return;
  401. }
  402.  
  403. const lookedUpURL = await lookUpOnMisskey(server.url, accessToken, url);
  404.  
  405. if (!lookedUpURL) {
  406. return;
  407. }
  408.  
  409. GM.openInTab(lookedUpURL, false);
  410. },
  411. );
  412. }
  413. });
  414. }
  415. }