Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support fedibird searchableBy #169

Merged
merged 30 commits into from
Sep 11, 2024
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
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
Loading