Media upload for avatars and banners, more work, fix tests

This commit is contained in:
Jesse Wierzbinski 2023-10-19 09:53:59 -10:00
parent 16cfd5d900
commit 460b68c381
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
17 changed files with 360 additions and 139 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -5,7 +5,7 @@ import {
S3Client,
} from "@aws-sdk/client-s3";
import { ConfigType } from "@config";
import sharp from "sharp";
class MediaBackend {
backend: string;
@ -16,26 +16,98 @@ class MediaBackend {
/**
* Adds media to the media backend
* @param media
* @returns The hash of the file in SHA-256 (hex format)
* @returns The hash of the file in SHA-256 (hex format) with the file extension added to it
*/
async addMedia(media: File) {
const hash = new Bun.SHA256()
.update(await media.arrayBuffer())
.digest("hex");
return hash;
return `${hash}.${media.name.split(".").pop()}`;
}
async convertMedia(media: File, config: ConfigType) {
const sharpCommand = sharp(await media.arrayBuffer());
// Rename ".jpg" files to ".jpeg" to avoid sharp errors
let name = media.name;
if (media.name.endsWith(".jpg")) {
name = media.name.replace(".jpg", ".jpeg");
}
const fileFormatToConvertTo = config.media.conversion.convert_to;
switch (fileFormatToConvertTo) {
case "png":
return new File(
[(await sharpCommand.png().toBuffer()).buffer],
// Replace the file extension with PNG
name.replace(/\.[^/.]+$/, ".png"),
{
type: "image/png",
}
);
case "webp":
return new File(
[(await sharpCommand.webp().toBuffer()).buffer],
// Replace the file extension with WebP
name.replace(/\.[^/.]+$/, ".webp"),
{
type: "image/webp",
}
);
case "jpeg":
return new File(
[(await sharpCommand.jpeg().toBuffer()).buffer],
// Replace the file extension with JPEG
name.replace(/\.[^/.]+$/, ".jpeg"),
{
type: "image/jpeg",
}
);
case "avif":
return new File(
[(await sharpCommand.avif().toBuffer()).buffer],
// Replace the file extension with AVIF
name.replace(/\.[^/.]+$/, ".avif"),
{
type: "image/avif",
}
);
// Needs special build of libvips
case "jxl":
return new File(
[(await sharpCommand.jxl().toBuffer()).buffer],
// Replace the file extension with JXL
name.replace(/\.[^/.]+$/, ".jxl"),
{
type: "image/jxl",
}
);
case "heif":
return new File(
[(await sharpCommand.heif().toBuffer()).buffer],
// Replace the file extension with HEIF
name.replace(/\.[^/.]+$/, ".heif"),
{
type: "image/heif",
}
);
default:
return media;
}
}
/**
* Retrieves element from media backend by hash
* @param hash The hash of the element in SHA-256 hex format
* @param extension The extension of the file
* @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
hash: string
): Promise<File | null> {
return new File([], "test");
}
@ -45,35 +117,29 @@ class MediaBackend {
* 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;
config: ConfigType;
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.config = config;
this.client = new S3Client({
endpoint: this.endpoint,
region: this.region || "auto",
endpoint: this.config.s3.endpoint,
region: this.config.s3.region || "auto",
credentials: {
accessKeyId: this.accessKey,
secretAccessKey: this.secretKey,
accessKeyId: this.config.s3.access_key,
secretAccessKey: this.config.s3.secret_access_key,
},
});
}
async addMedia(media: File): Promise<string> {
if (this.config.media.conversion.convert_images) {
media = await this.convertMedia(media, this.config);
}
const hash = await super.addMedia(media);
if (!hash) {
@ -81,10 +147,7 @@ export class S3Backend extends MediaBackend {
}
// Check if file is already present
const existingFile = await this.getMediaByHash(
hash,
media.name.split(".").pop() || ""
);
const existingFile = await this.getMediaByHash(hash);
if (existingFile) {
// File already exists, so return the hash without uploading it
@ -92,7 +155,7 @@ export class S3Backend extends MediaBackend {
}
const command = new PutObjectCommand({
Bucket: this.bucket,
Bucket: this.config.s3.bucket_name,
Key: hash,
Body: Buffer.from(await media.arrayBuffer()),
ContentType: media.type,
@ -111,12 +174,9 @@ export class S3Backend extends MediaBackend {
return hash;
}
async getMediaByHash(
hash: string,
extension: string
): Promise<File | null> {
async getMediaByHash(hash: string): Promise<File | null> {
const command = new GetObjectCommand({
Bucket: this.bucket,
Bucket: this.config.s3.bucket_name,
Key: hash,
});
@ -138,7 +198,7 @@ export class S3Backend extends MediaBackend {
throw new Error("Failed to get file");
}
return new File([body], `${hash}.${extension}`, {
return new File([body], hash, {
type: response.ContentType,
});
}
@ -148,11 +208,19 @@ export class S3Backend extends MediaBackend {
* Local backend, stores files on filesystem
*/
export class LocalBackend extends MediaBackend {
constructor() {
config: ConfigType;
constructor(config: ConfigType) {
super("local");
this.config = config;
}
async addMedia(media: File): Promise<string> {
if (this.config.media.conversion.convert_images) {
media = await this.convertMedia(media, this.config);
}
const hash = await super.addMedia(media);
await Bun.write(Bun.file(`${process.cwd()}/uploads/${hash}`), media);
@ -160,18 +228,41 @@ export class LocalBackend extends MediaBackend {
return hash;
}
async getMediaByHash(
hash: string,
extension: string
): Promise<File | null> {
async getMediaByHash(hash: 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}`, {
return new File([await file.arrayBuffer()], `${hash}`, {
type: file.type,
});
}
}
export const uploadFile = (file: File, config: ConfigType) => {
const backend = config.media.backend;
if (backend === "local") {
return new LocalBackend(config).addMedia(file);
} else if (backend === "s3") {
return new S3Backend(config).addMedia(file);
}
};
export const getFile = (
hash: string,
extension: string,
config: ConfigType
) => {
const backend = config.media.backend;
if (backend === "local") {
return new LocalBackend(config).getMediaByHash(hash);
} else if (backend === "s3") {
return new S3Backend(config).getMediaByHash(hash);
}
return null;
};

View file

@ -23,14 +23,17 @@ tls = true
[media]
# Can be "s3" or "local", where "local" uploads the file to the local filesystem
backend = "s3" # NOT IMPLEMENTED
# If you need to change this value after setting up your instance, you must move all the files
# from one backend to the other manually
backend = "s3"
# Whether to check the hash of media when uploading to avoid duplication
deduplicate_media = true # NOT IMPLEMENTED
deduplicate_media = true
[media.conversion]
convert_images = false # NOT IMPLEMENTED
# Can be: "jxl", "webp", "avif", "png", "jpg", "gif"
convert_to = "webp" # NOT IMPLEMENTED
convert_images = false
# Can be: "jxl", "webp", "avif", "png", "jpg", "heif"
# JXL support will likely not work
convert_to = "webp"
[s3]
# Can be left blank if you don't use the S3 media backend

View file

@ -120,10 +120,10 @@ export class RawActor extends BaseEntity {
isLocalUser ? "" : `@${this.getInstanceDomain()}`
}`,
avatar:
((icon as APImage).url as string | undefined) ??
((icon as APImage).url as string | undefined) ||
config.defaults.avatar,
header:
((image as APImage).url as string | undefined) ??
((image as APImage).url as string | undefined) ||
config.defaults.header,
locked: false,
created_at: new Date(published ?? 0).toISOString(),

View file

@ -1,4 +1,4 @@
import { getConfig } from "@config";
import { ConfigType, getConfig } from "@config";
import {
BaseEntity,
Column,
@ -14,7 +14,11 @@ import {
} from "typeorm";
import { APIAccount } from "~types/entities/account";
import { RawActor } from "./RawActor";
import { APActor, APOrderedCollectionPage } from "activitypub-types";
import {
APActor,
APCollectionPage,
APOrderedCollectionPage,
} from "activitypub-types";
import { RawObject } from "./RawObject";
import { Token } from "./Token";
import { Status } from "./Status";
@ -90,13 +94,13 @@ export class User extends BaseEntity {
source!: APISource;
/**
* The avatar for the user.
* The avatar for the user (filename, as UUID)
*/
@Column("varchar")
avatar!: string;
/**
* The header for the user.
* The header for the user (filename, as UUID)
*/
@Column("varchar")
header!: string;
@ -156,6 +160,32 @@ export class User extends BaseEntity {
@JoinTable()
pinned_notes!: RawObject[];
/**
* Get the user's avatar in raw URL format
* @param config The config to use
* @returns The raw URL for the user's avatar
*/
getAvatarUrl(config: ConfigType) {
if (config.media.backend === "local") {
return `${config.http.base_url}/media/${this.avatar}`;
} else if (config.media.backend === "s3") {
return `${config.s3.public_url}/${this.avatar}`;
}
}
/**
* Get the user's header in raw URL format
* @param config The config to use
* @returns The raw URL for the user's header
*/
getHeaderUrl(config: ConfigType) {
if (config.media.backend === "local") {
return `${config.http.base_url}/media/${this.header}`;
} else if (config.media.backend === "s3") {
return `${config.s3.public_url}/${this.header}`;
}
}
static async getFromRequest(req: Request) {
// Check auth token
const token = req.headers.get("Authorization")?.split(" ")[1] || "";
@ -196,7 +226,7 @@ export class User extends BaseEntity {
while (followers.type === "OrderedCollectionPage" && followers.next) {
followers = await fetch((followers.next as string).toString(), {
headers: { Accept: "application/activity+json" },
}).then(res => res.json());
}).then(res => res.json() as APCollectionPage);
followersList = {
...followersList,
@ -408,11 +438,11 @@ export class User extends BaseEntity {
summary: this.note,
icon: {
type: "Image",
url: this.avatar,
url: this.getAvatarUrl(config),
},
image: {
type: "Image",
url: this.header,
url: this.getHeaderUrl(config),
},
publicKey: {
id: `${config.http.base_url}/users/${this.username}/actor#main-key`,

View file

@ -35,6 +35,9 @@
"dev": "bun run index.ts",
"start": "bun run index.ts"
},
"trustedDependencies": [
"sharp"
],
"devDependencies": {
"@julr/unocss-preset-forms": "^0.0.5",
"@types/jsonld": "^1.5.9",

View file

@ -5,6 +5,7 @@ import { User } from "~database/entities/User";
import { applyConfig } from "@api";
import { sanitize } from "isomorphic-dompurify";
import { sanitizeHtml } from "@sanitization";
import { uploadFile } from "~classes/media";
export const meta = applyConfig({
allowedMethods: ["PATCH"],
@ -145,7 +146,9 @@ export default async (req: Request): Promise<Response> => {
);
}
// TODO: Store the file somewhere and then change the user's actual avatar
const hash = await uploadFile(avatar, config);
user.avatar = hash || "";
}
if (header) {
@ -156,7 +159,10 @@ export default async (req: Request): Promise<Response> => {
422
);
}
// TODO: Store the file somewhere and then change the user's actual header
const hash = await uploadFile(header, config);
user.header = hash || "";
}
if (locked) {

View file

@ -48,6 +48,12 @@ export default async (): Promise<Response> => {
characters_reserved_per_url: 0,
max_characters: config.validation.max_note_size,
max_media_attachments: config.validation.max_media_attachments,
supported_mime_types: [
"text/plain",
"text/markdown",
"text/html",
"text/x.misskeymarkdown",
],
},
},
description: "A test instance",
@ -67,7 +73,43 @@ export default async (): Promise<Response> => {
urls: {
streaming_api: "",
},
version: "0.0.1",
version: "4.2.0+glitch (compatible; Lysand 0.0.1)",
max_toot_chars: config.validation.max_note_size,
pleroma: {
metadata: {
// account_activation_required: false,
features: [
"pleroma_api",
"akkoma_api",
"mastodon_api",
// "mastodon_api_streaming",
// "polls",
// "v2_suggestions",
// "pleroma_explicit_addressing",
// "shareable_emoji_packs",
// "multifetch",
// "pleroma:api/v1/notifications:include_types_filter",
"quote_posting",
"editing",
// "bubble_timeline",
// "relay",
// "pleroma_emoji_reactions",
// "exposable_reactions",
// "profile_directory",
// "custom_emoji_reactions",
// "pleroma:get:main/ostatus",
],
post_formats: [
"text/plain",
"text/html",
"text/markdown",
"text/x.misskeymarkdown",
],
privileged_staff: false,
},
stats: {
mau: 2,
},
},
});
};

View file

@ -30,8 +30,6 @@ export default async (
const { user } = await User.getFromRequest(req);
// TODO: Add checks for user's permissions to view this status
let foundStatus: RawObject | null;
try {
foundStatus = await RawObject.findOneBy({
@ -43,6 +41,14 @@ export default async (
if (!foundStatus) return errorResponse("Record not found", 404);
// Check if user is authorized to view this status (if it's private)
if (
(await foundStatus.toAPI()).visibility === "private" &&
(await foundStatus.toAPI()).account.id !== user?.id
) {
return errorResponse("Record not found", 404);
}
if (req.method === "GET") {
return jsonResponse(await foundStatus.toAPI());
} else if (req.method === "DELETE") {

View file

@ -8,6 +8,7 @@ import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization";
import { APActor } from "activitypub-types";
import { sanitize } from "isomorphic-dompurify";
import { parse } from "marked";
import { Application } from "~database/entities/Application";
import { RawObject } from "~database/entities/RawObject";
import { Status } from "~database/entities/Status";
@ -50,6 +51,7 @@ export default async (req: Request): Promise<Response> => {
sensitive,
spoiler_text,
visibility,
content_type,
} = await parseRequest<{
status: string;
media_ids?: string[];
@ -67,14 +69,22 @@ export default async (req: Request): Promise<Response> => {
content_type?: string;
}>(req);
// TODO: Parse Markdown statuses
// Validate status
if (!status) {
return errorResponse("Status is required", 422);
}
const sanitizedStatus = await sanitizeHtml(status);
let sanitizedStatus: string;
if (content_type === "text/markdown") {
sanitizedStatus = await sanitizeHtml(parse(status));
} else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM
// TODO: Parse as MFM
sanitizedStatus = await sanitizeHtml(parse(status));
} else {
sanitizedStatus = await sanitizeHtml(status);
}
if (sanitizedStatus.length > config.validation.max_note_size) {
return errorResponse(

View file

@ -23,27 +23,34 @@ beforeAll(async () => {
describe("POST /@test/actor", () => {
test("should return a valid ActivityPub Actor when querying an existing user", async () => {
const response = await fetch(`${config.http.base_url}/users/test/actor`, {
method: "GET",
headers: {
Accept: "application/activity+json",
},
});
const response = await fetch(
`${config.http.base_url}/users/test/actor`,
{
method: "GET",
headers: {
Accept: "application/activity+json",
},
}
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(
"application/activity+json"
);
const actor: APActor = await response.json();
const actor = (await response.json()) as APActor;
expect(actor.type).toBe("Person");
expect(actor.id).toBe(`${config.http.base_url}/users/test`);
expect(actor.preferredUsername).toBe("test");
expect(actor.inbox).toBe(`${config.http.base_url}/users/test/inbox`);
expect(actor.outbox).toBe(`${config.http.base_url}/users/test/outbox`);
expect(actor.followers).toBe(`${config.http.base_url}/users/test/followers`);
expect(actor.following).toBe(`${config.http.base_url}/users/test/following`);
expect(actor.followers).toBe(
`${config.http.base_url}/users/test/followers`
);
expect(actor.following).toBe(
`${config.http.base_url}/users/test/following`
);
expect((actor as any).publicKey).toBeDefined();
expect((actor as any).publicKey.id).toBeDefined();
expect((actor as any).publicKey.owner).toBe(
@ -82,4 +89,6 @@ afterAll(async () => {
if (user) {
await user.remove();
}
await AppDataSource.destroy();
});

View file

@ -66,6 +66,28 @@ describe("API Tests", () => {
token = await token.save();
});
afterAll(async () => {
const activities = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'actor' = :actor", {
actor: `${config.http.base_url}/users/test`,
})
.leftJoinAndSelect("activity.objects", "objects")
.getMany();
// Delete all created objects and activities as part of testing
for (const activity of activities) {
for (const object of activity.objects) {
await object.remove();
}
await activity.remove();
}
await user.remove();
await user2.remove();
await AppDataSource.destroy();
});
describe("POST /api/v1/accounts/:id", () => {
test("should return a 404 error when trying to fetch a non-existent user", async () => {
const response = await fetch(
@ -150,7 +172,7 @@ describe("API Tests", () => {
"application/json"
);
const statuses: APIStatus[] = await response.json();
const statuses = (await response.json()) as APIStatus[];
expect(statuses.some(s => s.id === status?.id)).toBe(true);
});
@ -177,7 +199,7 @@ describe("API Tests", () => {
"application/json"
);
const user: APIAccount = await response.json();
const user = (await response.json()) as APIAccount;
expect(user.display_name).toBe("New Display Name");
});
@ -201,7 +223,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIAccount = await response.json();
const account = (await response.json()) as APIAccount;
expect(account.username).toBe(user.username);
expect(account.bot).toBe(false);
@ -246,7 +268,7 @@ describe("API Tests", () => {
"application/json"
);
const statuses: APIStatus[] = await response.json();
const statuses = (await response.json()) as APIStatus[];
expect(statuses.length).toBe(1);
@ -278,7 +300,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIRelationship = await response.json();
const account = (await response.json()) as APIRelationship;
expect(account.id).toBe(user2.id);
expect(account.following).toBe(true);
@ -304,7 +326,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIRelationship = await response.json();
const account = (await response.json()) as APIRelationship;
expect(account.id).toBe(user2.id);
expect(account.following).toBe(false);
@ -330,7 +352,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIRelationship = await response.json();
const account = (await response.json()) as APIRelationship;
expect(account.id).toBe(user2.id);
expect(account.followed_by).toBe(false);
@ -356,7 +378,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIRelationship = await response.json();
const account = (await response.json()) as APIRelationship;
expect(account.id).toBe(user2.id);
expect(account.blocking).toBe(true);
@ -382,7 +404,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIRelationship = await response.json();
const account = (await response.json()) as APIRelationship;
expect(account.id).toBe(user2.id);
expect(account.blocking).toBe(false);
@ -408,7 +430,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIRelationship = await response.json();
const account = (await response.json()) as APIRelationship;
expect(account.id).toBe(user2.id);
expect(account.muting).toBe(true);
@ -433,7 +455,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIRelationship = await response.json();
const account = (await response.json()) as APIRelationship;
expect(account.id).toBe(user2.id);
expect(account.muting).toBe(true);
@ -460,7 +482,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIRelationship = await response.json();
const account = (await response.json()) as APIRelationship;
expect(account.id).toBe(user2.id);
expect(account.muting).toBe(false);
@ -486,7 +508,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIRelationship = await response.json();
const account = (await response.json()) as APIRelationship;
expect(account.id).toBe(user2.id);
expect(account.endorsed).toBe(true);
@ -512,7 +534,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIRelationship = await response.json();
const account = (await response.json()) as APIRelationship;
expect(account.id).toBe(user2.id);
expect(account.endorsed).toBe(false);
@ -538,7 +560,7 @@ describe("API Tests", () => {
"application/json"
);
const account: APIAccount = await response.json();
const account = (await response.json()) as APIAccount;
expect(account.id).toBe(user2.id);
expect(account.note).toBe("This is a new note");
@ -562,7 +584,7 @@ describe("API Tests", () => {
"application/json"
);
const relationships: APIRelationship[] = await response.json();
const relationships = (await response.json()) as APIRelationship[];
expect(Array.isArray(relationships)).toBe(true);
expect(relationships.length).toBeGreaterThan(0);
@ -595,8 +617,10 @@ describe("API Tests", () => {
"application/json"
);
const familiarFollowers: { id: string; accounts: APIAccount[] }[] =
await response.json();
const familiarFollowers = (await response.json()) as {
id: string;
accounts: APIAccount[];
}[];
expect(Array.isArray(familiarFollowers)).toBe(true);
expect(familiarFollowers.length).toBeGreaterThan(0);
@ -657,7 +681,7 @@ describe("API Tests", () => {
"application/json"
);
const statusJson = await response.json();
const statusJson = (await response.json()) as APIStatus;
expect(statusJson.id).toBe(status?.id);
expect(statusJson.content).toBeDefined();
@ -718,7 +742,7 @@ describe("API Tests", () => {
"application/json"
);
const instance: APIInstance = await response.json();
const instance = (await response.json()) as APIInstance;
expect(instance.uri).toBe(new URL(config.http.base_url).hostname);
expect(instance.title).toBeDefined();
@ -764,7 +788,7 @@ describe("API Tests", () => {
"application/json"
);
const emojis: APIEmoji[] = await response.json();
const emojis = (await response.json()) as APIEmoji[];
expect(emojis.length).toBeGreaterThan(0);
expect(emojis[0].shortcode).toBe("test");
@ -774,26 +798,4 @@ describe("API Tests", () => {
await Emoji.delete({ shortcode: "test" });
});
});
afterAll(async () => {
const activities = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'actor' = :actor", {
actor: `${config.http.base_url}/users/test`,
})
.leftJoinAndSelect("activity.objects", "objects")
.getMany();
// Delete all created objects and activities as part of testing
await Promise.all(
activities.map(async activity => {
await Promise.all(
activity.objects.map(async object => await object.remove())
);
await activity.remove();
})
);
await user.remove();
await user2.remove();
});
});

View file

@ -37,4 +37,6 @@ describe("Instance", () => {
afterAll(async () => {
await instance.remove();
await AppDataSource.destroy();
});

View file

@ -1,17 +1,27 @@
import { getConfig } from "@config";
import { ConfigType, 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();
const originalConfig = getConfig();
const modifiedConfig: ConfigType = {
...originalConfig,
media: {
...originalConfig.media,
conversion: {
...originalConfig.media.conversion,
convert_images: false,
},
},
};
describe("LocalBackend", () => {
let localBackend: LocalBackend;
let fileName: string;
beforeAll(() => {
localBackend = new LocalBackend();
localBackend = new LocalBackend(modifiedConfig);
});
afterAll(async () => {
@ -25,7 +35,7 @@ describe("LocalBackend", () => {
});
const hash = await localBackend.addMedia(media);
fileName = `${hash}`;
fileName = hash;
expect(hash).toBeDefined();
});
@ -33,16 +43,14 @@ describe("LocalBackend", () => {
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");
const media = await localBackend.getMediaByHash(fileName);
expect(media).toBeInstanceOf(File);
});
it("should return null if the file does not exist", async () => {
const media = await localBackend.getMediaByHash(
"does-not-exist",
"txt"
);
const media =
await localBackend.getMediaByHash("does-not-exist.txt");
expect(media).toBeNull();
});
@ -50,12 +58,12 @@ describe("LocalBackend", () => {
});
describe("S3Backend", () => {
const s3Backend = new S3Backend(config);
const s3Backend = new S3Backend(modifiedConfig);
let fileName: string;
afterAll(async () => {
const command = new DeleteObjectCommand({
Bucket: config.s3.bucket_name,
Bucket: modifiedConfig.s3.bucket_name,
Key: fileName,
});
@ -69,7 +77,7 @@ describe("S3Backend", () => {
});
const hash = await s3Backend.addMedia(media);
fileName = `${hash}`;
fileName = hash;
expect(hash).toBeDefined();
});
@ -77,16 +85,13 @@ describe("S3Backend", () => {
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");
const media = await s3Backend.getMediaByHash(fileName);
expect(media).toBeInstanceOf(File);
});
it("should return null if the file does not exist", async () => {
const media = await s3Backend.getMediaByHash(
"does-not-exist",
"txt"
);
const media = await s3Backend.getMediaByHash("does-not-exist.txt");
expect(media).toBeNull();
});

View file

@ -210,6 +210,7 @@ describe("POST /@test/inbox", () => {
manuallyApprovesFollowers: false,
followers: `${config.http.base_url}/users/test/followers`,
following: `${config.http.base_url}/users/test/following`,
published: expect.any(String),
name: "",
"@context": [
"https://www.w3.org/ns/activitystreams",
@ -217,11 +218,11 @@ describe("POST /@test/inbox", () => {
],
icon: {
type: "Image",
url: "",
url: expect.any(String),
},
image: {
type: "Image",
url: "",
url: expect.any(String),
},
inbox: `${config.http.base_url}/users/test/inbox`,
type: "Person",
@ -311,4 +312,6 @@ afterAll(async () => {
await Promise.all(tokens.map(async token => await token.remove()));
if (user) await user.remove();
await AppDataSource.destroy();
});

View file

@ -33,6 +33,7 @@ describe("POST /api/v1/apps/", () => {
formData.append("redirect_uris", "https://example.com");
formData.append("scopes", "read write");
// @ts-expect-error FormData works
const response = await fetch(`${config.http.base_url}/api/v1/apps/`, {
method: "POST",
body: formData,
@ -42,7 +43,7 @@ describe("POST /api/v1/apps/", () => {
expect(response.headers.get("content-type")).toBe("application/json");
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const json = await response.json();
const json = (await response.json()) as any;
expect(json).toEqual({
id: expect.any(String),
@ -67,6 +68,8 @@ describe("POST /auth/login/", () => {
formData.append("email", "test@test.com");
formData.append("password", "test");
// @ts-expect-error FormData works
const response = await fetch(
`${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
{
@ -96,13 +99,17 @@ describe("POST /oauth/token/", () => {
formData.append("client_secret", client_secret);
formData.append("scope", "read+write");
// @ts-expect-error FormData works
const response = await fetch(`${config.http.base_url}/oauth/token/`, {
method: "POST",
headers: {
"Content-Type": "multipart/form-data",
},
body: formData,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const json = await response.json();
const json = (await response.json()) as any;
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
@ -134,7 +141,7 @@ describe("GET /api/v1/apps/verify_credentials", () => {
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const credentials: Partial<Application> = await response.json();
const credentials = (await response.json()) as Partial<Application>;
expect(credentials.name).toBe("Test Application");
expect(credentials.website).toBe("https://example.com");
@ -167,4 +174,6 @@ afterAll(async () => {
);
if (user) await user.remove();
await AppDataSource.destroy();
});

View file

@ -38,11 +38,11 @@ export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
// If a file, set as a file
if (value instanceof File) {
data[key] = value;
} else {
// Otherwise, set as a string
// eslint-disable-next-line @typescript-eslint/no-base-to-string
data[key] = value.toString();
}
// Otherwise, set as a string
// eslint-disable-next-line @typescript-eslint/no-base-to-string
data[key] = value.toString();
}
}