[WIP] Add media to posts

- Add graphql upload handling
- Add file handling for posts
- Add media url to Post model
pull/4/head
Trivernis 5 years ago
parent 010066d819
commit 2899eb6762

@ -51,7 +51,6 @@ angularIndex = "index.html"
# The path to the public files
publicPath = "./public"
# Configuration for the api
[api]

@ -93,11 +93,12 @@
"reflect-metadata": "^0.1.13",
"sequelize": "^5.19.6",
"sequelize-typescript": "^1.0.0",
"sharp": "^0.23.4",
"sharp": "^0.24.0",
"socket.io": "^2.2.0",
"socket.io-redis": "^5.2.0",
"sqlite3": "^4.1.0",
"stream-buffers": "^3.0.2",
"stream-to-array": "^2.3.0",
"toml": "^3.0.0",
"uuid": "^3.3.3",
"winston": "^3.2.1",

@ -4,14 +4,9 @@ import * as cookieParser from "cookie-parser";
import * as cors from "cors";
import * as express from "express";
import {Request, Response} from "express";
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, GraphQLError} from "graphql";
import {importSchema} from "graphql-import";
import queryComplexity, {directiveEstimator, simpleEstimator} from "graphql-query-complexity";
import {graphqlUploadExpress} from "graphql-upload";
import * as http from "http";
import {IncomingMessage} from "http";
import * as httpStatus from "http-status";
@ -21,9 +16,9 @@ import {RedisClient} from "redis";
import {Sequelize} from "sequelize-typescript";
import * as socketIo from "socket.io";
import * as socketIoRedis from "socket.io-redis";
import {resolver} from "./graphql/resolvers";
import dataaccess from "./lib/dataAccess";
import globals from "./lib/globals";
import {GraphqlRoute} from "./routes/GraphqlRoute";
import HomeRoute from "./routes/HomeRoute";
import {UploadRoute} from "./routes/UploadRoute";
@ -161,8 +156,10 @@ class App {
const uploadRoute = new UploadRoute(this.publicPath);
const homeRoute = new HomeRoute();
const graphqlRoute = new GraphqlRoute();
await uploadRoute.init();
await homeRoute.init(this.io);
await graphqlRoute.init();
this.app.use("/home", homeRoute.router);
this.limiter({
@ -191,46 +188,8 @@ class App {
path: "/graphql",
total: config.get("api.rateLimit.graphql.total"),
});
this.app.use("/graphql", graphqlRoute.router);
this.app.use("/graphql", graphqlUploadExpress({
maxFileSize: config.get<number>("api.maxFileSize"),
maxFiles: 10,
}));
// @ts-ignore
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<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,
}),
],
};
}));
// allow access to cluster information
this.app.use("/cluster-info", (req: Request, res: Response) => {
res.json({

@ -13,7 +13,7 @@ if (cluster.isMaster) {
cluster.on("exit", (worker, code) => {
console.error(`[CLUSTER-M] Worker ${worker.id} died! (code: ${code})`);
console.log("[CLUSTER-M] Starting new worker");
cluster.fork();
setTimeout(cluster.fork, 1000);
});
cluster.on("online", (worker) => {
worker.process.stdout.on("data", (data) => {

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

@ -1,5 +1,6 @@
import * as config from "config";
import {EventEmitter} from "events";
import * as path from "path";
import * as winston from "winston";
require("winston-daily-rotate-file");
@ -38,6 +39,18 @@ namespace globals {
}),
],
});
/**
* Returns the absolute public path
*/
export function getPublicDir(): string {
let publicPath = config.get<string>("frontend.publicPath");
if (!path.isAbsolute(publicPath)) {
publicPath = path.normalize(path.join(__dirname, "../", publicPath));
}
return publicPath;
}
export const internalEmitter: EventEmitter = new EventEmitter();
}

@ -33,6 +33,12 @@ export class Post extends Model<Post> {
@Column({allowNull: true})
public activityId: number;
/**
* An url pointing to any media that belongs to the post
*/
@Column({allowNull: true, type: sqz.STRING(512)})
public mediaUrl: string;
/**
* The author of the post
*/

@ -1,5 +1,7 @@
export namespace is {
const emailRegex = /\S+?@\S+?(\.\S+?)?\.\w{2,3}(.\w{2-3})?/g;
const videoRegex = /video\/.*/g;
const imageRegex = /image\/.*/g;
/**
* Tests if a string is a valid email.
@ -8,4 +10,20 @@ export namespace is {
export function email(testString: string) {
return emailRegex.test(testString);
}
/**
* Returns if the mimetype is a video
* @param mimetype
*/
export function video(mimetype: string) {
return videoRegex.test(mimetype);
}
/**
* Returns if the mimetype is an image
* @param mimetype
*/
export function image(mimetype: string) {
return imageRegex.test(mimetype);
}
}

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

@ -2,17 +2,16 @@ import * as bodyParser from "body-parser";
import * as config from "config";
import * as crypto from "crypto";
import {Router} from "express";
import * as fileUpload from "express-fileupload";
import {UploadedFile} from "express-fileupload";
import * as fileUpload from "express-fileupload";
import * as ffmpeg from "fluent-ffmpeg";
import * as fsx from "fs-extra";
import * as status from "http-status";
import * as path from "path";
import * as sharp from "sharp";
import {ReadableStreamBuffer} from "stream-buffers";
import globals from "../lib/globals";
import {Group, User} from "../lib/models";
import Route from "../lib/Route";
import {UploadManager} from "../lib/UploadManager";
const ffmpegPath = require("@ffmpeg-installer/ffmpeg").path;
const dataDirName = "data";
@ -54,12 +53,14 @@ export class UploadRoute extends Route {
* The directory where the uploaded data will be saved in
*/
public readonly dataDir: string;
private uploadManager: UploadManager;
constructor(private publicPath: string) {
super();
this.router = Router();
this.dataDir = path.join(this.publicPath, dataDirName);
ffmpeg.setFfmpegPath(ffmpegPath);
this.uploadManager = new UploadManager();
}
/**
@ -118,9 +119,9 @@ export class UploadRoute extends Route {
try {
const user = await User.findByPk(request.session.userId);
if (user) {
fileName = await this.processAndStoreImage(profilePic.data);
fileName = await this.uploadManager.processAndStoreImage(profilePic.data);
if (user.profilePicture) {
await this.deleteWebFile(user.profilePicture);
await this.uploadManager.deleteWebFile(user.profilePicture);
}
user.profilePicture = fileName;
await user.save();
@ -162,9 +163,9 @@ export class UploadRoute extends Route {
}
const isAdmin = await group.$has("rAdmins", user);
if (isAdmin) {
fileName = await this.processAndStoreImage(groupPicture.data);
fileName = await this.uploadManager.processAndStoreImage(groupPicture.data);
if (group.picture) {
await this.deleteWebFile(group.picture);
await this.uploadManager.deleteWebFile(group.picture);
}
group.picture = fileName;
await group.save();
@ -186,76 +187,4 @@ export class UploadRoute extends Route {
success,
};
}
/**
* 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
*/
private async processAndStoreImage(data: Buffer, width = 512, height = 512,
fit: ImageFit = "cover"): Promise<string> {
const fileBasename = UploadRoute.getFileName() + "." + config.get("api.imageFormat");
await fsx.ensureDir(this.dataDir);
const filePath = path.join(this.dataDir, fileBasename);
let image = await sharp(data)
.resize(width, height, {
fit,
})
.normalise();
if (config.get<string>("api.imageFormat") === "webp") {
image = await image.webp({
reductionEffort: 6,
smartSubsample: true,
});
} else {
image = await 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
*/
private async processAndStoreVideo(data: Buffer, width: number = 720): Promise<string> {
return new Promise(async (resolve) => {
const fileBasename = UploadRoute.getFileName() + ".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);
});
}
/**
* Deletes an image for a provided web path.
* @param webPath
*/
private async deleteWebFile(webPath: string) {
const realPath = path.join(this.dataDir, path.basename(webPath));
if (await fsx.pathExists(realPath)) {
await fsx.unlink(realPath);
} else {
globals.logger.warn(`Could not delete web image ${realPath}: Not found!`);
}
}
}

@ -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,17 +1,21 @@
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 {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 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";
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";
const legit = require("legit");
@ -21,6 +25,17 @@ const legit = require("legit");
*/
export class MutationResolver extends BaseResolver {
/**
* An instance of the upload manager to handle uploads
*/
protected uploadManager: UploadManager;
constructor() {
super();
this.uploadManager = new UploadManager();
}
/**
* Accepts the usage of cookies and stores the session
* @param args
@ -130,13 +145,27 @@ export class MutationResolver extends BaseResolver {
* @param activityId
* @param request
*/
public async createPost({content, activityId}: { content: string, activityId?: number }, request: any):
Promise<Post> {
public async createPost({content, activityId, file}: { content: string, activityId?: number, file: FileUpload },
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);
if (file) {
let fileUrl: string;
if (is.video(file.mimetype)) {
const fileBuffer = await this.uploadManager.streamToBuffer(file.createReadStream());
fileUrl = await this.uploadManager.processAndStoreVideo(fileBuffer);
} else if (is.image(file.mimetype)) {
const fileBuffer = await this.uploadManager.streamToBuffer(file.createReadStream());
fileUrl = await this.uploadManager.processAndStoreImage(fileBuffer);
} else {
throw new InvalidFileError(file.mimetype);
}
post.mediaUrl = fileUrl;
await post.save();
}
globals.internalEmitter.emit(InternalEvents.GQLPOSTCREATE, post);
return post;
}

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

@ -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, file: Upload): Post!
"delete the post for a given post id"
deletePost(postId: ID!): Boolean!
@ -340,6 +340,9 @@ type Post {
"the activity that belongs to the post"
activity: Activity
"the uploaded file or video for the post"
media: Media
}
"represents a request of any type"
@ -508,8 +511,21 @@ type BlacklistedResult {
phrases: [String!]!
}
"a type of uploaded media"
type Media {
"the url pointing to the media in the data folder"
url: String!
"the type of media that is uploaded"
type: MediaType
}
"represents the type of media"
enum MediaType {
VIDEO
IMAGE
}
"represents the type of vote performed on a post"
enum VoteType {

@ -534,7 +534,7 @@ ansi-wrap@0.1.0, ansi-wrap@^0.1.0:
resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf"
integrity sha1-qCJQ3bABXponyoLoLqYDu/pF768=
any-promise@^1.3.0:
any-promise@^1.1.0, any-promise@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
@ -4655,6 +4655,11 @@ semver@^6.3.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
semver@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.1.1.tgz#29104598a197d6cbe4733eeecbe968f7b43a9667"
integrity sha512-WfuG+fl6eh3eZ2qAf6goB7nhiCd7NPXhmyFxigB/TOkQyeLP8w8GsVehvtGNtnNmyboz4TgeK40B1Kbql/8c5A==
send@0.17.1:
version "0.17.1"
resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
@ -4737,17 +4742,17 @@ setprototypeof@1.1.1:
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
sharp@^0.23.4:
version "0.23.4"
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.23.4.tgz#ca36067cb6ff7067fa6c77b01651cb9a890f8eb3"
integrity sha512-fJMagt6cT0UDy9XCsgyLi0eiwWWhQRxbwGmqQT6sY8Av4s0SVsT/deg8fobBQCTDU5iXRgz0rAeXoE2LBZ8g+Q==
sharp@^0.24.0:
version "0.24.0"
resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.24.0.tgz#1200f4bb36ccc2bb36a78f0bcba0302cf1f7a5fd"
integrity sha512-kUtQE6+HJnNqO0H6ueOBtRXahktuqydIBaFMvhDelf/KaK9j/adEdjf4Y3+bbjYOa5i6hi2EAa2Y2G9umP4s2g==
dependencies:
color "^3.1.2"
detect-libc "^1.0.3"
nan "^2.14.0"
npmlog "^4.1.2"
prebuild-install "^5.3.3"
semver "^6.3.0"
semver "^7.1.1"
simple-get "^3.1.0"
tar "^5.0.5"
tunnel-agent "^0.6.0"
@ -5025,6 +5030,13 @@ stream-shift@^1.0.0:
resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952"
integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=
stream-to-array@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/stream-to-array/-/stream-to-array-2.3.0.tgz#bbf6b39f5f43ec30bc71babcb37557acecf34353"
integrity sha1-u/azn19D7DC8cbq8s3VXrOzzQ1M=
dependencies:
any-promise "^1.1.0"
streamsearch@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"

Loading…
Cancel
Save