[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
|
* Base resolver class to provide common methods to all resolver classes
|
@ -1,12 +1,12 @@
|
|||||||
import {GraphQLError} from "graphql";
|
import {GraphQLError} from "graphql";
|
||||||
import {Op} from "sequelize";
|
import {Op} from "sequelize";
|
||||||
import dataaccess from "../lib/dataAccess";
|
import dataaccess from "../../lib/dataAccess";
|
||||||
import {ChatNotFoundError} from "../lib/errors/ChatNotFoundError";
|
import {ChatNotFoundError} from "../../lib/errors/ChatNotFoundError";
|
||||||
import {PostNotFoundGqlError} from "../lib/errors/graphqlErrors";
|
import {PostNotFoundGqlError} from "../../lib/errors/graphqlErrors";
|
||||||
import {GroupNotFoundError} from "../lib/errors/GroupNotFoundError";
|
import {GroupNotFoundError} from "../../lib/errors/GroupNotFoundError";
|
||||||
import {RequestNotFoundError} from "../lib/errors/RequestNotFoundError";
|
import {RequestNotFoundError} from "../../lib/errors/RequestNotFoundError";
|
||||||
import {UserNotFoundError} from "../lib/errors/UserNotFoundError";
|
import {UserNotFoundError} from "../../lib/errors/UserNotFoundError";
|
||||||
import {Activity, BlacklistedPhrase, ChatRoom, Event, Group, Post, Request, User} from "../lib/models";
|
import {Activity, BlacklistedPhrase, ChatRoom, Event, Group, Post, Request, User} from "../../lib/models";
|
||||||
import {BlacklistedResult} from "./BlacklistedResult";
|
import {BlacklistedResult} from "./BlacklistedResult";
|
||||||
import {MutationResolver} from "./MutationResolver";
|
import {MutationResolver} from "./MutationResolver";
|
||||||
import {SearchResult} from "./SearchResult";
|
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
|
* A class to wrap search results returned by the search resolver
|
Loading…
Reference in New Issue