diff --git a/README.md b/README.md index f878752fe..79a63cb50 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,31 @@ -# Mastodon Glitch Edition +# Mastodon Glitch Edition (standalone frontend) -> Now with automated deploys! +This is a very hacky fork of akkoma-masto-fe that adds standalone support (meaning your browser can OAuth against an arbitrary instance). It's currently tested to "work" (login doesn't break, basic functionality works) with Iceshrimp and GoToSocial (and it obviously works with Mastodon). -[![Build Status](https://img.shields.io/circleci/project/github/glitch-soc/mastodon.svg)][circleci] -[![Code Climate](https://img.shields.io/codeclimate/maintainability/glitch-soc/mastodon.svg)][code_climate] +To try this out, go to [masto-fe.iceshrimp.dev](https://masto-fe.iceshrimp.dev), type in your instance domain name (for split domain setups, use the web domain) & press the button. -[circleci]: https://circleci.com/gh/glitch-soc/mastodon -[code_climate]: https://codeclimate.com/github/glitch-soc/mastodon +To set this up yourself, clone the repo into e.g. `/home/user/masto-fe-standalone` and run `yarn && yarn build:production` (you might have to use `NODE_OPTIONS=--openssl-legacy-provider` until we've rebased this onto upstream glitch). -So here's the deal: we all work on this code, and anyone who uses that does so absolutely at their own risk. can you dig it? +Then configure nginx for a subdomain like this: -- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/). -- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/). +``` +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + include sites/example.com/inc/ssl.conf; + server_name masto.example.com; + + location / { + root /home/user/masto-fe-standalone/public/; + index index.html; + try_files $uri /index.html; + } +} +``` + +And open `https://masto.example.com` in your browser, type in your instance domain, press the button & follow the OAuth flow. + +Should anything break, open `https://masto.example.com/logout.html` or clear local storage manually. diff --git a/app/javascript/flavours/glitch/api.js b/app/javascript/flavours/glitch/api.js index 948ffbc95..73e3d8b37 100644 --- a/app/javascript/flavours/glitch/api.js +++ b/app/javascript/flavours/glitch/api.js @@ -53,6 +53,15 @@ const authorizationHeaderFromState = getState => { /** * @param {() => import('immutable').Map} getState + * @returns string + */ +const baseUrlFromState = getState => { + const baseUrl = getState && getState().getIn(['meta', 'base_url'], ''); + return `${baseUrl}`; +}; + +/** + * @param {() => import('immutable').Map} getState * @returns {import('axios').AxiosInstance} */ export default function api(getState) { @@ -62,6 +71,8 @@ export default function api(getState) { ...authorizationHeaderFromState(getState), }, + baseURL: baseUrlFromState(getState), + transformResponse: [ function (data) { try { diff --git a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx index ff7d4d03d..3c971a7c0 100644 --- a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx @@ -42,8 +42,6 @@ class ActionBar extends PureComponent { let menu = []; - menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink }); - menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink }); menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx index 383a9db52..988dfd30c 100644 --- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx @@ -27,16 +27,10 @@ export default class NavigationBar extends ImmutablePureComponent {
+
{this.props.account.get('display_name')}
@{this.props.account.get('acct')} - - { profileLink !== undefined && ( - - )}
diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx b/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx index 16916ba9c..16a235852 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx +++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx @@ -19,7 +19,7 @@ const mapStateToProps = state => ({ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { if (needsLockWarning) { - return }} />} />; + return }} />} />; } if (hashtagWarning) { diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/index.jsx b/app/javascript/flavours/glitch/features/local_settings/navigation/index.jsx index 022d81712..39894be40 100644 --- a/app/javascript/flavours/glitch/features/local_settings/navigation/index.jsx +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/index.jsx @@ -73,15 +73,8 @@ class LocalSettingsNavigation extends PureComponent { /> - + - + ), }} /> diff --git a/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.jsx index ba77feb6a..e3728bc17 100644 --- a/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/deprecated_settings_modal.jsx @@ -68,7 +68,7 @@ class DeprecatedSettingsModal extends PureComponent {
    { settings.map((setting_name) => (
  • - +
  • )) }
diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx b/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx index 0ef37bb23..8343de711 100644 --- a/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.jsx @@ -64,42 +64,12 @@ class LinkFooter extends PureComponent { return (

- {domain}: - {' '} - - {statusPageUrl && ( - <> - {DividingCircle} - - - )} - {canInvite && ( - <> - {DividingCircle} - - - )} - {canProfileDirectory && ( - <> - {DividingCircle} - - - )} + Masto-FE-standalone {DividingCircle} - -

- -

- Mastodon: - {' '} - - {DividingCircle} - + {DividingCircle} {DividingCircle} - - {DividingCircle} v{version}

diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx index f6984d5ad..38c8c1531 100644 --- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx @@ -104,7 +104,6 @@ class NavigationPanel extends Component {
- {!!preferencesLink && } )} diff --git a/app/javascript/flavours/glitch/stream.js b/app/javascript/flavours/glitch/stream.js index 55f009e13..554889bc3 100644 --- a/app/javascript/flavours/glitch/stream.js +++ b/app/javascript/flavours/glitch/stream.js @@ -235,8 +235,9 @@ const createConnection = (streamingAPIBaseURL, accessToken, channelName, { conne channelName = params.shift(); if (streamingAPIBaseURL.startsWith('ws')) { + params.push(`access_token=${accessToken}`); // @ts-expect-error - const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken); + const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming?${params.join('&')}`, accessToken); // @ts-expect-error ws.onopen = connected; diff --git a/app/javascript/flavours/glitch/utils/backend_links.js b/app/javascript/flavours/glitch/utils/backend_links.js index 2028a1e60..fc2005290 100644 --- a/app/javascript/flavours/glitch/utils/backend_links.js +++ b/app/javascript/flavours/glitch/utils/backend_links.js @@ -1,6 +1,6 @@ -export const preferencesLink = '/settings/preferences'; -export const profileLink = '/settings/profile'; -export const signOutLink = '/auth/sign_out'; +export const preferencesLink = undefined; +export const profileLink = undefined; +export const signOutLink = '/logout.html'; export const privacyPolicyLink = '/privacy-policy'; export const accountAdminLink = (id) => `/admin/accounts/${id}`; export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`; diff --git a/app/javascript/flavours/glitch/utils/log_out.js b/app/javascript/flavours/glitch/utils/log_out.js index a7c7ef545..8c604e102 100644 --- a/app/javascript/flavours/glitch/utils/log_out.js +++ b/app/javascript/flavours/glitch/utils/log_out.js @@ -26,7 +26,7 @@ export const logOut = () => { submitButton.setAttribute('type', 'submit'); form.appendChild(submitButton); - form.method = 'post'; + form.method = 'get'; form.action = signOutLink; form.style.display = 'none'; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 4399b9995..68f66ba9c 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -283,7 +283,7 @@ "footer.invite": "Invite people", "footer.keyboard_shortcuts": "Keyboard shortcuts", "footer.privacy_policy": "Privacy policy", - "footer.source_code": "View source code", + "footer.source_code": "Source code", "footer.status": "Status", "generic.saved": "Saved", "getting_started.heading": "Getting started", diff --git a/public/auth.js b/public/auth.js new file mode 100644 index 000000000..6066b6896 --- /dev/null +++ b/public/auth.js @@ -0,0 +1,101 @@ +document.addEventListener("DOMContentLoaded", async function() { + await ready(); +}); + +async function ready() { + const domain = localStorage.getItem('domain'); + let accessToken = localStorage.getItem(`access_token`); + + if (domain) document.getElementById('instance').value = domain; + + const urlParams = new URLSearchParams(window.location.search); + const code = urlParams.get('code'); + + if (domain && code && !accessToken) await getToken(code, domain).then(res => accessToken = res); + if (accessToken) { + window.location.href = '/prepare.html'; + } +} + +async function auth() { + setMessage('Please wait'); + const instance = document.getElementById('instance').value; + const domain = instance.match(/(?:https?:\/\/)?(.*)/)[1]; + if (!domain) { + setMessage('Invalid instance', false); + return; + } + + localStorage.setItem('domain', domain); + + // We need to run this every time in cases like Iceshrimp, where the client id/secret aren't reusable (yet) because they contain use-once session information + await registerApp(domain); + + authorize(domain); +} + +async function registerApp(domain) { + setMessage('Registering app'); + + const appsUrl = `https://${domain}/api/v1/apps`; + const formData = new FormData(); + formData.append('client_name', 'Masto-FE standalone'); + formData.append('redirect_uris', document.location.origin + document.location.pathname); + formData.append('scopes', 'read write follow push'); + + // eslint-disable-next-line promise/catch-or-return + await fetch(appsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(formData), + }) + .then(async res => { + const app = await res.json(); + localStorage.setItem(`client_id`, app.client_id); + localStorage.setItem(`client_secret`, app.client_secret); + }); +} + +function authorize(domain) { + setMessage('Authorizing'); + const clientId = localStorage.getItem(`client_id`); + document.location.href = `https://${domain}/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=${document.location.origin + document.location.pathname}&scope=read+write+follow+push`; +} + +async function getToken(code, domain) { + setMessage('Getting token'); + + const tokenUrl = `https://${domain}/oauth/token`; + const clientId = localStorage.getItem(`client_id`); + const clientSecret = localStorage.getItem(`client_secret`); + + const formData = new FormData(); + formData.append('grant_type', 'authorization_code'); + formData.append('code', code); + formData.append('client_id', clientId); + formData.append('client_secret', clientSecret); + formData.append('scope', 'read write follow push'); + formData.append('redirect_uri', document.location.origin + document.location.pathname); + + + // eslint-disable-next-line promise/catch-or-return + return fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(formData), + }) + .then(async res => { + const app = await res.json(); + if (app.access_token) localStorage.setItem(`access_token`, app.access_token); + return app.access_token; + }); +} + +function setMessage(message, disabled = true) { + document.getElementById('message').textContent = message; + document.getElementById('btn').disabled = disabled; +} \ No newline at end of file diff --git a/public/images/mascot.svg b/public/images/mascot.svg new file mode 100644 index 000000000..23384b661 --- /dev/null +++ b/public/images/mascot.svg @@ -0,0 +1,11 @@ + +image/svg+xml + + + + + + + + + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 000000000..b9f329c6a --- /dev/null +++ b/public/index.html @@ -0,0 +1,33 @@ + + + + + + Masto-FE standalone + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + diff --git a/public/login.html b/public/login.html new file mode 100644 index 000000000..90e56024a --- /dev/null +++ b/public/login.html @@ -0,0 +1,13 @@ + + + + + Login | Masto-FE standalone + + + + + + + + \ No newline at end of file diff --git a/public/logout.html b/public/logout.html new file mode 100644 index 000000000..f49e3dc50 --- /dev/null +++ b/public/logout.html @@ -0,0 +1,14 @@ + + + + + Logout | Masto-FE standalone + + + +Clearing local storage and redirecting back to login... + + \ No newline at end of file diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 000000000..c1c0f9dda --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,12 @@ +{ + "background_color": "#191b22", + "categories": ["social"], + "description": "Masto-FE standalone", + "display": "standalone", + "name": "Masto-FE standalone", + "serviceworker": { + "src": "/sw.js" + }, + "start_url": "/getting-started", + "theme_color": "#282c37" +} diff --git a/public/prepare.html b/public/prepare.html new file mode 100644 index 000000000..e6e5f1e0c --- /dev/null +++ b/public/prepare.html @@ -0,0 +1,11 @@ + + + + + Login | Masto-FE standalone + + + +

Preparing state object...

+ + \ No newline at end of file diff --git a/public/verify-state.js b/public/verify-state.js new file mode 100644 index 000000000..5308ff6f4 --- /dev/null +++ b/public/verify-state.js @@ -0,0 +1,103 @@ +loadState().then(_ => null); + +async function loadState() { + const domain = localStorage.getItem('domain'); + const access_token = localStorage.getItem('access_token'); + const storedState = localStorage.getItem('initial_state'); + + if (!domain || !access_token) { + window.location.href = '/login.html'; + return; + } + + if (storedState && window.location.pathname !== '/prepare.html') { + document.getElementById('initial-state').textContent = storedState; + } + + const apiUrl = `https://${domain}/api`; + const instance = await fetch(`${apiUrl}/v1/instance`).then(async p => await p.json()); + const options = {headers: {Authorization: `Bearer ${access_token}`}}; + const credentials = await fetch(`${apiUrl}/v1/accounts/verify_credentials`, options).then(async p => await p.json()); + const state = { + "accounts": { + "plc":{ + "accepts_direct_messages_from":"everybody", + "acct": credentials.acct, + "avatar": credentials.avatar, + "avatar_static": credentials.avatar_static, + "bot": credentials.bot, + "created_at": credentials.created_at, + "display_name": credentials.display_name, + "emojis":[], + "fields":[], + "follow_requests_count":0, + "followers_count": credentials.followers_count, + "following_count": credentials.following_count, + "fqn":`${credentials.acct}@${domain}`, + "header": credentials.header, + "header_static": credentials.header_static, + "id": credentials.id, + "last_status_at": credentials.created_at, + "locked": credentials.locked, + "note":"", + "source": credentials.source, + "statuses_count": credentials.statuses_count, + "url": credentials.url, + "username": credentials.acct + } + }, + "char_limit": instance.configuration.statuses.max_characters, + "compose": { + "allow_content_types": [ + "text/x.misskeymarkdown" + ], + "default_privacy": credentials.source.privacy, + "default_sensitive": credentials.source.sensitive, + "me": credentials.id + }, + "media_attachments": { + "accept_content_types": instance.configuration.media_attachments.supported_mime_types + }, + "meta": { + "access_token": access_token, + "admin": "0", + "advanced_layout": true, + "auto_play_gif": false, + "boost_modal": false, + "compact_reaction": false, + "delete_modal": true, + "display_sensitive_media": false, + "domain": domain, + "enable_reaction": true, + "locale": "en", + "mascot": "/images/mascot.svg", + "max_toot_chars": instance.configuration.statuses.max_characters, + "me": credentials.id, + "reduce_motion": false, + "show_quote_button": true, + "base_url": `https://${domain}`, + "streaming_api_base_url": `wss://${domain}`, + "title": `${instance.title}`, + "unfollow_modal": true, + "source_url": 'https://iceshrimp.dev/iceshrimp/masto-fe-standalone', + "version": instance.version + }, + "poll_limits": { + "max_expiration": instance.configuration.polls.max_expiration, + "max_option_chars": instance.configuration.polls.max_characters_per_option, + "max_options": instance.configuration.polls.max_options, + "min_expiration": instance.configuration.polls.min_expiration + }, + "push_subscription": null, + "rights": { + "admin": false, + "delete_others_notice": false + }, + "settings": {} + }; + + const json = JSON.stringify(state); + if (window.location.pathname !== '/prepare.html') document.getElementById('initial-state').textContent = json; + localStorage.setItem("initial_state", json); + if (window.location.pathname === '/prepare.html') window.location.href = '/'; +} \ No newline at end of file