Files
Tom Moor 00fb4d1af7 chore: Update node style imports (#11277)
- crypto → node:crypto
  - fs → node:fs
  - fs/promises → node:fs/promises
  - path → node:path
  - http → node:http
  - https → node:https
  - stream → node:stream
  - buffer → node:buffer
  - url → node:url
  - os → node:os
  - net → node:net
  - dns → node:dns
  - events → node:events
  - readline → node:readline
  - querystring → node:querystring
  - util → node:util
2026-01-26 20:51:50 -05:00

270 lines
6.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createReadStream } from "node:fs";
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 { 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 Fix from "./decorators/Fix";
import Length from "./validators/Length";
import Logger from "@server/logging/Logger";
import { Buckets } from "./helpers/AttachmentHelper";
@Table({ tableName: "attachments", modelName: "attachment" })
@Fix
class Attachment extends IdModel<
InferAttributes<Attachment>,
Partial<InferCreationAttributes<Attachment>>
> {
@Length({
max: 4096,
msg: "key must be 4096 characters or less",
})
@Column
key: string;
@Length({
max: 255,
msg: "contentType must be 255 characters or less",
})
@Column
contentType: string;
@IsNumeric
@Column(DataType.BIGINT)
size: number;
@Default("public-read")
@IsIn([["private", "public-read"]])
@Column
acl: string;
@Column
@SkipChangeset
lastAccessedAt: Date | null;
@Column
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,
});
}
// 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: err.message,
}
);
}
}
// 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;