1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 01:39:37 +02:00

Add ability to disable channel followers

This commit is contained in:
Chocobozzz 2025-06-17 11:37:55 +02:00
parent ce28c64750
commit 031b61c466
No known key found for this signature in database
GPG key ID: 583A612D890159BE
17 changed files with 274 additions and 151 deletions

View file

@ -453,6 +453,102 @@
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>FEDERATION</h2>
<div i18n class="inner-form-description">
Manage <a class="link-primary" routerLink="/admin/settings/follows">relations</a> with other platforms.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="followers">
<ng-container formGroupName="channels">
<div class="form-group">
<my-peertube-checkbox
inputName="followersChannelsEnabled" formControlName="enabled"
i18n-labelText labelText="Remote actors can follow channels of your platform"
>
<ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current followers of channels of your platform will not be affected</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="instance">
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceEnabled" formControlName="enabled"
i18n-labelText labelText="Remote actors can follow your platform"
>
<ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current followers of your platform will not be affected</span>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceManualApproval" formControlName="manualApproval"
i18n-labelText labelText="Manually approve new followers that follow your platform"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="followings">
<ng-container formGroupName="instance">
<ng-container formGroupName="autoFollowBack">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow back followers that follow your platform"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="autoFollowIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow platforms of a public index"
>
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
<span i18n>
See <a class="link-primary" href="https://docs.joinpeertube.org/admin/following-instances#automatically-follow-other-instances" rel="noopener noreferrer" target="_blank">the documentation</a> for more information about the expected URL
</span>
</ng-container>
<ng-container ngProjectAs="extra">
<div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors.followings.instance.autoFollowIndex.indexUrl }"
>
<div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>PLAYER</h2>
@ -651,83 +747,4 @@
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>FEDERATION</h2>
<div i18n class="inner-form-description">
Manage <a class="link-primary" routerLink="/admin/settings/follows">relations</a> with other platforms.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="followers">
<ng-container formGroupName="instance">
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceEnabled" formControlName="enabled"
i18n-labelText labelText="Remote actors can follow your platform"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceManualApproval" formControlName="manualApproval"
i18n-labelText labelText="Manually approve new followers that follow your platform"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="followings">
<ng-container formGroupName="instance">
<ng-container formGroupName="autoFollowBack">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow back followers that follow your platform"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="autoFollowIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow platforms of a public index"
>
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
<span i18n>
See <a class="link-primary" href="https://docs.joinpeertube.org/admin/following-instances#automatically-follow-other-instances" rel="noopener noreferrer" target="_blank">the documentation</a> for more information about the expected URL
</span>
</ng-container>
<ng-container ngProjectAs="extra">
<div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors.followings.instance.autoFollowIndex.indexUrl }"
>
<div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
</form>

View file

