Code block
import http from "http";
import qs from "querystring";
import { WebApp } from "meteor/webapp";
enum Codes {
OK = 200,
SEE_OTHER = 303,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
NOT_FOUND = 404,
TOO_MANY_REQUESTS = 429,
METHOD_NOT_ALLOWED = 405,
SERVER_ERROR = 500,
}
const rateLimits = {
GET: 5000,
POST: 500,
DELETE: 500,
}
type Method = keyof typeof rateLimits;
type Handler = (options: {
user: Meteor.User,
params?: Record<string, string>,
json: Record<string, any>,
key: string,
sendResult: (result?: any) => void,
sendError: (code: Codes, msg?: string) => void,
}) => void;
const endpoints = {
GET: {} as Record<string, Handler>,
POST: {} as Record<string, Handler>,
DELETE: {} as Record<string, Handler>,
add: function(method: Method, url: string, handler: Handler) {
this[method][url] = handler;
},
};
WebApp.connectHandlers.use('/api-data/', (req: http.IncomingMessage, res: http.ServerResponse /*, next*/) => {
var body = "";
const now = new Date;
let handled = false;
req.on('data', Meteor.bindEnvironment(data => body += data));
req.on('end', Meteor.bindEnvironment(() => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
const method = req.method as Method | 'OPTIONS';
console.log('api', method, req.url);
// respond to a CORS preflight request: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
if (method=="OPTIONS") {
// if (req.headers["access-control-request-method"] && req.headers.origin) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours
res.writeHead(Codes.OK);
res.end();
console.log('api', 'CORS preflight', req.headers.origin);
return;
}
function sendError(code: Codes, msg?: string) {
handled = true;
console.warn('api', code, msg || http.STATUS_CODES[code], method, req.url, req.headers, body);
res.writeHead(code, { 'Content-Type': 'text/plain'});
res.end(msg || http.STATUS_CODES[code] || '');
}
try {
// check method
if (!rateLimits[method] || !req.url) return sendError(Codes.METHOD_NOT_ALLOWED);
// get api end point and version
const parts = req.url.substr(1).split('/');
let reqVersion = version; // default to latest version
if (/^v\d+$/.test(parts[0])) {
reqVersion = parseFloat(parts[0].substr(1)) * 1;
parts.shift();
}
req.url = parts.filter(a => a).join('/'); // use filter() to remove trailing slash.
// check auth
let code = (req.headers.authorization || (req.headers.Authorization as string) || '');
const [, userId, key] = code.match(/^(?:basic|Bearer) (\S+):(\S+)$/i) || [];
// console.log({userId, key});
if (!userId) return sendError(Codes.UNAUTHORIZED);
let user = Meteor.users.findOne({
_id: userId,
"apiKeys.key": key,
deleted: {$exists: false},
}, {fields: {
apiKeys: {$elemMatch: {key}} as any,
}});
if (!user?.apiKeys) return sendError(Codes.UNAUTHORIZED);
// check rate limit
const apiKey = user.apiKeys[0];
if (now.valueOf() - apiKey.touched.valueOf() < rateLimits[method]) {
sendError(Codes.TOO_MANY_REQUESTS);
return;
}
let json: Record<string, any> = {};
if (body) {
try {
json = req.headers['content-type'] == 'application/x-www-form-urlencoded'
? qs.parse(body)
: JSON.parse(body);
} catch(e) {
return sendError(Codes.BAD_REQUEST, 'Could not parse body');
}
}
// find the handler method for this url
let params: Record<string, string> | undefined;
let handler = endpoints[method][req.url];
if (!handler) {
// find a handler matching /:userId/ and replace with /(?<userId>\\w+)/ so we can supply the named parameters to the handler
const found = Object.keys(endpoints[method]).find(p => {
if (p.indexOf(":") == -1) return false;
p = p.replace(/(:\w+)/g, a => `(?<${a.replace(':', '')}>\\w+)`)
return params = req.url!.match(new RegExp(p))?.groups;
});
if (found) {
handler = endpoints[method][found];
}
}
if (!handler) {
return sendError(Codes.NOT_FOUND);
}
function sendResult(result: any) {
handled = true;
// update the touched time for rate limiting
Meteor.users.update({_id: userId, 'apiKeys.key': key}, {$set: {"apiKeys.$.touched": now}});
res.writeHead(Codes.OK, { 'Content-Type': 'application/json' });
const response = JSON.stringify(result)
res.end(response);
console.log('api', method, 'v'+reqVersion, req.url, userId, body, response, (new Date().valueOf() - now.valueOf())+'ms');
}
// call the handler - each handler must call sendResult or sendError
handler({user: user!, params, json, key, sendResult, sendError});
if (!handled) throw new Error("Handler didn't call sendResult or sendError");
} catch (e) {
console.error('api', e.message);
console.log(e.stack);
sendError(Codes.SERVER_ERROR);
throw e; // this gets the error logged in MontiAPM
}
}));
});
endpoints.add("GET", "users", ({user, sendResult}) => {
const users = findUsersThisUserIsAllowedToSee(user);
sendResult(users);
});
endpoints.add("GET", "user/status", ({user, sendResult}) => {
const userStatus = findUserStatus(user);
sendResult(userStatus);
});
endpoints.add("GET", "users/:userId/status", ({user, params, sendResult, sendError}) => {
const userId = params!.userId;
if (!isUserAllowedToSeeUserId(user, userId)) {
return sendError(Codes.UNAUTHORIZED, "User exists but this API key doesn't have permission to view this user's status");
}
const user = Meteor.users.findOne(userId, {fields: {fields the user is permitted to see}))
if (!user) {
return sendError(Codes.NOT_FOUND);
}
sendResult(user);
});
endpoints.add("POST", "user/status", ({user, json, sendResult}) => {
setUserStatus(user, json);
sendResult({status: 'success'})
});