mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
Media upload for avatars and banners, more work, fix tests
This commit is contained in:
parent
16cfd5d900
commit
460b68c381
169
classes/media.ts
169
classes/media.ts
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,4 +37,6 @@ describe("Instance", () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await instance.remove();
|
||||
|
||||
await AppDataSource.destroy();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,13 +38,13 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue