Source

classes/file.ts

import { FileState, FileType, fileTypeExt, fileTypeMime } from "../types/file";
import Snowflake from "./snowflake";
import User from "./user";
import Collection from "@discordjs/collection";
import { SnowflakeResolvable, SnowflakeString } from "../types/snowflake";
import { s3 } from "../lib/s3";
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import Config from "./config";
import { RequestContext } from "./requestContext";
import { DBFile, DBFileFull, prisma } from "../../prisma";
import { UserType } from "../types/basicUser";

/**
 * File data class
 */
class File {
    id: Snowflake;
    ctx: RequestContext;

    author: User;

    name: string;
    description: string;
    tags: string[];
    state: FileState;
    type: FileType;
    length: number;

    /**
     * @param {object} param0
     */
    constructor({
        id = Snowflake.newSnowflake(),
        ctx,
        author,
        name = "",
        state,
        description = "",
        tags = [],
        type = FileType.IMAGE,
        length = 0
    } : {
        id?: Snowflake,
        ctx: RequestContext,
        author: User,
        name?: string,
        state: FileState,
        description?: string,
        tags?: string[],
        type?: FileType,
        length?: number
    }) {
        this.id = id;
        this.ctx = ctx;
        this.name = name;
        this.state = state;
        this.type = type;
        this.author = author;
        this.description = description;
        this.tags = tags;
        this.length = length;
    }

    /**
     * @param {DBFile | DBFileFull} data
     * @param {RequestContext} ctx
     */
    static async fromDatabase(data: DBFile | DBFileFull, ctx: RequestContext) {
        if(ctx.files.has(data.snowflake.toString())) {
            return ctx.files.get(data.snowflake.toString())!;
        }

        if(data.state === FileState.DELETED && !ctx.user?.comparePosition(UserType.MODERATOR)) {
            throw new Error("File does not exist");
        }

        return new File({
            ctx,
            id: new Snowflake(data.snowflake),
            author: "author" in data ?
                await User.fromDatabase(data.author, ctx) :
                await User.getUser(data.authorId, ctx),
            name: data.name,
            state: data.state,
            type: data.type,
            description: data.description,
            tags: JSON.parse(data.tags),
            length: data.length
        });
    }

    /**
     * @param {SnowflakeResolvable} id
     * @param {RequestContext} ctx
     * @return {Promise<User>}
     */
    static async load(id: SnowflakeResolvable, ctx: RequestContext): Promise<File> {
        const fid = (new Snowflake(id)).toBigInt();
        if(ctx.files.has(fid.toString())) return ctx.files.get(fid.toString())!;
        const file = await prisma.file.findUniqueOrThrow({
            where: {
                snowflake: fid
            }
        });

        return await File.fromDatabase(file, ctx);
    }

    /**
     * @param {object} param0 file data
     * @return {Promise<File>} file
     */
    static async newFile({
        id = Snowflake.newSnowflake(),
        ctx,
        author,
        name = "",
        description = "",
        tags = [],
        state,
        type = FileType.IMAGE,
        length = 0
    }: {
        id?: Snowflake,
        ctx: RequestContext,
        author: User,
        name?: string,
        description?: string,
        tags?: string[],
        state: number,
        type?: FileType,
        length?: number
    }): Promise<File> {
        if(!ctx.client) throw new Error("Cannot create file without client");
        const file = new File({
            id,
            ctx,
            author,
            name,
            type,
            description,
            state,
            tags,
            length
        });
        await prisma.file.create({
            data: {
                snowflake: id.toBigInt(),
                author: {
                    connect: {
                        snowflake: author.id.toBigInt()
                    }
                },
                name,
                type,
                description,
                state,
                tags: JSON.stringify(tags),
                length
            }
        });
        return file;
    }

