server/models/Attachment.ts
Rama main · réplica de lectura del repositorio original.
import { createReadStream } from "node:fs";
import { subMinutes } from "date-fns";
import path from "node:path";
import type { File } from "formidable";
import type {
InferAttributes,
InferCreationAttributes,
FindOptions,
Sequelize,
} from "sequelize";
import { QueryTypes } from "sequelize";
import {
BeforeDestroy,
BelongsTo,
Column,
Default,
ForeignKey,
IsIn,
Table,
DataType,
IsNumeric,
BeforeCreate,
BeforeUpdate,
} from "sequelize-typescript";
import { errToString } from "@shared/utils/error";
import { ValidationError } from "@server/errors";
import FileStorage from "@server/storage/files";
import { ValidateKey } from "@server/validation";
import Document from "./Document";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import { SkipChangeset } from "./decorators/Changeset";
import Length from "./validators/Length";
import Logger from "@server/logging/Logger";
import { Buckets } from "./helpers/AttachmentHelper";
@Table({ tableName: "attachments", modelName: "attachment" })
class Attachment extends IdModel<
InferAttributes<Attachment>,
Partial<InferCreationAttributes<Attachment>>
> {
@Length({
max: 4096,
msg: "key must be 4096 characters or less",
})
@Column(DataType.STRING)
key: string;
@Length({
max: 255,
msg: "contentType must be 255 characters or less",
})
@Column(DataType.STRING)
contentType: string;
@IsNumeric
@Column(DataType.BIGINT)
size: number;
@Default("public-read")
@IsIn([["private", "public-read"]])
@Column(DataType.STRING)
acl: string;
@Column(DataType.DATE)
@SkipChangeset
lastAccessedAt: Date | null;
@Column(DataType.DATE)
expiresAt: Date | null;
// getters
/**
* Get the original uploaded file name.
*/
get name() {
return path.parse(this.key).base;
}
/**
* Whether the attachment is stored in a public bucket. This does not relate
* to the ACL of the attachment itself. Previously "public" attachments were
* stored in a separate bucket – now all attachments are stored in a private
* bucket and ACL is checked per attachment.
*/
get isStoredInPublicBucket() {
const bucket = this.key.split("/")[0];
return [Buckets.avatars, Buckets.public].includes(bucket as Buckets);
}
/**
* Whether the attachment is private or not.
*/
get isPrivate() {
return this.acl === "private";
}
/**
* Get the contents of this attachment as a readable stream.
*/
get stream() {
return FileStorage.getFileStream(this.key);
}
/**
* Get the contents of this attachment as a buffer.
*/
get buffer() {
return FileStorage.getFileBuffer(this.key);
}
/**
* Get a url that can be used to download the attachment if the user has a valid session.
*/
get url() {
return this.isPrivate ? this.redirectUrl : this.canonicalUrl;
}
/**
* Get a url that can be used to download a private attachment if the user has a valid session.
*/
get redirectUrl() {
return Attachment.getRedirectUrl(this.id);
}
/**
* Get a direct URL to the attachment in storage. Note that this will not work
* for private attachments, a signed URL must be used.
*/
get canonicalUrl() {
return encodeURI(FileStorage.getUrlForKey(this.key));
}
/**
* Get a signed URL with the default expiry to download the attachment from storage.
*/
get signedUrl() {
return FileStorage.getSignedUrl(this.key);
}
/**
* Store the given file in storage at the location specified by the attachment key.
* If the attachment already exists, an error will be thrown.
*
* @param file The file to store
* @returns A promise resolving to the attachment
*/
async writeFile(file: File) {
return FileStorage.store({
body: createReadStream(file.filepath),
contentLength: file.size,
contentType: this.contentType,
key: this.key,
acl: this.acl,
});
}
/**
* Record that the attachment was accessed. The write happens at most once
* every five minutes.
*
* @returns A promise resolving to the attachment.
*/
async updateAccessedAt() {
const fiveMinutesAgo = subMinutes(new Date(), 5);
if (this.lastAccessedAt && this.lastAccessedAt >= fiveMinutesAgo) {
return this;
}
this.lastAccessedAt = new Date();
return this.save({ silent: true });
}
// hooks
@BeforeCreate
static async sanitizeKey(model: Attachment) {
model.key = ValidateKey.sanitize(model.key);
return model;
}
@BeforeUpdate
static async preventKeyChange(model: Attachment) {
if (model.changed("key")) {
throw ValidationError("Cannot change the key of an attachment");
}
}
@BeforeDestroy
static async deleteAttachmentFromS3(model: Attachment) {
try {
await FileStorage.deleteFile(model.key);
} catch (err) {
// do not block deletion of the database record if S3 deletion fails
Logger.warn(
`Failed to delete attachment file ${model.key} from storage`,
{
id: model.id,
teamId: model.teamId,
message: errToString(err),
}
);
}
}
// static methods
/**
* Find an attachment by its key.
*
* @param key - The key of the attachment to find.
* @param options - Additional options for the query.
* @returns A promise resolving to the attachment with the given key, or null if not found.
*/
static async findByKey(
key: string,
options?: FindOptions<Attachment>
): Promise<Attachment | null> {
return this.findOne({ where: { key }, ...options });
}
/**
* Get the total size of all attachments for a given team.
*
* @param connection - The Sequelize connection to use for the query.
* @param teamId - The ID of the team to get the total size for.
* @returns A promise resolving to the total size of all attachments for the given team in bytes.
*/
static async getTotalSizeForTeam(
connection: Sequelize,
teamId: string
): Promise<number> {
const result = await connection.query<{ total: string }>(
`
SELECT SUM(size) as total
FROM attachments
WHERE "teamId" = :teamId
`,
{
replacements: { teamId },
type: QueryTypes.SELECT,
}
);
return parseInt(result?.[0]?.total ?? "0", 10);
}
/**
* Get the redirect URL for a private attachment. Use `attachment.redirectUrl` if you already have
* an instance of the attachment.
*
* @param id The ID of the attachment to get the redirect URL for.
* @returns The redirect URL for the attachment.
*/
static getRedirectUrl(id: string) {
return `/api/attachments.redirect?id=${id}`;
}
// associations
@BelongsTo(() => Team, "teamId")
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string | null;
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
}
export default Attachment;