Merge branch 'develop' of Software_Engineering_I/greenvironment-server into master

pull/5/head
Trivernis 5 years ago committed by Gitea
commit c2daac84bc

@ -60,6 +60,7 @@
},
"dependencies": {
"@types/body-parser": "^1.17.1",
"@types/lodash": "^4.14.149",
"body-parser": "^1.19.0",
"compression": "^1.7.4",
"config": "^3.2.4",
@ -79,6 +80,7 @@
"http-status": "^1.3.2",
"js-yaml": "^3.13.1",
"legit": "^1.0.7",
"lodash": "^4.17.15",
"markdown-it": "^10.0.0",
"markdown-it-emoji": "^1.4.0",
"markdown-it-html5-media": "^0.6.0",

@ -8,7 +8,7 @@ import * as graphqlHTTP from "express-graphql";
import * as session from "express-session";
import sharedsession = require("express-socket.io-session");
import * as fsx from "fs-extra";
import {buildSchema} from "graphql";
import {buildSchema, GraphQLError} from "graphql";
import {importSchema} from "graphql-import";
import queryComplexity, {directiveEstimator, simpleEstimator} from "graphql-query-complexity";
import * as http from "http";
@ -192,11 +192,21 @@ class App {
});
// @ts-ignore
this.app.use("/graphql", graphqlHTTP(async (request, response, {variables}) => {
this.app.use("/graphql", graphqlHTTP(async (request: any, response: any, {variables}) => {
response.setHeader("X-Max-Query-Complexity", config.get("api.maxQueryComplexity"));
return {
// @ts-ignore all
context: {session: request.session},
formatError: (err: GraphQLError | any) => {
if (err.statusCode) {
response.status(err.statusCode);
} else {
response.status(400);
}
logger.debug(err.message);
logger.silly(err.stack);
return err.graphqlError ?? err;
},
graphiql: config.get("api.graphiql"),
rootValue: resolver(request, response),
schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))),

@ -0,0 +1,17 @@
import {NotLoggedInGqlError} from "../lib/errors/graphqlErrors";
/**
* Base resolver class to provide common methods to all resolver classes
*/
export abstract class BaseResolver {
/**
* Checks if the user is logged in and throws an exception if not
* @param request
*/
protected ensureLoggedIn(request: any) {
if (!request.session.userId) {
throw new NotLoggedInGqlError();
}
}
}

@ -0,0 +1,10 @@
/**
* A result of a query to check if a phrase contains blacklisted phrases
*/
export class BlacklistedResult {
constructor(
public blacklisted: boolean,
public phrases: string[],
) {
}
}

@ -0,0 +1,467 @@
import {GraphQLError} from "graphql";
import * as yaml from "js-yaml";
import isEmail from "validator/lib/isEmail";
import dataaccess from "../lib/dataAccess";
import {BlacklistedError} from "../lib/errors/BlacklistedError";
import {GroupNotFoundError} from "../lib/errors/GroupNotFoundError";
import {InvalidEmailError} from "../lib/errors/InvalidEmailError";
import {NotAGroupAdminError} from "../lib/errors/NotAGroupAdminError";
import {NotAnAdminError} from "../lib/errors/NotAnAdminError";
import {NotTheGroupCreatorError} from "../lib/errors/NotTheGroupCreatorError";
import {PostNotFoundError} from "../lib/errors/PostNotFoundError";
import globals from "../lib/globals";
import {InternalEvents} from "../lib/InternalEvents";
import {Activity, BlacklistedPhrase, ChatMessage, ChatRoom, Event, Group, Post, Request, User} from "../lib/models";
import {BaseResolver} from "./BaseResolver";
const legit = require("legit");
/**
* A class that provides methods to resolve mutations
*/
export class MutationResolver extends BaseResolver {
/**
* Accepts the usage of cookies and stores the session
* @param args
* @param request
*/
public acceptCookies(args: null, request: any): boolean {
request.session.cookiesAccepted = true;
return true;
}
/**
* Loggs in and appends the user id to the session
* @param email
* @param passwordHash
* @param request
*/
public async login({email, passwordHash}: { email: string, passwordHash: string }, request: any): Promise<User> {
const user = await dataaccess.getUserByLogin(email, passwordHash);
request.session.userId = user.id;
return user;
}
/**
* Loggs out by removing the user from the session
* @param args
* @param request
*/
public logout(args: null, request: any) {
this.ensureLoggedIn(request);
delete request.session.userId;
request.session.save((err: any) => {
if (err) {
globals.logger.error(err.message);
globals.logger.debug(err.stack);
}
});
}
/**
* Registers a new user account
* @param username
* @param email
* @param passwordHash
* @param request
*/
public async register({username, email, passwordHash}: { username: string, email: string, passwordHash: string },
request: any): Promise<User> {
let mailValid = isEmail(email);
if (mailValid) {
try {
mailValid = (await legit(email)).isValid;
} catch (err) {
globals.logger.warn(`Mail legit check returned: ${err.message}`);
globals.logger.debug(err.stack);
mailValid = false;
}
}
if (!mailValid) {
throw new InvalidEmailError(email);
}
const user = await dataaccess.registerUser(username, email, passwordHash);
request.session.userId = user.id;
return user;
}
/**
* Sets the frontend settings for the logged in user
* @param settings
* @param request
*/
public async setUserSettings({settings}: { settings: string }, request: any): Promise<string> {
this.ensureLoggedIn(request);
const user = await User.findByPk(request.session.userId);
try {
user.frontendSettings = yaml.safeLoad(settings);
await user.save();
return user.settings;
} catch (err) {
throw new GraphQLError("Invalid settings json.");
}
}
/**
* Toggles a vote of a specific type on a post and returns the post and the result
* @param postId
* @param type
* @param request
*/
public async vote({postId, type}: { postId: number, type: dataaccess.VoteType }, request: any):
Promise<{ post: Post, voteType: dataaccess.VoteType }> {
this.ensureLoggedIn(request);
const post = await Post.findByPk(postId);
if (post) {
const voteType = await post.vote(request.session.userId, type);
return {
post,
voteType,
};
} else {
throw new PostNotFoundError(postId);
}
}
/**
* Creates a new post
* @param content
* @param activityId
* @param request
*/
public async createPost({content, activityId}: { content: string, activityId?: number }, request: any):
Promise<Post> {
this.ensureLoggedIn(request);
if (content.length > 2048) {
throw new GraphQLError("Content too long.");
}
const post = await dataaccess.createPost(content, request.session.userId, activityId);
globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post);
return post;
}
/**
* Deletes a post if the user is either the author or a site admin.
* @param postId
* @param request
*/
public async deletePost({postId}: { postId: number }, request: any): Promise<boolean> {
this.ensureLoggedIn(request);
const post = await Post.findByPk(postId, {
include: [{
as: "rAuthor",
model: User,
}],
});
const isAdmin = (await User.findOne({where: {id: request.session.userId}})).isAdmin;
if (post.rAuthor.id === request.session.userId || isAdmin) {
return await dataaccess.deletePost(post.id);
} else {
throw new GraphQLError("User is not author of the post.");
}
}
/**
* Creates a chat with several members
* @param members
* @param request
*/
public async createChat({members}: { members?: number[] }, request: any): Promise<ChatRoom> {
this.ensureLoggedIn(request);
const chatMembers = [request.session.userId];
if (members) {
chatMembers.push(...members);
}
return await dataaccess.createChat(...chatMembers);
}
/**
* Sends a message into a chat the user has joined
* @param chatId
* @param content
* @param request
*/
public async sendMessage({chatId, content}: { chatId: number, content: string }, request: any):
Promise<ChatMessage> {
this.ensureLoggedIn(request);
const message = await dataaccess.sendChatMessage(request.session.userId, chatId, content);
globals.internalEmitter.emit(InternalEvents.GQLCHATMESSAGE, message);
return message;
}
/**
* Sends a request to a specific user
* @param receiver
* @param type
* @param request
*/
public async sendRequest({receiver, type}: { receiver: number, type: dataaccess.RequestType }, request: any):
Promise<Request> {
this.ensureLoggedIn(request);
return dataaccess.createRequest(request.session.userId, receiver, type);
}
/**
* Denies a request
* @param sender
* @param type
* @param request
*/
public async denyRequest({sender, type}: { sender: number, type: dataaccess.RequestType }, request: any) {
this.ensureLoggedIn(request);
const user = await User.findByPk(request.session.userId);
await user.acceptRequest(sender, type);
return true;
}
/**
* Accepts a request
* @param sender
* @param type
* @param request
*/
public async acceptRequest({sender, type}: { sender: number, type: dataaccess.RequestType }, request: any) {
this.ensureLoggedIn(request);
const user = await User.findByPk(request.session.userId);
await user.acceptRequest(sender, type);
return true;
}
/**
* Removes a friend
* @param friendId
* @param request
*/
public async removeFriend({friendId}: { friendId: number }, request: any): Promise<boolean> {
this.ensureLoggedIn(request);
const user = await User.findByPk(request.session.userId);
return user.removeFriend(friendId);
}
/**
* Creates a new group
* @param name
* @param members
* @param request
*/
public async createGroup({name, members}: { name: string, members: number[] }, request: any): Promise<Group> {
this.ensureLoggedIn(request);
return await dataaccess.createGroup(name, request.session.userId, members);
}
/**
* Deletes a group if the user is either the creator or a site admin
* @param groupId
* @param request
*/
public async deleteGroup({groupId}: { groupId: number }, request: any): Promise<boolean> {
this.ensureLoggedIn(request);
const user = await User.findByPk(request.session.userId);
const group = await Group.findByPk(groupId);
if (group) {
if (user.isAdmin || group.creatorId === user.id) {
await group.destroy();
return true;
}
} else {
throw new GroupNotFoundError(groupId);
}
}
/**
* Joins a group
* @param groupId
* @param request
*/
public async joinGroup({groupId}: { groupId: number }, request: any): Promise<Group> {
this.ensureLoggedIn(request);
return dataaccess.changeGroupMembership(groupId, request.session.userId,
dataaccess.MembershipChangeAction.ADD);
}
/**
* Leaves a group
* @param groupId
* @param request
*/
public async leaveGroup({groupId}: { groupId: number }, request: any): Promise<Group> {
this.ensureLoggedIn(request);
return dataaccess.changeGroupMembership(groupId, request.session.userId,
dataaccess.MembershipChangeAction.REMOVE);
}
/**
* Adds a user to the group admins
* @param groupId
* @param userId
* @param request
*/
public async addGroupAdmin({groupId, userId}: { groupId: number, userId: number }, request: any): Promise<Group> {
this.ensureLoggedIn(request);
const group = await Group.findByPk(groupId);
const user: User = await User.findByPk(request.session.userId);
if (group && !(await group.$has("rAdmins", user)) && (await group.creator()) !== user.id) {
throw new NotAGroupAdminError(groupId);
}
return dataaccess.changeGroupMembership(groupId, userId,
dataaccess.MembershipChangeAction.OP);
}
/**
* Removes an admin from a group
* @param groupId
* @param userId
* @param request
*/
public async removeGroupAdmin({groupId, userId}: { groupId: number, userId: number },
request: any): Promise<Group> {
this.ensureLoggedIn(request);
const group = await Group.findByPk(groupId);
const isCreator = Number(group.creatorId) === Number(request.session.userId);
const userIsCreator = Number(group.creatorId) === Number(userId);
if (group && !isCreator && Number(userId) !== Number(request.session.userId)) {
throw new NotTheGroupCreatorError(groupId);
} else if (userIsCreator) {
throw new GraphQLError(
"You are not allowed to remove a creator as an admin.");
}
return await dataaccess.changeGroupMembership(groupId, userId,
dataaccess.MembershipChangeAction.DEOP);
}
/**
* Creates a new event for a specific group
* @param name
* @param dueDate
* @param groupId
* @param request
*/
public async createEvent({name, dueDate, groupId}: { name: string, dueDate: string, groupId: number },
request: any): Promise<Event> {
this.ensureLoggedIn(request);
const date = new Date(Number(dueDate));
const user: User = await User.findByPk(request.session.userId);
const group = await Group.findByPk(groupId, {include: [{association: "rAdmins"}]});
if (!(await group.$has("rAdmins", user))) {
throw new NotAGroupAdminError(groupId);
}
const blacklisted = await dataaccess.checkBlacklisted(name);
if (blacklisted.length > 0) {
throw new BlacklistedError(blacklisted.map((p) => p.phrase), "event name");
}
return group.$create<Event>("rEvent", {name, dueDate: date});
}
/**
* Deletes an event
* @param eventId
* @param request
*/
public async deleteEvent({eventId}: { eventId: number }, request: any): Promise<boolean> {
this.ensureLoggedIn(request);
const event = await Event.findByPk(eventId, {include: [Group]});
const user = await User.findByPk(request.session.userId);
const group = await event.group();
if (await group.$has("rAdmins", user)) {
await event.destroy();
return true;
} else {
throw new NotAGroupAdminError(group.id);
}
}
/**
* Joins an event
* @param eventId
* @param request
*/
public async joinEvent({eventId}: { eventId: number }, request: any): Promise<Event> {
this.ensureLoggedIn(request);
const event = await Event.findByPk(eventId);
const self = await User.findByPk(request.session.userId);
await event.$add("rParticipants", self);
return event;
}
/**
* Leaves an event
* @param eventId
* @param request
*/
public async leaveEvent({eventId}: { eventId: number }, request: any): Promise<Event> {
this.ensureLoggedIn(request);
const event = await Event.findByPk(eventId);
const self = await User.findByPk(request.session.userId);
await event.$remove("rParticipants", self);
return event;
}
/**
* Creates a new activity or throws an error if the activity already exists
* @param name
* @param description
* @param points
* @param request
*/
public async createActivity({name, description, points}: { name: string, description: string, points: number },
request: any): Promise<Activity> {
this.ensureLoggedIn(request.session.userId);
const user = await User.findByPk(request.session.userId);
if (!user.isAdmin) {
throw new NotAnAdminError();
}
const nameExists = await Activity.findOne({where: {name}});
if (!nameExists) {
return Activity.create({name, description, points});
} else {
throw new GraphQLError(`An activity with the name '${name}' already exists.`);
}
}
/**
* Adds a phrase to the blaclist
* @param phrase
* @param languageCode
* @param request
*/
public async addToBlacklist({phrase, languageCode}: { phrase: string, languageCode?: string }, request: any):
Promise<boolean> {
this.ensureLoggedIn(request);
const user = await User.findByPk(request.session.userId);
if (!user.isAdmin) {
throw new NotAnAdminError();
}
const phraseExists = await BlacklistedPhrase.findOne(
{where: {phrase, language: languageCode}});
if (!phraseExists) {
await BlacklistedPhrase.create({phrase, language: languageCode});
return true;
} else {
return false;
}
}
/**
* Removes a phrase from the blacklist
* @param phrase
* @param languageCode
* @param request
*/
public async removeFromBlacklist({phrase, languageCode}: { phrase: string, languageCode: string }, request: any):
Promise<boolean> {
this.ensureLoggedIn(request);
const user = await User.findByPk(request.session.userId);
if (!user.isAdmin) {
throw new NotAnAdminError();
}
const phraseEntry = await BlacklistedPhrase.findOne(
{where: {phrase, language: languageCode}});
if (phraseEntry) {
await phraseEntry.destroy();
return true;
} else {
return false;
}
}
}

@ -0,0 +1,187 @@
import {GraphQLError} from "graphql";
import {Op} from "sequelize";
import dataaccess from "../lib/dataAccess";
import {ChatNotFoundError} from "../lib/errors/ChatNotFoundError";
import {PostNotFoundGqlError} from "../lib/errors/graphqlErrors";
import {GroupNotFoundError} from "../lib/errors/GroupNotFoundError";
import {RequestNotFoundError} from "../lib/errors/RequestNotFoundError";
import {UserNotFoundError} from "../lib/errors/UserNotFoundError";
import {Activity, BlacklistedPhrase, ChatRoom, Event, Group, Post, Request, User} from "../lib/models";
import {BlacklistedResult} from "./BlacklistedResult";
import {MutationResolver} from "./MutationResolver";
import {SearchResult} from "./SearchResult";
import {Token} from "./Token";
/**
* A class that provides functions to resolve queries
*/
export class QueryResolver extends MutationResolver {
/**
* Gets a user by id or handle
* @param userId
* @param handle
*/
public async getUser({userId, handle}: { userId?: number, handle?: string }): Promise<User> {
let user: User;
if (userId) {
user = await User.findByPk(userId);
} else if (handle) {
user = await User.findOne({where: {handle}});
} else {
throw new GraphQLError("No handle or userId provided");
}
if (user) {
return user;
} else {
throw new UserNotFoundError(userId ?? handle);
}
}
/**
* Returns the instance of the currently logged in user
* @param args
* @param request
*/
public async getSelf(args: null, request: any): Promise<User> {
this.ensureLoggedIn(request);
return User.findByPk(request.session.userId);
}
/**
* Returns a post for a given post id.
* @param postId
*/
public async getPost({postId}: { postId: number }): Promise<Post> {
const post = await Post.findByPk(postId);
if (post) {
return post;
} else {
throw new PostNotFoundGqlError(postId);
}
}
/**
* Returns a chat for a given chat id
* @param chatId
*/
public async getChat({chatId}: { chatId: number }): Promise<ChatRoom> {
const chat = await ChatRoom.findByPk(chatId);
if (chat) {
return chat;
} else {
throw new ChatNotFoundError(chatId);
}
}
/**
* Returns a group for a given group id.
* @param groupId
*/
public async getGroup({groupId}: { groupId: number }): Promise<Group> {
const group = await Group.findByPk(groupId);
if (group) {
return group;
} else {
throw new GroupNotFoundError(groupId);
}
}
/**
* Returns the request for a given id.
* @param requestId
*/
public async getRequest({requestId}: { requestId: number }): Promise<Request> {
const request = await Request.findByPk(requestId);
if (request) {
return request;
} else {
throw new RequestNotFoundError(requestId);
}
}
/**
* Searches for posts, groups, users, events and returns a search result.
* @param query
* @param first
* @param offset
*/
public async search({query, first, offset}: { query: number, first: number, offset: number }):
Promise<SearchResult> {
const limit = first;
const users = await User.findAll({
limit,
offset,
where: {
[Op.or]: [
{handle: {[Op.iRegexp]: query}},
{username: {[Op.iRegexp]: query}},
],
},
});
const groups = await Group.findAll({
limit,
offset,
where: {name: {[Op.iRegexp]: query}},
});
const posts = await Post.findAll({
limit,
offset,
where: {content: {[Op.iRegexp]: query}},
});
const events = await Event.findAll({
limit,
offset,
where: {name: {[Op.iRegexp]: query}},
});
return new SearchResult(users, groups, posts, events);
}
/**
* Returns the posts with a specific sorting
* @param first
* @param offset
* @param sort
*/
public async getPosts({first, offset, sort}: { first: number, offset: number, sort: dataaccess.SortType }):
Promise<Post[]> {
return await dataaccess.getPosts(first, offset, sort);
}
/**
* Returns all activities
*/
public async getActivities(): Promise<Activity[]> {
return Activity.findAll();
}
/**
* Returns the token for a user by login
* @param email
* @param passwordHash
*/
public async getToken({email, passwordHash}: { email: string, passwordHash: string }): Promise<Token> {
const user = await dataaccess.getUserByLogin(email, passwordHash);
return new Token(await user.token(), Number(user.authExpire).toString());
}
/**
* Returns if a input phrase contains blacklisted phrases and which one
* @param phrase
*/
public async blacklisted({phrase}: { phrase: string }): Promise<BlacklistedResult> {
const phrases = await dataaccess.checkBlacklisted(phrase);
return new BlacklistedResult(phrases.length > 0, phrases
.map((p) => p.phrase));
}
/**
* Returns all blacklisted phrases with pagination
* @param first
* @param offset
*/
public async getBlacklistedPhrases({first, offset}: { first: number, offset: number }): Promise<string[]> {
return (await BlacklistedPhrase.findAll({limit: first, offset}))
.map((p) => p.phrase);
}
}

@ -0,0 +1,14 @@
import {Event, Group, Post, User} from "../lib/models";
/**
* A class to wrap search results returned by the search resolver
*/
export class SearchResult {
constructor(
public users: User[],
public groups: Group[],
public posts: Post[],
public events: Event[],
) {
}
}

@ -0,0 +1,10 @@
/**
* A class representing a token that can be used with bearer authentication
*/
export class Token {
constructor(
public value: string,
public expires: string,
) {
}
}

@ -1,21 +1,4 @@
import {GraphQLError} from "graphql";
import * as status from "http-status";
import * as yaml from "js-yaml";
import {Op} from "sequelize";
import isEmail from "validator/lib/isEmail";
import dataaccess from "../lib/dataAccess";
import {BlacklistedError} from "../lib/errors/BlacklistedError";
import {NotAnAdminGqlError, NotLoggedInGqlError, PostNotFoundGqlError} from "../lib/errors/graphqlErrors";
import {GroupNotFoundError} from "../lib/errors/GroupNotFoundError";
import {InvalidLoginError} from "../lib/errors/InvalidLoginError";
import globals from "../lib/globals";
import {InternalEvents} from "../lib/InternalEvents";
import * as models from "../lib/models";
import {is} from "../lib/regex";
const legit = require("legit");
// tslint:disable:completed-docs
import {QueryResolver} from "./QueryResolver";
/**
* Returns the resolvers for the graphql api.
@ -23,608 +6,5 @@ const legit = require("legit");
* @param res - the response object
*/
export function resolver(req: any, res: any): any {
return {
async search({first, offset, query}: { first: number, offset: number, query: string }) {
const limit = first;
const users = await models.User.findAll({
limit,
offset,
where: {
[Op.or]: [
{handle: {[Op.iRegexp]: query}},
{username: {[Op.iRegexp]: query}},
],
},
});
const groups = await models.Group.findAll({
limit,
offset,
where: {name: {[Op.iRegexp]: query}},
});
const posts = await models.Post.findAll({
limit,
offset,
where: {content: {[Op.iRegexp]: query}},
});
const events = await models.Event.findAll({
limit,
offset,
where: {name: {[Op.iRegexp]: query}},
});
return {users, posts, groups, events};
},
async findUser({first, offset, name, handle}:
{ first: number, offset: number, name: string, handle: string }) {
res.status(status.MOVED_PERMANENTLY);
if (name) {
return models.User.findAll({where: {username: {[Op.like]: `%${name}%`}}, offset, limit: first});
} else if (handle) {
return models.User.findAll({where: {handle: {[Op.like]: `%${handle}%`}}, offset, limit: first});
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No search parameters provided.");
}
},
async getSelf() {
if (req.session.userId) {
return models.User.findByPk(req.session.userId);
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async getUser({userId, handle}: { userId: number, handle: string }) {
if (handle) {
return await dataaccess.getUserByHandle(handle);
} else if (userId) {
return models.User.findByPk(userId);
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No userId or handle provided.");
}
},
async getPost({postId}: { postId: number }) {
if (postId) {
return await dataaccess.getPost(postId);
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No postId given.");
}
},
async getChat({chatId}: { chatId: number }) {
if (chatId) {
return models.ChatRoom.findByPk(chatId);
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No chatId given.");
}
},
async getGroup({groupId}: { groupId: number }) {
if (groupId) {
return models.Group.findByPk(groupId);
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No group id given.");
}
},
async getRequest({requestId}: { requestId: number }) {
if (requestId) {
return models.Request.findByPk(requestId);
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No requestId given.");
}
},
async blacklisted({phrase}: {phrase: string}) {
const phrases = await dataaccess.checkBlacklisted(phrase);
return {
blacklisted: phrases.length > 0,
phrases: phrases.map((p) => p.phrase),
};
},
async getBlacklistedPhrases({first, offset}: {first: number, offset: number}) {
return (await models.BlacklistedPhrase.findAll({limit: first, offset})).map((p) => p.phrase);
},
acceptCookies() {
req.session.cookiesAccepted = true;
return true;
},
async login({email, passwordHash}: { email: string, passwordHash: string }) {
if (email && passwordHash) {
try {
const user = await dataaccess.getUserByLogin(email, passwordHash);
req.session.userId = user.id;
return user;
} catch (err) {
globals.logger.warn(err.message);
globals.logger.debug(err.stack);
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No email or password given.");
}
},
logout() {
if (req.session.userId) {
delete req.session.userId;
req.session.save((err: any) => {
if (err) {
globals.logger.error(err.message);
globals.logger.debug(err.stack);
}
});
return true;
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async getToken({email, passwordHash}: { email: string, passwordHash: string }) {
if (email && passwordHash) {
try {
const user = await dataaccess.getUserByLogin(email, passwordHash);
if (!user) {
res.status(status.BAD_REQUEST);
return new InvalidLoginError(email);
} else {
return {
expires: Number(user.authExpire),
value: user.token(),
};
}
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No email or password specified.");
}
},
async register({username, email, passwordHash}: { username: string, email: string, passwordHash: string }) {
if (username && email && passwordHash) {
let mailValid = isEmail(email);
if (mailValid) {
try {
mailValid = (await legit(email)).isValid;
} catch (err) {
globals.logger.warn(`Mail legit check returned: ${err.message}`);
globals.logger.debug(err.stack);
mailValid = false;
}
}
if (!mailValid) {
res.status(status.BAD_REQUEST);
return new GraphQLError(`'${email}' is not a valid email address!`);
}
try {
const user = await dataaccess.registerUser(username, email, passwordHash);
req.session.userId = user.id;
return user;
} catch (err) {
globals.logger.warn(err.message);
globals.logger.debug(err.stack);
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No username, email or password given.");
}
},
async setUserSettings({settings}: { settings: string }) {
if (req.session.userId) {
const user = await models.User.findByPk(req.session.userId);
try {
user.frontendSettings = yaml.safeLoad(settings);
await user.save();
return user.settings;
} catch (err) {
res.status(status.BAD_REQUEST);
return new GraphQLError("Invalid settings json.");
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async vote({postId, type}: { postId: number, type: dataaccess.VoteType }) {
if (postId && type) {
if (req.session.userId) {
const post = await models.Post.findByPk(postId);
if (post) {
const voteType = await post.vote(req.session.userId, type);
return {
post,
voteType,
};
} else {
res.status(status.BAD_REQUEST);
return new PostNotFoundGqlError(postId);
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No postId or type given.");
}
},
async createPost({content, activityId}: { content: string, activityId: number }) {
if (content) {
if (req.session.userId) {
if (content.length > 2048) {
return new GraphQLError("Content too long.");
} else {
try {
const post = await dataaccess.createPost(content, req.session.userId, activityId);
globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post);
return post;
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("Can't create empty post.");
}
},
async deletePost({postId}: { postId: number }) {
if (postId) {
const post = await models.Post.findByPk(postId, {
include: [{
as: "rAuthor",
model: models.User,
}],
});
const isAdmin = (await models.User.findOne({where: {id: req.session.userId}})).isAdmin;
if (post.rAuthor.id === req.session.userId || isAdmin) {
try {
return await dataaccess.deletePost(post.id);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.FORBIDDEN);
return new GraphQLError("User is not author of the post.");
}
} else {
return new GraphQLError("No postId given.");
}
},
async createChat({members}: { members: number[] }) {
if (req.session.userId) {
const chatMembers = [req.session.userId];
if (members) {
chatMembers.push(...members);
}
return await dataaccess.createChat(...chatMembers);
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async sendMessage({chatId, content}: { chatId: number, content: string }) {
if (!req.session.userId) {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
if (chatId && content) {
try {
const message = await dataaccess.sendChatMessage(req.session.userId, chatId, content);
globals.internalEmitter.emit(InternalEvents.GQLCHATMESSAGE, message);
return message;
} catch (err) {
globals.logger.warn(err.message);
globals.logger.debug(err.stack);
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No chatId or content given.");
}
},
async sendRequest({receiver, type}: { receiver: number, type: dataaccess.RequestType }) {
if (!req.session.userId) {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
if (receiver && type) {
try {
return await dataaccess.createRequest(req.session.userId, receiver, type);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No receiver or type given.");
}
},
async denyRequest({sender, type}: { sender: number, type: dataaccess.RequestType }) {
if (!req.session.userId) {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
if (sender && type) {
const user = await models.User.findByPk(req.session.userId);
await user.denyRequest(sender, type);
return true;
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No sender or type given.");
}
},
async acceptRequest({sender, type}: { sender: number, type: dataaccess.RequestType }) {
if (!req.session.userId) {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
if (sender && type) {
try {
const user = await models.User.findByPk(req.session.userId);
await user.acceptRequest(sender, type);
return true;
} catch (err) {
globals.logger.warn(err.message);
globals.logger.debug(err.stack);
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.BAD_REQUEST);
return new GraphQLError("No sender or type given.");
}
},
async removeFriend({friendId}: { friendId: number }) {
if (req.session.userId) {
const self = await models.User.findByPk(req.session.userId);
return await self.removeFriend(friendId);
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async getPosts({first, offset, sort}: { first: number, offset: number, sort: dataaccess.SortType }) {
return await dataaccess.getPosts(first, offset, sort);
},
async createGroup({name, members}: { name: string, members: number[] }) {
if (req.session.userId) {
try {
return await dataaccess.createGroup(name, req.session.userId, members);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
return new NotLoggedInGqlError();
}
},
async deleteGroup({groupId}: {groupId: number}) {
if (req.session.userId) {
const group = await models.Group.findByPk(groupId);
if (!group) {
res.status(status.BAD_REQUEST);
return new GroupNotFoundError(groupId).graphqlError;
}
if (group.creatorId === req.session.userId) {
await group.destroy();
return true;
} else {
res.status(status.FORBIDDEN);
return new GraphQLError("You are not the group admin.");
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async joinGroup({id}: { id: number }) {
if (req.session.userId) {
try {
return await dataaccess
.changeGroupMembership(id, req.session.userId, dataaccess.MembershipChangeAction.ADD);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async leaveGroup({id}: { id: number }) {
if (req.session.userId) {
try {
return await dataaccess
.changeGroupMembership(id, req.session.userId, dataaccess.MembershipChangeAction.REMOVE);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async addGroupAdmin({groupId, userId}: { groupId: number, userId: number }) {
if (req.session.userId) {
const group = await models.Group.findByPk(groupId);
const self = await models.User.findByPk(req.session.userId);
if (group && !(await group.$has("rAdmins", self)) && (await group.creator()) !== self.id) {
res.status(status.FORBIDDEN);
return new GraphQLError("You are not a group admin!");
}
try {
return await dataaccess
.changeGroupMembership(groupId, userId, dataaccess.MembershipChangeAction.OP);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async removeGroupAdmin({groupId, userId}: { groupId: number, userId: number }) {
if (req.session.userId) {
const group = await models.Group.findByPk(groupId);
const isCreator = Number(group.creatorId) === Number(req.session.userId);
const userIsCreator = Number(group.creatorId) === Number(userId);
if (group && !isCreator && Number(userId) !== Number(req.session.userId)) {
res.status(status.FORBIDDEN);
return new GraphQLError("You are not the group creator!");
} else if (userIsCreator) {
res.status(status.FORBIDDEN);
return new GraphQLError("You are not allowed to remove a creator as an admin.");
}
try {
return await dataaccess
.changeGroupMembership(groupId, userId, dataaccess.MembershipChangeAction.DEOP);
} catch (err) {
res.status(status.BAD_REQUEST);
return err.graphqlError ?? new GraphQLError(err.message);
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async createEvent({name, dueDate, groupId}: { name: string, dueDate: string, groupId: number }) {
if (req.session.userId) {
const date = new Date(Number(dueDate));
const group = await models.Group.findByPk(groupId, {include: [{association: "rAdmins"}]});
if (group.rAdmins.find((x) => x.id === req.session.userId)) {
const blacklisted = await dataaccess.checkBlacklisted(name);
if (blacklisted.length > 0) {
res.status(status.BAD_REQUEST);
return new BlacklistedError(blacklisted.map((p) => p.phrase), "event name").graphqlError;
}
return group.$create<models.Event>("rEvent", {name, dueDate: date});
} else {
res.status(status.FORBIDDEN);
return new GraphQLError("You are not a group admin!");
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async deleteEvent({eventId}: {eventId: number}) {
if (req.session.userId) {
const event = await models.Event.findByPk(eventId, {include: [models.Group]});
const user = await models.User.findByPk(req.session.userId);
if (!event) {
res.status(status.BAD_REQUEST);
return new GraphQLError(`No event with id '${eventId}' found.`);
}
const group = await event.group();
if (await group.$has("rAdmins", user)) {
await event.destroy();
return true;
} else {
res.status(status.FORBIDDEN);
return new NotAnAdminGqlError();
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async joinEvent({eventId}: { eventId: number }) {
if (req.session.userId) {
const event = await models.Event.findByPk(eventId);
const self = await models.User.findByPk(req.session.userId);
await event.$add("rParticipants", self);
return event;
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async leaveEvent({eventId}: { eventId: number }) {
if (req.session.userId) {
const event = await models.Event.findByPk(eventId);
const self = await models.User.findByPk(req.session.userId);
await event.$remove("rParticipants", self);
return event;
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async getActivities() {
return models.Activity.findAll();
},
async createActivity({name, description, points}:
{ name: string, description: string, points: number }) {
if (req.session.userId) {
const user = await models.User.findByPk(req.session.userId);
if (user.isAdmin) {
const nameExists = await models.Activity.findOne({where: {name}});
if (!nameExists) {
return models.Activity.create({name, description, points});
} else {
return new GraphQLError(`An activity with the name '${name}' already exists.`);
}
} else {
res.status(status.FORBIDDEN);
return new NotAnAdminGqlError();
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async addToBlacklist({phrase, languageCode}: {phrase: string, languageCode: string}) {
if (req.session.userId) {
const user = await models.User.findByPk(req.session.userId);
if (user.isAdmin) {
const phraseExists = await models.BlacklistedPhrase.findOne(
{where: {phrase, language: languageCode}});
if (!phraseExists) {
await models.BlacklistedPhrase.create({phrase, language: languageCode});
return true;
} else {
return false;
}
} else {
return new NotAnAdminGqlError();
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
async removeFromBlacklist({phrase, languageCode}: {phrase: string, languageCode: string}) {
if (req.session.userId) {
const user = await models.User.findByPk(req.session.userId);
if (user.isAdmin) {
const phraseEntry = await models.BlacklistedPhrase.findOne(
{where: {phrase, language: languageCode}});
if (phraseEntry) {
await phraseEntry.destroy();
return true;
} else {
return false;
}
}
} else {
res.status(status.UNAUTHORIZED);
return new NotLoggedInGqlError();
}
},
};
return new QueryResolver();
}

@ -1,5 +1,4 @@
import * as crypto from "crypto";
import {GraphQLError} from "graphql";
import * as sqz from "sequelize";
import {Sequelize} from "sequelize-typescript";
import {ActivityNotFoundError} from "./errors/ActivityNotFoundError";
@ -166,11 +165,20 @@ namespace dataaccess {
} else {
// more performant way to get the votes with plain sql
return await sequelize.query(
`SELECT * FROM (
SELECT *,
(SELECT count(*) FROM post_votes WHERE vote_type = 'UPVOTE' AND post_id = posts.id) AS upvotes ,
(SELECT count(*) FROM post_votes WHERE vote_type = 'DOWNVOTE' AND post_id = posts.id) AS downvotes
FROM posts) AS a ORDER BY (a.upvotes - a.downvotes) DESC, a.upvotes DESC, a.id LIMIT ? OFFSET ?`,
`SELECT *
FROM (
SELECT *,
(SELECT count(*)
FROM post_votes
WHERE vote_type = 'UPVOTE' AND post_id = posts.id) AS upvotes,
(SELECT count(*)
FROM post_votes
WHERE vote_type = 'DOWNVOTE' AND post_id = posts.id) AS downvotes
FROM posts) AS a
ORDER BY (a.upvotes - a.downvotes) DESC, a.upvotes DESC, a.id
LIMIT ?
OFFSET
?`,
{replacements: [first, offset], mapToModel: true, model: models.Post}) as models.Post[];
}
}
@ -205,7 +213,7 @@ namespace dataaccess {
* Deletes a post
* @param postId
*/
export async function deletePost(postId: number): Promise<boolean | GraphQLError> {
export async function deletePost(postId: number): Promise<boolean> {
try {
const post = await models.Post.findByPk(postId, {include: [{model: Activity}, {association: "rAuthor"}]});
const activity = await post.activity();
@ -359,7 +367,10 @@ namespace dataaccess {
export async function checkBlacklisted(phrase: string, language: string = "en"):
Promise<models.BlacklistedPhrase[]> {
return sequelize.query<BlacklistedPhrase>(`
SELECT * FROM blacklisted_phrases WHERE ? ~* phrase AND language = ?`,
SELECT *
FROM blacklisted_phrases
WHERE ? ~* phrase
AND language = ?`,
{replacements: [phrase, language], mapToModel: true, model: BlacklistedPhrase});
}

@ -4,6 +4,9 @@ import {BaseError} from "./BaseError";
* An error that is thrown when an activity was not found.
*/
export class ActivityNotFoundError extends BaseError {
public readonly statusCode = httpStatus.NOT_FOUND;
constructor(id: number) {
super(`The activity with the id ${id} could not be found.`);
}

@ -8,6 +8,7 @@ export class BaseError extends Error {
* The graphql error with a frontend error message
*/
public readonly graphqlError: GraphQLError;
public readonly statusCode: number = 400;
constructor(message?: string, friendlyMessage?: string) {
super(message);

@ -4,6 +4,9 @@ import {BaseError} from "./BaseError";
* Represents an error that is thrown when a blacklisted phrase is used.
*/
export class BlacklistedError extends BaseError {
public readonly statusCode = httpStatus.NOT_ACCEPTABLE;
constructor(public phrases: string[], field: string = "input") {
super(`The ${field} contains the blacklisted words: ${phrases.join(", ")}`);
}

@ -4,6 +4,9 @@ import {BaseError} from "./BaseError";
* An error that is thrown when the chatroom doesn't exist
*/
export class ChatNotFoundError extends BaseError {
public readonly statusCode = httpStatus.NOT_FOUND;
constructor(chatId: number) {
super(`Chat with id ${chatId} not found.`);
}

@ -4,6 +4,9 @@ import {BaseError} from "./BaseError";
* An error that is thrown when a group was not found for a specified id
*/
export class GroupNotFoundError extends BaseError {
public readonly statusCode = httpStatus.NOT_FOUND;
constructor(groupId: number) {
super(`Group ${groupId} not found!`);
}

@ -0,0 +1,10 @@
import {BaseError} from "./BaseError";
/**
* An error that is thrown when a user tries to register with an invalid email
*/
export class InvalidEmailError extends BaseError {
constructor(email: string) {
super(`'${email}' is not a valid email address!`);
}
}

@ -4,6 +4,10 @@ import {BaseError} from "./BaseError";
* An error that is thrown when no action was specified on a group membership change
*/
export class NoActionSpecifiedError extends BaseError {
public readonly statusCode = httpStatus.NO_CONTENT;
// @ts-ignore
constructor(actions?: any) {
if (actions) {
super(`No action of '${Object.keys(actions).join(", ")}'`);

@ -0,0 +1,14 @@
import * as httpStatus from "http-status";
import {BaseError} from "./BaseError";
/**
* An error that is thrown when a non-admin tries to perform an admin action
*/
export class NotAGroupAdminError extends BaseError {
public readonly statusCode = httpStatus.FORBIDDEN;
constructor(groupId: number) {
super(`You are not an admin of '${groupId}'`);
}
}

@ -0,0 +1,13 @@
import {BaseError} from "./BaseError";
/**
* An error that is thrown when a non admin tries to perform an admin action
*/
export class NotAnAdminError extends BaseError {
public readonly statusCode = httpStatus.FORBIDDEN;
constructor() {
super("You are not a site admin!");
}
}

@ -0,0 +1,13 @@
import * as status from "http-status";
import {BaseError} from "./BaseError";
/**
* An error that is thrown when a non-admin tries to perform an admin action
*/
export class NotTheGroupCreatorError extends BaseError {
public readonly statusCode = status.FORBIDDEN;
constructor(groupId: number) {
super(`You are not the creator of '${groupId}'`);
}
}

@ -0,0 +1,13 @@
import {BaseError} from "./BaseError";
/**
* An error that is thrown when a post was not found
*/
export class PostNotFoundError extends BaseError {
public readonly statusCode = httpStatus.NOT_FOUND;
constructor(postId: number) {
super(`Post '${postId}' not found!`);
}
}

@ -5,7 +5,14 @@ import {BaseError} from "./BaseError";
* An error that is thrown when a request for a sender, receiver and type was not found
*/
export class RequestNotFoundError extends BaseError {
constructor(sender: number, receiver: number, type: dataaccess.RequestType) {
super(`Request with sender '${sender}' and receiver '${receiver}' of type '${type}' not found.`);
public readonly statusCode = httpStatus.NOT_FOUND;
// @ts-ignore
constructor(sender: number, receiver?: number, type?: dataaccess.RequestType) {
if (!receiver) {
super(`Request with id '${sender} not found.'`);
} else {
super(`Request with sender '${sender}' and receiver '${receiver}' of type '${type}' not found.`);
}
}
}

@ -4,6 +4,9 @@ import {BaseError} from "./BaseError";
* An error that is thrown when a specified user was not found
*/
export class UserNotFoundError extends BaseError {
public readonly statusCode = httpStatus.NOT_FOUND;
constructor(username: (string | number)) {
super(`User ${username} not found!`);
}

@ -17,21 +17,3 @@ export class PostNotFoundGqlError extends GraphQLError {
super(`Post '${postId}' not found!`);
}
}
/**
* An error for the frontend that is thrown when a group was not found
*/
export class GroupNotFoundGqlError extends GraphQLError {
constructor(groupId: number) {
super(`Group '${groupId}' not found!`);
}
}
/**
* An error for the frontend that is thrown when a nonadmin tries to perform an admin operation.
*/
export class NotAnAdminGqlError extends GraphQLError {
constructor() {
super("You are not an admin.");
}
}

@ -1,11 +1,13 @@
import * as MarkdownIt from "markdown-it/lib";
const { html5Media } = require("markdown-it-html5-media");
const {html5Media} = require("markdown-it-html5-media");
const mdEmoji = require("markdown-it-emoji");
namespace markdown {
const md = new MarkdownIt()
const md = new MarkdownIt({
linkify: true,
})
.use(html5Media)
.use(mdEmoji);

@ -1,15 +1,5 @@
import * as sqz from "sequelize";
import {
BelongsTo,
BelongsToMany,
Column,
ForeignKey,
HasMany,
Model,
NotNull,
Table,
Unique,
} from "sequelize-typescript";
import {Column, Model, NotNull, Table, Unique} from "sequelize-typescript";
/**
* Represents a blacklisted phrase

@ -81,7 +81,7 @@ export class Event extends Model<Event> {
* @param userId
* @param request
*/
public async deletable({userId}: {userId: number}, request: any): Promise<boolean> {
public async deletable({userId}: { userId: number }, request: any): Promise<boolean> {
userId = userId ?? request.session.userId;
if (userId) {
const group = await this.$get<Group>("rGroup") as Group;

@ -150,7 +150,7 @@ export class Group extends Model<Group> {
* @param userId
* @param request
*/
public async deletable({userId}: {userId?: number}, request: any): Promise<boolean> {
public async deletable({userId}: { userId?: number }, request: any): Promise<boolean> {
userId = userId ?? request.session.userId;
if (userId) {
return this.creatorId === userId;

@ -80,7 +80,7 @@ export class User extends Model<User> {
* The auth token for bearer authentication
*/
@Unique
@Column({defaultValue: uuidv4, unique: true, type: sqz.UUIDV4})
@Column({defaultValue: uuidv4, unique: true, type: sqz.UUID})
public authToken: string;
/**

@ -47,6 +47,12 @@ class HomeRoute extends Route {
globals.internalEmitter.on(InternalEvents.GQLPOSTCREATE, async (post: Post) => {
socket.emit("post", Object.assign(post, {htmlContent: post.htmlContent}));
});
globals.internalEmitter.on(InternalEvents.CHATCREATE, async (chat: ChatRoom) => {
const user = await User.findByPk(socket.handshake.session.userId);
if (await chat.$has("rMembers", user)) {
socket.emit("chatCreate", chat);
}
});
});
const chats = await dataaccess.getAllChats();

@ -5,7 +5,6 @@ import {Router} from "express";
import * as fileUpload from "express-fileupload";
import {UploadedFile} from "express-fileupload";
import * as fsx from "fs-extra";
import {IncomingMessage} from "http";
import * as status from "http-status";
import * as path from "path";
import * as sharp from "sharp";

@ -179,6 +179,11 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.148.tgz#ffa2786721707b335c6aa1465e6d3d74016fbd3e"
integrity sha512-05+sIGPev6pwpHF7NZKfP3jcXhXsIVFnYyVRT4WOB0me62E8OlWfTN+sKyt2/rqN+ETxuHAtgTSK1v71F0yncg==
"@types/lodash@^4.14.149":
version "4.14.149"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440"
integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==
"@types/markdown-it@0.0.9":
version "0.0.9"
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-0.0.9.tgz#a5d552f95216c478e0a27a5acc1b28dcffd989ce"

Loading…
Cancel
Save