Skip to content

Commit

Permalink
feat: support fedibird searchableBy (#169)
Browse files Browse the repository at this point in the history
* wip: activitypub contextを追加

* wip: activitypubのtypeでsearchableByを拡張

* wip: ApRendererに追加

* wip: DBモデル定義

* wip: add to ApNoteService

* wip

* wip: migration

* wip

* wip: api

* wip: update

* wip

* wip

* fix?: searchableByはundefinedではないものとする

* fix?

* testどうにかする

* NOT NULL制約消す

* わからん

* マイグレがCJSだった

* 検索でsearchableByを考慮

* いけるかも?

* insertの修正

* inboxいじった

* わからん

* apnote

* unitテスト通るはず

* reactedに対応するはず?

* fix: 検索が壊れてた

* 日本語ロケールを変更

* update changelog

* ApInboxに不要なimportが追加されていたのを修正

---------

Co-authored-by: Esurio <[email protected]>
  • Loading branch information
1673beta and 1673beta authored Sep 11, 2024
1 parent 399fa32 commit 1bf4d08
Show file tree
Hide file tree
Showing 23 changed files with 212 additions and 11 deletions.
17 changes: 17 additions & 0 deletions CHANGELOG_engawa.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@
### Misc
-->
## 0.6.0

### Release Date

### General
- feat: `fedibird:searchableBy`に対応
- 対応しているソフトウェア(fedibird, kmyblue)に対して、自身の投稿を検索できる範囲を制限することができます。

### Client
-

### Server
- fix: 検索のオプションが効かなくなっていた問題を修正

### Misc


## 0.5.4

### Release Date
Expand Down
24 changes: 23 additions & 1 deletion locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11708,7 +11708,7 @@ export interface Locale extends ILocale {
};
"_searchOption": {
/**
* CW付きを除外する
* NSFWを除外する
*/
"toggleCW": string;
/**
Expand Down Expand Up @@ -11819,6 +11819,28 @@ export interface Locale extends ILocale {
*/
"postAnyWay": string;
};
"_searchableBy": {
/**
* 検索可能範囲
*/
"searchableBy": string;
/**
* 公開(検索)
*/
"public": string;
/**
* フォロワー限定(検索)
*/
"followers": string;
/**
* リアクション限定(検索)
*/
"reacted": string;
/**
* 自分限定(検索)
*/
"limited": string;
};
}
declare const locales: {
[lang: string]: Locale;
Expand Down
9 changes: 8 additions & 1 deletion locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3121,7 +3121,7 @@ _advancedSearch:
noFile: "なし"
combined: "全て"
_searchOption:
toggleCW: "CW付きを除外する"
toggleCW: "NSFWを除外する"
toggleReply: "リプライを除外する"
toggleDate: "日時を指定する"
toggleAdvancedSearch: "高度な検索を有効にする"
Expand Down Expand Up @@ -3156,3 +3156,10 @@ _altWarning:
noAltWarning: "ファイルに代替テキストが設定されていません。"
showNoAltWarning: "画像に代替テキストが設定されていない場合に警告を表示する"
postAnyWay: "投稿フォームへ"

_searchableBy:
searchableBy: "検索可能範囲"
public: "公開(検索)"
followers: "フォロワー限定(検索)"
reacted: "リアクション限定(検索)"
limited: "自分限定(検索)"
13 changes: 13 additions & 0 deletions packages/backend/migration/1725875666723-AddSearchableBy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export class AddSearchableBy1725875666723 {
name = 'AddSearchableBy1725875666723'

async up(queryRunner) {
await queryRunner.query(`CREATE TYPE "note_searchableBy_enum" AS ENUM('public', 'followers', 'reacted', 'limited')`);
await queryRunner.query(`ALTER TABLE "note" ADD "searchableBy" "note_searchableBy_enum" NOT NULL DEFAULT 'public'`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "searchableBy"`);
await queryRunner.query(`DROP TYPE "note_searchableBy_enum"`);
}
}
11 changes: 11 additions & 0 deletions packages/backend/migration/1725891731600-SearchbleByNull.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export class SearchbleByNull1725891731600 {
name = 'SearchbleByNull1725891731600'

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "searchableBy" DROP NOT NULL`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "searchableBy" SET NOT NULL`);
}
}
3 changes: 3 additions & 0 deletions packages/backend/src/core/NoteCreateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ type Option = {
url?: string | null;
app?: MiApp | null;
deleteAt?: Date | null;
searchableBy?: string[] | string;
};

@Injectable()
Expand Down Expand Up @@ -446,6 +447,8 @@ export class NoteCreateService implements OnApplicationShutdown {

attachedFileTypes: data.files ? data.files.map(file => file.type) : [],

searchableBy: data.searchableBy ? data.searchableBy as any : 'public',

// 以下非正規化データ
replyUserId: data.reply ? data.reply.userId : null,
replyUserHost: data.reply ? data.reply.userHost : null,
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/core/NoteUpdateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export class NoteUpdateService implements OnApplicationShutdown {
attachedFileTypes: data.files ? data.files.map(file => file.type) : [],
updatedAtHistory: [...updatedAtHistory, new Date()],
noteEditHistory: [...note.noteEditHistory, (note.cw ? note.cw + '\n' : '') + note.text!],
searchableBy: note.searchableBy,
});

// 投稿を更新
Expand Down
26 changes: 18 additions & 8 deletions packages/backend/src/core/SearchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { Brackets, In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
Expand Down Expand Up @@ -77,8 +77,14 @@ export class SearchService {

query
.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` })
.orWhere('note.cw ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` })
.innerJoinAndSelect('note.user', 'user', 'user.isIndexable = true')
.andWhere(new Brackets(qb => {
qb.andWhere('note.searchableBy = :public', { public: 'public' })
.orWhere(new Brackets(qb2 => {
qb2.where('note.searchableBy = :followers AND (note."userId" IN (SELECT "followeeId" FROM following WHERE following."followerId" = :meId) OR note."userId" = :meId)', { followers: 'followers', meId: me?.id })
.orWhere('note.searchableBy = :limited AND note."userId" = :meId', { limited: 'limited', meId: me?.id })
.orWhere('note.searchableBy = :reacted AND (note."userId" IN (SELECT "userId" FROM note_reaction) OR note."userId" = :meId)', { reacted: 'reacted', meId: me?.id })
}))
}))
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
Expand All @@ -95,19 +101,23 @@ export class SearchService {

if (opts.fileOption) {
if (opts.fileOption === 'fileOnly') {
query.andWhere('note.fileIds != \'{}\' ')
query.andWhere('note."fileIds" != \'{}\' ')
} else if (opts.fileOption === 'noFile') {
query.andWhere('note.fileIds = \'{}\' ')
query.andWhere('note."fileIds" = \'{}\' ')
}
}

if (opts.excludeNsfw) {
query.andWhere('note.cw IS NULL');
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE )');
query.andWhere('note."cw" IS NULL');
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = true)');
} else {
query.orWhere('note."cw" ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` });
}

if (opts.excludeBot) {
query.andWhere(' (SELECT "isBot" FROM "user" WHERE id = note."userId") = FALSE ');
query.innerJoinAndSelect('note.user', 'user', 'user.isIndexable = true AND user.isBot = false');
} else {
query.innerJoinAndSelect('note.user', 'user', 'user.isIndexable = true');
}

/**
Expand Down
27 changes: 27 additions & 0 deletions packages/backend/src/core/activitypub/ApRendererService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,19 @@ export class ApRendererService {
throw new Error('renderAnnounce: cannot render non-public note');
}

let searchable: string[] = [];
if (note.searchableBy === 'public') {
searchable = ['https://www.w3.org/ns/activitystreams#Public'];
} else if (note.searchableBy === 'followers') {
searchable = [`${attributedTo}/followers`];
} else if (note.searchableBy === 'limited') {
searchable = ['as:Limited', 'kmyblue:Limited'];
} else if (note.searchableBy === 'reacted') {
searchable = [];
} else {
searchable = [];
}

return {
id: `${this.config.url}/notes/${note.id}/activity`,
actor: this.userEntityService.genLocalUserUri(note.userId),
Expand Down Expand Up @@ -379,6 +392,19 @@ export class ApRendererService {
to = mentions;
}

let searchable: string[] = [];
if (note.searchableBy === 'public') {
searchable = ['https://www.w3.org/ns/activitystreams#Public'];
} else if (note.searchableBy === 'followers') {
searchable = [`${attributedTo}/followers`];
} else if (note.searchableBy === 'limited') {
searchable = ['as:Limited', 'kmyblue:Limited'];
} else if (note.searchableBy === 'reacted') {
searchable = [];
} else {
searchable = [];
}

const mentionedUsers = note.mentions.length > 0 ? await this.usersRepository.findBy({
id: In(note.mentions),
}) : [];
Expand Down Expand Up @@ -463,6 +489,7 @@ export class ApRendererService {
to,
cc,
inReplyTo,
searchableBy: [...searchable],
attachment: files.map(x => this.renderDocument(x)),
sensitive: note.cw != null || files.some(file => file.isSensitive),
tag,
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/core/activitypub/misc/contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,15 @@ const extension_context_definition = {
'isCat': 'misskey:isCat',
// vcard
vcard: 'http://www.w3.org/2006/vcard/ns#',
// Fedibird
fedibird: 'http://fedibird.com/ns#',
searchableBy: {
'@id': 'fedibird:searchableBy',
'@type': '@id',
},
// kmyblue
kmyblue: 'http://kmy.blue/ns#',
limitedScope: 'kmyblue:limitedScope',
} satisfies Context;

export const CONTEXT: (string | Context)[] = [...context_iris, extension_context_definition];
Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/core/activitypub/models/ApNoteService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { ApEventService } from './ApEventService.js';
import { ApImageService } from './ApImageService.js';
import type { Resolver } from '../ApResolverService.js';
import type { IObject, IPost } from '../type.js';
import search from '@/server/api/endpoints/hashtags/search.js';

@Injectable()
export class ApNoteService {
Expand Down Expand Up @@ -221,6 +222,18 @@ export class ApNoteService {

let isMessaging = note._misskey_talk && visibility === 'specified';

let searchableActivity = toArray(note.searchableBy);
let searchable: string[] = [];
if (searchableActivity.includes('https://www.w3.org/ns/activitystreams#Public')) {
searchable = ['public'];
} else if (searchableActivity.includes('kmyblue:Limited') || searchableActivity.includes('as:Limited')) {
searchable = ['limited'];
} else if (searchableActivity.includes('/followers')) {
searchable = ['followers'];
} else {
searchable = ['reacted'];
}

// 添付ファイル
const files: MiDriveFile[] = [];

Expand Down Expand Up @@ -348,6 +361,7 @@ export class ApNoteService {
event,
uri: note.id,
url: url,
searchableBy: note.searchableBy ? searchable : ['public'],
}, silent);
} catch (err: any) {
if (err.name !== 'duplicated') {
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/core/activitypub/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface IObject {
href?: string;
tag?: IObject | IObject[];
sensitive?: boolean;
searchableBy?: string[] | string;
}

/**
Expand Down Expand Up @@ -127,6 +128,7 @@ export interface IPost extends IObject {
_misskey_content?: string;
quoteUrl?: string;
_misskey_talk?: boolean;
searchableBy?: string[] | string;
}

export interface IQuestion extends IObject {
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/core/entities/NoteEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ export class NoteEntityService implements OnModuleInit {
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
event: note.hasEvent ? this.populateEvent(note) : undefined,
deleteAt: note.deleteAt?.toISOString() ?? undefined,
searchableBy: note.searchableBy,

...(meId && Object.keys(note.reactions).length > 0 ? {
myReaction: this.populateMyReaction(note, meId, options?._hint_),
Expand Down
8 changes: 7 additions & 1 deletion packages/backend/src/models/Note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
import { noteVisibilities } from '@/types.js';
import { noteVisibilities, noteSearchableBy } from '@/types.js';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiChannel } from './Channel.js';
Expand Down Expand Up @@ -264,6 +264,12 @@ export class MiNote {
comment: '[Denormalized]',
})
public renoteUserHost: string | null;

@Column('enum', {
enum: noteSearchableBy,
nullable: true,
})
public searchableBy: typeof noteSearchableBy[number];
//#endregion

constructor(data: Partial<MiNote>) {
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/models/json-schema/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,5 +292,10 @@ export const packedNoteSchema = {
type: 'string',
optional: true, nullable: true,
},
searchableBy: {
type: 'string',
optional: true, nullable: false,
enum: ['public', 'followers', 'reacted', 'limited'],
},
},
} as const;
2 changes: 2 additions & 0 deletions packages/backend/src/server/api/endpoints/notes/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export const paramDef = {
deleteAfter: { type: 'integer', nullable: true, minimum: 1 },
},
},
searchableBy: { type: 'string', enum: ['public', 'followers', 'reacted', 'limited' ], default: 'public' },
},
// (re)note with text, files and poll are optional
if: {
Expand Down Expand Up @@ -427,6 +428,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined,
deleteAt: ps.scheduledDelete?.deleteAt ? new Date(ps.scheduledDelete.deleteAt) : ps.scheduledDelete?.deleteAfter ? new Date(Date.now() + ps.scheduledDelete.deleteAfter) : null,
searchableBy: ps.searchableBy,
});

return {
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/server/api/endpoints/notes/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const paramDef = {
},
cw: { type: 'string', nullable: true, maxLength: 100 },
disableRightClick: { type: 'boolean', default: false },
searchableBy: { type: 'string', enum: ['public', 'followers', 'reacted', 'limited'], default: 'public' },
},
required: ['noteId', 'text', 'cw'],
} as const;
Expand Down Expand Up @@ -153,6 +154,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
multiple: ps.poll.multiple ?? false,
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
} : undefined,
searchableBy: ps.searchableBy,
};

const updatedNote = await this.noteUpdateService.update(me, data, note, false);
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const groupedNotificationTypes = [
export const obsoleteNotificationTypes = ['pollVote'/*, 'groupInvited'*/] as const;

export const noteVisibilities = ['public', 'home', 'followers', 'specified', 'private'] as const;
export const noteSearchableBy = ['public', 'followers', 'reacted', 'limited'] as const;

export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;

Expand Down
1 change: 1 addition & 0 deletions packages/backend/test/unit/activitypub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ function createRandomNote(actor: NonTransientIActor): NonTransientIPost {
type: 'Note',
attributedTo: actor.id,
content: 'test test foo',
searchableBy: 'public',
};
}

Expand Down
Loading

0 comments on commit 1bf4d08

Please sign in to comment.