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
8 changes: 8 additions & 0 deletions config/mbin_routes/entry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,14 @@ entry_pin:
requirements:
entry_id: \d+

entry_lock:
controller: App\Controller\Entry\EntryLockController
defaults: { slug: -, sortBy: default }
path: /m/{magazine_name}/t/{entry_id}/{slug}/lock
methods: [ POST ]
requirements:
entry_id: \d+

entry_voters:
controller: App\Controller\Entry\EntryVotersController
defaults: { slug: -, sortBy: default }
Expand Down
12 changes: 12 additions & 0 deletions config/mbin_routes/moderation_api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ api_moderate_entry_toggle_pin:
methods: [ PUT ]
format: json

api_moderate_entry_toggle_lock:
controller: App\Controller\Api\Entry\Moderate\EntriesLockApi
path: /api/moderate/entry/{entry_id}/lock
methods: [ PUT ]
format: json

api_moderate_entry_trash:
controller: App\Controller\Api\Entry\Moderate\EntriesTrashApi::trash
path: /api/moderate/entry/{entry_id}/trash
Expand Down Expand Up @@ -60,6 +66,12 @@ api_moderate_post_toggle_pin:
methods: [ PUT ]
format: json

api_moderate_post_toggle_lock:
controller: App\Controller\Api\Post\Moderate\PostsLockApi
path: /api/moderate/post/{post_id}/lock
methods: [ PUT ]
format: json

api_moderate_post_trash:
controller: App\Controller\Api\Post\Moderate\PostsTrashApi::trash
path: /api/moderate/post/{post_id}/trash
Expand Down
8 changes: 8 additions & 0 deletions config/mbin_routes/post.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ post_pin:
requirements:
post_id: \d+

post_lock:
controller: App\Controller\Post\PostLockController
defaults: { slug: - }
path: /m/{magazine_name}/p/{post_id}/{slug}/lock
methods: [ POST ]
requirements:
post_id: \d+

post_voters:
controller: App\Controller\Post\PostVotersController
defaults: { slug: -, }
Expand Down
2 changes: 2 additions & 0 deletions config/packages/league_oauth2_server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ league_oauth2_server:
"moderate:entry",
"moderate:entry:language",
"moderate:entry:pin",
"moderate:entry:lock",
"moderate:entry:set_adult",
"moderate:entry:trash",
"moderate:entry_comment",
Expand All @@ -91,6 +92,7 @@ league_oauth2_server:
"moderate:post",
"moderate:post:language",
"moderate:post:pin",
"moderate:post:lock",
"moderate:post:set_adult",
"moderate:post:trash",
"moderate:post_comment",
Expand Down
2 changes: 2 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ security:
'ROLE_OAUTH2_MODERATE:ENTRY:PIN',
'ROLE_OAUTH2_MODERATE:ENTRY:SET_ADULT',
'ROLE_OAUTH2_MODERATE:ENTRY:TRASH',
'ROLE_OAUTH2_MODERATE:ENTRY:LOCK',
]
'ROLE_OAUTH2_MODERATE:ENTRY_COMMENT':
[
Expand All @@ -280,6 +281,7 @@ security:
[
'ROLE_OAUTH2_MODERATE:POST:LANGUAGE',
'ROLE_OAUTH2_MODERATE:POST:PIN',
'ROLE_OAUTH2_MODERATE:POST:LOCK',
'ROLE_OAUTH2_MODERATE:POST:SET_ADULT',
'ROLE_OAUTH2_MODERATE:POST:TRASH',
]
Expand Down
6 changes: 6 additions & 0 deletions docs/04-app_developers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ POST /api/client
- Allows changing the language of threads moderated by the user
- `moderate:entry:pin`
- Allows pinning/unpinning threads to the top of magazines moderated by the user
- `moderate:entry:lock`
- Allows locking/unlocking of threads
- `moderate:entry:set_adult`
- Allows toggling the NSFW status of threads moderated by the user
- `moderate:entry:trash`
Expand All @@ -219,6 +221,10 @@ POST /api/client
- Allows toggling the NSFW status of posts moderated by the user
- `moderate:post:trash`
- Allows soft deletion or restoration of posts moderated by the user
- `moderate:post:pin`
- Allows pinning/unpinning posts to the top of magazines moderated by the user
- `moderate:post:lock`
- Allows locking/unlocking of posts
- `moderate:post_comment`
- `moderate:post_comment:language`
- Allows changing the language of comments on posts moderated by the user
Expand Down
19 changes: 18 additions & 1 deletion docs/05-fediverse_developers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,15 @@ If a user boosts content:
%activity_user_delete_account%
```

### Lock own content

Only top level content (meaning no comments) can be locked.
When content is locked, comments can no longer be created for it.

```json
%activity_user_lock%
```

## Moderator Activities

### Add or Remove moderator
Expand Down Expand Up @@ -266,6 +275,14 @@ When a thread is unpinned:
%activity_mod_ban%
```

### Lock content

When content is locked, comments can no longer be created for it.

```json
%activity_mod_lock%
```

## Admin Activities

### Ban user from instance
Expand All @@ -287,7 +304,7 @@ If an admin deletes another user's account the activity actually does not reflec
### Announce activities

The magazine is mainly there to announce the activities users do with it as the audience.
The announced type can be `Create`, `Update`, `Add`, `Remove`, `Announce`, `Delete`, `Like`, `Dislike` and `Flag`.
The announced type can be `Create`, `Update`, `Add`, `Remove`, `Announce`, `Delete`, `Like`, `Dislike`, `Flag` and `Lock`.
`Announce(Flag)` activities are only sent to instances with moderators of this magazine on them.

```json
Expand Down
28 changes: 28 additions & 0 deletions migrations/Version20251031174052.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20251031174052 extends AbstractMigration
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

due to the fact we might introduced other migrations.. it might be wise to upgrade the file name and class name to reflect the current date again..

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory yes, but I have already been running this migration locally and on gehirneimer, so it would complicate things there... If you do not insist on it, I'd prefer to leave it as is

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK let's hope then it won't case any issues and symfony migration is smart enough

{
public function getDescription(): string
{
return 'Add is_locked column to post and entry';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE entry ADD is_locked BOOLEAN DEFAULT false NOT NULL');
$this->addSql('ALTER TABLE post ADD is_locked BOOLEAN DEFAULT false NOT NULL');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE post DROP is_locked');
$this->addSql('ALTER TABLE entry DROP is_locked');
}
}
6 changes: 6 additions & 0 deletions src/Command/DocumentationGenerateFederationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use App\Factory\ActivityPub\FlagFactory;
use App\Factory\ActivityPub\GroupFactory;
use App\Factory\ActivityPub\InstanceFactory;
use App\Factory\ActivityPub\LockFactory;
use App\Factory\ActivityPub\MessageFactory;
use App\Factory\ActivityPub\PersonFactory;
use App\Factory\ActivityPub\PostCommentNoteFactory;
Expand Down Expand Up @@ -101,6 +102,7 @@ public function __construct(
private readonly UserRepository $userRepository,
private readonly CollectionInfoWrapper $collectionInfoWrapper,
private readonly BlockFactory $blockFactory,
private readonly LockFactory $lockFactory,
) {
parent::__construct();
}
Expand Down Expand Up @@ -248,6 +250,7 @@ private function generateMarkdown(string $content): string
$activityUserEdit = $this->updateWrapper->buildForActivity($entry);
$activityUserDelete = $this->deleteWrapper->build($entry, includeContext: false);
$activityUserDeleteAccount = $this->deleteWrapper->buildForUser($user);
$activityUserLock = $this->lockFactory->build($user2, $entry);

$magazineBan = new MagazineBan($magazine, $user, $user2, 'A very specific reason', \DateTimeImmutable::createFromFormat('Y-m-d', '2025-01-01'));
$this->entityManager->persist($magazineBan);
Expand All @@ -258,6 +261,7 @@ private function generateMarkdown(string $content): string
$activityModRemovePin = $this->addRemoveFactory->buildRemovePinnedPost($user, $entry);
$activityModDelete = $this->deleteWrapper->adjustDeletePayload($user, $entryComment, false);
$activityModBan = $this->blockFactory->createActivityFromMagazineBan($magazineBan);
$activityModLock = $this->lockFactory->build($user, $entry);

$activityMagAnnounce = $this->announceWrapper->build($magazine, $entryCreate);
$activityAdminBan = $this->blockFactory->createActivityFromInstanceBan($user2, $user);
Expand Down Expand Up @@ -296,10 +300,12 @@ private function generateMarkdown(string $content): string
'%activity_user_update_content%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserEdit, false), $jsonFlags),
'%activity_user_delete%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserDelete, false), $jsonFlags),
'%activity_user_delete_account%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserDeleteAccount, false), $jsonFlags),
'%activity_user_lock%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityUserLock, false), $jsonFlags),
'%activity_mod_add_mod%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModAddMod, false), $jsonFlags),
'%activity_mod_remove_mod%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModRemoveMod, false), $jsonFlags),
'%activity_mod_add_pin%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModAddPin, false), $jsonFlags),
'%activity_mod_remove_pin%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModRemovePin, false), $jsonFlags),
'%activity_mod_lock%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModLock, false), $jsonFlags),
'%activity_mod_delete%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModDelete, false), $jsonFlags),
'%activity_mod_ban%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityModBan, false), $jsonFlags),
'%activity_mag_announce%' => json_encode($this->activityJsonBuilder->buildActivityJson($activityMagAnnounce, false), $jsonFlags),
Expand Down
87 changes: 87 additions & 0 deletions src/Controller/Api/Entry/Moderate/EntriesLockApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace App\Controller\Api\Entry\Moderate;

use App\Controller\Api\Entry\EntriesBaseApi;
use App\DTO\EntryResponseDto;
use App\Entity\Entry;
use App\Factory\EntryFactory;
use App\Schema\Errors\ForbiddenErrorSchema;
use App\Schema\Errors\NotFoundErrorSchema;
use App\Schema\Errors\TooManyRequestsErrorSchema;
use App\Schema\Errors\UnauthorizedErrorSchema;
use App\Service\EntryManager;
use Nelmio\ApiDocBundle\Attribute\Model;
use Nelmio\ApiDocBundle\Attribute\Security;
use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class EntriesLockApi extends EntriesBaseApi
{
#[OA\Response(
response: 200,
description: 'Entry lock status toggled',
headers: [
new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
],
content: new Model(type: EntryResponseDto::class)
)]
#[OA\Response(
response: 401,
description: 'Permission denied due to missing or expired token',
content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
)]
#[OA\Response(
response: 403,
description: 'You are not authorized to lock this entry',
content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))
)]
#[OA\Response(
response: 404,
description: 'Entry not found',
content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))
)]
#[OA\Response(
response: 429,
description: 'You are being rate limited',
headers: [
new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
],
content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
)]
#[OA\Parameter(
name: 'entry_id',
description: 'The entry to lock or unlock',
in: 'path',
schema: new OA\Schema(type: 'integer'),
)]
#[OA\Tag(name: 'moderation/entry')]
#[Security(name: 'oauth2', scopes: ['moderate:entry:lock'])]
#[IsGranted('ROLE_OAUTH2_MODERATE:ENTRY:LOCK')]
#[IsGranted('lock', subject: 'entry')]
public function __invoke(
#[MapEntity(id: 'entry_id')]
Entry $entry,
EntryManager $manager,
EntryFactory $factory,
RateLimiterFactory $apiModerateLimiter,
): JsonResponse {
$headers = $this->rateLimit($apiModerateLimiter);

$manager->toggleLock($entry, $this->getUserOrThrow());

return new JsonResponse(
$this->serializeEntry($factory->createDto($entry), $this->tagLinkRepository->getTagsOfContent($entry), $this->entryRepository->findCross($entry)),
headers: $headers
);
}
}
87 changes: 87 additions & 0 deletions src/Controller/Api/Post/Moderate/PostsLockApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace App\Controller\Api\Post\Moderate;

use App\Controller\Api\Post\PostsBaseApi;
use App\DTO\PostResponseDto;
use App\Entity\Post;
use App\Factory\PostFactory;
use App\Schema\Errors\ForbiddenErrorSchema;
use App\Schema\Errors\NotFoundErrorSchema;
use App\Schema\Errors\TooManyRequestsErrorSchema;
use App\Schema\Errors\UnauthorizedErrorSchema;
use App\Service\PostManager;
use Nelmio\ApiDocBundle\Attribute\Model;
use Nelmio\ApiDocBundle\Attribute\Security;
use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class PostsLockApi extends PostsBaseApi
{
#[OA\Response(
response: 200,
description: 'Post lock status toggled',
headers: [
new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
],
content: new Model(type: PostResponseDto::class)
)]
#[OA\Response(
response: 401,
description: 'Permission denied due to missing or expired token',
content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
)]
#[OA\Response(
response: 403,
description: 'You are not authorized to lock this post',
content: new OA\JsonContent(ref: new Model(type: ForbiddenErrorSchema::class))
)]
#[OA\Response(
response: 404,
description: 'Post not found',
content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class))
)]
#[OA\Response(
response: 429,
description: 'You are being rate limited',
headers: [
new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
],
content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
)]
#[OA\Parameter(
name: 'post_id',
description: 'The post to lock or unlock',
in: 'path',
schema: new OA\Schema(type: 'integer'),
)]
#[OA\Tag(name: 'moderation/post')]
#[Security(name: 'oauth2', scopes: ['moderate:post:lock'])]
#[IsGranted('ROLE_OAUTH2_MODERATE:POST:LOCK')]
#[IsGranted('lock', subject: 'post')]
public function __invoke(
#[MapEntity(id: 'post_id')]
Post $post,
PostManager $manager,
PostFactory $factory,
RateLimiterFactory $apiModerateLimiter,
): JsonResponse {
$headers = $this->rateLimit($apiModerateLimiter);

$manager->toggleLock($post, $this->getUserOrThrow());

return new JsonResponse(
$this->serializePost($factory->createDto($post), $this->tagLinkRepository->getTagsOfContent($post)),
headers: $headers
);
}
}
Loading
Loading