[WIP] Add media to posts
- Add graphql upload handling - Add file handling for posts - Add media url to Post modelpull/4/head
parent
010066d819
commit
2899eb6762
@ -0,0 +1,136 @@
|
||||
import * as config from "config";
|
||||
import * as crypto from "crypto";
|
||||
import * as ffmpeg from "fluent-ffmpeg";
|
||||
import * as fsx from "fs-extra";
|
||||
import * as path from "path";
|
||||
import * as sharp from "sharp";
|
||||
import {Readable} from "stream";
|
||||
import {ReadableStreamBuffer} from "stream-buffers";
|
||||
import globals from "./globals";
|
||||
|
||||
const toArray = require("stream-to-array");
|
||||
|
||||
const dataDirName = "data";
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* A helper class for uploading and managing files
|
||||
*/
|
||||
export class UploadManager {
|
||||
|
||||
/**
|
||||
* Returns the hash of the current time to be used as a filename.
|
||||
*/
|
||||
private static getCrypticFileName() {
|
||||
const hash = crypto.createHash("md5");
|
||||
hash.update(Number(Date.now()).toString());
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
private readonly dataDir: string;
|
||||
|
||||
constructor() {
|
||||
this.dataDir = path.join(globals.getPublicDir(), dataDirName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an image for a provided web path.
|
||||
* @param webPath
|
||||
*/
|
||||
public async deleteWebFile(webPath: string) {
|
||||
const realPath = path.join(dataDirName, path.basename(webPath));
|
||||
if (await fsx.pathExists(realPath)) {
|
||||
await fsx.unlink(realPath);
|
||||
} else {
|
||||
globals.logger.warn(`Could not delete web image ${realPath}: Not found!`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a file to the webp format and stores it with a uuid filename.
|
||||
* The web path for the image is returned.
|
||||
* @param data
|
||||
* @param width
|
||||
* @param height
|
||||
* @param fit
|
||||
*/
|
||||
public async processAndStoreImage(data: Buffer, width = 512, height = 512,
|
||||
fit: ImageFit = "cover"): Promise<string> {
|
||||
const fileBasename = UploadManager.getCrypticFileName() + "." + config.get("api.imageFormat");
|
||||
await fsx.ensureDir(this.dataDir);
|
||||
const filePath = path.join(this.dataDir, fileBasename);
|
||||
let image = sharp(data)
|
||||
.resize(width, height, {
|
||||
fit,
|
||||
})
|
||||
.normalise();
|
||||
if (config.get<string>("api.imageFormat") === "webp") {
|
||||
image = image.webp({
|
||||
reductionEffort: 6,
|
||||
smartSubsample: true,
|
||||
});
|
||||
} else {
|
||||
image = image.png({
|
||||
adaptiveFiltering: true,
|
||||
colors: 128,
|
||||
});
|
||||
}
|
||||
await image.toFile(filePath);
|
||||
return `/${dataDirName}/${fileBasename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a video into a smaller format and .mp4 and returns the web path
|
||||
* @param data
|
||||
* @param width
|
||||
*/
|
||||
public async processAndStoreVideo(data: Buffer, width: number = 720): Promise<string> {
|
||||
return new Promise(async (resolve) => {
|
||||
const fileBasename = UploadManager.getCrypticFileName() + ".mp4";
|
||||
await fsx.ensureDir(this.dataDir);
|
||||
const filePath = path.join(this.dataDir, fileBasename);
|
||||
const videoFileStream = new ReadableStreamBuffer({
|
||||
chunkSize: 2048,
|
||||
frequency: 10,
|
||||
});
|
||||
videoFileStream.put(data);
|
||||
const video = ffmpeg(videoFileStream);
|
||||
video
|
||||
.on("end", () => {
|
||||
resolve(`/${dataDirName}/${fileBasename}`);
|
||||
})
|
||||
.size(`${width}x?`)
|
||||
.toFormat("libx264")
|
||||
.output(filePath);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convers a readable to a buffer
|
||||
* @param stream
|
||||
*/
|
||||
public async streamToBuffer(stream: Readable) {
|
||||
const parts = await toArray(stream);
|
||||
const buffers = parts
|
||||
.map((part: any) => Buffer.isBuffer(part) ? part : Buffer.from(part));
|
||||
return Buffer.concat(buffers);
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import * as httpStatus from "http-status";
|
||||
import {BaseError} from "./BaseError";
|
||||
|
||||
/**
|
||||
* An error that is thrown when a invalid file type is uploaded
|
||||
*/
|
||||
export class InvalidFileError extends BaseError {
|
||||
|
||||
public readonly statusCode = httpStatus.NOT_ACCEPTABLE;
|
||||
|
||||
constructor(mimetype: string) {
|
||||
super(`The mimetype '${mimetype}' is not allowed.`);
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import {BaseError} from "./BaseError";
|
||||
|
||||
/**
|
||||
* An error that is thrown when a file failed to upload
|
||||
*/
|
||||
export class UploadFailedError extends BaseError {
|
||||
constructor() {
|
||||
super("Failed to upload the file");
|
||||
}
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
import * as config from "config";
|
||||
import {Router} from "express";
|
||||
import * as graphqlHTTP from "express-graphql";
|
||||
import {buildSchema, GraphQLError} from "graphql";
|
||||
import {importSchema} from "graphql-import";
|
||||
import queryComplexity, {directiveEstimator, simpleEstimator} from "graphql-query-complexity";
|
||||
import {graphqlUploadExpress} from "graphql-upload";
|
||||
import * as path from "path";
|
||||
import globals from "../lib/globals";
|
||||
import Route from "../lib/Route";
|
||||
import {resolver} from "./graphql/resolvers";
|
||||
|
||||
const logger = globals.logger;
|
||||
|
||||
/**
|
||||
* A class for the /grpahql route
|
||||
*/
|
||||
export class GraphqlRoute extends Route {
|
||||
/**
|
||||
* Constructor, creates new router.
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
this.router = Router();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initializes the route
|
||||
* @param params
|
||||
*/
|
||||
public async init(...params: any): Promise<any> {
|
||||
this.router.use(graphqlUploadExpress({
|
||||
maxFileSize: config.get<number>("api.maxFileSize"),
|
||||
maxFiles: 10,
|
||||
}));
|
||||
// @ts-ignore
|
||||
this.router.use(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<boolean>("api.graphiql"),
|
||||
rootValue: resolver(request, response),
|
||||
schema: buildSchema(importSchema(path.join(__dirname, "./graphql/schema.graphql"))),
|
||||
validationRules: [
|
||||
queryComplexity({
|
||||
estimators: [
|
||||
directiveEstimator(),
|
||||
simpleEstimator(),
|
||||
],
|
||||
maximumComplexity: config.get<number>("api.maxQueryComplexity"),
|
||||
onComplete: (complexity: number) => {
|
||||
logger.debug(`QueryComplexity: ${complexity}`);
|
||||
response.setHeader("X-Query-Complexity", complexity);
|
||||
},
|
||||
variables,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the route
|
||||
* @param params
|
||||
*/
|
||||
public async destroy(...params: any): Promise<any> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import {NotLoggedInGqlError} from "../lib/errors/graphqlErrors";
|
||||
import {NotLoggedInGqlError} from "../../lib/errors/graphqlErrors";
|
||||
|
||||
/**
|
||||
* Base resolver class to provide common methods to all resolver classes
|
@ -1,12 +1,12 @@
|
||||
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 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";
|
@ -1,4 +1,4 @@
|
||||
import {Event, Group, Post, User} from "../lib/models";
|
||||
import {Event, Group, Post, User} from "../../lib/models";
|
||||
|
||||
/**
|
||||
* A class to wrap search results returned by the search resolver
|
Loading…
Reference in New Issue