    /**
     * Searches files
     * @param {RequestContext} ctx
     * @param {any} options
     * @return {Promise<Collection<SnowflakeString, File>>}
     */
    static search(
        ctx: RequestContext,
        options: { limit?: number, offset?: number, tags?: string[], query?: string }
    ): Promise<Collection<SnowflakeString, File>> {
        return new Promise((resolve, reject) => {
            let query = "SELECT * FROM files WHERE state != 3";
            const variables = [];
            if(options.tags) {
                query += " AND json_contains(`tags`, ?)";
                variables.push(JSON.stringify(options.tags));
            }
            if(options.query) {
                query += " AND MATCH (name, description, tags) AGAINST (? IN BOOLEAN MODE)";
                let query2 = options.query;
                query2 = `(${query2.split(" ")
                    .map((t) => t + "*").join(" ")}) ("${query2.replace(/"/g, "")}")`;
                variables.push(query2);
            }
            query += " ORDER BY snowflake DESC";
            if(options.limit) {
                query += " LIMIT " + options.limit;
            }
            if(options.offset) {
                query += " OFFSET " + options.offset;
            }
            ctx.conn.query(query, variables, async (err, results) => {
                if(err) return reject(err);
                var col = new Collection<SnowflakeString, File>();

                for(var result of results) {
                    col.set(result.snowflake, new File({
                        id: new Snowflake(BigInt(result.snowflake)),
                        ctx,
                        author: await User.getUser(result.author, ctx),
                        name: result.name,
                        description: result.description,
                        tags: JSON.parse(result.tags),
                        type: result.type,
                        state: result.state,
                        length: result.length
                    }));
                }

                resolve(col);
            });
        });
    }

    /**
     * Returns the number of files that match the search
     * @param {RequestContext} ctx
     * @param {any} options
     * @return {Promise<number>}
     */
    static searchCount(ctx: RequestContext, options: { tags?: string[], query?: string }):
    Promise<number> {
        return new Promise((resolve, reject) => {
            let query = "SELECT COUNT(*) as count FROM files WHERE state != 3";
            const variables = [];
            if(options.tags) {
                query += " AND json_contains(`tags`, ?)";
                variables.push(JSON.stringify(options.tags));
            }
            if(options.query) {
                query += " AND MATCH (name, description, tags) AGAINST (? IN BOOLEAN MODE)";
                variables.push(options.query);
            }
            query += " ORDER BY snowflake DESC";
            ctx.conn.query(query, variables, async (err, results) => {
                if(err) return reject(err);

                resolve(results[0].count);
            });
        });
    }

    /**
     * Updates file (sync DB)
     * @return {Promise<void>}
     */
    async update(): Promise<void> {
        await prisma.file.update({
            where: {
                snowflake: this.id.toBigInt()
            },
            data: {
                name: this.name,
                type: this.type,
                description: this.description,
                state: this.state,
                tags: JSON.stringify(this.tags)
            }
        });
    }

    /**
     * Deletes file (user)
     */
    async delete(): Promise<void> {
        this.state = FileState.DELETED;
        await this.update();
    }

    /**
     * Hard deletes file (moderator nuke)
     */
    async hardDelete(): Promise<void> {
        await prisma.file.delete({
            where: {
                snowflake: this.id.toBigInt()
            }
        });
        await s3.send(new DeleteObjectCommand({
            Key: `${this.id.toString()}.${fileTypeExt[this.type]}`,
            Bucket: Config.s3.bucket
        }));
    }

    /**
     * Formats URL (for server)
     * @return {string}
     */
    formatURL() {
        return `https://cdn.animasher.net/file/${Config.s3.bucket}/${
            this.id.toString()}.${fileTypeExt[this.type]}`;
    }

    /**
     * Formats public URL (for client)
     * @return {string}
     */
    formatPublicURL() {
        return `${Config.domains.files ?
            (!Config.isDev ? "https://" : "http://") + Config.domains.files :
            "https://static.animasher.net"
        }/v1/file/${this.id.toString()}`;
    }

    /**
     * Formats public URL with extension (for client)
     * @return {string}
     */
    formatPublicURLWithExt() {
        return this.formatPublicURL() + "." + fileTypeExt[this.type];
    }

    /**
     * Formats public URL (for client)
     * @param {SnowflakeString} id
     * @return {string}
     */
    static formatPublicURL(id: SnowflakeString) {
        return `${Config.domains.files ?
            (!Config.isDev ? "https://" : "http://") + Config.domains.files :
            "https://static.animasher.net"
        }/v1/file/${id}`;
    }

    /**
     * Creates S3 upload command
     * @return {PutObjectCommand}
     */
    uploadCommand() {
        return new PutObjectCommand({
            Key: `${this.id.toString()}.${fileTypeExt[this.type]}`,
            Bucket: Config.s3.bucket,
            ContentType: fileTypeMime[this.type],
            CacheControl: "public, max-age=604800, immutable, stale-while-revalidate=86400"
        });
    }

    /**
     * Creates a JSON-compatible object
     * @return {Object}
     */
    toJSON() {
        return {
            id: this.id.getSnowflake(),
            author: this.author.toJSON(),
            name: this.name,
            url: this.formatPublicURL(),
            type: this.type,
            description: this.description,
            tags: this.tags,
            state: this.state,
            length: this.length
        };
    }
}

export default File;