You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
471 lines
15 KiB
471 lines
15 KiB
import crypto from 'crypto';
|
|
|
|
import { Meteor } from 'meteor/meteor';
|
|
import { check } from 'meteor/check';
|
|
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
|
|
import { EJSON } from 'meteor/ejson';
|
|
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
|
|
import { escapeHTML } from '@rocket.chat/string-helpers';
|
|
|
|
import { hasPermission } from '../../../authorization/server';
|
|
import { Users } from '../../../models/server';
|
|
import { settings } from '../../../settings/server';
|
|
import { API } from '../api';
|
|
import { getDefaultUserFields } from '../../../utils/server/functions/getDefaultUserFields';
|
|
import { getURL } from '../../../utils/lib/getURL';
|
|
import { getLogs } from '../../../../server/stream/stdout';
|
|
import { SystemLogger } from '../../../../server/lib/logger/system';
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/v1/me:
|
|
* get:
|
|
* description: Gets user data of the authenticated user
|
|
* security:
|
|
* - authenticated: []
|
|
* responses:
|
|
* 200:
|
|
* description: The user data of the authenticated user
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* allOf:
|
|
* - $ref: '#/components/schemas/ApiSuccessV1'
|
|
* - type: object
|
|
* properties:
|
|
* name:
|
|
* type: string
|
|
* username:
|
|
* type: string
|
|
* nickname:
|
|
* type: string
|
|
* emails:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* address:
|
|
* type: string
|
|
* verified:
|
|
* type: boolean
|
|
* email:
|
|
* type: string
|
|
* status:
|
|
* $ref: '#/components/schemas/UserStatus'
|
|
* statusDefault:
|
|
* $ref: '#/components/schemas/UserStatus'
|
|
* statusText:
|
|
* $ref: '#/components/schemas/UserStatus'
|
|
* statusConnection:
|
|
* $ref: '#/components/schemas/UserStatus'
|
|
* bio:
|
|
* type: string
|
|
* avatarOrigin:
|
|
* type: string
|
|
* enum: [none, local, upload, url]
|
|
* utcOffset:
|
|
* type: number
|
|
* language:
|
|
* type: string
|
|
* settings:
|
|
* type: object
|
|
* properties:
|
|
* preferences:
|
|
* type: object
|
|
* enableAutoAway:
|
|
* type: boolean
|
|
* idleTimeLimit:
|
|
* type: number
|
|
* roles:
|
|
* type: array
|
|
* active:
|
|
* type: boolean
|
|
* defaultRoom:
|
|
* type: string
|
|
* customFields:
|
|
* type: array
|
|
* requirePasswordChange:
|
|
* type: boolean
|
|
* requirePasswordChangeReason:
|
|
* type: string
|
|
* services:
|
|
* type: object
|
|
* properties:
|
|
* github:
|
|
* type: object
|
|
* gitlab:
|
|
* type: object
|
|
* tokenpass:
|
|
* type: object
|
|
* blockstack:
|
|
* type: object
|
|
* password:
|
|
* type: object
|
|
* properties:
|
|
* exists:
|
|
* type: boolean
|
|
* totp:
|
|
* type: object
|
|
* properties:
|
|
* enabled:
|
|
* type: boolean
|
|
* email2fa:
|
|
* type: object
|
|
* properties:
|
|
* enabled:
|
|
* type: boolean
|
|
* statusLivechat:
|
|
* type: string
|
|
* enum: [available, 'not-available']
|
|
* banners:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* id:
|
|
* type: string
|
|
* title:
|
|
* type: string
|
|
* text:
|
|
* type: string
|
|
* textArguments:
|
|
* type: array
|
|
* items: {}
|
|
* modifiers:
|
|
* type: array
|
|
* items:
|
|
* type: string
|
|
* infoUrl:
|
|
* type: string
|
|
* oauth:
|
|
* type: object
|
|
* properties:
|
|
* authorizedClients:
|
|
* type: array
|
|
* items:
|
|
* type: string
|
|
* _updatedAt:
|
|
* type: string
|
|
* format: date-time
|
|
* avatarETag:
|
|
* type: string
|
|
* default:
|
|
* description: Unexpected error
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ApiFailureV1'
|
|
*/
|
|
API.v1.addRoute(
|
|
'me',
|
|
{ authRequired: true },
|
|
{
|
|
get() {
|
|
const fields = getDefaultUserFields();
|
|
const user = Users.findOneById(this.userId, { fields });
|
|
|
|
// The password hash shouldn't be leaked but the client may need to know if it exists.
|
|
if (user?.services?.password?.bcrypt) {
|
|
user.services.password.exists = true;
|
|
delete user.services.password.bcrypt;
|
|
}
|
|
|
|
return API.v1.success(this.getUserInfo(user));
|
|
},
|
|
},
|
|
);
|
|
|
|
let onlineCache = 0;
|
|
let onlineCacheDate = 0;
|
|
const cacheInvalid = 60000; // 1 minute
|
|
API.v1.addRoute(
|
|
'shield.svg',
|
|
{ authRequired: false, rateLimiterOptions: { numRequestsAllowed: 60, intervalTimeInMS: 60000 } },
|
|
{
|
|
get() {
|
|
const { type, icon } = this.queryParams;
|
|
let { channel, name } = this.queryParams;
|
|
if (!settings.get('API_Enable_Shields')) {
|
|
throw new Meteor.Error('error-endpoint-disabled', 'This endpoint is disabled', {
|
|
route: '/api/v1/shield.svg',
|
|
});
|
|
}
|
|
|
|
const types = settings.get('API_Shield_Types');
|
|
if (
|
|
type &&
|
|
types !== '*' &&
|
|
!types
|
|
.split(',')
|
|
.map((t) => t.trim())
|
|
.includes(type)
|
|
) {
|
|
throw new Meteor.Error('error-shield-disabled', 'This shield type is disabled', {
|
|
route: '/api/v1/shield.svg',
|
|
});
|
|
}
|
|
const hideIcon = icon === 'false';
|
|
if (hideIcon && (!name || !name.trim())) {
|
|
return API.v1.failure('Name cannot be empty when icon is hidden');
|
|
}
|
|
|
|
let text;
|
|
let backgroundColor = '#4c1';
|
|
switch (type) {
|
|
case 'online':
|
|
if (Date.now() - onlineCacheDate > cacheInvalid) {
|
|
onlineCache = Users.findUsersNotOffline().count();
|
|
onlineCacheDate = Date.now();
|
|
}
|
|
|
|
text = `${onlineCache} ${TAPi18n.__('Online')}`;
|
|
break;
|
|
case 'channel':
|
|
if (!channel) {
|
|
return API.v1.failure('Shield channel is required for type "channel"');
|
|
}
|
|
|
|
text = `#${channel}`;
|
|
break;
|
|
case 'user':
|
|
if (settings.get('API_Shield_user_require_auth') && !this.getLoggedInUser()) {
|
|
return API.v1.failure('You must be logged in to do this.');
|
|
}
|
|
const user = this.getUserFromParams();
|
|
|
|
// Respect the server's choice for using their real names or not
|
|
if (user.name && settings.get('UI_Use_Real_Name')) {
|
|
text = `${user.name}`;
|
|
} else {
|
|
text = `@${user.username}`;
|
|
}
|
|
|
|
switch (user.status) {
|
|
case 'online':
|
|
backgroundColor = '#1fb31f';
|
|
break;
|
|
case 'away':
|
|
backgroundColor = '#dc9b01';
|
|
break;
|
|
case 'busy':
|
|
backgroundColor = '#bc2031';
|
|
break;
|
|
case 'offline':
|
|
backgroundColor = '#a5a1a1';
|
|
}
|
|
break;
|
|
default:
|
|
text = TAPi18n.__('Join_Chat').toUpperCase();
|
|
}
|
|
|
|
const iconSize = hideIcon ? 7 : 24;
|
|
const leftSize = name ? name.length * 6 + 7 + iconSize : iconSize;
|
|
const rightSize = text.length * 6 + 20;
|
|
const width = leftSize + rightSize;
|
|
const height = 20;
|
|
|
|
channel = escapeHTML(channel);
|
|
text = escapeHTML(text);
|
|
name = escapeHTML(name);
|
|
|
|
return {
|
|
headers: { 'Content-Type': 'image/svg+xml;charset=utf-8' },
|
|
body: `
|
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}">
|
|
<linearGradient id="b" x2="0" y2="100%">
|
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
<stop offset="1" stop-opacity=".1"/>
|
|
</linearGradient>
|
|
<mask id="a">
|
|
<rect width="${width}" height="${height}" rx="3" fill="#fff"/>
|
|
</mask>
|
|
<g mask="url(#a)">
|
|
<path fill="#555" d="M0 0h${leftSize}v${height}H0z"/>
|
|
<path fill="${backgroundColor}" d="M${leftSize} 0h${rightSize}v${height}H${leftSize}z"/>
|
|
<path fill="url(#b)" d="M0 0h${width}v${height}H0z"/>
|
|
</g>
|
|
${hideIcon ? '' : `<image x="5" y="3" width="14" height="14" xlink:href="${getURL('/assets/favicon.svg', { full: true })}"/>`}
|
|
<g fill="#fff" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
|
${
|
|
name
|
|
? `<text x="${iconSize}" y="15" fill="#010101" fill-opacity=".3">${name}</text>
|
|
<text x="${iconSize}" y="14">${name}</text>`
|
|
: ''
|
|
}
|
|
<text x="${leftSize + 7}" y="15" fill="#010101" fill-opacity=".3">${text}</text>
|
|
<text x="${leftSize + 7}" y="14">${text}</text>
|
|
</g>
|
|
</svg>
|
|
`
|
|
.trim()
|
|
.replace(/\>[\s]+\</gm, '><'),
|
|
};
|
|
},
|
|
},
|
|
);
|
|
|
|
API.v1.addRoute(
|
|
'spotlight',
|
|
{ authRequired: true },
|
|
{
|
|
get() {
|
|
check(this.queryParams, {
|
|
query: String,
|
|
});
|
|
|
|
const { query } = this.queryParams;
|
|
|
|
const result = Meteor.runAsUser(this.userId, () => Meteor.call('spotlight', query));
|
|
|
|
return API.v1.success(result);
|
|
},
|
|
},
|
|
);
|
|
|
|
API.v1.addRoute(
|
|
'directory',
|
|
{ authRequired: true },
|
|
{
|
|
get() {
|
|
const { offset, count } = this.getPaginationItems();
|
|
const { sort, query } = this.parseJsonQuery();
|
|
|
|
const { text, type, workspace = 'local' } = query;
|
|
|
|
if (sort && Object.keys(sort).length > 1) {
|
|
return API.v1.failure('This method support only one "sort" parameter');
|
|
}
|
|
const sortBy = sort ? Object.keys(sort)[0] : undefined;
|
|
const sortDirection = sort && Object.values(sort)[0] === 1 ? 'asc' : 'desc';
|
|
|
|
const result = Meteor.runAsUser(this.userId, () =>
|
|
Meteor.call('browseChannels', {
|
|
text,
|
|
type,
|
|
workspace,
|
|
sortBy,
|
|
sortDirection,
|
|
offset: Math.max(0, offset),
|
|
limit: Math.max(0, count),
|
|
}),
|
|
);
|
|
|
|
if (!result) {
|
|
return API.v1.failure('Please verify the parameters');
|
|
}
|
|
return API.v1.success({
|
|
result: result.results,
|
|
count: result.results.length,
|
|
offset,
|
|
total: result.total,
|
|
});
|
|
},
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/v1/stdout.queue:
|
|
* get:
|
|
* description: Retrieves last 1000 lines of server logs
|
|
* security:
|
|
* - authenticated: ['view-logs']
|
|
* responses:
|
|
* 200:
|
|
* description: The user data of the authenticated user
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* allOf:
|
|
* - $ref: '#/components/schemas/ApiSuccessV1'
|
|
* - type: object
|
|
* properties:
|
|
* queue:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* id:
|
|
* type: string
|
|
* string:
|
|
* type: string
|
|
* ts:
|
|
* type: string
|
|
* format: date-time
|
|
* default:
|
|
* description: Unexpected error
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ApiFailureV1'
|
|
*/
|
|
API.v1.addRoute(
|
|
'stdout.queue',
|
|
{ authRequired: true },
|
|
{
|
|
get() {
|
|
if (!hasPermission(this.userId, 'view-logs')) {
|
|
return API.v1.unauthorized();
|
|
}
|
|
return API.v1.success({ queue: getLogs() });
|
|
},
|
|
},
|
|
);
|
|
|
|
const mountResult = ({ id, error, result }) => ({
|
|
message: EJSON.stringify({
|
|
msg: 'result',
|
|
id,
|
|
error,
|
|
result,
|
|
}),
|
|
});
|
|
|
|
const methodCall = () => ({
|
|
post() {
|
|
check(this.bodyParams, {
|
|
message: String,
|
|
});
|
|
|
|
const { method, params, id } = EJSON.parse(this.bodyParams.message);
|
|
|
|
const connectionId =
|
|
this.token ||
|
|
crypto
|
|
.createHash('md5')
|
|
.update(this.requestIp + this.request.headers['user-agent'])
|
|
.digest('hex');
|
|
|
|
const rateLimiterInput = {
|
|
userId: this.userId,
|
|
clientAddress: this.requestIp,
|
|
type: 'method',
|
|
name: method,
|
|
connectionId,
|
|
};
|
|
|
|
try {
|
|
DDPRateLimiter._increment(rateLimiterInput);
|
|
const rateLimitResult = DDPRateLimiter._check(rateLimiterInput);
|
|
if (!rateLimitResult.allowed) {
|
|
throw new Meteor.Error('too-many-requests', DDPRateLimiter.getErrorMessage(rateLimitResult), {
|
|
timeToReset: rateLimitResult.timeToReset,
|
|
});
|
|
}
|
|
|
|
const result = Meteor.call(method, ...params);
|
|
return API.v1.success(mountResult({ id, result }));
|
|
} catch (error) {
|
|
SystemLogger.error(`Exception while invoking method ${method}`, error.message);
|
|
if (settings.get('Log_Level') === '2') {
|
|
Meteor._debug(`Exception while invoking method ${method}`, error);
|
|
}
|
|
return API.v1.success(mountResult({ id, error }));
|
|
}
|
|
},
|
|
});
|
|
|
|
// had to create two different endpoints for authenticated and non-authenticated calls
|
|
// because restivus does not provide 'this.userId' if 'authRequired: false'
|
|
API.v1.addRoute('method.call/:method', { authRequired: true, rateLimiterOptions: false }, methodCall());
|
|
API.v1.addRoute('method.callAnon/:method', { authRequired: false, rateLimiterOptions: false }, methodCall());
|
|
|