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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
DROP INDEX idx_accounts_username_webfinger_host_hash ON accounts;
DROP INDEX idx_accounts_webfinger_host_hash ON accounts;
DROP INDEX idx_accounts_fulltext_search ON accounts;
CREATE FULLTEXT INDEX idx_accounts_fulltext_search
ON accounts(name, username, domain);

ALTER TABLE accounts
DROP COLUMN webfinger_host_hash,
DROP COLUMN webfinger_host;
14 changes: 14 additions & 0 deletions migrate/migrations/000081_add-webfinger-host-to-accounts.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ALTER TABLE accounts
ADD COLUMN webfinger_host VARCHAR(255) NULL,
ADD COLUMN webfinger_host_hash BINARY(32)
GENERATED ALWAYS AS (UNHEX(SHA2(LOWER(webfinger_host), 256))) STORED;

DROP INDEX idx_accounts_fulltext_search ON accounts;
CREATE FULLTEXT INDEX idx_accounts_fulltext_search
ON accounts(name, username, domain, webfinger_host);

CREATE UNIQUE INDEX idx_accounts_username_webfinger_host_hash
ON accounts(username, webfinger_host_hash);

CREATE INDEX idx_accounts_webfinger_host_hash
ON accounts(webfinger_host_hash);
28 changes: 28 additions & 0 deletions src/account/account.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface Account {
readonly apLiked: URL | null;
readonly isInternal: boolean;
readonly customFields: Record<string, string> | null;
readonly webfingerHost: string | null;
unblock(account: Account): Account;
block(account: Account): Account;
blockDomain(domain: URL): Account;
Expand All @@ -43,6 +44,7 @@ export interface Account {
* Returns a new Account instance which needs to be saved.
*/
updateProfile(params: ProfileUpdateParams): Account;
setWebfingerHost(webfingerHost: string | null): Account;
addAlias(alias: URL): Account;
removeAlias(alias: URL): Account;
/**
Expand Down Expand Up @@ -70,6 +72,7 @@ export interface AccountDraft {
apPublicKey: CryptoKey;
apPrivateKey: CryptoKey | null;
isInternal: boolean;
webfingerHost: string | null;
}

export type AccountEvent = {
Expand All @@ -94,6 +97,7 @@ export class AccountEntity implements Account {
public readonly apLiked: URL | null,
public readonly isInternal: boolean,
public readonly customFields: Record<string, string> | null,
public readonly webfingerHost: string | null,
private events: AccountEvent[],
) {}

Expand Down Expand Up @@ -124,6 +128,7 @@ export class AccountEntity implements Account {
data.apLiked,
data.isInternal,
data.customFields,
data.webfingerHost,
events,
);
}
Expand All @@ -147,6 +152,7 @@ export class AccountEntity implements Account {
draft.apLiked,
draft.isInternal,
draft.customFields,
draft.webfingerHost,
events,
);
}
Expand Down Expand Up @@ -174,6 +180,7 @@ export class AccountEntity implements Account {
: new URL('/.ghost/activitypub/liked/index', from.host);
const url = from.url || apId;
const apPrivateKey = !from.isInternal ? null : from.apPrivateKey;
const webfingerHost = from.webfingerHost ?? null;

return {
...from,
Expand All @@ -187,6 +194,7 @@ export class AccountEntity implements Account {
apFollowing,
apLiked,
apPrivateKey,
webfingerHost,
};
}

Expand Down Expand Up @@ -258,6 +266,24 @@ export class AccountEntity implements Account {
);
}

setWebfingerHost(webfingerHost: string | null): Account {
const account = AccountEntity.create(
{
...this,
webfingerHost,
},
this.events,
);

if (account.webfingerHost !== this.webfingerHost) {
account.events = account.events.concat(
new AccountUpdatedEvent(account.id),
);
}

return account;
}

removeAlias(alias: URL): Account {
return AccountEntity.create(
this,
Expand Down Expand Up @@ -355,6 +381,7 @@ type InternalAccountDraftData = {
customFields: Record<string, string> | null;
apPublicKey: CryptoKey;
apPrivateKey: CryptoKey;
webfingerHost?: string | null;
};

/**
Expand All @@ -377,6 +404,7 @@ type ExternalAccountDraftData = {
apFollowing: URL | null;
apLiked: URL | null;
apPublicKey: CryptoKey;
webfingerHost?: string | null;
};

type AccountDraftData = InternalAccountDraftData | ExternalAccountDraftData;
Expand Down
69 changes: 69 additions & 0 deletions src/account/account.repository.knex.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,75 @@ describe('KnexAccountRepository', () => {
fromDraftSpy.mockRestore();
});

it('persists and resolves a custom WebFinger host', async () => {
const site = await fixtureManager.createSite('blog.example.com');
const draftData = await createInternalAccountDraftData({
host: new URL(`https://${site.host}`),
username: 'index',
name: 'Test',
bio: null,
url: new URL(`https://${site.host}`),
avatarUrl: null,
bannerImageUrl: null,
customFields: null,
});

const account = await accountRepository.create(
AccountEntity.draft(draftData),
);
const updated = account.setWebfingerHost('example.com');

await accountRepository.save(updated);

const fetched = await accountRepository.getByWebfingerHandle(
'index',
'example.com',
);

expect(fetched?.id).toBe(account.id);
expect(fetched?.webfingerHost).toBe('example.com');
await expect(
accountRepository.hasWebfingerHandleConflict(
'index',
'example.com',
account.id,
),
).resolves.toBe(false);
});

it('resolves a custom WebFinger host by stable actor username', async () => {
const site = await fixtureManager.createSite('blog.example.com');
const draftData = await createInternalAccountDraftData({
host: new URL(`https://${site.host}`),
username: 'index',
name: 'Test',
bio: null,
url: new URL(`https://${site.host}`),
avatarUrl: null,
bannerImageUrl: null,
customFields: null,
});

const account = await accountRepository.create(
AccountEntity.draft(draftData),
);
const updated = account
.setWebfingerHost('example.com')
.updateProfile({ username: 'alice' });

await accountRepository.save(updated);

const fetched = await accountRepository.getByWebfingerHandle(
'index',
'example.com',
);

expect(fetched?.id).toBe(account.id);
expect(fetched?.username).toBe('alice');
expect(fetched?.apId.pathname.endsWith('/index')).toBe(true);
expect(fetched?.webfingerHost).toBe('example.com');
});

it('Handles events when creating an account', async () => {
const emitSpy = vi.spyOn(events, 'emitAsync');

Expand Down
84 changes: 83 additions & 1 deletion src/account/account.repository.knex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface AccountRow {
ap_liked_url: string | null;
custom_fields: Record<string, string> | null;
site_id: number | null;
webfinger_host: string | null;
}

export class KnexAccountRepository {
Expand Down Expand Up @@ -74,6 +75,7 @@ export class KnexAccountRepository {
? JSON.stringify(draft.customFields)
: null,
domain: draft.apId.hostname,
webfinger_host: draft.webfingerHost,
});

if (draft.isInternal) {
Expand Down Expand Up @@ -123,6 +125,7 @@ export class KnexAccountRepository {
custom_fields: account.customFields
? JSON.stringify(account.customFields)
: null,
webfinger_host: account.webfingerHost,
})
.where({ id: account.id });

Expand Down Expand Up @@ -266,7 +269,7 @@ export class KnexAccountRepository {
// We can safely assume that there is an account for the user due to
// the foreign key constraint on the users table
const accountRow = await this.db('accounts')
.where('id', user.account_id)
.where('accounts.id', user.account_id)
.select(
'accounts.id',
'accounts.uuid',
Expand All @@ -283,7 +286,10 @@ export class KnexAccountRepository {
'accounts.ap_following_url',
'accounts.ap_liked_url',
'accounts.custom_fields',
'accounts.webfinger_host',
'users.site_id',
)
.leftJoin('users', 'users.account_id', 'accounts.id')
.first();

if (!accountRow) {
Expand Down Expand Up @@ -313,6 +319,7 @@ export class KnexAccountRepository {
'accounts.ap_following_url',
'accounts.ap_liked_url',
'accounts.custom_fields',
'accounts.webfinger_host',
'users.site_id',
)
.first();
Expand Down Expand Up @@ -345,6 +352,7 @@ export class KnexAccountRepository {
'accounts.ap_following_url',
'accounts.ap_liked_url',
'users.site_id',
'accounts.webfinger_host',
)
.first();

Expand Down Expand Up @@ -378,6 +386,7 @@ export class KnexAccountRepository {
'accounts.ap_following_url',
'accounts.ap_liked_url',
'accounts.custom_fields',
'accounts.webfinger_host',
'users.site_id',
)
.first();
Expand Down Expand Up @@ -442,6 +451,78 @@ export class KnexAccountRepository {
return rows.map((row) => new URL(row.ap_id));
}

async getByWebfingerHandle(
username: string,
host: string,
): Promise<Account | null> {
const accountRow = await this.db('accounts')
.where((qb) => {
qb.whereRaw('LOWER(accounts.username) = LOWER(?)', [
username,
]).orWhereRaw(
"LOWER(SUBSTRING_INDEX(accounts.ap_id, '/', -1)) = LOWER(?)",
[username],
);
})
.whereRaw(
'accounts.webfinger_host_hash = UNHEX(SHA2(LOWER(?), 256))',
[host],
)
.leftJoin('users', 'users.account_id', 'accounts.id')
.select(
'accounts.id',
'accounts.uuid',
'accounts.username',
'accounts.name',
'accounts.bio',
'accounts.url',
'accounts.avatar_url',
'accounts.banner_image_url',
'accounts.ap_id',
'accounts.ap_followers_url',
'accounts.ap_inbox_url',
'accounts.ap_outbox_url',
'accounts.ap_following_url',
'accounts.ap_liked_url',
'accounts.custom_fields',
'accounts.webfinger_host',
'users.site_id',
)
.first();

if (!accountRow) {
return null;
}

return this.mapRowToAccountEntity(accountRow);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async hasWebfingerHandleConflict(
username: string,
host: string,
excludeAccountId: number,
): Promise<boolean> {
const row = await this.db('accounts')
.where('accounts.username', username)
.whereNot('accounts.id', excludeAccountId)
.where((qb) => {
qb.whereRaw(
'accounts.webfinger_host_hash = UNHEX(SHA2(LOWER(?), 256))',
[host],
).orWhere((fallbackQb) => {
fallbackQb
.whereNull('accounts.webfinger_host')
.whereRaw(
'(accounts.domain_hash = UNHEX(SHA2(LOWER(?), 256)) OR accounts.domain_hash = UNHEX(SHA2(LOWER(?), 256)))',
[host, `www.${host}`],
);
});
})
.first('accounts.id');

return !!row;
}

private async mapRowToAccountEntity(row: AccountRow): Promise<Account> {
if (!row.uuid) {
row.uuid = randomUUID();
Expand All @@ -467,6 +548,7 @@ export class KnexAccountRepository {
apLiked: parseURL(row.ap_liked_url),
isInternal: row.site_id !== null,
customFields: row.custom_fields,
webfingerHost: row.webfinger_host ?? null,
});
}
}
29 changes: 29 additions & 0 deletions src/account/account.service.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,35 @@ describe('AccountService', () => {
expect(secondAccount).toMatchObject(account);
});

it('rejects creating a default handle that is already claimed as a custom WebFinger host', async () => {
const [otherSiteId] = await db('sites').insert({
host: 'blog.example.com',
webhook_secret: 'other-secret',
ghost_uuid: null,
});
const otherSite = {
id: otherSiteId,
host: 'blog.example.com',
webhook_secret: 'other-secret',
ghost_uuid: null,
};

const otherAccount = await service.createInternalAccount(
otherSite,
internalAccountData,
);

await db('accounts')
.where({ id: otherAccount.id })
.update({ webfinger_host: 'example.com' });

await expect(
service.createInternalAccount(site, internalAccountData),
).rejects.toThrow(
'WebFinger handle @index@example.com is already claimed',
);
});

it('should ensure the account is created with a domain', async () => {
const account = await service.createInternalAccount(
site,
Expand Down
Loading
Loading