Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NEW] Add new API endpoints #8947

Merged
merged 13 commits into from
Dec 6, 2017
3 changes: 3 additions & 0 deletions packages/rocketchat-api/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,15 @@ Package.onUse(function(api) {

//Add v1 routes
api.addFiles('server/v1/channels.js', 'server');
api.addFiles('server/v1/rooms.js', 'server');
api.addFiles('server/v1/subscriptions.js', 'server');
api.addFiles('server/v1/chat.js', 'server');
api.addFiles('server/v1/commands.js', 'server');
api.addFiles('server/v1/groups.js', 'server');
api.addFiles('server/v1/im.js', 'server');
api.addFiles('server/v1/integrations.js', 'server');
api.addFiles('server/v1/misc.js', 'server');
api.addFiles('server/v1/push.js', 'server');
api.addFiles('server/v1/settings.js', 'server');
api.addFiles('server/v1/stats.js', 'server');
api.addFiles('server/v1/users.js', 'server');
Expand Down
167 changes: 166 additions & 1 deletion packages/rocketchat-api/server/api.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* global Restivus */
/* global Restivus, Auth */
import _ from 'underscore';

class API extends Restivus {
Expand Down Expand Up @@ -143,8 +143,173 @@ class API extends Restivus {
super.addRoute(route, options, endpoints);
});
}

_initAuth() {
const self = this;
/*
Add a login endpoint to the API
After the user is logged in, the onLoggedIn hook is called (see Restfully.configure() for
adding hook).
*/
this.addRoute('login', {authRequired: false}, {
post() {
// Grab the username or email that the user is logging in with
const user = {};
if (this.bodyParams.user) {
if (this.bodyParams.user.indexOf('@') === -1) {
user.username = this.bodyParams.user;
} else {
user.email = this.bodyParams.user;
}
} else if (this.bodyParams.username) {
user.username = this.bodyParams.username;
} else if (this.bodyParams.email) {
user.email = this.bodyParams.email;
}

let password = this.bodyParams.password;
if (this.bodyParams.hashed) {
password = {
digest: password,
algorithm: 'sha-256'
};
}

let auth;
try {
// Try to log the user into the user's account (if successful we'll get an auth token back)
auth = Auth.loginWithPassword(user, password);
} catch (error) {
const e = error;
return {
statusCode: e.error,
body: {
status: 'error',
message: e.reason
}
};
}
// Get the authenticated user
// TODO: Consider returning the user in Auth.loginWithPassword(), instead of fetching it again here
if (auth.userId && auth.authToken) {
const searchQuery = {};
searchQuery[self._config.auth.token] = Accounts._hashLoginToken(auth.authToken);

this.user = Meteor.users.findOne({
'_id': auth.userId
}, searchQuery);

this.userId = this.user && this.user._id;
}

// Start changes
const attempt = {
allowed: true,
user: this.user,
methodArguments: [{
user,
password
}],
type: 'password'
};

if (this.bodyParams.code) {
attempt.methodArguments[0] = {
totp: {
code: this.bodyParams.code,
...attempt.methodArguments[0]
}
};
}

Accounts._validateLogin(null, attempt);

if (attempt.allowed !== true) {
const error = attempt.error || new Meteor.Error('invalid-login-attempt', 'Invalid Login Attempt');

return {
statusCode: 401,
body: {
status: 'error',
error: error.error,
reason: error.reason,
message: error.message
}
};
}
// End changes

const response = {
status: 'success',
data: auth
};

// Call the login hook with the authenticated user attached
const extraData = self._config.onLoggedIn && self._config.onLoggedIn.call(this);

if (extraData != null) {
_.extend(response.data, {
extra: extraData
});
}

return response;
}
});

const logout = function() {
// Remove the given auth token from the user's account
const authToken = this.request.headers['x-auth-token'];
const hashedToken = Accounts._hashLoginToken(authToken);
const tokenLocation = self._config.auth.token;
const index = tokenLocation.lastIndexOf('.');
const tokenPath = tokenLocation.substring(0, index);
const tokenFieldName = tokenLocation.substring(index + 1);
const tokenToRemove = {};
tokenToRemove[tokenFieldName] = hashedToken;
const tokenRemovalQuery = {};
tokenRemovalQuery[tokenPath] = tokenToRemove;

Meteor.users.update(this.user._id, {
$pull: tokenRemovalQuery
});

const response = {
status: 'success',
data: {
message: 'You\'ve been logged out!'
}
};

// Call the logout hook with the authenticated user attached
const extraData = self._config.onLoggedOut && self._config.onLoggedOut.call(this);
if (extraData != null) {
_.extend(response.data, {
extra: extraData
});
}
return response;
};

/*
Add a logout endpoint to the API
After the user is logged out, the onLoggedOut hook is called (see Restfully.configure() for
adding hook).
*/
return this.addRoute('logout', {
authRequired: true
}, {
get() {
console.warn('Warning: Default logout via GET will be removed in Restivus v1.0. Use POST instead.');
console.warn(' See https://github.com/kahmali/meteor-restivus/issues/100');
return logout.call(this);
},
post: logout
});
}
}


RocketChat.API = {};

