refactor: 🚨 Turn every linter rule on and fix issues (there were a LOT :3)

This commit is contained in:
Jesse Wierzbinski 2024-06-12 16:26:43 -10:00
parent 2e98859153
commit a1e02d0d78
No known key found for this signature in database
177 changed files with 1826 additions and 1248 deletions

View file

@ -3,7 +3,7 @@ import { z } from "zod";
import { ADMIN_ROLES, DEFAULT_ROLES, RolePermissions } from "~/drizzle/schema";
export enum MediaBackendType {
LOCAL = "local",
Local = "local",
S3 = "s3",
}
@ -220,7 +220,7 @@ export const configValidator = z.object({
.object({
backend: z
.nativeEnum(MediaBackendType)
.default(MediaBackendType.LOCAL),
.default(MediaBackendType.Local),
deduplicate_media: z.boolean().default(true),
local_uploads_folder: z.string().min(1).default("uploads"),
conversion: z
@ -234,7 +234,7 @@ export const configValidator = z.object({
}),
})
.default({
backend: MediaBackendType.LOCAL,
backend: MediaBackendType.Local,
deduplicate_media: true,
local_uploads_folder: "uploads",
conversion: {

View file

@ -6,8 +6,6 @@
*/
import { loadConfig, watchConfig } from "c12";
import chalk from "chalk";
import { fromError } from "zod-validation-error";
import { type Config, configValidator } from "./config.type";
const { config } = await watchConfig({
@ -23,21 +21,6 @@ const { config } = await watchConfig({
const parsed = await configValidator.safeParseAsync(config);
if (!parsed.success) {
console.log(
`${chalk.bgRed.white(
" CRITICAL ",
)} There was an error parsing the config file at ${chalk.bold(
"./config/config.toml",
)}. Please fix the file and try again.`,
);
console.log(
`${chalk.bgRed.white(
" CRITICAL ",
)} Follow the installation intructions and get a sample config file from the repository if needed.`,
);
console.log(
`${chalk.bgRed.white(" CRITICAL ")} ${fromError(parsed.error).message}`,
);
process.exit(1);
}

View file

@ -10,7 +10,7 @@ import {
} from "drizzle-orm";
import { db } from "~/drizzle/db";
import { Attachments } from "~/drizzle/schema";
import type { AsyncAttachment as APIAsyncAttachment } from "~/types/mastodon/async_attachment";
import type { AsyncAttachment as apiAsyncAttachment } from "~/types/mastodon/async_attachment";
import type { Attachment as APIAttachment } from "~/types/mastodon/attachment";
import { BaseInterface } from "./base";
@ -28,7 +28,9 @@ export class Attachment extends BaseInterface<typeof Attachments> {
}
public static async fromId(id: string | null): Promise<Attachment | null> {
if (!id) return null;
if (!id) {
return null;
}
return await Attachment.fromSql(eq(Attachments.id, id));
}
@ -46,7 +48,9 @@ export class Attachment extends BaseInterface<typeof Attachments> {
orderBy,
});
if (!found) return null;
if (!found) {
return null;
}
return new Attachment(found);
}
@ -86,7 +90,7 @@ export class Attachment extends BaseInterface<typeof Attachments> {
return updated.data;
}
async save(): Promise<AttachmentType> {
save(): Promise<AttachmentType> {
return this.update(this.data);
}
@ -120,52 +124,60 @@ export class Attachment extends BaseInterface<typeof Attachments> {
return this.data.id;
}
public toAPI(): APIAttachment | APIAsyncAttachment {
let type = "unknown";
public getMastodonType(): APIAttachment["type"] {
if (this.data.mimeType.startsWith("image/")) {
type = "image";
} else if (this.data.mimeType.startsWith("video/")) {
type = "video";
} else if (this.data.mimeType.startsWith("audio/")) {
type = "audio";
return "image";
}
if (this.data.mimeType.startsWith("video/")) {
return "video";
}
if (this.data.mimeType.startsWith("audio/")) {
return "audio";
}
return "unknown";
}
public toApiMeta(): APIAttachment["meta"] {
return {
id: this.data.id,
type: type as "image" | "video" | "audio" | "unknown",
url: proxyUrl(this.data.url) ?? "",
remote_url: proxyUrl(this.data.remoteUrl),
preview_url: proxyUrl(this.data.thumbnailUrl || this.data.url),
text_url: null,
meta: {
width: this.data.width || undefined,
height: this.data.height || undefined,
fps: this.data.fps || undefined,
size:
this.data.width && this.data.height
? `${this.data.width}x${this.data.height}`
: undefined,
duration: this.data.duration || undefined,
length: undefined,
aspect:
this.data.width && this.data.height
? this.data.width / this.data.height
: undefined,
original: {
width: this.data.width || undefined,
height: this.data.height || undefined,
fps: this.data.fps || undefined,
size:
this.data.width && this.data.height
? `${this.data.width}x${this.data.height}`
: undefined,
duration: this.data.duration || undefined,
length: undefined,
aspect:
this.data.width && this.data.height
? this.data.width / this.data.height
: undefined,
original: {
width: this.data.width || undefined,
height: this.data.height || undefined,
size:
this.data.width && this.data.height
? `${this.data.width}x${this.data.height}`
: undefined,
aspect:
this.data.width && this.data.height
? this.data.width / this.data.height
: undefined,
},
// Idk whether size or length is the right value
},
// Idk whether size or length is the right value
};
}
public toApi(): APIAttachment | apiAsyncAttachment {
return {
id: this.data.id,
type: this.getMastodonType(),
url: proxyUrl(this.data.url) ?? "",
remote_url: proxyUrl(this.data.remoteUrl),
preview_url: proxyUrl(this.data.thumbnailUrl || this.data.url),
text_url: null,
meta: this.toApiMeta(),
description: this.data.description,
blurhash: this.data.blurhash,
};

View file

@ -19,21 +19,21 @@ import { LogLevel } from "log-manager";
import { createRegExp, exactly, global } from "magic-regexp";
import {
type Application,
applicationToAPI,
} from "~/database/entities/Application";
applicationToApi,
} from "~/database/entities/application";
import {
type EmojiWithInstance,
emojiToAPI,
emojiToApi,
emojiToLysand,
fetchEmoji,
parseEmojis,
} from "~/database/entities/Emoji";
import { localObjectURI } from "~/database/entities/Federation";
} from "~/database/entities/emoji";
import { localObjectUri } from "~/database/entities/federation";
import {
type StatusWithRelations,
contentToHtml,
findManyNotes,
} from "~/database/entities/Status";
} from "~/database/entities/status";
import { db } from "~/drizzle/db";
import {
Attachments,
@ -44,8 +44,8 @@ import {
Users,
} from "~/drizzle/schema";
import { config } from "~/packages/config-manager";
import type { Attachment as APIAttachment } from "~/types/mastodon/attachment";
import type { Status as APIStatus } from "~/types/mastodon/status";
import type { Attachment as apiAttachment } from "~/types/mastodon/attachment";
import type { Status as apiStatus } from "~/types/mastodon/status";
import { Attachment } from "./attachment";
import { BaseInterface } from "./base";
import { User } from "./user";
@ -54,7 +54,7 @@ import { User } from "./user";
* Gives helpers to fetch notes from database in a nice format
*/
export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
async save(): Promise<StatusWithRelations> {
save(): Promise<StatusWithRelations> {
return this.update(this.data);
}
@ -87,7 +87,9 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
id: string | null,
userRequestingNoteId?: string,
): Promise<Note | null> {
if (!id) return null;
if (!id) {
return null;
}
return await Note.fromSql(
eq(Notes.id, id),
@ -123,7 +125,9 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
userId,
);
if (!found[0]) return null;
if (!found[0]) {
return null;
}
return new Note(found[0]);
}
@ -225,7 +229,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
);
}
async isRemote() {
isRemote() {
return this.author.isRemote();
}
@ -248,14 +252,14 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
static async fromData(
author: User,
content: typeof EntityValidator.$ContentFormat,
visibility: APIStatus["visibility"],
is_sensitive: boolean,
spoiler_text: string,
visibility: apiStatus["visibility"],
isSensitive: boolean,
spoilerText: string,
emojis: EmojiWithInstance[],
uri?: string,
mentions?: User[],
/** List of IDs of database Attachment objects */
media_attachments?: string[],
mediaAttachments?: string[],
replyId?: string,
quoteId?: string,
application?: Application,
@ -284,8 +288,8 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
"",
contentType: "text/html",
visibility,
sensitive: is_sensitive,
spoilerText: await sanitizedHtmlStrip(spoiler_text),
sensitive: isSensitive,
spoilerText: await sanitizedHtmlStrip(spoilerText),
uri: uri || null,
replyId: replyId ?? null,
quotingId: quoteId ?? null,
@ -315,13 +319,13 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
}
// Set attachment parents
if (media_attachments && media_attachments.length > 0) {
if (mediaAttachments && mediaAttachments.length > 0) {
await db
.update(Attachments)
.set({
noteId: newNote.id,
})
.where(inArray(Attachments.id, media_attachments));
.where(inArray(Attachments.id, mediaAttachments));
}
// Send notifications for mentioned local users
@ -341,13 +345,13 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
async updateFromData(
content?: typeof EntityValidator.$ContentFormat,
visibility?: APIStatus["visibility"],
is_sensitive?: boolean,
spoiler_text?: string,
visibility?: apiStatus["visibility"],
isSensitive?: boolean,
spoilerText?: string,
emojis: EmojiWithInstance[] = [],
mentions: User[] = [],
/** List of IDs of database Attachment objects */
media_attachments: string[] = [],
mediaAttachments: string[] = [],
replyId?: string,
quoteId?: string,
application?: Application,
@ -378,8 +382,8 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
: undefined,
contentType: "text/html",
visibility,
sensitive: is_sensitive,
spoilerText: spoiler_text,
sensitive: isSensitive,
spoilerText: spoilerText,
replyId,
quotingId: quoteId,
applicationId: application?.id,
@ -416,7 +420,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
}
// Set attachment parents
if (media_attachments) {
if (mediaAttachments) {
await db
.update(Attachments)
.set({
@ -424,13 +428,14 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
})
.where(eq(Attachments.noteId, this.data.id));
if (media_attachments.length > 0)
if (mediaAttachments.length > 0) {
await db
.update(Attachments)
.set({
noteId: this.data.id,
})
.where(inArray(Attachments.id, media_attachments));
.where(inArray(Attachments.id, mediaAttachments));
}
}
return await Note.fromId(newNote.id, newNote.authorId);
@ -443,13 +448,15 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
// Check if note not already in database
const foundNote = uri && (await Note.fromSql(eq(Notes.uri, uri)));
if (foundNote) return foundNote;
if (foundNote) {
return foundNote;
}
// Check if URI is of a local note
if (uri?.startsWith(config.http.base_url)) {
const uuid = uri.match(idValidator);
if (!uuid || !uuid[0]) {
if (!uuid?.[0]) {
throw new Error(
`URI ${uri} is of a local note, but it could not be parsed`,
);
@ -465,7 +472,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
uri?: string,
providedNote?: typeof EntityValidator.$Note,
): Promise<Note | null> {
if (!uri && !providedNote) {
if (!(uri || providedNote)) {
throw new Error("No URI or note provided");
}
@ -515,7 +522,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
attachment,
).catch((e) => {
dualLogger.logError(
LogLevel.ERROR,
LogLevel.Error,
"Federation.StatusResolver",
e,
);
@ -533,7 +540,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
?.emojis ?? []) {
const resolvedEmoji = await fetchEmoji(emoji).catch((e) => {
dualLogger.logError(
LogLevel.ERROR,
LogLevel.Error,
"Federation.StatusResolver",
e,
);
@ -552,7 +559,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
content: "",
},
},
note.visibility as APIStatus["visibility"],
note.visibility as apiStatus["visibility"],
note.is_sensitive ?? false,
note.subject ?? "",
emojis,
@ -582,7 +589,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
content: "",
},
},
note.visibility as APIStatus["visibility"],
note.visibility as apiStatus["visibility"],
note.is_sensitive ?? false,
note.subject ?? "",
emojis,
@ -638,9 +645,15 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
* @returns Whether this status is viewable by the user.
*/
async isViewableByUser(user: User | null) {
if (this.author.id === user?.id) return true;
if (this.data.visibility === "public") return true;
if (this.data.visibility === "unlisted") return true;
if (this.author.id === user?.id) {
return true;
}
if (this.data.visibility === "public") {
return true;
}
if (this.data.visibility === "unlisted") {
return true;
}
if (this.data.visibility === "private") {
return user
? await db.query.Relationships.findFirst({
@ -658,7 +671,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
);
}
async toAPI(userFetching?: User | null): Promise<APIStatus> {
async toApi(userFetching?: User | null): Promise<apiStatus> {
const data = this.data;
// Convert mentions of local users from @username@host to @username
@ -696,18 +709,18 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
id: data.id,
in_reply_to_id: data.replyId || null,
in_reply_to_account_id: data.reply?.authorId || null,
account: this.author.toAPI(userFetching?.id === data.authorId),
account: this.author.toApi(userFetching?.id === data.authorId),
created_at: new Date(data.createdAt).toISOString(),
application: data.application
? applicationToAPI(data.application)
? applicationToApi(data.application)
: null,
card: null,
content: replacedContent,
emojis: data.emojis.map((emoji) => emojiToAPI(emoji)),
emojis: data.emojis.map((emoji) => emojiToApi(emoji)),
favourited: data.liked,
favourites_count: data.likeCount,
media_attachments: (data.attachments ?? []).map(
(a) => new Attachment(a).toAPI() as APIAttachment,
(a) => new Attachment(a).toApi() as apiAttachment,
),
mentions: data.mentions.map((mention) => ({
id: mention.id,
@ -725,7 +738,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
// TODO: Add polls
poll: null,
reblog: data.reblog
? await new Note(data.reblog as StatusWithRelations).toAPI(
? await new Note(data.reblog as StatusWithRelations).toApi(
userFetching,
)
: null,
@ -736,13 +749,13 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
spoiler_text: data.spoilerText,
tags: [],
uri: data.uri || this.getUri(),
visibility: data.visibility as APIStatus["visibility"],
visibility: data.visibility as apiStatus["visibility"],
url: data.uri || this.getMastoUri(),
bookmarked: false,
// @ts-expect-error Glitch-SOC extension
quote: data.quotingId
? (await Note.fromId(data.quotingId, userFetching?.id).then(
(n) => n?.toAPI(userFetching),
(n) => n?.toApi(userFetching),
)) ?? null
: null,
quote_id: data.quotingId || undefined,
@ -750,12 +763,14 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
}
getUri() {
return localObjectURI(this.data.id);
return localObjectUri(this.data.id);
}
static getUri(id?: string | null) {
if (!id) return null;
return localObjectURI(id);
if (!id) {
return null;
}
return localObjectUri(id);
}
getMastoUri() {

View file

@ -13,7 +13,7 @@ import {
userInfoRequest,
validateAuthResponse,
} from "oauth4webapi";
import type { Application } from "~/database/entities/Application";
import type { Application } from "~/database/entities/application";
import { db } from "~/drizzle/db";
import { type Applications, OpenIdAccounts } from "~/drizzle/schema";
import { config } from "~/packages/config-manager";
@ -21,13 +21,13 @@ import { config } from "~/packages/config-manager";
export class OAuthManager {
public issuer: (typeof config.oidc.providers)[0];
constructor(public issuer_id: string) {
constructor(public issuerId: string) {
const found = config.oidc.providers.find(
(provider) => provider.id === this.issuer_id,
(provider) => provider.id === this.issuerId,
);
if (!found) {
throw new Error(`Issuer ${this.issuer_id} not found`);
throw new Error(`Issuer ${this.issuerId} not found`);
}
this.issuer = found;
@ -48,7 +48,7 @@ export class OAuthManager {
}).then((res) => processDiscoveryResponse(issuerUrl, res));
}
async getParameters(
getParameters(
authServer: AuthorizationServer,
issuer: (typeof config.oidc.providers)[0],
currentUrl: URL,
@ -101,7 +101,7 @@ export class OAuthManager {
async getUserInfo(
authServer: AuthorizationServer,
issuer: (typeof config.oidc.providers)[0],
access_token: string,
accessToken: string,
sub: string,
) {
return await userInfoRequest(
@ -110,7 +110,7 @@ export class OAuthManager {
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
access_token,
accessToken,
).then(
async (res) =>
await processUserInfoResponse(
@ -125,7 +125,7 @@ export class OAuthManager {
);
}
async processOAuth2Error(
processOAuth2Error(
application: InferInsertModel<typeof Applications> | null,
) {
return {

View file

@ -27,7 +27,9 @@ export class Role extends BaseInterface<typeof Roles> {
}
public static async fromId(id: string | null): Promise<Role | null> {
if (!id) return null;
if (!id) {
return null;
}
return await Role.fromSql(eq(Roles.id, id));
}
@ -45,7 +47,9 @@ export class Role extends BaseInterface<typeof Roles> {
orderBy,
});
if (!found) return null;
if (!found) {
return null;
}
return new Role(found);
}
@ -123,7 +127,7 @@ export class Role extends BaseInterface<typeof Roles> {
return updated.data;
}
async save(): Promise<RoleType> {
save(): Promise<RoleType> {
return this.update(this.data);
}
@ -173,7 +177,7 @@ export class Role extends BaseInterface<typeof Roles> {
return this.data.id;
}
public toAPI() {
public toApi() {
return {
id: this.id,
name: this.data.name,

View file

@ -5,20 +5,20 @@ import { Note } from "./note";
import { User } from "./user";
enum TimelineType {
NOTE = "Note",
USER = "User",
Note = "Note",
User = "User",
}
export class Timeline {
export class Timeline<Type extends Note | User> {
constructor(private type: TimelineType) {}
static async getNoteTimeline(
static getNoteTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
userId?: string,
) {
return new Timeline(TimelineType.NOTE).fetchTimeline<Note>(
return new Timeline<Note>(TimelineType.Note).fetchTimeline(
sql,
limit,
url,
@ -26,19 +26,158 @@ export class Timeline {
);
}
static async getUserTimeline(
static getUserTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
) {
return new Timeline(TimelineType.USER).fetchTimeline<User>(
return new Timeline<User>(TimelineType.User).fetchTimeline(
sql,
limit,
url,
);
}
private async fetchTimeline<T>(
private async fetchObjects(
sql: SQL<unknown> | undefined,
limit: number,
userId?: string,
): Promise<Type[]> {
switch (this.type) {
case TimelineType.Note:
return (await Note.manyFromSql(
sql,
undefined,
limit,
undefined,
userId,
)) as Type[];
case TimelineType.User:
return (await User.manyFromSql(
sql,
undefined,
limit,
)) as Type[];
}
}
private async fetchLinkHeader(
objects: Type[],
url: string,
limit: number,
): Promise<string> {
const linkHeader = [];
const urlWithoutQuery = new URL(
new URL(url).pathname,
config.http.base_url,
).toString();
if (objects.length > 0) {
switch (this.type) {
case TimelineType.Note:
linkHeader.push(
...(await this.fetchNoteLinkHeader(
objects as Note[],
urlWithoutQuery,
limit,
)),
);
break;
case TimelineType.User:
linkHeader.push(
...(await this.fetchUserLinkHeader(
objects as User[],
urlWithoutQuery,
limit,
)),
);
break;
}
}
return linkHeader.join(", ");
}
private async fetchNoteLinkHeader(
notes: Note[],
urlWithoutQuery: string,
limit: number,
): Promise<string[]> {
const linkHeader = [];
const objectBefore = await Note.fromSql(gt(Notes.id, notes[0].data.id));
if (objectBefore) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${notes[0].data.id}>; rel="prev"`,
);
}
if (notes.length >= (limit ?? 20)) {
const objectAfter = await Note.fromSql(
gt(Notes.id, notes[notes.length - 1].data.id),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${notes[notes.length - 1].data.id}>; rel="next"`,
);
}
}
return linkHeader;
}
private async fetchUserLinkHeader(
users: User[],
urlWithoutQuery: string,
limit: number,
): Promise<string[]> {
const linkHeader = [];
const objectBefore = await User.fromSql(gt(Users.id, users[0].id));
if (objectBefore) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${users[0].id}>; rel="prev"`,
);
}
if (users.length >= (limit ?? 20)) {
const objectAfter = await User.fromSql(
gt(Users.id, users[users.length - 1].id),
);
if (objectAfter) {
linkHeader.push(
`<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${users[users.length - 1].id}>; rel="next"`,
);
}
}
return linkHeader;
}
private async fetchTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
userId?: string,
): Promise<{ link: string; objects: Type[] }> {
const objects = await this.fetchObjects(sql, limit, userId);
const link = await this.fetchLinkHeader(objects, url, limit);
switch (this.type) {
case TimelineType.Note:
return {
link,
objects: objects,
};
case TimelineType.User:
return {
link,
objects: objects,
};
}
}
/* private async fetchTimeline<T>(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
@ -48,7 +187,7 @@ export class Timeline {
const users: User[] = [];
switch (this.type) {
case TimelineType.NOTE:
case TimelineType.Note:
notes.push(
...(await Note.manyFromSql(
sql,
@ -59,7 +198,7 @@ export class Timeline {
)),
);
break;
case TimelineType.USER:
case TimelineType.User:
users.push(...(await User.manyFromSql(sql, undefined, limit)));
break;
}
@ -72,7 +211,7 @@ export class Timeline {
if (notes.length > 0) {
switch (this.type) {
case TimelineType.NOTE: {
case TimelineType.Note: {
const objectBefore = await Note.fromSql(
gt(Notes.id, notes[0].data.id),
);
@ -102,7 +241,7 @@ export class Timeline {
}
break;
}
case TimelineType.USER: {
case TimelineType.User: {
const objectBefore = await User.fromSql(
gt(Users.id, users[0].id),
);
@ -136,16 +275,16 @@ export class Timeline {
}
switch (this.type) {
case TimelineType.NOTE:
case TimelineType.Note:
return {
link: linkHeader.join(", "),
objects: notes as T[],
};
case TimelineType.USER:
case TimelineType.User:
return {
link: linkHeader.join(", "),
objects: users as T[],
};
}
}
} */
}

View file

@ -5,6 +5,8 @@ import { addUserToMeilisearch } from "@/meilisearch";
import { proxyUrl } from "@/response";
import { EntityValidator } from "@lysand-org/federation";
import {
type InferInsertModel,
type InferSelectModel,
type SQL,
and,
count,
@ -19,20 +21,21 @@ import {
} from "drizzle-orm";
import { htmlToText } from "html-to-text";
import {
emojiToAPI,
emojiToApi,
emojiToLysand,
fetchEmoji,
} from "~/database/entities/Emoji";
import { objectToInboxRequest } from "~/database/entities/Federation";
import { addInstanceIfNotExists } from "~/database/entities/Instance";
} from "~/database/entities/emoji";
import { objectToInboxRequest } from "~/database/entities/federation";
import { addInstanceIfNotExists } from "~/database/entities/instance";
import {
type UserWithRelations,
findFirstUser,
findManyUsers,
} from "~/database/entities/User";
} from "~/database/entities/user";
import { db } from "~/drizzle/db";
import {
EmojiToUser,
type Instances,
NoteToMentions,
Notes,
type RolePermissions,
@ -40,8 +43,8 @@ import {
Users,
} from "~/drizzle/schema";
import { type Config, config } from "~/packages/config-manager";
import type { Account as APIAccount } from "~/types/mastodon/account";
import type { Mention as APIMention } from "~/types/mastodon/mention";
import type { Account as apiAccount } from "~/types/mastodon/account";
import type { Mention as apiMention } from "~/types/mastodon/mention";
import { BaseInterface } from "./base";
import type { Note } from "./note";
import { Role } from "./role";
@ -61,7 +64,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
static async fromId(id: string | null): Promise<User | null> {
if (!id) return null;
if (!id) {
return null;
}
return await User.fromSql(eq(Users.id, id));
}
@ -79,7 +84,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
orderBy,
});
if (!found) return null;
if (!found) {
return null;
}
return new User(found);
}
@ -137,7 +144,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
// If admin, add admin permissions
.concat(this.data.isAdmin ? config.permissions.admin : [])
.reduce((acc, permission) => {
if (!acc.includes(permission)) acc.push(permission);
if (!acc.includes(permission)) {
acc.push(permission);
}
return acc;
}, [] as RolePermissions[])
);
@ -220,11 +229,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
)[0];
}
async save(): Promise<UserWithRelations> {
save(): Promise<UserWithRelations> {
return this.update(this.data);
}
async updateFromRemote() {
async updateFromRemote(): Promise<User> {
if (!this.isRemote()) {
throw new Error(
"Cannot refetch a local user (they are not remote)",
@ -233,16 +242,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const updated = await User.saveFromRemote(this.getUri());
if (!updated) {
throw new Error("User not found after update");
}
this.data = updated.data;
return this;
}
static async saveFromRemote(uri: string): Promise<User | null> {
static async saveFromRemote(uri: string): Promise<User> {
if (!URL.canParse(uri)) {
throw new Error(`Invalid URI to parse ${uri}`);
}
@ -274,102 +279,25 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
emojis.push(await fetchEmoji(emoji));
}
// Check if new user already exists
const foundUser = await User.fromSql(eq(Users.uri, data.uri));
// If it exists, simply update it
if (foundUser) {
await foundUser.update({
updatedAt: new Date().toISOString(),
endpoints: {
dislikes: data.dislikes,
featured: data.featured,
likes: data.likes,
followers: data.followers,
following: data.following,
inbox: data.inbox,
outbox: data.outbox,
},
avatar: data.avatar
? Object.entries(data.avatar)[0][1].content
: "",
header: data.header
? Object.entries(data.header)[0][1].content
: "",
displayName: data.display_name ?? "",
note: getBestContentType(data.bio).content,
publicKey: data.public_key.public_key,
});
// Add emojis
if (emojis.length > 0) {
await db
.delete(EmojiToUser)
.where(eq(EmojiToUser.userId, foundUser.id));
await db.insert(EmojiToUser).values(
emojis.map((emoji) => ({
emojiId: emoji.id,
userId: foundUser.id,
})),
);
}
return foundUser;
}
const newUser = (
await db
.insert(Users)
.values({
username: data.username,
uri: data.uri,
createdAt: new Date(data.created_at).toISOString(),
endpoints: {
dislikes: data.dislikes,
featured: data.featured,
likes: data.likes,
followers: data.followers,
following: data.following,
inbox: data.inbox,
outbox: data.outbox,
},
fields: data.fields ?? [],
updatedAt: new Date(data.created_at).toISOString(),
instanceId: instance.id,
avatar: data.avatar
? Object.entries(data.avatar)[0][1].content
: "",
header: data.header
? Object.entries(data.header)[0][1].content
: "",
displayName: data.display_name ?? "",
note: getBestContentType(data.bio).content,
publicKey: data.public_key.public_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
})
.returning()
)[0];
const user = await User.fromLysand(data, instance);
// Add emojis to user
if (emojis.length > 0) {
await db.delete(EmojiToUser).where(eq(EmojiToUser.userId, user.id));
await db.insert(EmojiToUser).values(
emojis.map((emoji) => ({
emojiId: emoji.id,
userId: newUser.id,
userId: user.id,
})),
);
}
const finalUser = await User.fromId(newUser.id);
const finalUser = await User.fromId(user.id);
if (!finalUser) return null;
if (!finalUser) {
throw new Error("Failed to save user from remote");
}
// Add to Meilisearch
await addUserToMeilisearch(finalUser);
@ -377,17 +305,86 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return finalUser;
}
static async fromLysand(
user: typeof EntityValidator.$User,
instance: InferSelectModel<typeof Instances>,
): Promise<User> {
const data = {
username: user.username,
uri: user.uri,
createdAt: new Date(user.created_at).toISOString(),
endpoints: {
dislikes: user.dislikes,
featured: user.featured,
likes: user.likes,
followers: user.followers,
following: user.following,
inbox: user.inbox,
outbox: user.outbox,
},
fields: user.fields ?? [],
updatedAt: new Date(user.created_at).toISOString(),
instanceId: instance.id,
avatar: user.avatar
? Object.entries(user.avatar)[0][1].content
: "",
header: user.header
? Object.entries(user.header)[0][1].content
: "",
displayName: user.display_name ?? "",
note: getBestContentType(user.bio).content,
publicKey: user.public_key.public_key,
source: {
language: null,
note: "",
privacy: "public",
sensitive: false,
fields: [],
},
};
// Check if new user already exists
const foundUser = await User.fromSql(eq(Users.uri, user.uri));
// If it exists, simply update it
if (foundUser) {
await foundUser.update(data);
return foundUser;
}
// Else, create a new user
return await User.insert(data);
}
public static async insert(
data: InferInsertModel<typeof Users>,
): Promise<User> {
const inserted = (await db.insert(Users).values(data).returning())[0];
const user = await User.fromId(inserted.id);
if (!user) {
throw new Error("Failed to insert user");
}
return user;
}
static async resolve(uri: string): Promise<User | null> {
// Check if user not already in database
const foundUser = await User.fromSql(eq(Users.uri, uri));
if (foundUser) return foundUser;
if (foundUser) {
return foundUser;
}
// Check if URI is of a local user
if (uri.startsWith(config.http.base_url)) {
const uuid = uri.match(idValidator);
if (!uuid || !uuid[0]) {
if (!uuid?.[0]) {
throw new Error(
`URI ${uri} is of a local user, but it could not be parsed`,
);
@ -405,11 +402,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @returns The raw URL for the user's avatar
*/
getAvatarUrl(config: Config) {
if (!this.data.avatar)
if (!this.data.avatar) {
return (
config.defaults.avatar ||
`https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${this.data.username}`
);
}
return this.data.avatar;
}
@ -480,7 +478,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const finalUser = await User.fromId(newUser.id);
if (!finalUser) return null;
if (!finalUser) {
return null;
}
// Add to Meilisearch
await addUserToMeilisearch(finalUser);
@ -494,7 +494,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @returns The raw URL for the user's header
*/
getHeaderUrl(config: Config) {
if (!this.data.header) return config.defaults.header || "";
if (!this.data.header) {
return config.defaults.header || "";
}
return this.data.header;
}
@ -561,7 +563,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
}
toAPI(isOwnAccount = false): APIAccount {
toApi(isOwnAccount = false): apiAccount {
const user = this.data;
return {
id: user.id,
@ -578,7 +580,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
followers_count: user.followerCount,
following_count: user.followingCount,
statuses_count: user.statusCount,
emojis: user.emojis.map((emoji) => emojiToAPI(emoji)),
emojis: user.emojis.map((emoji) => emojiToApi(emoji)),
fields: user.fields.map((field) => ({
name: htmlToText(getBestContentType(field.key).content),
value: getBestContentType(field.value).content,
@ -625,7 +627,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
]
: [],
)
.map((r) => r.toAPI()),
.map((r) => r.toApi()),
group: false,
};
}
@ -699,7 +701,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
};
}
toMention(): APIMention {
toMention(): apiMention {
return {
url: this.getUri(),
username: this.data.username,

View file

@ -2,12 +2,12 @@ import { join } from "node:path";
import { redirect } from "@/response";
import type { BunFile } from "bun";
import { config } from "config-manager";
import { retrieveUserFromToken } from "~/database/entities/User";
import { retrieveUserFromToken } from "~/database/entities/user";
import type { User } from "~/packages/database-interface/user";
import type { LogManager, MultiLogManager } from "~/packages/log-manager";
import { languages } from "./glitch-languages";
const handleManifestRequest = async () => {
const handleManifestRequest = () => {
const manifest = {
id: "/home",
name: config.instance.name,
@ -99,7 +99,7 @@ const handleManifestRequest = async () => {
const handleSignInRequest = async (
req: Request,
path: string,
_path: string,
url: URL,
user: User | null,
accessToken: string,
@ -156,7 +156,7 @@ const handleSignInRequest = async (
);
};
const handleSignOutRequest = async (req: Request) => {
const handleSignOutRequest = (req: Request) => {
if (req.method === "POST") {
return redirect("/api/auth/mastodon-logout", 307);
}
@ -176,7 +176,7 @@ const returnFile = async (file: BunFile, content?: string) => {
};
const handleDefaultRequest = async (
req: Request,
_req: Request,
path: string,
user: User | null,
accessToken: string,
@ -198,10 +198,10 @@ const handleDefaultRequest = async (
return null;
};
const brandingTransforms = async (
const brandingTransforms = (
fileContents: string,
accessToken: string,
user: User | null,
_accessToken: string,
_user: User | null,
) => {
let newFileContents = fileContents;
for (const server of config.frontend.glitch.server) {
@ -287,7 +287,7 @@ const htmlTransforms = async (
},
accounts: user
? {
[user.id]: user.toAPI(true),
[user.id]: user.toApi(true),
}
: {},
media_attachments: {
@ -327,7 +327,7 @@ const htmlTransforms = async (
export const handleGlitchRequest = async (
req: Request,
logger: LogManager | MultiLogManager,
_logger: LogManager | MultiLogManager,
): Promise<Response | null> => {
const url = new URL(req.url);
let path = url.pathname;
@ -336,7 +336,9 @@ export const handleGlitchRequest = async (
const user = await retrieveUserFromToken(accessToken ?? "");
// Strip leading /web from path
if (path.startsWith("/web")) path = path.slice(4);
if (path.startsWith("/web")) {
path = path.slice(4);
}
if (path === "/manifest") {
return handleManifestRequest();

View file

@ -5,19 +5,19 @@ import chalk from "chalk";
import { config } from "config-manager";
export enum LogLevel {
DEBUG = "debug",
INFO = "info",
WARNING = "warning",
ERROR = "error",
CRITICAL = "critical",
Debug = "debug",
Info = "info",
Warning = "warning",
Error = "error",
Critical = "critical",
}
const logOrder = [
LogLevel.DEBUG,
LogLevel.INFO,
LogLevel.WARNING,
LogLevel.ERROR,
LogLevel.CRITICAL,
LogLevel.Debug,
LogLevel.Info,
LogLevel.Warning,
LogLevel.Error,
LogLevel.Critical,
];
/**
@ -37,15 +37,15 @@ export class LogManager {
getLevelColor(level: LogLevel) {
switch (level) {
case LogLevel.DEBUG:
case LogLevel.Debug:
return chalk.blue;
case LogLevel.INFO:
case LogLevel.Info:
return chalk.green;
case LogLevel.WARNING:
case LogLevel.Warning:
return chalk.yellow;
case LogLevel.ERROR:
case LogLevel.Error:
return chalk.red;
case LogLevel.CRITICAL:
case LogLevel.Critical:
return chalk.bgRed;
}
}
@ -79,8 +79,9 @@ export class LogManager {
if (
logOrder.indexOf(level) <
logOrder.indexOf(config.logging.log_level as LogLevel)
)
) {
return;
}
if (this.enableColors) {
await this.write(
@ -102,9 +103,8 @@ export class LogManager {
}
private async write(text: string) {
Bun.stdout.name;
if (this.output === Bun.stdout) {
await console.log(`${text}`);
console.info(text);
} else {
if (!(await exists(this.output.name ?? ""))) {
// Create file if it doesn't exist
@ -128,17 +128,112 @@ export class LogManager {
* @param error Error to log
*/
async logError(level: LogLevel, entity: string, error: Error) {
error.stack && (await this.log(LogLevel.DEBUG, entity, error.stack));
error.stack && (await this.log(LogLevel.Debug, entity, error.stack));
await this.log(level, entity, error.message);
}
/**
* Logs the headers of a request
* @param req Request to log
*/
public logHeaders(req: Request): string {
let string = " [Headers]\n";
for (const [key, value] of req.headers.entries()) {
string += ` ${key}: ${value}\n`;
}
return string;
}
/**
* Logs the body of a request
* @param req Request to log
*/
async logBody(req: Request): Promise<string> {
let string = " [Body]\n";
const contentType = req.headers.get("Content-Type");
if (contentType?.includes("application/json")) {
string += await this.logJsonBody(req);
} else if (
contentType &&
(contentType.includes("application/x-www-form-urlencoded") ||
contentType.includes("multipart/form-data"))
) {
string += await this.logFormData(req);
} else {
const text = await req.text();
string += ` ${text}\n`;
}
return string;
}
/**
* Logs the JSON body of a request
* @param req Request to log
*/
async logJsonBody(req: Request): Promise<string> {
let string = "";
try {
const json = await req.clone().json();
const stringified = JSON.stringify(json, null, 4)
.split("\n")
.map((line) => ` ${line}`)
.join("\n");
string += `${stringified}\n`;
} catch {
string += ` [Invalid JSON] (raw: ${await req.clone().text()})\n`;
}
return string;
}
/**
* Logs the form data of a request
* @param req Request to log
*/
async logFormData(req: Request): Promise<string> {
let string = "";
const formData = await req.clone().formData();
for (const [key, value] of formData.entries()) {
if (value.toString().length < 300) {
string += ` ${key}: ${value.toString()}\n`;
} else {
string += ` ${key}: <${value.toString().length} bytes>\n`;
}
}
return string;
}
/**
* Logs a request to the output
* @param req Request to log
* @param ip IP of the request
* @param logAllDetails Whether to log all details of the request
*/
async logRequest(req: Request, ip?: string, logAllDetails = false) {
async logRequest(
req: Request,
ip?: string,
logAllDetails = false,
): Promise<void> {
let string = ip ? `${ip}: ` : "";
string += `${req.method} ${req.url}`;
if (logAllDetails) {
string += "\n";
string += await this.logHeaders(req);
string += await this.logBody(req);
}
await this.log(LogLevel.Info, "Request", string);
}
/*
* Logs a request to the output
* @param req Request to log
* @param ip IP of the request
* @param logAllDetails Whether to log all details of the request
*/
/**async logRequest(req: Request, ip?: string, logAllDetails = false) {
let string = ip ? `${ip}: ` : "";
string += `${req.method} ${req.url}`;
@ -153,9 +248,9 @@ export class LogManager {
// Pretty print body
string += " [Body]\n";
const content_type = req.headers.get("Content-Type");
const contentType = req.headers.get("Content-Type");
if (content_type?.includes("application/json")) {
if (contentType?.includes("application/json")) {
try {
const json = await req.clone().json();
const stringified = JSON.stringify(json, null, 4)
@ -170,9 +265,9 @@ export class LogManager {
.text()})\n`;
}
} else if (
content_type &&
(content_type.includes("application/x-www-form-urlencoded") ||
content_type.includes("multipart/form-data"))
contentType &&
(contentType.includes("application/x-www-form-urlencoded") ||
contentType.includes("multipart/form-data"))
) {
const formData = await req.clone().formData();
for (const [key, value] of formData.entries()) {
@ -189,8 +284,8 @@ export class LogManager {
string += ` ${text}\n`;
}
}
await this.log(LogLevel.INFO, "Request", string);
}
await this.log(LogLevel.Info, "Request", string);
} */
}
/**

View file

@ -36,7 +36,7 @@ describe("LogManager", () => {
});
*/
it("should log message with timestamp", async () => {
await logManager.log(LogLevel.INFO, "TestEntity", "Test message");
await logManager.log(LogLevel.Info, "TestEntity", "Test message");
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("[INFO] TestEntity: Test message"),
@ -45,7 +45,7 @@ describe("LogManager", () => {
it("should log message without timestamp", async () => {
await logManager.log(
LogLevel.INFO,
LogLevel.Info,
"TestEntity",
"Test message",
false,
@ -56,9 +56,10 @@ describe("LogManager", () => {
);
});
// biome-ignore lint/suspicious/noSkippedTests: I need to fix this :sob:
test.skip("should write to stdout", async () => {
logManager = new LogManager(Bun.stdout);
await logManager.log(LogLevel.INFO, "TestEntity", "Test message");
await logManager.log(LogLevel.Info, "TestEntity", "Test message");
const writeMock = jest.fn();
@ -75,7 +76,7 @@ describe("LogManager", () => {
it("should log error message", async () => {
const error = new Error("Test error");
await logManager.logError(LogLevel.ERROR, "TestEntity", error);
await logManager.logError(LogLevel.Error, "TestEntity", error);
expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name,
expect.stringContaining("[ERROR] TestEntity: Test error"),
@ -191,10 +192,10 @@ describe("MultiLogManager", () => {
});
it("should log message to all logManagers", async () => {
await multiLogManager.log(LogLevel.INFO, "TestEntity", "Test message");
await multiLogManager.log(LogLevel.Info, "TestEntity", "Test message");
expect(mockLog).toHaveBeenCalledTimes(2);
expect(mockLog).toHaveBeenCalledWith(
LogLevel.INFO,
LogLevel.Info,
"TestEntity",
"Test message",
true,
@ -203,10 +204,10 @@ describe("MultiLogManager", () => {
it("should log error to all logManagers", async () => {
const error = new Error("Test error");
await multiLogManager.logError(LogLevel.ERROR, "TestEntity", error);
await multiLogManager.logError(LogLevel.Error, "TestEntity", error);
expect(mockLogError).toHaveBeenCalledTimes(2);
expect(mockLogError).toHaveBeenCalledWith(
LogLevel.ERROR,
LogLevel.Error,
"TestEntity",
error,
);

View file

@ -4,7 +4,7 @@ import type { Config } from "config-manager";
import { MediaConverter } from "./media-converter";
export enum MediaBackendType {
LOCAL = "local",
Local = "local",
S3 = "s3",
}
@ -35,12 +35,12 @@ export class MediaBackend {
public backend: MediaBackendType,
) {}
static async fromBackendType(
public static fromBackendType(
backend: MediaBackendType,
config: Config,
): Promise<MediaBackend> {
): MediaBackend {
switch (backend) {
case MediaBackendType.LOCAL:
case MediaBackendType.Local:
return new LocalMediaBackend(config);
case MediaBackendType.S3:
return new S3MediaBackend(config);
@ -64,15 +64,15 @@ export class MediaBackend {
* @returns The file as a File object
*/
public getFileByHash(
file: string,
databaseHashFetcher: (sha256: string) => Promise<string>,
_file: string,
_databaseHashFetcher: (sha256: string) => Promise<string>,
): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),
);
}
public deleteFileByUrl(url: string): Promise<void> {
public deleteFileByUrl(_url: string): Promise<void> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),
);
@ -83,7 +83,7 @@ export class MediaBackend {
* @param filename File name
* @returns The file as a File object
*/
public getFile(filename: string): Promise<File | null> {
public getFile(_filename: string): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),
);
@ -94,7 +94,7 @@ export class MediaBackend {
* @param file File to add
* @returns Metadata about the uploaded file
*/
public addFile(file: File): Promise<UploadedFileMetadata> {
public addFile(_file: File): Promise<UploadedFileMetadata> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass"),
);
@ -103,7 +103,7 @@ export class MediaBackend {
export class LocalMediaBackend extends MediaBackend {
constructor(config: Config) {
super(config, MediaBackendType.LOCAL);
super(config, MediaBackendType.Local);
}
public async addFile(file: File) {
@ -164,7 +164,9 @@ export class LocalMediaBackend extends MediaBackend {
): Promise<File | null> {
const filename = await databaseHashFetcher(hash);
if (!filename) return null;
if (!filename) {
return null;
}
return this.getFile(filename);
}
@ -174,7 +176,9 @@ export class LocalMediaBackend extends MediaBackend {
`${this.config.media.local_uploads_folder}/${filename}`,
);
if (!(await file.exists())) return null;
if (!(await file.exists())) {
return null;
}
return new File([await file.arrayBuffer()], filename, {
type: file.type,
@ -243,7 +247,9 @@ export class S3MediaBackend extends MediaBackend {
): Promise<File | null> {
const filename = await databaseHashFetcher(hash);
if (!filename) return null;
if (!filename) {
return null;
}
return this.getFile(filename);
}

View file

@ -36,7 +36,7 @@ describe("MediaBackend", () => {
describe("fromBackendType", () => {
it("should return a LocalMediaBackend instance for LOCAL backend type", async () => {
const backend = await MediaBackend.fromBackendType(
MediaBackendType.LOCAL,
MediaBackendType.Local,
mockConfig,
);
expect(backend).toBeInstanceOf(LocalMediaBackend);
@ -62,8 +62,8 @@ describe("MediaBackend", () => {
it("should throw an error for unknown backend type", () => {
expect(
// @ts-expect-error This is a test
MediaBackend.fromBackendType("unknown", mockConfig),
).rejects.toThrow("Unknown backend type: unknown");
() => MediaBackend.fromBackendType("unknown", mockConfig),
).toThrow("Unknown backend type: unknown");
});
});
@ -228,7 +228,7 @@ describe("LocalMediaBackend", () => {
it("should initialize with correct type", () => {
expect(localMediaBackend.getBackendType()).toEqual(
MediaBackendType.LOCAL,
MediaBackendType.Local,
);
});