@ -136,6 +136,9 @@ type Form = {
enabled: FormControl<boolean>
manualApproval: FormControl<boolean>
}>
channels: FormGroup<{
enabled: FormControl<boolean>
}>
}>
followings: FormGroup<{
@ -367,6 +370,9 @@ export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanCompon
instance: {
enabled: null,
manualApproval: null
},
channels: {
enabled: null
}
},
followings: {

View file

@ -24,7 +24,7 @@
<cdk-step i18n-label label="Configuration preview">
<my-admin-config-wizard-preview
[usageType]="usageType" [instanceInfo]="instanceInfo"
[usageType]="usageType" [instanceInfo]="instanceInfo" [dryRun]="dryRun"
[currentStep]="currentStep()" totalSteps="3"
(back)="stepper.previous()" (next)="showWelcome ? stepper.next() : hide()" (hide)="hide()"
></my-admin-config-wizard-preview>

View file

@ -39,9 +39,11 @@ export class AdminConfigWizardModalComponent implements OnInit {
readonly created = output()
usageType: UsageType
showWelcome: boolean
instanceInfo: FormInfo
showWelcome: boolean
dryRun: boolean
ngOnInit () {
this.created.emit()
}
@ -49,6 +51,7 @@ export class AdminConfigWizardModalComponent implements OnInit {
shouldAutoOpen (user: User) {
if (this.modalService.hasOpenModals()) return false
if (this.route.snapshot.fragment === 'admin-welcome-wizard') return true
if (this.route.snapshot.fragment === 'admin-welcome-wizard-test') return true
if (user.noWelcomeModal === true) return false
if (peertubeLocalStorage.getItem(getNoWelcomeModalLocalStorageKey()) === 'true') return false
@ -57,6 +60,7 @@ export class AdminConfigWizardModalComponent implements OnInit {
show ({ showWelcome }: { showWelcome: boolean }) {
this.showWelcome = showWelcome
this.dryRun = this.route.snapshot.fragment === 'admin-welcome-wizard-test'
this.modalService.open(this.modal(), {
centered: true,

View file

@ -1,6 +1,6 @@
import { CdkStepperModule } from '@angular/cdk/stepper'
import { CommonModule } from '@angular/common'
import { Component, inject, input, numberAttribute, OnChanges, output } from '@angular/core'
import { booleanAttribute, Component, inject, input, numberAttribute, OnChanges, output } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { HtmlRendererService, Notifier, ServerService } from '@app/core'
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
@ -39,6 +39,7 @@ export class AdminConfigWizardPreviewComponent implements OnChanges {
readonly totalSteps = input.required({ transform: numberAttribute })
readonly usageType = input.required<UsageType>()
readonly instanceInfo = input.required<FormInfo>()
readonly dryRun = input.required({ transform: booleanAttribute })
readonly back = output()
readonly next = output()
@ -76,6 +77,10 @@ export class AdminConfigWizardPreviewComponent implements OnChanges {
}
confirm () {
if (this.dryRun()) {
return this.next.emit()
}
this.updating = true
this.adminConfig.updateCustomConfig(this.config)

View file

@ -3,7 +3,7 @@ import { CustomConfig, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertu
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import { getBytes } from '@root-helpers/bytes'
import merge from 'lodash-es/merge'
import { PartialDeep } from 'type-fest'
import { Jsonify, PartialDeep } from 'type-fest'
export type RegistrationType = 'open' | 'closed' | 'approval'
export type EnabledDisabled = 'disabled' | 'enabled'
@ -29,25 +29,32 @@ export class UsageType {
private config: PartialDeep<CustomConfig> = {}
private plugins: string[] = []
private constructor () {
private constructor (options: Required<Jsonify<UsageType>>) {
for (const [ key, value ] of Object.entries(options)) {
;(this as any)[key] = value
}
}
static initForCommunity () {
const usageType = new UsageType()
const usageType = new UsageType({
registration: 'approval',
remoteImport: 'disabled',
live: 'enabled',
videoQuota: 5 * 1024 * 1024 * 1024, // Default to 5GB,
globalSearch: 'enabled',
usageType.registration = 'approval'
usageType.remoteImport = 'disabled'
usageType.live = 'enabled'
usageType.videoQuota = 5 * 1024 * 1024 * 1024 // Default to 5GB
usageType.globalSearch = 'enabled'
defaultPrivacy: VideoPrivacy.PUBLIC,
p2p: 'enabled',
federation: 'enabled',
keepOriginalVideo: 'disabled',
allowReplaceFile: 'disabled',
usageType.defaultPrivacy = VideoPrivacy.PUBLIC
usageType.p2p = 'enabled'
usageType.federation = 'enabled'
usageType.keepOriginalVideo = 'disabled'
usageType.allowReplaceFile = 'disabled'
// Use current config for: defaultCommentPolicy, authType, preferDisplayName and transcription
// Use current config
defaultCommentPolicy: undefined,
authType: undefined,
preferDisplayName: undefined,
transcription: undefined
})
usageType.compute()
@ -55,22 +62,25 @@ export class UsageType {
}
static initForPrivateInstance () {
const usageType = new UsageType()
const usageType = new UsageType({
registration: 'closed',
remoteImport: 'enabled',
live: 'enabled',
videoQuota: -1,
globalSearch: 'disabled',
usageType.registration = 'closed'
usageType.remoteImport = 'enabled'
usageType.live = 'enabled'
usageType.videoQuota = -1
usageType.globalSearch = 'disabled'
defaultPrivacy: VideoPrivacy.INTERNAL,
p2p: 'disabled',
federation: 'disabled',
keepOriginalVideo: 'enabled',
allowReplaceFile: 'enabled',
preferDisplayName: 'enabled',
usageType.defaultPrivacy = VideoPrivacy.INTERNAL
usageType.p2p = 'disabled'
usageType.federation = 'disabled'
usageType.keepOriginalVideo = 'enabled'
usageType.allowReplaceFile = 'enabled'
usageType.preferDisplayName = 'enabled'
// Use current config for: defaultCommentPolicy, authType and transcription
// Use current config
defaultCommentPolicy: undefined,
authType: undefined,
transcription: undefined
})
usageType.compute()
@ -78,26 +88,27 @@ export class UsageType {
}
static initForInstitution () {
const usageType = new UsageType()
const usageType = new UsageType({
registration: 'closed',
remoteImport: 'enabled',
live: 'enabled',
videoQuota: -1,
globalSearch: 'disabled',
usageType.registration = 'closed'
usageType.remoteImport = 'enabled'
usageType.live = 'enabled'
usageType.videoQuota = -1
usageType.globalSearch = 'disabled'
defaultPrivacy: VideoPrivacy.PUBLIC,
p2p: 'disabled',
keepOriginalVideo: 'enabled',
allowReplaceFile: 'enabled',
preferDisplayName: 'enabled',
usageType.defaultPrivacy = VideoPrivacy.PUBLIC
usageType.p2p = 'disabled'
usageType.keepOriginalVideo = 'enabled'
usageType.allowReplaceFile = 'enabled'
usageType.preferDisplayName = 'enabled'
authType: 'local',
transcription: 'enabled',
usageType.authType = 'local'
usageType.transcription = 'enabled'
defaultCommentPolicy: VideoCommentPolicy.REQUIRES_APPROVAL,
usageType.defaultCommentPolicy = VideoCommentPolicy.REQUIRES_APPROVAL
// Use current config for: federation
// Use current config
federation: undefined
})
usageType.compute()
@ -322,6 +333,9 @@ export class UsageType {
followers: {
instance: {
enabled: this.federation === 'enabled'
},
channels: {
enabled: this.federation === 'enabled'
}
}
})

View file

@ -977,10 +977,15 @@ services:
followers:
instance:
# Allow or not other instances to follow yours
# Allow remote actors to follow your instance
# This setting is not retroactive: current followers of your instance will not be affected
enabled: true
# Whether or not an administrator must manually validate a new follower
manual_approval: false
channels:
# Allow remote actors to follow channels/accounts of your instance
# This setting is not retroactive: current followers of local channels/accounts will not be affected
enabled: true
followings:
instance:

View file

@ -987,10 +987,15 @@ services:
followers:
instance:
# Allow or not other instances to follow yours
# Allow remote actors to follow your instance
# This setting is not retroactive: current followers of your instance will not be affected
enabled: true
# Whether or not an administrator must manually validate a new follower
manual_approval: false
channels:
# Allow remote actors to follow channels/accounts of your instance
# This setting is not retroactive: current followers of local channels/accounts will not be affected
enabled: true
followings:
instance:

View file

@ -292,6 +292,10 @@ export interface CustomConfig {
enabled: boolean
manualApproval: boolean
}
channels: {
enabled: boolean
}
}
followings: {

View file

@ -131,6 +131,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.followers.instance.enabled).to.be.true
expect(data.followers.instance.manualApproval).to.be.false
expect(data.followers.channels.enabled).to.be.true
expect(data.followings.instance.autoFollowBack.enabled).to.be.false
expect(data.followings.instance.autoFollowIndex.enabled).to.be.false
@ -393,6 +394,9 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
instance: {
enabled: false,
manualApproval: true
},
channels: {
enabled: false
}
},
followings: {

View file

@ -46,9 +46,7 @@ describe('Test follow constraints', function () {
})
describe('With a followed instance', function () {
describe('With an unlogged user', function () {
it('Should get the local video', async function () {
await servers[0].videos.get({ id: video1UUID })
})
@ -130,7 +128,6 @@ describe('Test follow constraints', function () {
})
describe('With a non followed instance', function () {
before(async function () {
this.timeout(30000)
@ -138,7 +135,6 @@ describe('Test follow constraints', function () {
})
describe('With an unlogged user', function () {
it('Should get the local video', async function () {
await servers[0].videos.get({ id: video1UUID })
})
@ -196,7 +192,6 @@ describe('Test follow constraints', function () {
})
describe('With a logged user', function () {
it('Should get the local video', async function () {
await servers[0].videos.getWithToken({ token: userToken, id: video1UUID })
})
@ -238,7 +233,6 @@ describe('Test follow constraints', function () {
})
describe('When following a remote account', function () {
before(async function () {
this.timeout(60000)
@ -256,7 +250,6 @@ describe('Test follow constraints', function () {
})
describe('When unfollowing a remote account', function () {
before(async function () {
this.timeout(60000)
@ -277,7 +270,6 @@ describe('Test follow constraints', function () {
})
describe('When following a remote channel', function () {
before(async function () {
this.timeout(60000)
@ -295,7 +287,6 @@ describe('Test follow constraints', function () {
})
describe('When unfollowing a remote channel', function () {
before(async function () {
this.timeout(60000)
@ -316,7 +307,6 @@ describe('Test follow constraints', function () {
})
describe('When disabling federation', function () {
before(async function () {
this.timeout(60_000)

View file

@ -1,7 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { expectStartWith } from '@tests/shared/checks.js'
import { ActorFollow, FollowState } from '@peertube/peertube-models'
import {
cleanupTests,
@ -11,6 +9,8 @@ import {
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { expectStartWith } from '@tests/shared/checks.js'
import { expect } from 'chai'
async function checkServer1And2HasFollowers (servers: PeerTubeServer[], state = 'accepted') {
const fns = [
@ -97,7 +97,6 @@ describe('Test follows moderation', function () {
})
describe('Default behaviour', function () {
it('Should have server 1 following server 2', async function () {
this.timeout(30000)
@ -121,8 +120,61 @@ describe('Test follows moderation', function () {
})
})
describe('Disabled/Enabled followers', function () {
describe('Disabled/Enabled followers for the channel', function () {
let channel: string
before(async function () {
channel = `root_channel@${servers[1].host}`
})
it('Should disable followers for channels on server 2', async function () {
const subConfig = {
followers: {
channels: {
enabled: false
}
}
}
await servers[1].config.updateExistingConfig({ newConfig: subConfig })
await commands[0].follow({ handles: [ channel ] })
await waitJobs(servers)
for (const server of [ servers[0], servers[1] ]) {
const { data, total } = await server.channels.listFollowers({ channelName: channel })
expect(total).to.equal(0)
expect(data).to.have.lengthOf(0)
}
})
it('Should re enable followers for channels on server 2', async function () {
const subConfig = {
followers: {
channels: {
enabled: true
}
}
}
await servers[1].config.updateExistingConfig({ newConfig: subConfig })
await commands[0].follow({ handles: [ channel ] })
await waitJobs(servers)
for (const server of [ servers[0], servers[1] ]) {
const { data, total } = await server.channels.listFollowers({ channelName: channel })
expect(total).to.equal(1)
expect(data).to.have.lengthOf(1)
}
})
after(async function () {
await commands[0].unfollow({ target: channel })
})
})
describe('Disabled/Enabled followers for the instance', function () {
it('Should disable followers on server 2', async function () {
const subConfig = {
followers: {
@ -160,8 +212,7 @@ describe('Test follows moderation', function () {
})
})
describe('Manual approbation', function () {
describe('Manual approbation for the instance', function () {
it('Should manually approve followers', async function () {
this.timeout(20000)
@ -257,7 +308,6 @@ describe('Test follows moderation', function () {
})
describe('Accept/reject state', function () {
it('Should not change the follow on refollow with and without auto accept', async function () {
const run = async () => {
await commands[0].follow({ hosts: [ servers[2].url ] })
@ -331,7 +381,6 @@ describe('Test follows moderation', function () {
})
describe('Muted servers', function () {
it('Should ignore follow requests of muted servers', async function () {
await servers[1].blocklist.addToServerBlocklist({ server: servers[0].host })

View file

@ -474,6 +474,9 @@ function customConfig (): CustomConfig {
instance: {
enabled: CONFIG.FOLLOWERS.INSTANCE.ENABLED,
manualApproval: CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL
},
channels: {
enabled: CONFIG.FOLLOWERS.CHANNELS.ENABLED
}
},
followings: {

View file

@ -146,6 +146,7 @@ export function checkMissedConfig () {
'services.twitter.username',
'followers.instance.enabled',
'followers.instance.manual_approval',
'followers.channels.enabled',
'tracker.enabled',
'tracker.private',
'tracker.reject_too_many_announces',

View file

@ -974,6 +974,11 @@ const CONFIG = {
get MANUAL_APPROVAL () {
return config.get<boolean>('followers.instance.manual_approval')
}
},
CHANNELS: {
get ENABLED () {
return config.get<boolean>('followers.channels.enabled')
}
}
},
FOLLOWINGS: {

View file

@ -90,12 +90,22 @@ async function processFollow (byActor: MActorSignature, activityId: string, targ
}
async function rejectIfInstanceFollowDisabled (byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
if (await isFollowingInstance(targetActor) && CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) {
logger.info('Rejecting %s because instance followers are disabled.', targetActor.url)
if (await isFollowingInstance(targetActor)) {
if (CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) {
logger.info('Rejecting %s because instance followers are disabled.', targetActor.url)
sendReject(activityId, byActor, targetActor)
sendReject(activityId, byActor, targetActor)
return true
return true
}
} else {
if (CONFIG.FOLLOWERS.CHANNELS.ENABLED === false) {
logger.info('Rejecting %s because channel followers are disabled.', targetActor.url)
sendReject(activityId, byActor, targetActor)
return true
}
}
return false

View file

@ -102,6 +102,7 @@ const customConfigUpdateValidator = [
body('followers.instance.enabled').isBoolean(),
body('followers.instance.manualApproval').isBoolean(),
body('followers.channels.enabled').isBoolean(),
body('theme.default').custom(v => isThemeNameValid(v) && isThemeRegistered(v)),