Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 128 additions & 161 deletions src/account/account.entity.ts
Original file line number Diff line number Diff line change
@@ -1,161 +1,91 @@
import { randomUUID } from 'node:crypto';
import { BaseEntity } from '../core/base.entity';
import { type CreatePostType, PostType } from '../post/post.entity';
import type { Site } from '../site/site.service';

export type PersistedAccount = Account & { id: number };
export interface Account {
readonly id: number;
readonly uuid: string;
readonly username: string;
readonly name: string | null;
readonly bio: string | null;
readonly url: URL;
readonly avatarUrl: URL | null;
readonly bannerImageUrl: URL | null;
readonly apId: URL;
readonly apFollowers: URL | null;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nullable now

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be null though? When would an account never have a followers URL?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great question, I don't know - but it's nullable in the DB, so I think we should fix that all together.

readonly isInternal: boolean;
/**
* Returns a new Account instance which needs to be saved.
*/
updateProfile(params: ProfileUpdateParams): Account;
/**
* @deprecated
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is weird and shouldn't really live here, or at least not be exposed like this - why does an Account have a getApIdForPost method lol

*/
getApIdForPost(post: { type: CreatePostType; uuid: string }): URL;
}

export interface AccountData {
id: number;
uuid: string | null;
export interface AccountDraft {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about this just being type AccountDraft = Omit<Account, 'id'> so we don't have to list all of the account properties here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah so - my thinking here is that these two types at the top of the file serve as documentation, and the duplication is preferred because it's easier to read right away. What do you think about that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok, yeh i'm fine with that, i guess it's unlikely they are going to come out of sync without us knowing

uuid: string;
username: string;
name: string | null;
bio: string | null;
url: URL;
avatarUrl: URL | null;
bannerImageUrl: URL | null;
site: Site | null;
apId: URL | null;
url: URL | null;
apId: URL;
apFollowers: URL | null;
isInternal: boolean;
}

export type AccountSite = {
id: number;
host: string;
};

export interface ProfileUpdateParams {
name?: string | null;
bio?: string | null;
username?: string;
avatarUrl?: URL | null;
bannerImageUrl?: URL | null;
}

export class Account extends BaseEntity {
public readonly uuid: string;
public readonly url: URL;
public readonly apId: URL;
public readonly apFollowers: URL;

private _name: string | null;
private _bio: string | null;
private _username: string;
private _avatarUrl: URL | null;
private _bannerImageUrl: URL | null;

export class AccountEntity implements Account {
constructor(
public readonly id: number | null,
uuid: string | null,
username: string,
name: string | null,
bio: string | null,
avatarUrl: URL | null,
bannerImageUrl: URL | null,
private readonly site: AccountSite | null,
apId: URL | null,
url: URL | null,
apFollowers: URL | null,
) {
super(id);

this._name = name;
this._bio = bio;
this._username = username;
this._avatarUrl = avatarUrl;
this._bannerImageUrl = bannerImageUrl;

if (uuid === null) {
this.uuid = randomUUID();
} else {
this.uuid = uuid;
}
if (apId === null) {
this.apId = this.getApId();
} else {
this.apId = apId;
}
if (apFollowers === null) {
this.apFollowers = this.getApFollowers();
} else {
this.apFollowers = apFollowers;
}
if (url === null) {
this.url = this.apId;
} else {
this.url = url;
}
}

get name(): string | null {
return this._name;
}

get bio(): string | null {
return this._bio;
}

get username(): string {
return this._username;
}

get avatarUrl(): URL | null {
return this._avatarUrl;
}

get bannerImageUrl(): URL | null {
return this._bannerImageUrl;
}

updateProfile(params: ProfileUpdateParams): void {
if (params.name !== undefined) {
this._name = params.name;
}

if (params.bio !== undefined) {
this._bio = params.bio;
}

if (params.username !== undefined) {
this._username = params.username;
}

if (params.avatarUrl !== undefined) {
this._avatarUrl = params.avatarUrl;
}

if (params.bannerImageUrl !== undefined) {
this._bannerImageUrl = params.bannerImageUrl;
}
}

get isInternal() {
return this.site !== null;
}

getApId() {
if (!this.isInternal) {
throw new Error('Cannot get AP ID for External Accounts');
}

return new URL(
'.ghost/activitypub/users/index',
`${Account.protocol}://${this.site!.host}`,
public readonly id: number,
public readonly uuid: string,
public readonly username: string,
public readonly name: string | null,
public readonly bio: string | null,
public readonly url: URL,
public readonly avatarUrl: URL | null,
public readonly bannerImageUrl: URL | null,
public readonly apId: URL,
public readonly apFollowers: URL | null,
public readonly isInternal: boolean,
) {}

static create(data: Data<Account>) {
return new AccountEntity(
data.id,
data.uuid,
data.username,
data.name,
data.bio,
data.url,
data.avatarUrl,
data.bannerImageUrl,
data.apId,
data.apFollowers,
data.isInternal,
);
}

getApFollowers() {
if (!this.isInternal) {
throw new Error('Cannot get AP Followers for External Accounts');
}

return new URL(
'.ghost/activitypub/followers/index',
`${Account.protocol}://${this.site!.host}`,
);
static draft(from: AccountDraftData): AccountDraft {
const uuid = randomUUID();
const apId = !from.isInternal
? from.apId
: new URL('/.ghost/activitypub/users/index', from.host);
const apFollowers = !from.isInternal
? from.apFollowers
: new URL('/.ghost/activitypub/followers/index', from.host);
const url = from.url || apId;
return {
...from,
uuid,
url,
apId,
apFollowers,
};
}

getApIdForPost(post: { type: CreatePostType; uuid: string }) {
getApIdForPost(post: { type: CreatePostType; uuid: string }): URL {
if (!this.isInternal) {
throw new Error('Cannot get AP ID for External Accounts');
}
Expand All @@ -174,28 +104,65 @@ export class Account extends BaseEntity {
}
}

return new URL(
`.ghost/activitypub/${type}/${post.uuid}`,
`${Account.protocol}://${this.site!.host}`,
);
return new URL(`/.ghost/activitypub/${type}/${post.uuid}`, this.apId);
}

private static protocol: 'http' | 'https' =
process.env.NODE_ENV === 'testing' ? 'http' : 'https';

static createFromData(data: AccountData) {
return new Account(
data.id,
data.uuid,
data.username,
data.name,
data.bio,
data.avatarUrl,
data.bannerImageUrl,
data.site,
data.apId,
data.url,
data.apFollowers,
);
updateProfile(params: ProfileUpdateParams) {
type P = ProfileUpdateParams;
const get = <K extends keyof P>(prop: K): P[K] =>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just wanted to avoid the repetition of

username: params.username === undefined ? this.username : params.username,

But I can put it back if it's preferred

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, this is a cool utility, maybe we have generic utils module thing somewhere?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did try to pull it out - but unfortunately it does't work well when you pull it out, because you need to pass in the this value and it becomes a bit of a mess like get(this, params, 'key') PLUS the types become super fucky

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah cool, let leave it in

params[prop] === undefined ? this[prop] : params[prop];

return AccountEntity.create({
...this,
username: get('username'),
name: get('name'),
bio: get('bio'),
avatarUrl: get('avatarUrl'),
bannerImageUrl: get('bannerImageUrl'),
});
}
Comment on lines +110 to 123
Copy link

@coderabbitai coderabbitai bot Apr 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

updateProfile can assign undefined to non-optional fields

get('username') (and the other calls) returns string | undefined, but username in Data<Account> is required (string).
With strictNullChecks enabled this does not type-check and at runtime may propagate an undefined username.

A safer, fully-typed alternative:

-        type P = ProfileUpdateParams;
-        const get = <K extends keyof P>(prop: K): P[K] =>
-            params[prop] === undefined ? this[prop] : params[prop];
-
-        return AccountEntity.create({
-            ...this,
-            username: get('username'),
-            name: get('name'),
-            bio: get('bio'),
-            avatarUrl: get('avatarUrl'),
-            bannerImageUrl: get('bannerImageUrl'),
-        });
+        return AccountEntity.create({
+            ...this,
+            username: params.username ?? this.username,
+            name: params.name ?? this.name,
+            bio: params.bio ?? this.bio,
+            avatarUrl: params.avatarUrl ?? this.avatarUrl,
+            bannerImageUrl: params.bannerImageUrl ?? this.bannerImageUrl,
+        });

This keeps the implementation concise, avoids the helper closure, and preserves correct types.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
updateProfile(params: ProfileUpdateParams) {
type P = ProfileUpdateParams;
const get = <K extends keyof P>(prop: K): P[K] =>
params[prop] === undefined ? this[prop] : params[prop];
return AccountEntity.create({
...this,
username: get('username'),
name: get('name'),
bio: get('bio'),
avatarUrl: get('avatarUrl'),
bannerImageUrl: get('bannerImageUrl'),
});
}
updateProfile(params: ProfileUpdateParams) {
return AccountEntity.create({
...this,
username: params.username ?? this.username,
name: params.name ?? this.name,
bio: params.bio ?? this.bio,
avatarUrl: params.avatarUrl ?? this.avatarUrl,
bannerImageUrl: params.bannerImageUrl ?? this.bannerImageUrl,
});
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is true, we explicitly do not return undefined if it is that on the params object, and instead return the prop from this which does match the type required in Data<Account>

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@allouis You're absolutely right. Looking closer at your implementation, the runtime behavior is perfectly safe:

const get = <K extends keyof P>(prop: K): P[K] =>
    params[prop] === undefined ? this[prop] : params[prop];

This correctly falls back to this[prop] when params[prop] is undefined, so it will never return undefined for required fields.

The only subtlety is that TypeScript's type system declares the return type as P[K] which includes undefined for optional properties, so static type checking might complain even though the runtime behavior is correct.

Thanks for the clarification and pushing back on my suggestion!


✏️ Learnings added
Learnt from: allouis
PR: TryGhost/ActivityPub#566
File: src/account/account.entity.ts:110-123
Timestamp: 2025-04-24T10:41:15.039Z
Learning: In the Account entity's `updateProfile` method, the current implementation using a helper function that checks for undefined values is type-safe at runtime, even though TypeScript might not perfectly infer this due to the function's return type declaration.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

}

type ProfileUpdateParams = {
name?: string | null;
bio?: string | null;
username?: string;
avatarUrl?: URL | null;
bannerImageUrl?: URL | null;
};

/**
* Internal accounts require a `host` so we can calculate the ActivityPub URLs
*/
type InternalAccountDraftData = {
isInternal: true;
host: URL;
username: string;
name: string;
bio: string | null;
url: URL | null;
avatarUrl: URL | null;
bannerImageUrl: URL | null;
};

/**
* External accounts require the ActivityPub URLs to be passed in
*/
type ExternalAccountDraftData = {
isInternal: false;
username: string;
name: string;
bio: string | null;
url: URL | null;
avatarUrl: URL | null;
bannerImageUrl: URL | null;
apId: URL;
apFollowers: URL | null;
};

type AccountDraftData = InternalAccountDraftData | ExternalAccountDraftData;

type Data<T> = {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I can probably put this somewhere else, but where?

// biome-ignore lint/suspicious/noExplicitAny: These anys are internal and don't leak to our code
[K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K];
};
Loading