const getUserAuth = function _getUserAuth() {
Expand Down
22 changes: 22 additions & 0 deletions packages/rocketchat-api/server/v1/push.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/* globals Push */

RocketChat.API.v1.addRoute('push.token/:token', { authRequired: true }, {
delete() {
const affectedRecords = Push.appCollection.remove({
$or: [{
apn: this.urlParams._id
}, {
gcm: this.urlParams._id
}],
userId: this.userId
});

if (affectedRecords === 0) {
return {
statusCode: 404
};
}

return RocketChat.API.v1.success();
}
});
91 changes: 91 additions & 0 deletions packages/rocketchat-api/server/v1/rooms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
RocketChat.API.v1.addRoute('rooms.get', { authRequired: true }, {
get: {
//This is defined as such only to provide an example of how the routes can be defined :X
action() {
let updatedAt;

if (typeof this.queryParams.updatedAt === 'string') {
try {
updatedAt = new Date(this.queryParams.updatedAt);

if (updatedAt.toString() === 'Invalid Date') {
return RocketChat.API.v1.failure('Invalid date for `updatedAt`');
}
} catch (error) {
return RocketChat.API.v1.failure('Invalid date for `updatedAt`');
}
}

return Meteor.runAsUser(this.userId, () => {
return RocketChat.API.v1.success(Meteor.call('rooms/get', updatedAt));
});
}
}
});

RocketChat.API.v1.addRoute('rooms.upload/:rid', { authRequired: true }, {
post() {
const room = Meteor.call('canAccessRoom', this.urlParams.rid, this.userId);

if (!room) {
return RocketChat.API.v1.unauthorized();
}

const Busboy = Npm.require('busboy');
const busboy = new Busboy({ headers: this.request.headers });
const files = [];
const fields = {};

Meteor.wrapAsync((callback) => {
busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
if (fieldname !== 'file') {
return files.push(new Meteor.Error('invalid-field'));
}

const fileDate = [];
file.on('data', data => fileDate.push(data));

file.on('end', () => {
files.push({ fieldname, file, filename, encoding, mimetype, fileBuffer: Buffer.concat(fileDate) });
});
});

busboy.on('field', (fieldname, value) => fields[fieldname] = value);

busboy.on('finish', Meteor.bindEnvironment(() => callback()));

this.request.pipe(busboy);
})();

if (files.length === 0) {
return RocketChat.API.v1.failure('File required');
}

if (files.length > 1) {
return RocketChat.API.v1.failure('Just 1 file is allowed');
}

const file = files[0];

const fileStore = FileUpload.getStore('Uploads');

const details = {
name: file.filename,
size: file.fileBuffer.length,
type: file.mimetype,
rid: this.urlParams.rid
};

Meteor.runAsUser(this.userId, () => {
const uploadedFile = Meteor.wrapAsync(fileStore.insert.bind(fileStore))(details, file.fileBuffer);

uploadedFile.description = fields.description;

delete fields.description;

RocketChat.API.v1.success(Meteor.call('sendFileMessage', this.urlParams.rid, null, uploadedFile, fields));
});

return RocketChat.API.v1.success();
}
});
36 changes: 36 additions & 0 deletions packages/rocketchat-api/server/v1/settings.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import _ from 'underscore';

// settings endpoints
RocketChat.API.v1.addRoute('settings.public', { authRequired: false }, {
get() {
const { offset, count } = this.getPaginationItems();
const { sort, fields, query } = this.parseJsonQuery();

let ourQuery = {
hidden: { $ne: true },
'public': true
};

ourQuery = Object.assign({}, query, ourQuery);

const settings = RocketChat.models.Settings.find(ourQuery, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be changed to a model call? We already have RocketChat.models.Settings.findNotHiddenPublic which has the same filter, just need to implement additional sort, skip and limit options.

I also think this should only return _id and value fields, I cannot see any use for the other fields.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ideia is to have an API that is versatile, that's why the API pass the query and allow the caller to pass more filters and require other fields if necessary

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my only concern is how it can be abused.

but still bypassing the model (using .find directly) defeats model's purpose and I thought was strongly discouraged.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to agree that this particular endpoint should use the findNotHIddenPublic with pagination support, however adding that support requires additional work and can be done via a secondary pull request after this one is merged. This way we don't delay any longer getting this out and come back to it.

sort: sort ? sort : { _id: 1 },
skip: offset,
limit: count,
fields: Object.assign({ _id: 1, value: 1 }, fields)
}).fetch();

return RocketChat.API.v1.success({
settings,
count: settings.length,
offset,
total: RocketChat.models.Settings.find(ourQuery).count()
});
}
});

RocketChat.API.v1.addRoute('settings', { authRequired: true }, {
get() {
const { offset, count } = this.getPaginationItems();
Expand Down Expand Up @@ -56,3 +84,11 @@ RocketChat.API.v1.addRoute('settings/:_id', { authRequired: true }, {
return RocketChat.API.v1.failure();
}
});

RocketChat.API.v1.addRoute('service.configurations', { authRequired: false }, {
get() {
const ServiceConfiguration = Package['service-configuration'].ServiceConfiguration;

return RocketChat.API.v1.success(ServiceConfiguration.configurations.find({}, {fields: {secret: 0}}).fetch());
}
});
24 changes: 24 additions & 0 deletions packages/rocketchat-api/server/v1/subscriptions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
RocketChat.API.v1.addRoute('subscriptions.get', { authRequired: true }, {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be to hard to have the same response format for calls with and without the updatedAt filter?

Copy link
Contributor

@graywolf336 graywolf336 Dec 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, not at all...aka check now. ;)

get: {
//This is defined as such only to provide an example of how the routes can be defined :X
action() {
let updatedAt;

if (typeof this.queryParams.updatedAt === 'string') {
try {
updatedAt = new Date(this.queryParams.updatedAt);

if (updatedAt.toString() === 'Invalid Date') {
return RocketChat.API.v1.failure('Invalid date for `updatedAt`');
}
} catch (error) {
return RocketChat.API.v1.failure('Invalid date for `updatedAt`');
}
}

return Meteor.runAsUser(this.userId, () => {
return RocketChat.API.v1.success(Meteor.call('subscriptions/get', updatedAt));
});
}
}
});