diff --git a/README.md b/README.md index f878752fe3..79a63cb505 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 948ffbc95c..73e3d8b371 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 ff7d4d03dc..3c971a7c0c 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 383a9db528..988dfd30c2 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 16916ba9c0..16a2358529 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 022d817126..39894be400 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 ba77feb6a5..e3728bc173 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 0ef37bb239..8343de711b 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 f6984d5adb..38c8c15311 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 55f009e130..554889bc3d 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 2028a1e608..fc20052907 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 a7c7ef5454..8c604e102f 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 4399b99951..68f66ba9c3 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 0000000000..6066b68962 --- /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 0000000000..23384b6617 --- /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 0000000000..b9f329c6a0 --- /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 0000000000..90e56024af --- /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 0000000000..f49e3dc501 --- /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 0000000000..c1c0f9dda7 --- /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 0000000000..e6e5f1e0cc --- /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 0000000000..5308ff6f46 --- /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