diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 47cea3e3a8..ae07b89075 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -163,21 +163,28 @@ class ColumnsArea extends ImmutablePureComponent {
if (singleColumn) {
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : ;
- return columnIndex !== -1 ? [
- ,
-
+ const content = columnIndex !== -1 ? (
{links.map(this.renderView)}
- ,
+
+ ) : (
+
{children}
+ );
- floatingActionButton,
- ] : [
- ,
+ return (
+
+
-
{children}
,
+
+
+ {content}
+
- floatingActionButton,
- ];
+
+
+ {floatingActionButton}
+
+ );
}
return (
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
index 306a068b7a..c4642344fc 100644
--- a/app/javascript/mastodon/stream.js
+++ b/app/javascript/mastodon/stream.js
@@ -71,11 +71,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
const params = [ `stream=${stream}` ];
- if (accessToken !== null) {
- params.push(`access_token=${accessToken}`);
- }
-
- const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`);
+ const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
ws.onopen = connected;
ws.onmessage = e => received(JSON.parse(e.data));
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 56610374e1..fe3c55755f 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1786,6 +1786,39 @@ a.account__display-name {
&.unscrollable {
overflow-x: hidden;
}
+
+ &__panels {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+
+ &__pane {
+ flex: 1 1 auto;
+ height: 100%;
+ overflow: hidden;
+ pointer-events: none;
+ display: flex;
+ justify-content: flex-end;
+
+ &__inner {
+ pointer-events: auto;
+ height: 100%;
+ }
+ }
+
+ &__main {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: 600px;
+ display: flex;
+ flex-direction: column;
+
+ @media screen and (min-width: 360px) {
+ padding: 0 10px;
+ }
+ }
+ }
}
.react-swipeable-view-container {
@@ -1936,7 +1969,6 @@ a.account__display-name {
.columns-area--mobile {
flex-direction: column;
width: 100%;
- max-width: 600px;
margin: 0 auto;
.column,
@@ -1952,7 +1984,7 @@ a.account__display-name {
}
@media screen and (min-width: 360px) {
- padding: 10px;
+ padding: 10px 0;
}
@media screen and (min-width: 630px) {
@@ -2013,8 +2045,7 @@ a.account__display-name {
.tabs-bar {
margin: 10px auto;
margin-bottom: 0;
- width: calc(100% - 20px);
- max-width: 600px;
+ width: 100%;
}
.react-swipeable-view-container .columns-area--mobile {
@@ -5427,6 +5458,10 @@ noscript {
&:active {
background: lighten($ui-highlight-color, 7%);
}
+
+ @media screen and (min-width: 630px) {
+ display: none;
+ }
}
.account__header__content {
diff --git a/streaming/index.js b/streaming/index.js
index 55ecc3ba34..10df210a3d 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -195,14 +195,14 @@ const startWorker = (workerId) => {
next();
};
- const accountFromToken = (token, req, next) => {
+ const accountFromToken = (token, allowedScopes, req, next) => {
pgPool.connect((err, client, done) => {
if (err) {
next(err);
return;
}
- client.query('SELECT oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL LIMIT 1', [token], (err, result) => {
+ client.query('SELECT oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages, oauth_access_tokens.scopes FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL LIMIT 1', [token], (err, result) => {
done();
if (err) {
@@ -218,18 +218,29 @@ const startWorker = (workerId) => {
return;
}
+ const scopes = result.rows[0].scopes.split(' ');
+
+ if (allowedScopes.size > 0 && !scopes.some(scope => allowedScopes.includes(scope))) {
+ err = new Error('Access token does not cover required scopes');
+ err.statusCode = 401;
+
+ next(err);
+ return;
+ }
+
req.accountId = result.rows[0].account_id;
req.chosenLanguages = result.rows[0].chosen_languages;
+ req.allowNotifications = scopes.some(scope => ['read', 'read:notifications'].includes(scope));
next();
});
});
};
- const accountFromRequest = (req, next, required = true) => {
+ const accountFromRequest = (req, next, required = true, allowedScopes = ['read']) => {
const authorization = req.headers.authorization;
const location = url.parse(req.url, true);
- const accessToken = location.query.access_token;
+ const accessToken = location.query.access_token || req.headers['sec-websocket-protocol'];
if (!authorization && !accessToken) {
if (required) {
@@ -246,7 +257,7 @@ const startWorker = (workerId) => {
const token = authorization ? authorization.replace(/^Bearer /, '') : accessToken;
- accountFromToken(token, req, next);
+ accountFromToken(token, allowedScopes, req, next);
};
const PUBLIC_STREAMS = [
@@ -261,6 +272,16 @@ const startWorker = (workerId) => {
const wsVerifyClient = (info, cb) => {
const location = url.parse(info.req.url, true);
const authRequired = !PUBLIC_STREAMS.some(stream => stream === location.query.stream);
+ const allowedScopes = [];
+
+ if (authRequired) {
+ allowedScopes.push('read');
+ if (location.query.stream === 'user:notification') {
+ allowedScopes.push('read:notifications');
+ } else {
+ allowedScopes.push('read:statuses');
+ }
+ }
accountFromRequest(info.req, err => {
if (!err) {
@@ -269,7 +290,7 @@ const startWorker = (workerId) => {
log.error(info.req.requestId, err.toString());
cb(false, 401, 'Unauthorized');
}
- }, authRequired);
+ }, authRequired, allowedScopes);
};
const PUBLIC_ENDPOINTS = [
@@ -286,7 +307,18 @@ const startWorker = (workerId) => {
}
const authRequired = !PUBLIC_ENDPOINTS.some(endpoint => endpoint === req.path);
- accountFromRequest(req, next, authRequired);
+ const allowedScopes = [];
+
+ if (authRequired) {
+ allowedScopes.push('read');
+ if (req.path === '/api/v1/streaming/user/notification') {
+ allowedScopes.push('read:notifications');
+ } else {
+ allowedScopes.push('read:statuses');
+ }
+ }
+
+ accountFromRequest(req, next, authRequired, allowedScopes);
};
const errorMiddleware = (err, req, res, {}) => {
@@ -339,6 +371,10 @@ const startWorker = (workerId) => {
return;
}
+ if (event === 'notification' && !req.allowNotifications) {
+ return;
+ }
+
// Only send local-only statuses to logged-in users
if (payload.local_only && !req.accountId) {
log.silly(req.requestId, `Message ${payload.id} filtered because it was local-only`);