Feat: UGCの公開範囲設定機能 (#15938)

* wip

* Update CHANGELOG.md

* wip

* wip

* Update show.ts
This commit is contained in:
syuilo 2025-05-09 17:44:35 +09:00 committed by GitHub
parent 8c2ab25e5f
commit ebf291084f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 144 additions and 6 deletions

View File

@ -1,7 +1,10 @@
## Unreleased
### General
-
- Feat: 非ログインでサーバーを閲覧された際に、サーバー内のコンテンツを非公開にすることができるようになりました
- モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます
- 「全て公開(今までの挙動)」「ローカルのコンテンツだけ公開(=サーバー内で受信されたリモートのコンテンツは公開しない)」「何も公開しない」から選択できます
- デフォルト値は「ローカルのコンテンツだけ公開」になっています
### Client
- Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta)

26
locales/index.d.ts vendored
View File

@ -6412,6 +6412,32 @@ export interface Locale extends ILocale {
* semver 使>= 2024.3.1 2024.3.1-custom.0 >= 2024.3.1-0 prerelease
*/
"deliverSuspendedSoftwareDescription": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor_description": string;
/**
*
*/
"userGeneratedContentsVisibilityForVisitor_description2": string;
"_userGeneratedContentsVisibilityForVisitor": {
/**
*
*/
"all": string;
/**
*
*/
"localOnly": string;
/**
*
*/
"none": string;
};
};
"_accountMigration": {
/**

View File

@ -1629,6 +1629,14 @@ _serverSettings:
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
deliverSuspendedSoftware: "配信停止中のソフトウェア"
deliverSuspendedSoftwareDescription: "脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。"
userGeneratedContentsVisibilityForVisitor: "非利用者に対するユーザー作成コンテンツの公開範囲"
userGeneratedContentsVisibilityForVisitor_description: "モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます。"
userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。"
_userGeneratedContentsVisibilityForVisitor:
all: "全て公開"
localOnly: "ローカルコンテンツのみ公開し、リモートコンテンツは非公開"
none: "全て非公開"
_accountMigration:
moveFrom: "別のアカウントからこのアカウントに移行"

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644 {
name = 'VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "ugcVisibilityForVisitor" character varying(128) NOT NULL DEFAULT 'local'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ugcVisibilityForVisitor"`);
}
}

View File

@ -659,6 +659,12 @@ export class MiMeta {
})
public federationHosts: string[];
@Column('varchar', {
length: 128,
default: 'local',
})
public ugcVisibilityForVisitor: 'all' | 'local' | 'none';
@Column('varchar', {
length: 64,
nullable: true,

View File

@ -546,6 +546,11 @@ export const meta = {
},
},
},
ugcVisibilityForVisitor: {
type: 'string',
enum: ['all', 'local', 'none'],
optional: false, nullable: false,
},
},
},
} as const;
@ -691,6 +696,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
federation: instance.federation,
federationHosts: instance.federationHosts,
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,
ugcVisibilityForVisitor: instance.ugcVisibilityForVisitor,
};
});
}

View File

@ -196,6 +196,10 @@ export const paramDef = {
required: ['software', 'versionRange'],
},
},
ugcVisibilityForVisitor: {
type: 'string',
enum: ['all', 'local', 'none'],
},
},
required: [],
} as const;
@ -690,6 +694,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
}
if (ps.ugcVisibilityForVisitor !== undefined) {
set.ugcVisibilityForVisitor = ps.ugcVisibilityForVisitor;
}
const before = await this.metaService.fetch(true);
await this.metaService.update(set);

View File

@ -3,10 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/Meta.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -46,6 +48,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
private noteEntityService: NoteEntityService,
private getterService: GetterService,
) {
@ -59,6 +64,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.signinRequired);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
throw new ApiError(meta.errors.signinRequired);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) {
throw new ApiError(meta.errors.signinRequired);
}
return await this.noteEntityService.pack(note, me, {
detail: true,
});

View File

@ -5,7 +5,7 @@
import { In, IsNull } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository } from '@/models/_.js';
import type { MiMeta, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
@ -82,6 +82,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@ -92,6 +95,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private apiLoggerService: ApiLoggerService,
) {
super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => {
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
let user;
const isModerator = await this.roleService.isModerator(me);
@ -123,6 +130,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} else {
// Lookup user
if (typeof ps.host === 'string' && typeof ps.username === 'string') {
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`);
throw new ApiError(meta.errors.failedToResolveRemoteUser);
@ -139,6 +150,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchUser);
}
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && user.host != null && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
if (user.host == null) {
if (me == null && ip != null) {
this.perUserPvChart.commitByVisitor(user, ip);

View File

@ -513,7 +513,12 @@ export class ClientServerService {
vary(reply.raw, 'Accept');
if (user != null) {
if (
user != null && (
this.meta.ugcVisibilityForVisitor === 'all' ||
(this.meta.ugcVisibilityForVisitor === 'local' && user.host == null)
)
) {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
const me = profile.fields
? profile.fields
@ -577,7 +582,13 @@ export class ClientServerService {
relations: ['user'],
});
if (note && !note.user!.requireSigninToViewContents) {
if (
note &&
!note.user!.requireSigninToViewContents &&
(this.meta.ugcVisibilityForVisitor === 'all' ||
(this.meta.ugcVisibilityForVisitor === 'local' && note.userHost == null)
)
) {
const _note = await this.noteEntityService.pack(note);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
reply.header('Cache-Control', 'public, max-age=15');

View File

@ -17,9 +17,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
<MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup">
<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
<template #label>{{ i18n.ts.emailRequiredForSignup }} ({{ i18n.ts.recommended }})</template>
</MkSwitch>
<MkSelect v-model="ugcVisibilityForVisitor" @update:modelValue="onChange_ugcVisibilityForVisitor">
<template #label>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</template>
<option value="all">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all }}</option>
<option value="local">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly }} ({{ i18n.ts.recommended }})</option>
<option value="none">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none }}</option>
<template #caption>
<div>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description2 }}</div>
</template>
</MkSelect>
<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
<MkFolder>
@ -137,9 +148,11 @@ import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import FormLink from '@/components/form/link.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSelect from '@/components/MkSelect.vue';
const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false);
const ugcVisibilityForVisitor = ref<string>('all');
const sensitiveWords = ref<string>('');
const prohibitedWords = ref<string>('');
const prohibitedWordsForNameOfUser = ref<string>('');
@ -153,6 +166,7 @@ async function init() {
const meta = await misskeyApi('admin/meta');
enableRegistration.value = !meta.disableRegistration;
emailRequiredForSignup.value = meta.emailRequiredForSignup;
ugcVisibilityForVisitor.value = meta.ugcVisibilityForVisitor;
sensitiveWords.value = meta.sensitiveWords.join('\n');
prohibitedWords.value = meta.prohibitedWords.join('\n');
prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n');
@ -189,6 +203,14 @@ function onChange_emailRequiredForSignup(value: boolean) {
});
}
function onChange_ugcVisibilityForVisitor(value: string) {
os.apiWithDialog('admin/update-meta', {
ugcVisibilityForVisitor: value,
}).then(() => {
fetchInstance(true);
});
}
function save_preservedUsernames() {
os.apiWithDialog('admin/update-meta', {
preservedUsernames: preservedUsernames.value.split('\n'),

View File

@ -8780,6 +8780,8 @@ export type operations = {
software: string;
versionRange: string;
}[];
/** @enum {string} */
ugcVisibilityForVisitor: 'all' | 'local' | 'none';
};
};
};
@ -11450,6 +11452,8 @@ export type operations = {
software: string;
versionRange: string;
}[];
/** @enum {string} */
ugcVisibilityForVisitor?: 'all' | 'local' | 'none';
};
};
};