From bf7aed7c46d9b250d5d8142462f568bd1f551163 Mon Sep 17 00:00:00 2001 From: trivernis Date: Fri, 24 Jan 2020 12:02:03 +0100 Subject: [PATCH 1/5] Update docker configuration It's still broken tho --- Dockerfile | 9 +++++---- docker-compose.yml | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6a47f66..bee1f64 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,12 @@ -FROM node:current-alpine +FROM node:13.7.0 COPY . /home/node/green WORKDIR /home/node/green +RUN apt update +RUN apt install redis-server -y RUN npm install -g gulp -RUN npm install --save-dev -RUN npm rebuild node-sass +RUN yarn install RUN gulp COPY . . EXPOSE 8080 -CMD ["npm" , "run"] +CMD ["redis-server", "&", "node" , "./dist"] diff --git a/docker-compose.yml b/docker-compose.yml index 75de1cd..af0d472 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,4 +8,4 @@ services: - NODE_ENV=production ports: - "8080:8080" - command: "npm start" + command: "yarn start" From 8dc1424775afdcbb3a4fca1891bc96dce5dd900e Mon Sep 17 00:00:00 2001 From: trivernis Date: Fri, 24 Jan 2020 13:36:41 +0100 Subject: [PATCH 2/5] Add media table for media files - Add media_id fk to user - Add media_id fk to group - Add media_id fk to post - Add routine to cleanup orphaned media entries (not referenced by post, user, group) - Add delete handler for media to delete the corresponding file --- CHANGELOG.md | 5 +++ config/default.toml | 4 ++ src/lib/UploadManager.ts | 25 +++++++++-- src/lib/dataAccess.ts | 18 ++++++++ src/lib/models/Group.ts | 25 ++++++++--- src/lib/models/Media.ts | 58 ++++++++++++++++++++++++++ src/lib/models/Post.ts | 40 +++++++++++------- src/lib/models/User.ts | 28 ++++++++++--- src/lib/models/index.ts | 1 + src/routes/UploadRoute.ts | 38 +++++++++-------- src/routes/graphql/MutationResolver.ts | 8 ---- 11 files changed, 196 insertions(+), 54 deletions(-) create mode 100644 src/lib/models/Media.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6689bc1..13425a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - rate limits with defaults of 10/min for `/upload` and 30/min for `/graphql` - complexity limits for graphql queries that can be configured with the `api.maxQueryComplexity` option - complexity headers `X-Query-Complexity` and `X-Max-Query-Complexity` +- Media model to store information about media (videos and images) +- Media association to users, groups and posts +- Upload handling for media entries (via /upload) +- routine to cleanup orphaned media entries (not referenced by post, user, group) +- delete handler for media to delete the corresponding file ### Removed diff --git a/config/default.toml b/config/default.toml index b804130..1b186aa 100644 --- a/config/default.toml +++ b/config/default.toml @@ -4,6 +4,9 @@ # A connection uri string to the database connectionUri = "sqlite://greenvironment.db" +# The cleanup interval of orphaned entries in seconds +cleanupInterval = 6000 + # Configuration for the redis connection [redis] @@ -54,6 +57,7 @@ publicPath = "./public" # Configuration for the api [api] + # if graphiql should be enabled graphiql = true diff --git a/src/lib/UploadManager.ts b/src/lib/UploadManager.ts index fd1b7cd..307ea70 100644 --- a/src/lib/UploadManager.ts +++ b/src/lib/UploadManager.ts @@ -5,11 +5,20 @@ import * as path from "path"; import * as sharp from "sharp"; import {Readable} from "stream"; import globals from "./globals"; +import {Media} from "./models/Media"; const toArray = require("stream-to-array"); const dataDirName = "data"; +/** + * An enum representing a type of media + */ +export enum MediaType { + IMAGE = "IMAGE", + VIDEO = "VIDEO", +} + interface IUploadConfirmation { /** * Indicates the error that might have occured during the upload @@ -71,7 +80,7 @@ export class UploadManager { * @param fit */ public async processAndStoreImage(data: Buffer, width = 512, height = 512, - fit: ImageFit = "cover"): Promise { + fit: ImageFit = "cover"): Promise { const fileBasename = UploadManager.getCrypticFileName() + "." + config.get("api.imageFormat"); await fsx.ensureDir(this.dataDir); const filePath = path.join(this.dataDir, fileBasename); @@ -93,7 +102,11 @@ export class UploadManager { }); } await image.toFile(filePath); - return `/${dataDirName}/${fileBasename}`; + return Media.create({ + path: filePath, + type: MediaType.IMAGE, + url: `/${dataDirName}/${fileBasename}`, + }); } /** @@ -101,12 +114,16 @@ export class UploadManager { * @param data * @param extension */ - public async processAndStoreVideo(data: Buffer, extension: string): Promise { + public async processAndStoreVideo(data: Buffer, extension: string): Promise { const fileBasename = UploadManager.getCrypticFileName() + extension; await fsx.ensureDir(this.dataDir); const filePath = path.join(this.dataDir, fileBasename); await fsx.writeFile(filePath, data); - return `/${dataDirName}/${fileBasename}`; + return Media.create({ + path: filePath, + type: MediaType.VIDEO, + url: `/${dataDirName}/${fileBasename}`, + }); } /** diff --git a/src/lib/dataAccess.ts b/src/lib/dataAccess.ts index 3b1ad3b..9668b4c 100644 --- a/src/lib/dataAccess.ts +++ b/src/lib/dataAccess.ts @@ -1,3 +1,4 @@ +import * as config from "config"; import * as crypto from "crypto"; import * as sqz from "sequelize"; import {Sequelize} from "sequelize-typescript"; @@ -63,11 +64,28 @@ namespace dataaccess { models.Event, models.Activity, models.BlacklistedPhrase, + models.Media, ]); } catch (err) { globals.logger.error(err.message); globals.logger.debug(err.stack); } + await databaseCleanup(); + setInterval(databaseCleanup, config.get("database.cleanupInterval")); + } + + /** + * Cleans the database. + * - deletes all media entries without associations + */ + async function databaseCleanup() { + const allMedia = await models.Media + .findAll({include: [models.Post, models.User, models.Group]}) as models.Media[]; + for (const media of allMedia) { + if (!media.user && !media.post && !media.group) { + await media.destroy(); + } + } } /** diff --git a/src/lib/models/Group.ts b/src/lib/models/Group.ts index 85c88ce..41a7c01 100644 --- a/src/lib/models/Group.ts +++ b/src/lib/models/Group.ts @@ -4,7 +4,7 @@ import { BelongsToMany, Column, ForeignKey, - HasMany, + HasMany, HasOne, Model, NotNull, Table, @@ -14,6 +14,7 @@ import {ChatRoom} from "./ChatRoom"; import {Event} from "./Event"; import {GroupAdmin} from "./GroupAdmin"; import {GroupMember} from "./GroupMember"; +import {Media} from "./Media"; import {User} from "./User"; /** @@ -30,12 +31,12 @@ export class Group extends Model { @Column({allowNull: false, unique: true}) public name: string; - /** - * The url of the groups avatar picture + * The id of the media that represents the groups profile picture */ - @Column({type: sqz.STRING(512)}) - public picture: string; + @ForeignKey(() => Media) + @Column({allowNull: true}) + public mediaId: number; /** * The id of the user who created the group @@ -53,6 +54,12 @@ export class Group extends Model { @Column({allowNull: false}) public chatId: number; + /** + * The media of the group + */ + @BelongsTo(() => Media, "mediaId") + public rMedia: Media; + /** * The creator of the group */ @@ -83,6 +90,14 @@ export class Group extends Model { @HasMany(() => Event, "groupId") public rEvents: Event[]; + /** + * Returns the media url of the group which is the profile picture + */ + public async picture(): Promise { + const media = await this.$get("rMedia") as Media; + return media ? media.url : undefined; + } + /** * Returns the creator of the group */ diff --git a/src/lib/models/Media.ts b/src/lib/models/Media.ts new file mode 100644 index 0000000..6bf6e84 --- /dev/null +++ b/src/lib/models/Media.ts @@ -0,0 +1,58 @@ +import * as fsx from "fs-extra"; +import * as sqz from "sequelize"; +import {BeforeDestroy, Column, HasOne, Model, NotNull, Table} from "sequelize-typescript"; +import {MediaType} from "../UploadManager"; +import {Group} from "./Group"; +import {Post} from "./Post"; +import {User} from "./User"; + +@Table({underscored: true}) +export class Media extends Model { + + /** + * Deletes the media file before the media is destroyed + * @param instance + */ + @BeforeDestroy + public static deleteMediaFile(instance: Media) { + fsx.unlinkSync(instance.path); + } + + /** + * The api url for the media + */ + @NotNull + @Column({type: sqz.STRING(512), allowNull: false}) + public url: string; + + /** + * The local path of the file + */ + @NotNull + @Column({allowNull: false}) + public path: string; + + /** + * The type of media + */ + @Column({type: sqz.ENUM, values: ["IMAGE", "VIDEO"]}) + public type: MediaType; + + /** + * The user that uses the media + */ + @HasOne(() => User) + public user: User; + + /** + * The group that uses the media + */ + @HasOne(() => Group) + public group: Group; + + /** + * The post that uses the media + */ + @HasOne(() => Post) + public post: Post; +} diff --git a/src/lib/models/Post.ts b/src/lib/models/Post.ts index dc0574e..9463981 100644 --- a/src/lib/models/Post.ts +++ b/src/lib/models/Post.ts @@ -1,8 +1,20 @@ import * as config from "config"; import * as sqz from "sequelize"; -import {BelongsTo, BelongsToMany, Column, CreatedAt, ForeignKey, Model, NotNull, Table} from "sequelize-typescript"; +import { + BelongsTo, + BelongsToMany, + Column, + CreatedAt, + ForeignKey, + HasOne, + Model, + NotNull, + Table, +} from "sequelize-typescript"; import markdown from "../markdown"; +import {MediaType} from "../UploadManager"; import {Activity} from "./Activity"; +import {Media} from "./Media"; import {PostVote, VoteType} from "./PostVote"; import {User} from "./User"; @@ -35,10 +47,11 @@ export class Post extends Model { public activityId: number; /** - * An url pointing to any media that belongs to the post + * An id pointing to a media entry */ - @Column({allowNull: true, type: sqz.STRING(512)}) - public mediaUrl: string; + @ForeignKey(() => Media) + @Column({allowNull: true}) + public mediaId: number; /** * The author of the post @@ -52,6 +65,12 @@ export class Post extends Model { @BelongsTo(() => Activity, "activityId") public rActivity?: Activity; + /** + * The media of the post + */ + @BelongsTo(() => Media, "mediaId") + public rMedia?: Media; + /** * The votes that were performed on the post */ @@ -110,17 +129,8 @@ export class Post extends Model { /** * Returns the media description object of the post */ - public get media() { - const url = this.getDataValue("mediaUrl"); - if (url) { - const type = url.endsWith(config.get("api.imageFormat")) ? "IMAGE" : "VIDEO"; - return { - type, - url, - }; - } else { - return null; - } + public async media() { + return await this.$get("rMedia") as Media; } /** diff --git a/src/lib/models/User.ts b/src/lib/models/User.ts index ad15e01..f8f71f1 100644 --- a/src/lib/models/User.ts +++ b/src/lib/models/User.ts @@ -1,9 +1,10 @@ import * as sqz from "sequelize"; import { + BelongsTo, BelongsToMany, Column, - CreatedAt, - HasMany, + CreatedAt, ForeignKey, + HasMany, HasOne, Model, NotNull, Table, @@ -22,6 +23,7 @@ import {Friendship} from "./Friendship"; import {Group} from "./Group"; import {GroupAdmin} from "./GroupAdmin"; import {GroupMember} from "./GroupMember"; +import {Media} from "./Media"; import {Post} from "./Post"; import {PostVote} from "./PostVote"; import {Request, RequestType} from "./Request"; @@ -97,10 +99,18 @@ export class User extends Model { public isAdmin: boolean; /** - * The url of the users profile picture + * The id of the media that is the users profile picture */ - @Column({type: sqz.STRING(512)}) - public profilePicture: string; + @ForeignKey(() => Media) + @Column({allowNull: false}) + public mediaId: number; + + + /** + * The media of the user + */ + @BelongsTo(() => Media) + public rMedia: Media; /** * The friends of the user @@ -221,6 +231,14 @@ export class User extends Model { return JSON.stringify(this.getDataValue("frontendSettings")); } + /** + * Returns the media url which is the profile picture + */ + public async profilePicture(): Promise { + const media = await this.$get("rMedia") as Media; + return media ? media.url : undefined; + } + /** * Returns the token for the user that can be used as a bearer in requests */ diff --git a/src/lib/models/index.ts b/src/lib/models/index.ts index 7aba87a..5fafbce 100644 --- a/src/lib/models/index.ts +++ b/src/lib/models/index.ts @@ -13,3 +13,4 @@ export {Event} from "./Event"; export {EventParticipant} from "./EventParticipant"; export {Activity} from "./Activity"; export {BlacklistedPhrase} from "./BlacklistedPhrase"; +export {Media} from "./Media"; diff --git a/src/routes/UploadRoute.ts b/src/routes/UploadRoute.ts index f2bddd3..95b9d2f 100644 --- a/src/routes/UploadRoute.ts +++ b/src/routes/UploadRoute.ts @@ -9,6 +9,7 @@ import * as status from "http-status"; import * as path from "path"; import globals from "../lib/globals"; import {Group, Post, User} from "../lib/models"; +import {Media} from "../lib/models/Media"; import {is} from "../lib/regex"; import Route from "../lib/Route"; import {UploadManager} from "../lib/UploadManager"; @@ -117,14 +118,15 @@ export class UploadRoute extends Route { let fileName: string; const profilePic = request.files.profilePicture as UploadedFile; try { - const user = await User.findByPk(request.session.userId); + const user = await User.findByPk(request.session.userId, {include: [Media]}); if (user) { - fileName = await this.uploadManager.processAndStoreImage(profilePic.data); - if (user.profilePicture) { - await this.uploadManager.deleteWebFile(user.profilePicture); + const media = await this.uploadManager.processAndStoreImage(profilePic.data); + if (user.mediaId) { + const previousMedia = await user.$get("rMedia") as Media; + await previousMedia.destroy(); } - user.profilePicture = fileName; - await user.save(); + await user.$set("rMedia", media); + fileName = media.url; success = true; } else { error = "User not found"; @@ -153,7 +155,7 @@ export class UploadRoute extends Route { if (request.body.groupId) { try { const user = await User.findByPk(request.session.userId); - const group = await Group.findByPk(request.body.groupId); + const group = await Group.findByPk(request.body.groupId, {include: [Media]}); if (!group) { error = `No group with the id '${request.body.groupId}' found.`; return { @@ -163,12 +165,13 @@ export class UploadRoute extends Route { } const isAdmin = await group.$has("rAdmins", user); if (isAdmin) { - fileName = await this.uploadManager.processAndStoreImage(groupPicture.data); - if (group.picture) { - await this.uploadManager.deleteWebFile(group.picture); + const media = await this.uploadManager.processAndStoreImage(groupPicture.data); + if (group.mediaId) { + const previousMedia = await group.$get("rMedia") as Media; + await previousMedia.destroy(); } - group.picture = fileName; - await group.save(); + await group.$set("rMedia", media); + fileName = media.url; success = true; } else { error = "You are not a group admin."; @@ -200,19 +203,20 @@ export class UploadRoute extends Route { const postMedia = request.files.postMedia as UploadedFile; if (postId) { try { + let media: Media; const post = await Post.findByPk(postId); if (post.authorId === request.session.userId) { if (is.image(postMedia.mimetype)) { - fileName = await this.uploadManager.processAndStoreImage(postMedia.data, 1080, 720, "inside"); + media = await this.uploadManager.processAndStoreImage(postMedia.data, 1080, 720, "inside"); } else if (is.video(postMedia.mimetype)) { - fileName = await this.uploadManager.processAndStoreVideo(postMedia.data, postMedia.mimetype + media = await this.uploadManager.processAndStoreVideo(postMedia.data, postMedia.mimetype .replace("video/", "")); } else { error = "Wrong type of file provided"; } - if (fileName) { - post.mediaUrl = fileName; - await post.save(); + if (media) { + await post.$set("rMedia", media); + fileName = media.url; success = true; } } else { diff --git a/src/routes/graphql/MutationResolver.ts b/src/routes/graphql/MutationResolver.ts index ac30772..edf8f60 100644 --- a/src/routes/graphql/MutationResolver.ts +++ b/src/routes/graphql/MutationResolver.ts @@ -171,14 +171,6 @@ export class MutationResolver extends BaseResolver { }); const isAdmin = (await User.findOne({where: {id: request.session.userId}})).isAdmin; if (post.rAuthor.id === request.session.userId || isAdmin) { - if (post.mediaUrl) { - try { - await this.uploadManager.deleteWebFile(post.mediaUrl); - } catch (err) { - globals.logger.error(err.message); - globals.logger.debug(err.stack); - } - } return await dataaccess.deletePost(post.id); } else { throw new GraphQLError("User is not author of the post."); From 6ede632507a88eed7c699a8297ab5a2577c86042 Mon Sep 17 00:00:00 2001 From: trivernis Date: Fri, 24 Jan 2020 14:49:16 +0100 Subject: [PATCH 3/5] Add differentiation between media and text posts Add type argument when creating posts. If the post is of type MEDIA it will be invisible until an image was uploaded for the post. --- CHANGELOG.md | 1 + src/lib/dataAccess.ts | 24 +++++++++++- src/lib/models/Media.ts | 4 +- src/lib/models/Post.ts | 7 ++++ src/lib/models/User.ts | 8 +++- src/routes/UploadRoute.ts | 2 + src/routes/graphql/MutationResolver.ts | 52 +++++++++++++------------- src/routes/graphql/schema.graphql | 14 ++++++- 8 files changed, 80 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13425a5..267adef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upload handling for media entries (via /upload) - routine to cleanup orphaned media entries (not referenced by post, user, group) - delete handler for media to delete the corresponding file +- type for create post to know if it is a media or text post (media posts are invisible until a media file is uploaded) ### Removed diff --git a/src/lib/dataAccess.ts b/src/lib/dataAccess.ts index 9668b4c..e821e4e 100644 --- a/src/lib/dataAccess.ts +++ b/src/lib/dataAccess.ts @@ -179,6 +179,9 @@ namespace dataaccess { limit: first, offset, order: [["createdAt", "DESC"]], + where: { + visible: true, + }, }); } else { // more performant way to get the votes with plain sql @@ -206,15 +209,17 @@ namespace dataaccess { * @param content * @param authorId * @param activityId + * @param type */ - export async function createPost(content: string, authorId: number, activityId?: number): Promise { + export async function createPost(content: string, authorId: number, activityId?: number, + type: PostType = PostType.TEXT): Promise { const blacklisted = await checkBlacklisted(content); if (blacklisted.length > 0) { throw new BlacklistedError(blacklisted.map((p) => p.phrase), "content"); } const activity = await models.Activity.findByPk(activityId); if (!activityId || activity) { - const post = await models.Post.create({content, authorId, activityId}); + const post = await models.Post.create({content, authorId, activityId, visible: type !== PostType.MEDIA}); globals.internalEmitter.emit(InternalEvents.POSTCREATE, post); if (activity) { const user = await models.User.findByPk(authorId); @@ -236,11 +241,15 @@ namespace dataaccess { const post = await models.Post.findByPk(postId, {include: [{model: Activity}, {association: "rAuthor"}]}); const activity = await post.activity(); const author = await post.author(); + const media = await post.$get("rMedia") as models.Media; if (activity && author) { author.rankpoints -= activity.points; await author.save(); } await post.destroy(); + if (media) { + await media.destroy(); + } } catch (err) { globals.logger.error(err.message); globals.logger.debug(err.stack); @@ -417,6 +426,17 @@ namespace dataaccess { NEW = "NEW", } + /** + * The type of the post + */ + export enum PostType { + TEXT = "TEXT", + MEDIA = "MEDIA", + } + + /** + * Enum representing the type of membership change for the membership change function + */ export enum MembershipChangeAction { ADD, REMOVE, diff --git a/src/lib/models/Media.ts b/src/lib/models/Media.ts index 6bf6e84..a9440d5 100644 --- a/src/lib/models/Media.ts +++ b/src/lib/models/Media.ts @@ -14,8 +14,8 @@ export class Media extends Model { * @param instance */ @BeforeDestroy - public static deleteMediaFile(instance: Media) { - fsx.unlinkSync(instance.path); + public static async deleteMediaFile(instance: Media) { + await fsx.unlink(instance.path); } /** diff --git a/src/lib/models/Post.ts b/src/lib/models/Post.ts index 9463981..c39c545 100644 --- a/src/lib/models/Post.ts +++ b/src/lib/models/Post.ts @@ -31,6 +31,13 @@ export class Post extends Model { @Column({type: sqz.STRING(2048), allowNull: false}) public content: string; + /** + * If the post is publically visible + */ + @NotNull + @Column({defaultValue: true, allowNull: false}) + public visible: boolean; + /** * The id of the post author */ diff --git a/src/lib/models/User.ts b/src/lib/models/User.ts index f8f71f1..a98fc88 100644 --- a/src/lib/models/User.ts +++ b/src/lib/models/User.ts @@ -305,11 +305,15 @@ export class User extends Model { * a list of posts the user has created * @param first * @param offset + * @param request */ - public async posts({first, offset}: { first: number, offset: number }): Promise { + public async posts({first, offset}: { first: number, offset: number }, request: any): Promise { const limit = first ?? 10; offset = offset ?? 0; - return await this.$get("rPosts", {limit, offset}) as Post[]; + if (request.session.userId === this.getDataValue("id")) { + return await this.$get("rPosts", { limit, offset, order: [["id", "desc"]]}) as Post[]; + } + return await this.$get("rPosts", { limit, offset, where: {visible: true}, order: [["id", "desc"]]}) as Post[]; } /** diff --git a/src/routes/UploadRoute.ts b/src/routes/UploadRoute.ts index 95b9d2f..2899788 100644 --- a/src/routes/UploadRoute.ts +++ b/src/routes/UploadRoute.ts @@ -216,6 +216,8 @@ export class UploadRoute extends Route { } if (media) { await post.$set("rMedia", media); + post.visible = true; + await post.save(); fileName = media.url; success = true; } diff --git a/src/routes/graphql/MutationResolver.ts b/src/routes/graphql/MutationResolver.ts index edf8f60..c54db01 100644 --- a/src/routes/graphql/MutationResolver.ts +++ b/src/routes/graphql/MutationResolver.ts @@ -2,7 +2,7 @@ import {GraphQLError} from "graphql"; import {FileUpload} from "graphql-upload"; import * as yaml from "js-yaml"; import isEmail from "validator/lib/isEmail"; -import dataaccess from "../../lib/dataAccess"; +import dataAccess from "../../lib/dataAccess"; import {BlacklistedError} from "../../lib/errors/BlacklistedError"; import {GroupNotFoundError} from "../../lib/errors/GroupNotFoundError"; import {InvalidEmailError} from "../../lib/errors/InvalidEmailError"; @@ -53,7 +53,7 @@ export class MutationResolver extends BaseResolver { * @param request */ public async login({email, passwordHash}: { email: string, passwordHash: string }, request: any): Promise { - const user = await dataaccess.getUserByLogin(email, passwordHash); + const user = await dataAccess.getUserByLogin(email, passwordHash); request.session.userId = user.id; return user; } @@ -96,7 +96,7 @@ export class MutationResolver extends BaseResolver { if (!mailValid) { throw new InvalidEmailError(email); } - const user = await dataaccess.registerUser(username, email, passwordHash); + const user = await dataAccess.registerUser(username, email, passwordHash); request.session.userId = user.id; return user; } @@ -124,8 +124,8 @@ export class MutationResolver extends BaseResolver { * @param type * @param request */ - public async vote({postId, type}: { postId: number, type: dataaccess.VoteType }, request: any): - Promise<{ post: Post, voteType: dataaccess.VoteType }> { + 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) { @@ -143,15 +143,17 @@ export class MutationResolver extends BaseResolver { * Creates a new post * @param content * @param activityId + * @param type * @param request */ - public async createPost({content, activityId}: { content: string, activityId?: number}, - request: any): Promise { + public async createPost( + {content, activityId, type}: { content: string, activityId?: number, type: dataAccess.PostType}, + request: any): Promise { this.ensureLoggedIn(request); if (content.length > 2048) { throw new GraphQLError("Content too long."); } - const post = await dataaccess.createPost(content, request.session.userId, activityId); + const post = await dataAccess.createPost(content, request.session.userId, activityId, type); globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post); return post; } @@ -171,7 +173,7 @@ export class MutationResolver extends BaseResolver { }); 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); + return await dataAccess.deletePost(post.id); } else { throw new GraphQLError("User is not author of the post."); } @@ -188,7 +190,7 @@ export class MutationResolver extends BaseResolver { if (members) { chatMembers.push(...members); } - return await dataaccess.createChat(...chatMembers); + return await dataAccess.createChat(...chatMembers); } /** @@ -200,7 +202,7 @@ export class MutationResolver extends BaseResolver { public async sendMessage({chatId, content}: { chatId: number, content: string }, request: any): Promise { this.ensureLoggedIn(request); - const message = await dataaccess.sendChatMessage(request.session.userId, chatId, content); + const message = await dataAccess.sendChatMessage(request.session.userId, chatId, content); globals.internalEmitter.emit(InternalEvents.GQLCHATMESSAGE, message); return message; } @@ -211,10 +213,10 @@ export class MutationResolver extends BaseResolver { * @param type * @param request */ - public async sendRequest({receiver, type}: { receiver: number, type: dataaccess.RequestType }, request: any): + public async sendRequest({receiver, type}: { receiver: number, type: dataAccess.RequestType }, request: any): Promise { this.ensureLoggedIn(request); - return dataaccess.createRequest(request.session.userId, receiver, type); + return dataAccess.createRequest(request.session.userId, receiver, type); } /** @@ -223,7 +225,7 @@ export class MutationResolver extends BaseResolver { * @param type * @param request */ - public async denyRequest({sender, type}: { sender: number, type: dataaccess.RequestType }, request: any) { + 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); @@ -236,7 +238,7 @@ export class MutationResolver extends BaseResolver { * @param type * @param request */ - public async acceptRequest({sender, type}: { sender: number, type: dataaccess.RequestType }, request: any) { + 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); @@ -262,7 +264,7 @@ export class MutationResolver extends BaseResolver { */ public async createGroup({name, members}: { name: string, members: number[] }, request: any): Promise { this.ensureLoggedIn(request); - return await dataaccess.createGroup(name, request.session.userId, members); + return await dataAccess.createGroup(name, request.session.userId, members); } /** @@ -291,8 +293,8 @@ export class MutationResolver extends BaseResolver { */ public async joinGroup({groupId}: { groupId: number }, request: any): Promise { this.ensureLoggedIn(request); - return dataaccess.changeGroupMembership(groupId, request.session.userId, - dataaccess.MembershipChangeAction.ADD); + return dataAccess.changeGroupMembership(groupId, request.session.userId, + dataAccess.MembershipChangeAction.ADD); } /** @@ -302,8 +304,8 @@ export class MutationResolver extends BaseResolver { */ public async leaveGroup({groupId}: { groupId: number }, request: any): Promise { this.ensureLoggedIn(request); - return dataaccess.changeGroupMembership(groupId, request.session.userId, - dataaccess.MembershipChangeAction.REMOVE); + return dataAccess.changeGroupMembership(groupId, request.session.userId, + dataAccess.MembershipChangeAction.REMOVE); } /** @@ -319,8 +321,8 @@ export class MutationResolver extends BaseResolver { if (group && !(await group.$has("rAdmins", user)) && (await group.creator()) !== user.id) { throw new NotAGroupAdminError(groupId); } - return dataaccess.changeGroupMembership(groupId, userId, - dataaccess.MembershipChangeAction.OP); + return dataAccess.changeGroupMembership(groupId, userId, + dataAccess.MembershipChangeAction.OP); } /** @@ -341,8 +343,8 @@ export class MutationResolver extends BaseResolver { throw new GraphQLError( "You are not allowed to remove a creator as an admin."); } - return await dataaccess.changeGroupMembership(groupId, userId, - dataaccess.MembershipChangeAction.DEOP); + return await dataAccess.changeGroupMembership(groupId, userId, + dataAccess.MembershipChangeAction.DEOP); } /** @@ -361,7 +363,7 @@ export class MutationResolver extends BaseResolver { if (!(await group.$has("rAdmins", user))) { throw new NotAGroupAdminError(groupId); } - const blacklisted = await dataaccess.checkBlacklisted(name); + const blacklisted = await dataAccess.checkBlacklisted(name); if (blacklisted.length > 0) { throw new BlacklistedError(blacklisted.map((p) => p.phrase), "event name"); } diff --git a/src/routes/graphql/schema.graphql b/src/routes/graphql/schema.graphql index 41b009a..5ed3592 100644 --- a/src/routes/graphql/schema.graphql +++ b/src/routes/graphql/schema.graphql @@ -85,7 +85,7 @@ type Mutation { sendMessage(chatId: ID!, content: String!): ChatMessage "create a post that can belong to an activity" - createPost(content: String!, activityId: ID): Post! + createPost(content: String!, activityId: ID, type: PostType = TEXT): Post! "delete the post for a given post id" deletePost(postId: ID!): Boolean! @@ -317,6 +317,9 @@ type Post { "the text of the post" content: String + "If the post is publically visible" + visible: Boolean! + "the content of the post rendered by markdown-it" htmlContent: String @@ -543,7 +546,16 @@ enum RequestType { EVENTINVITE } +"the type of sorting for getPosts" enum SortType { TOP NEW } +""" +The type of the post. If the post was created with the type MEDIA, +It stays invisible until a media file has been uploaded for the post +""" +enum PostType { + MEDIA + TEXT +} From 0e232b1c71442f822e73dfce7514ef70b369d33b Mon Sep 17 00:00:00 2001 From: trivernis Date: Fri, 24 Jan 2020 14:52:21 +0100 Subject: [PATCH 4/5] Cleanup code - Cleanup - Optimization - Style fixes --- src/lib/UploadManager.ts | 19 +------------------ src/lib/dataAccess.ts | 6 ++++-- src/lib/models/Group.ts | 3 +-- src/lib/models/Media.ts | 3 +++ src/lib/models/Post.ts | 14 +------------- src/lib/models/User.ts | 9 +++++---- src/routes/UploadRoute.ts | 4 ++-- src/routes/graphql/MutationResolver.ts | 5 +---- 8 files changed, 18 insertions(+), 45 deletions(-) diff --git a/src/lib/UploadManager.ts b/src/lib/UploadManager.ts index 307ea70..223ea89 100644 --- a/src/lib/UploadManager.ts +++ b/src/lib/UploadManager.ts @@ -5,7 +5,7 @@ import * as path from "path"; import * as sharp from "sharp"; import {Readable} from "stream"; import globals from "./globals"; -import {Media} from "./models/Media"; +import {Media} from "./models"; const toArray = require("stream-to-array"); @@ -19,23 +19,6 @@ export enum MediaType { VIDEO = "VIDEO", } -interface IUploadConfirmation { - /** - * Indicates the error that might have occured during the upload - */ - error?: string; - - /** - * The file that has been uploaded - */ - fileName?: string; - - /** - * If the upload was successful - */ - success: boolean; -} - type ImageFit = "cover" | "contain" | "fill" | "inside" | "outside"; /** diff --git a/src/lib/dataAccess.ts b/src/lib/dataAccess.ts index e821e4e..668c8b1 100644 --- a/src/lib/dataAccess.ts +++ b/src/lib/dataAccess.ts @@ -191,10 +191,12 @@ namespace dataaccess { SELECT *, (SELECT count(*) FROM post_votes - WHERE vote_type = 'UPVOTE' AND post_id = posts.id) AS upvotes, + 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 + 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 ? diff --git a/src/lib/models/Group.ts b/src/lib/models/Group.ts index 41a7c01..245003f 100644 --- a/src/lib/models/Group.ts +++ b/src/lib/models/Group.ts @@ -1,10 +1,9 @@ -import * as sqz from "sequelize"; import { BelongsTo, BelongsToMany, Column, ForeignKey, - HasMany, HasOne, + HasMany, Model, NotNull, Table, diff --git a/src/lib/models/Media.ts b/src/lib/models/Media.ts index a9440d5..1d3a58d 100644 --- a/src/lib/models/Media.ts +++ b/src/lib/models/Media.ts @@ -6,6 +6,9 @@ import {Group} from "./Group"; import {Post} from "./Post"; import {User} from "./User"; +/** + * Represents a single media file that can be used as a profile picture for groups and users or a post picture + */ @Table({underscored: true}) export class Media extends Model { diff --git a/src/lib/models/Post.ts b/src/lib/models/Post.ts index c39c545..cda62d3 100644 --- a/src/lib/models/Post.ts +++ b/src/lib/models/Post.ts @@ -1,18 +1,6 @@ -import * as config from "config"; import * as sqz from "sequelize"; -import { - BelongsTo, - BelongsToMany, - Column, - CreatedAt, - ForeignKey, - HasOne, - Model, - NotNull, - Table, -} from "sequelize-typescript"; +import {BelongsTo, BelongsToMany, Column, CreatedAt, ForeignKey, Model, NotNull, Table} from "sequelize-typescript"; import markdown from "../markdown"; -import {MediaType} from "../UploadManager"; import {Activity} from "./Activity"; import {Media} from "./Media"; import {PostVote, VoteType} from "./PostVote"; diff --git a/src/lib/models/User.ts b/src/lib/models/User.ts index a98fc88..7fbe1f5 100644 --- a/src/lib/models/User.ts +++ b/src/lib/models/User.ts @@ -3,8 +3,9 @@ import { BelongsTo, BelongsToMany, Column, - CreatedAt, ForeignKey, - HasMany, HasOne, + CreatedAt, + ForeignKey, + HasMany, Model, NotNull, Table, @@ -311,9 +312,9 @@ export class User extends Model { const limit = first ?? 10; offset = offset ?? 0; if (request.session.userId === this.getDataValue("id")) { - return await this.$get("rPosts", { limit, offset, order: [["id", "desc"]]}) as Post[]; + return await this.$get("rPosts", {limit, offset, order: [["id", "desc"]]}) as Post[]; } - return await this.$get("rPosts", { limit, offset, where: {visible: true}, order: [["id", "desc"]]}) as Post[]; + return await this.$get("rPosts", {limit, offset, where: {visible: true}, order: [["id", "desc"]]}) as Post[]; } /** diff --git a/src/routes/UploadRoute.ts b/src/routes/UploadRoute.ts index 2899788..76b7a76 100644 --- a/src/routes/UploadRoute.ts +++ b/src/routes/UploadRoute.ts @@ -2,14 +2,14 @@ import * as bodyParser from "body-parser"; import * as config from "config"; import * as crypto from "crypto"; import {Router} from "express"; -import {UploadedFile} from "express-fileupload"; import * as fileUpload from "express-fileupload"; +import {UploadedFile} from "express-fileupload"; import * as fsx from "fs-extra"; import * as status from "http-status"; import * as path from "path"; import globals from "../lib/globals"; import {Group, Post, User} from "../lib/models"; -import {Media} from "../lib/models/Media"; +import {Media} from "../lib/models"; import {is} from "../lib/regex"; import Route from "../lib/Route"; import {UploadManager} from "../lib/UploadManager"; diff --git a/src/routes/graphql/MutationResolver.ts b/src/routes/graphql/MutationResolver.ts index c54db01..aea1be2 100644 --- a/src/routes/graphql/MutationResolver.ts +++ b/src/routes/graphql/MutationResolver.ts @@ -1,12 +1,10 @@ import {GraphQLError} from "graphql"; -import {FileUpload} from "graphql-upload"; 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 {InvalidFileError} from "../../lib/errors/InvalidFileError"; import {NotAGroupAdminError} from "../../lib/errors/NotAGroupAdminError"; import {NotAnAdminError} from "../../lib/errors/NotAnAdminError"; import {NotTheGroupCreatorError} from "../../lib/errors/NotTheGroupCreatorError"; @@ -14,7 +12,6 @@ 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 {is} from "../../lib/regex"; import {UploadManager} from "../../lib/UploadManager"; import {BaseResolver} from "./BaseResolver"; @@ -147,7 +144,7 @@ export class MutationResolver extends BaseResolver { * @param request */ public async createPost( - {content, activityId, type}: { content: string, activityId?: number, type: dataAccess.PostType}, + {content, activityId, type}: { content: string, activityId?: number, type: dataAccess.PostType }, request: any): Promise { this.ensureLoggedIn(request); if (content.length > 2048) { From ee60508953a61ec58eaaea3c8b635c2cc295d0ef Mon Sep 17 00:00:00 2001 From: trivernis Date: Fri, 24 Jan 2020 14:55:40 +0100 Subject: [PATCH 5/5] Fix style rule for array type --- tslint.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tslint.json b/tslint.json index 28549b8..4265452 100644 --- a/tslint.json +++ b/tslint.json @@ -29,7 +29,8 @@ "privacies": "all", "locations": "instance" } - }] + }], + "array-type": false }, "jsRules": { "max-line-length": {