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
pull/4/head
trivernis 4 years ago
parent bf7aed7c46
commit 8dc1424775

@ -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

@ -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

@ -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<string> {
fit: ImageFit = "cover"): Promise<Media> {
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<string> {
public async processAndStoreVideo(data: Buffer, extension: string): Promise<Media> {
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}`,
});
}
/**

@ -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<number>("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();
}
}
}
/**

@ -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<Group> {
@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<Group> {
@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<Group> {
@HasMany(() => Event, "groupId")
public rEvents: Event[];
/**
* Returns the media url of the group which is the profile picture
*/
public async picture(): Promise<string> {
const media = await this.$get<Media>("rMedia") as Media;
return media ? media.url : undefined;
}
/**
* Returns the creator of the group
*/

@ -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<Media> {
/**
* 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;
}

@ -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<Post> {
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<Post> {
@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<Post> {
/**
* 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<Media>("rMedia") as Media;
}
/**

@ -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<User> {
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<User> {
return JSON.stringify(this.getDataValue("frontendSettings"));
}
/**
* Returns the media url which is the profile picture
*/
public async profilePicture(): Promise<string> {
const media = await this.$get<Media>("rMedia") as Media;
return media ? media.url : undefined;
}
/**
* Returns the token for the user that can be used as a bearer in requests
*/

@ -13,3 +13,4 @@ export {Event} from "./Event";
export {EventParticipant} from "./EventParticipant";
export {Activity} from "./Activity";
export {BlacklistedPhrase} from "./BlacklistedPhrase";
export {Media} from "./Media";

@ -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 {

@ -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.");

Loading…
Cancel
Save