mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
Add more cases for media backend
This commit is contained in:
parent
47a53b6990
commit
16cfd5d900
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -168,3 +168,4 @@ dist
|
|||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
config/config.toml
|
||||
uploads/
|
||||
177
classes/media.ts
Normal file
177
classes/media.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import {
|
||||
GetObjectCommand,
|
||||
GetObjectCommandOutput,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { ConfigType } from "@config";
|
||||
|
||||
class MediaBackend {
|
||||
backend: string;
|
||||
|
||||
constructor(backend: string) {
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds media to the media backend
|
||||
* @param media
|
||||
* @returns The hash of the file in SHA-256 (hex format)
|
||||
*/
|
||||
async addMedia(media: File) {
|
||||
const hash = new Bun.SHA256()
|
||||
.update(await media.arrayBuffer())
|
||||
.digest("hex");
|
||||
|
||||
return hash;
|
||||
}
|
||||
/**
|
||||
* Retrieves element from media backend by hash
|
||||
* @param hash The hash of the element in SHA-256 hex format
|
||||
* @returns The file as a File object
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
|
||||
async getMediaByHash(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
hash: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
extension: string
|
||||
): Promise<File | null> {
|
||||
return new File([], "test");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S3 Backend, stores files in S3
|
||||
*/
|
||||
export class S3Backend extends MediaBackend {
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
publicUrl: string;
|
||||
client: S3Client;
|
||||
|
||||
constructor(config: ConfigType) {
|
||||
super("s3");
|
||||
|
||||
this.endpoint = config.s3.endpoint;
|
||||
this.bucket = config.s3.bucket_name;
|
||||
this.region = config.s3.region;
|
||||
this.accessKey = config.s3.access_key;
|
||||
this.secretKey = config.s3.secret_access_key;
|
||||
this.publicUrl = config.s3.public_url;
|
||||
|
||||
this.client = new S3Client({
|
||||
endpoint: this.endpoint,
|
||||
region: this.region || "auto",
|
||||
credentials: {
|
||||
accessKeyId: this.accessKey,
|
||||
secretAccessKey: this.secretKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addMedia(media: File): Promise<string> {
|
||||
const hash = await super.addMedia(media);
|
||||
|
||||
if (!hash) {
|
||||
throw new Error("Failed to hash file");
|
||||
}
|
||||
|
||||
// Check if file is already present
|
||||
const existingFile = await this.getMediaByHash(
|
||||
hash,
|
||||
media.name.split(".").pop() || ""
|
||||
);
|
||||
|
||||
if (existingFile) {
|
||||
// File already exists, so return the hash without uploading it
|
||||
return hash;
|
||||
}
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: hash,
|
||||
Body: Buffer.from(await media.arrayBuffer()),
|
||||
ContentType: media.type,
|
||||
ContentLength: media.size,
|
||||
Metadata: {
|
||||
"x-amz-meta-original-name": media.name,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await this.client.send(command);
|
||||
|
||||
if (response.$metadata.httpStatusCode !== 200) {
|
||||
throw new Error("Failed to upload file");
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
async getMediaByHash(
|
||||
hash: string,
|
||||
extension: string
|
||||
): Promise<File | null> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: hash,
|
||||
});
|
||||
|
||||
let response: GetObjectCommandOutput;
|
||||
|
||||
try {
|
||||
response = await this.client.send(command);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.$metadata.httpStatusCode !== 200) {
|
||||
throw new Error("Failed to get file");
|
||||
}
|
||||
|
||||
const body = await response.Body?.transformToByteArray();
|
||||
|
||||
if (!body) {
|
||||
throw new Error("Failed to get file");
|
||||
}
|
||||
|
||||
return new File([body], `${hash}.${extension}`, {
|
||||
type: response.ContentType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local backend, stores files on filesystem
|
||||
*/
|
||||
export class LocalBackend extends MediaBackend {
|
||||
constructor() {
|
||||
super("local");
|
||||
}
|
||||
|
||||
async addMedia(media: File): Promise<string> {
|
||||
const hash = await super.addMedia(media);
|
||||
|
||||
await Bun.write(Bun.file(`${process.cwd()}/uploads/${hash}`), media);
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
async getMediaByHash(
|
||||
hash: string,
|
||||
extension: string
|
||||
): Promise<File | null> {
|
||||
const file = Bun.file(`${process.cwd()}/uploads/${hash}`);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new File([await file.arrayBuffer()], `${hash}.${extension}`, {
|
||||
type: file.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.429.0",
|
||||
"ip-matching": "^2.1.2",
|
||||
"isomorphic-dompurify": "^1.9.0",
|
||||
"jsonld": "^8.3.1",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ let user: User;
|
|||
let user2: User;
|
||||
let status: APIStatus | null = null;
|
||||
|
||||
describe("API Tests", () => {
|
||||
beforeAll(async () => {
|
||||
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
|
||||
|
||||
|
|
@ -79,7 +80,9 @@ describe("POST /api/v1/accounts/:id", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -101,7 +104,9 @@ describe("POST /api/v1/statuses", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
status = (await response.json()) as APIStatus;
|
||||
|
|
@ -141,7 +146,9 @@ describe("GET /api/v1/timelines/public", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const statuses: APIStatus[] = await response.json();
|
||||
|
||||
|
|
@ -166,7 +173,9 @@ describe("PATCH /api/v1/accounts/update_credentials", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const user: APIAccount = await response.json();
|
||||
|
||||
|
|
@ -188,7 +197,9 @@ describe("GET /api/v1/accounts/verify_credentials", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIAccount = await response.json();
|
||||
|
||||
|
|
@ -231,7 +242,9 @@ describe("GET /api/v1/accounts/:id/statuses", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const statuses: APIStatus[] = await response.json();
|
||||
|
||||
|
|
@ -261,7 +274,9 @@ describe("POST /api/v1/accounts/:id/follow", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIRelationship = await response.json();
|
||||
|
||||
|
|
@ -285,7 +300,9 @@ describe("POST /api/v1/accounts/:id/unfollow", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIRelationship = await response.json();
|
||||
|
||||
|
|
@ -309,7 +326,9 @@ describe("POST /api/v1/accounts/:id/remove_from_followers", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIRelationship = await response.json();
|
||||
|
||||
|
|
@ -333,7 +352,9 @@ describe("POST /api/v1/accounts/:id/block", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIRelationship = await response.json();
|
||||
|
||||
|
|
@ -357,7 +378,9 @@ describe("POST /api/v1/accounts/:id/unblock", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIRelationship = await response.json();
|
||||
|
||||
|
|
@ -381,7 +404,9 @@ describe("POST /api/v1/accounts/:id/mute with notifications parameter", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIRelationship = await response.json();
|
||||
|
||||
|
|
@ -404,7 +429,9 @@ describe("POST /api/v1/accounts/:id/mute with notifications parameter", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIRelationship = await response.json();
|
||||
|
||||
|
|
@ -429,7 +456,9 @@ describe("POST /api/v1/accounts/:id/unmute", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIRelationship = await response.json();
|
||||
|
||||
|
|
@ -453,7 +482,9 @@ describe("POST /api/v1/accounts/:id/pin", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIRelationship = await response.json();
|
||||
|
||||
|
|
@ -477,7 +508,9 @@ describe("POST /api/v1/accounts/:id/unpin", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIRelationship = await response.json();
|
||||
|
||||
|
|
@ -501,7 +534,9 @@ describe("POST /api/v1/accounts/:id/note", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const account: APIAccount = await response.json();
|
||||
|
||||
|
|
@ -523,7 +558,9 @@ describe("GET /api/v1/accounts/relationships", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const relationships: APIRelationship[] = await response.json();
|
||||
|
||||
|
|
@ -554,7 +591,9 @@ describe("GET /api/v1/accounts/familiar_followers", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const familiarFollowers: { id: string; accounts: APIAccount[] }[] =
|
||||
await response.json();
|
||||
|
|
@ -563,7 +602,9 @@ describe("GET /api/v1/accounts/familiar_followers", () => {
|
|||
expect(familiarFollowers.length).toBeGreaterThan(0);
|
||||
expect(typeof familiarFollowers[0].id).toBe("string");
|
||||
expect(Array.isArray(familiarFollowers[0].accounts)).toBe(true);
|
||||
expect(familiarFollowers[0].accounts.length).toBeGreaterThanOrEqual(0);
|
||||
expect(familiarFollowers[0].accounts.length).toBeGreaterThanOrEqual(
|
||||
0
|
||||
);
|
||||
|
||||
if (familiarFollowers[0].accounts.length === 0) return;
|
||||
expect(familiarFollowers[0].accounts[0].id).toBeDefined();
|
||||
|
|
@ -578,12 +619,22 @@ describe("GET /api/v1/accounts/familiar_followers", () => {
|
|||
expect(familiarFollowers[0].accounts[0].note).toBeDefined();
|
||||
expect(familiarFollowers[0].accounts[0].url).toBeDefined();
|
||||
expect(familiarFollowers[0].accounts[0].avatar).toBeDefined();
|
||||
expect(familiarFollowers[0].accounts[0].avatar_static).toBeDefined();
|
||||
expect(
|
||||
familiarFollowers[0].accounts[0].avatar_static
|
||||
).toBeDefined();
|
||||
expect(familiarFollowers[0].accounts[0].header).toBeDefined();
|
||||
expect(familiarFollowers[0].accounts[0].header_static).toBeDefined();
|
||||
expect(familiarFollowers[0].accounts[0].followers_count).toBeDefined();
|
||||
expect(familiarFollowers[0].accounts[0].following_count).toBeDefined();
|
||||
expect(familiarFollowers[0].accounts[0].statuses_count).toBeDefined();
|
||||
expect(
|
||||
familiarFollowers[0].accounts[0].header_static
|
||||
).toBeDefined();
|
||||
expect(
|
||||
familiarFollowers[0].accounts[0].followers_count
|
||||
).toBeDefined();
|
||||
expect(
|
||||
familiarFollowers[0].accounts[0].following_count
|
||||
).toBeDefined();
|
||||
expect(
|
||||
familiarFollowers[0].accounts[0].statuses_count
|
||||
).toBeDefined();
|
||||
expect(familiarFollowers[0].accounts[0].emojis).toBeDefined();
|
||||
expect(familiarFollowers[0].accounts[0].fields).toBeDefined();
|
||||
});
|
||||
|
|
@ -602,7 +653,9 @@ describe("GET /api/v1/statuses/:id", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const statusJson = await response.json();
|
||||
|
||||
|
|
@ -661,7 +714,9 @@ describe("GET /api/v1/instance", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const instance: APIInstance = await response.json();
|
||||
|
||||
|
|
@ -705,7 +760,9 @@ describe("GET /api/v1/custom_emojis", () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
expect(response.headers.get("content-type")).toBe(
|
||||
"application/json"
|
||||
);
|
||||
|
||||
const emojis: APIEmoji[] = await response.json();
|
||||
|
||||
|
|
@ -739,3 +796,4 @@ afterAll(async () => {
|
|||
await user.remove();
|
||||
await user2.remove();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
94
tests/entities/Media.test.ts
Normal file
94
tests/entities/Media.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { getConfig } from "@config";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { LocalBackend, S3Backend } from "~classes/media";
|
||||
import { unlink } from "fs/promises";
|
||||
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
describe("LocalBackend", () => {
|
||||
let localBackend: LocalBackend;
|
||||
let fileName: string;
|
||||
|
||||
beforeAll(() => {
|
||||
localBackend = new LocalBackend();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await unlink(`${process.cwd()}/uploads/${fileName}`);
|
||||
});
|
||||
|
||||
describe("addMedia", () => {
|
||||
it("should write the file to the local filesystem and return the hash", async () => {
|
||||
const media = new File(["test"], "test.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
|
||||
const hash = await localBackend.addMedia(media);
|
||||
fileName = `${hash}`;
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMediaByHash", () => {
|
||||
it("should retrieve the file from the local filesystem and return it as a File object", async () => {
|
||||
const media = await localBackend.getMediaByHash(fileName, "txt");
|
||||
|
||||
expect(media).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it("should return null if the file does not exist", async () => {
|
||||
const media = await localBackend.getMediaByHash(
|
||||
"does-not-exist",
|
||||
"txt"
|
||||
);
|
||||
|
||||
expect(media).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("S3Backend", () => {
|
||||
const s3Backend = new S3Backend(config);
|
||||
let fileName: string;
|
||||
|
||||
afterAll(async () => {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: config.s3.bucket_name,
|
||||
Key: fileName,
|
||||
});
|
||||
|
||||
await s3Backend.client.send(command);
|
||||
});
|
||||
|
||||
describe("addMedia", () => {
|
||||
it("should write the file to the S3 bucket and return the hash", async () => {
|
||||
const media = new File(["test"], "test.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
|
||||
const hash = await s3Backend.addMedia(media);
|
||||
fileName = `${hash}`;
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMediaByHash", () => {
|
||||
it("should retrieve the file from the S3 bucket and return it as a File object", async () => {
|
||||
const media = await s3Backend.getMediaByHash(fileName, "txt");
|
||||
|
||||
expect(media).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it("should return null if the file does not exist", async () => {
|
||||
const media = await s3Backend.getMediaByHash(
|
||||
"does-not-exist",
|
||||
"txt"
|
||||
);
|
||||
|
||||
expect(media).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue