105 Commits

Author SHA1 Message Date
b999c1bd62 yet another iteration 2022-01-22 18:49:25 +00:00
9dc6243822 fix firewalls a little 2022-01-22 18:49:25 +00:00
ce8f54dc46 support json on oauth 2022-01-22 18:49:24 +00:00
9e7db08e50 more grants 2022-01-22 18:49:24 +00:00
841d10cde0 buah 2022-01-22 18:49:24 +00:00
95c8f3bdc7 damn 2022-01-22 18:49:24 +00:00
b82818646f wip42 2022-01-22 18:49:24 +00:00
5ac764f3e5 move to plugin 2022-01-22 18:49:24 +00:00
4ad1de2616 some logic 2022-01-22 18:49:23 +00:00
29f53bb698 wip5 2022-01-22 18:49:23 +00:00
cb16b627b4 cenas 2022-01-22 18:49:23 +00:00
19dd4ba368 wip4 2022-01-22 18:49:23 +00:00
53a1a3fad1 wip3 2022-01-22 18:49:23 +00:00
737648359d wip2 2022-01-22 18:49:23 +00:00
57c09c6f8f wip 2022-01-22 18:49:22 +00:00
08e3da092b [OAuth2] Add scopes 2022-01-22 18:49:22 +00:00
7959ea497b [PLUGIN][OAuth2] Add OAuth2 support 2022-01-22 18:49:22 +00:00
559f6d650b [COMPONENT][Language] Fix template name in language sorting 2022-01-21 22:05:34 +00:00
3d9edd1db8 [COMPONENTS][LeftPanel] Edit feeds page polish, existing links are shown in a grid, saving space
[COMPONENTS][Collection] Fixing details summary class [PLUGINS][WebMonetization] Fixing widget details summary class
2022-01-21 22:05:34 +00:00
402300fe93 [COMPONENTS][Search] Fixing Search form incorrect class names 2022-01-21 22:05:34 +00:00
e2e1b0172d [COMPONENTS][Collection] Actors view template ordering section polished [PLUGINS][RepeatNote] Note to be repeated now uses full note card
[CSS] Simplyfying rules, re-ordering and removing unnecessary and costly 'display: flex' rules
[CARDS][Note] Minimal note macro has info inside the note itself now, since horizontal space is limited
2022-01-21 22:05:33 +00:00
f731850f5c [CSS] .section-widget class and derivatives replaced as .frame-section, since a widget implies a simple element with a specific function 2022-01-21 22:05:33 +00:00
7d546e8901 [CSS] Improved performance, reduced padding [COMPONENTS][LeftPanel] Consolidated CSS into base.css [COMPONENTS][RightPanel] Consolidated CSS into base.css [PLUGINS][WebMonetization] Replaced fieldset with section
Accessibility tests failed if the fieldset had no legend, since it
wasn't really neeeded, it was replaced as another element.
2022-01-21 22:05:33 +00:00
bdeb3bcff5 [PLUGIN][ActivityPub] Federate Actor of types other than Person
Fix some other minor bugs
2022-01-21 22:05:31 +00:00
25b2847201 [TOOLS][AYY1] Improve accesibility testing to save images and compare the differences against a reference (tests/screenshots/ 2022-01-21 21:03:09 +00:00
23d45ffab7 [UTIL][Formatting] Mention prefix was hardcoded, fixed. 2022-01-21 21:03:09 +00:00
b253ce5e70 [DOCS][Design] Add guidelines menu entry 2022-01-21 21:03:09 +00:00
c4f9e58e8d [COMPONENTS][Attachment] Fixed typo on attachmentShowWithNote, where the template called was somehow replaced with a child of it 2022-01-21 21:03:09 +00:00
6ab740d780 [COMPONENT][Search][UI] Fix template, which included the search builder form inside the search form, chaos ensuing 2022-01-21 21:03:09 +00:00
de795b78f9 [DOCKER][DEPENDENCIES] Restructure PHP Dockerfile to install each package in separate layers and add WikiMedia texvc 2022-01-21 21:03:09 +00:00
29d498770c [COMPONENTS][Group] Create a group route added, template polished
[COMPONENTS][Circle] Removed any Group related route from shouldAddToRightPanel event
[CARDS][Profile] Block should now allow inline long nicknames to not
break
2022-01-21 21:03:09 +00:00
d7039b1c5c [COMPONENTS][Group] Create a group route added, template polished
[COMPONENTS][Circle] Removed any Group related route from shouldAddToRightPanel event
[CARDS][Profile] Block should now allow inline long nicknames to not
break
2022-01-21 21:03:08 +00:00
1856af68b3 [PLUGIN][RepeatNote][COMPONENTS][Posting] Review and fix RepeatNote. Handle attachment lives in Posting 2022-01-21 21:03:08 +00:00
9bd1f42843 [TOOLS] Use sudo to remove files 2022-01-21 21:03:08 +00:00
145c88d43f [ENTITY][Note] Only attempt to find mentions if we have content 2022-01-21 21:03:08 +00:00
4717dde12e [TWIG][I18N] Improve base template facilitate translations of the accessibility panel text 2022-01-21 21:03:08 +00:00
c028a601a5 [COMPONENTS][Group] Create a group route added, template polished
[COMPONENTS][Circle] Removed any Group related route from shouldAddToRightPanel event
[CARDS][Profile] Block should now allow inline long nicknames to not
break
2022-01-21 21:03:08 +00:00
692ecf1c99 [TWIG] Improved templates HTML structure, removed unnecessary element nesting, and refactored content sectioning
[COMPONENTS][Search] Refactored widget event as 'PrependRightPanel' (making it able to accomodate more generic blocks)
2022-01-21 21:03:08 +00:00
242fe3fd6e [PLUGINS][PinnedNotes] Replacing arbitary size values with common variables 2022-01-21 21:03:08 +00:00
dbdf1d9b0b [CSS] Fixed footer responsiveness, since its content wouldn't wrap up from insuficient space for all of its content 2022-01-21 21:03:08 +00:00
7daa61500d [COMPONENTS][Collection] Notes collection template now has a default title
[CARDS][Note] Removed note actions from minimal note block
2022-01-21 21:03:07 +00:00
077cbcf424 [TWIG] Improved accessibility menu
[COMPONENTS][RightPanel] Content form row is now preceeded by the content type form row
2022-01-21 21:03:07 +00:00
04431885aa [PLUGIN][PinnedNotes] Fix ActivityPub config 2022-01-21 21:03:07 +00:00
b8a35f9d6d [PLUGIN][WebMonetization] Fix ActivityPub config 2022-01-21 21:03:07 +00:00
184d0246a5 [COMPONENTS][RightPanel] AppendRightPanelBlock event refactored,
replaced with src/Twig/Rintime::getRightPanelBlocks
[COMPONENTS] Re-ordered onAppendRightPanelBlock event calls arguments for improved consistency across events
2022-01-21 21:03:07 +00:00
da7ae5e1f5 [TESTS][A11Y] Login and check all user pages for accessibility 2022-01-21 21:03:07 +00:00
9e4aed84f8 [PLUGIN][LatexNotes] add LaTeX support for notes 2022-01-21 21:03:07 +00:00
db42ade2b6 [PLUGIN][MarkdownNotes] add markdown support for notes 2022-01-21 21:03:07 +00:00
06d11d8337 [PLUGINS[WebMonetization] Basic activityPub support 2022-01-21 21:03:07 +00:00
148dd6db50 [PLUGINS][PinnedNotes] Basic activityPub support 2022-01-21 21:03:06 +00:00
21c7912702 [PLUGIN][Pinned Notes] Allow user to pin his notes 2022-01-21 21:03:06 +00:00
f7cbfbff8c [COMPONENT][Collection] Add event to render html before drawing feed 2022-01-21 21:03:06 +00:00
3f0d996dc9 [COMPONENT][Tag] Fix event handling 2022-01-21 21:03:06 +00:00
9e891ed020 [TOOLS][PHPStan] Ignore errors due to lack of namespace in CodeCeption acceptance tester 2022-01-21 21:03:06 +00:00
6c6c0270c5 [TESTS][CodeCeption] Update acceptance tests to remove accesibility test kludge 2022-01-21 21:03:06 +00:00
a59997b41f [TOOLS][DOCKER][A11Y] Switch to Pa11y CI and don't run it 'integrated' with codeception, since there was no good way to share cookies 2022-01-21 21:03:06 +00:00
d542be1df4 [ACCESSIBILITY] Fix regressions in panel checkboxes and accessibility menu accesskeys
Accessibility menu accesskey regressions introduced with [ACCESSIBILITY][BASE] Accessibility menu was unreachable.
2022-01-13 19:47:41 +00:00
eff9318c1d [UTIL][Formatting] Mention title is not always defined 2022-01-13 18:07:19 +00:00
fa9df9962e [COMPONENTS][Conversation] Route 'conversation_mute' now has the
corresponding conversation view embedded, user is also redirected
properly
[PLUGINS][Favourite] Fixed typo
2022-01-13 17:47:47 +00:00
859bf0c0bf [CONTROLLER][UserPanel] Notification settings panel debug information added for future reference 2022-01-13 16:24:23 +00:00
d29e28b829 [CORE][Entity] Replaced get_called_class() calls with static::class since the former is deprecated 2022-01-13 16:24:12 +00:00
14b03c7137 [UI][UserPanel] Do not try to re-set an unchanged nickname 2022-01-12 17:46:13 +00:00
480f570238 [CORE][SECURITY][HTML] Refactor Security::sanitize to HTML::sanitize
Update composer dependencies, move more general deps from ActivityPub to Core
2022-01-12 17:12:58 +00:00
968b1751fd [CSS] Further styling optimizations, compacting common rules. Removed select dropdown images, since they are no longer required 2022-01-12 16:42:33 +00:00
c8daa82c1d [TWIG] Replaced base.css @import with HTML link imports
According to 'High Performance Web Sites' (ISBN 10: 0596529309), @import has a negative impact on web page performance. Since all imports dependant on base.css will only start downloading after that one is fully gathered.
2022-01-12 16:32:47 +00:00
600a1511cb [TWIG] Removed all instances were 'arrow-down' icon was called in twig templates, replaced it's intended feedback by using :after pseudo-selector within CSS
In user panel render time reduced dramatically, by ~70ms. Said icon was requested by twig >10 times.
2022-01-12 16:07:28 +00:00
59b8bdf99b [PLUGIN][ActivityPub] Provide ActivityStreams 2.0 responses for every Collection
Implemented ActivityPub Outbox
2022-01-11 20:30:25 +00:00
f3a7e8f04d [TOOLS] Remove CodeCeption files from composer autoload-dev, since they're not available before installing, and ignore errors in PHPStan 2022-01-10 23:14:43 +00:00
65504b72bb [TOOLS] Setup pa11y to run on pages after codeception 2022-01-10 23:09:39 +00:00
José Marques
d713429d88 [CORE][Nickname] Properly set nickname for existing accounts 2022-01-10 11:33:13 +00:00
1056bc661f [COMPONENT][FreeNetwork] Restore Galaxy feed 2022-01-10 10:29:55 +00:00
f40eb3955f [TOOLS] Update makefile to add an acceptance and accesibility testing target 2022-01-09 14:45:33 +00:00
b2b445d21e [TOOLS][DOCKER] Add pa11y and nginx container to tooling toolchain 2022-01-09 14:44:56 +00:00
528f6df240 [TOOLS][PHPStan] Ignore autogenerated Codeception classes 2022-01-09 14:44:08 +00:00
894c78bf99 [TOOLS] Keep git in docker image, since we use patched composer packages, temporarily 2022-01-09 14:43:33 +00:00
38baa192d8 [DEPENDENCIES][TOOL] Add codeception and bootstrap it for acceptance and accessibility testing 2022-01-09 14:42:16 +00:00
a697399a6f [PLUGIN][DeleteNote][Favourite][ProfileColor][RepeatNote][WebMonetization] Refactor, cleanup and cache results 2022-01-09 12:29:34 +00:00
cdf1d67d0f [CORE][Cache] Workaround to redis not allowing empty lists 2022-01-09 12:29:34 +00:00
06ece5b72e [COMPONENT][Collection] Only run queries if the criteria is not empty 2022-01-09 12:29:34 +00:00
da6d3bd351 [COMPONENT][Collection] Use current locale 2022-01-09 12:29:34 +00:00
c835fc6aca [COMPONENT][Collection][Feed][Attachment][Feed][Language][Tag] Refactor and consolidate Search and Feed query mechanisms into Collection. Remame 'onSearch' events to 'onCollectionQuery' 2022-01-09 12:29:34 +00:00
57604b3851 [PLUGIN][NoteTypeFilter] Always show filtering options 2022-01-09 12:29:34 +00:00
b1abd81aca [DEPENDENCIES] Update dependencies 2022-01-08 00:11:12 +00:00
5cfed3d536 [TWIG] Display errors in templates that display forms with form_start 2022-01-08 00:11:08 +00:00
0758d6145b [COMPONENT][Collection][CONTROLLER][Collection] Use null-safe calls to attempt to get a language 2022-01-08 00:07:32 +00:00
d17f276419 [COMPONENTS][Conversation] Added missing foreign keys to ConversationMute Entity
Fixed 'is_muted' variable check logic that impeded the Conversation from being muted
2022-01-07 21:14:51 +00:00
fc57b3290e [COMPONENTS][Search] Polished results page HTML view 2022-01-07 21:14:51 +00:00
1438433859 [PLUGINS][NoteTypeFeedFilter] Polish feed actions HTML, adding proper anchor titles and better user feedback when a filter in applied
[COMPONENTS][Collection] Notes feed template HTML polish to accomodate changes needed for NoteTypeFeedFilter
2022-01-07 21:14:51 +00:00
cb1dc4c10f [PLUGIN][WebMonetization] Adding Web Monetization plugin which allows for donations using the Web Monetizations protocol 2022-01-07 14:55:35 -03:00
9cf8970603 [TEMPLATES][Base] AppendToHead event added to base template 2022-01-07 14:53:55 -03:00
c3d58c350e [COMPONENTS][Collections] Iterating documentation 2022-01-07 09:23:37 -03:00
e056920de4 [COMPONENT][Subscription] Fix Notifications 2022-01-06 12:13:11 +00:00
0c245fcb6e [COMPONENTS][Subscription] Subscribe Actor action implemented
[TWIG] AddProfileAction event added
[CARDS][Profile] Refactor and restyling to accomodate Actor actions
2022-01-06 12:13:10 +00:00
0d1ab2c9cf [SECURITY][Register] New users should have their current browser language set as first language preference 2022-01-05 04:19:35 +00:00
3f8fab0021 [PLUGIN][Favourite] Fix routes 2022-01-05 04:19:35 +00:00
cd6ce3542e [COMPONENT][Circle] Move circles to a component, various bug fixes
Mention links are now correct
2022-01-05 04:19:22 +00:00
627d92b290 [COMPONENT][Tag] Improve Note Tag Handling and start extracting Circles logic out of the plugin, various bug fixes 2022-01-05 01:30:02 +00:00
ee007befa4 [COMPONENT][Posting] DB::Flush after Notification and fix minor issues with In targets 2022-01-05 01:30:01 +00:00
9df9c6a19c [COMPONENT][Collection] Make MetaCollectionPlugin a trait and abstract collection delete and name update 2022-01-05 01:30:00 +00:00
754135743e [COMPONENT][Subscription] Move respective routes to component 2022-01-05 01:29:27 +00:00
5a0bbfc795 [UTIL][Common][I18N] Use actor's preferred language for _m and utility to retrieve current language even when no actor is logged in 2022-01-05 01:29:26 +00:00
6247dd4c1a [COMPONENT][RightPanel] Display form errors 2022-01-04 18:58:32 +00:00
de8eab2cf8 [CORE][FORM][FormTypeNonceExtension] Add a nonce to all forms with a CSRF token 2022-01-04 18:58:32 +00:00
b7e4f79ccc [CORE][Cache] Add Cache::incr which increments a value at , atomically, in the case of Redis 2022-01-04 18:58:32 +00:00
a5b5362be2 [DOCS][Designer] General guidelines for styling initiated
Added wireframes of default page, dividing page into 4 distinct general
areas.

Added CSS classes reference table.
2022-01-04 00:02:21 +00:00
209 changed files with 7624 additions and 3537 deletions

View File

@@ -37,10 +37,16 @@ database-force-schema-update:
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c "/var/www/social/bin/console doctrine:schema:update --dump-sql --force"
tooling-docker: .PHONY
@cd docker/tooling && docker-compose up -d > /dev/null 2>&1
@cd docker/tooling && docker-compose up -d --build > /dev/null 2>&1
accessibility: .PHONY
@cd docker/accessibility && docker-compose up
stop-tooling: .PHONY
cd docker/tooling && docker-compose down
tooling-php-shell: tooling-docker
docker exec -it $(call translate-container-name,tooling_php_1) sh
test-accesibility: tooling-docker
cd docker/tooling && docker-compose run pa11y /accessibility.sh
test: tooling-docker
docker exec $(call translate-container-name,tooling_php_1) /var/tooling/coverage.sh $(call args,'')
@@ -54,14 +60,11 @@ doc-check: tooling-docker
phpstan: tooling-docker
bin/phpstan
stop-tooling: .PHONY
cd docker/tooling && docker-compose down
remove-var:
rm -rf var/*
remove-file:
rm -rf file/*
sudo rm -rf file/*
flush-redis-cache:
docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli flushall'

10
codeception.yml Normal file
View File

@@ -0,0 +1,10 @@
paths:
tests: tests/CodeCeption
output: tests/CodeCeption/_output
data: tests/CodeCeption/_data
support: tests/CodeCeption/_support
envs: tests/CodeCeption/_envs
actor_suffix: Tester
extensions:
enabled:
- Codeception\Extension\RunFailed

View File

@@ -68,7 +68,7 @@ class Attachment extends Component
return Event::next;
}
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_qb->leftJoin(E\AttachmentToNote::class, 'attachment_to_note', Expr\Join::WITH, 'note.id = attachment_to_note.note_id');
return Event::next;
@@ -77,7 +77,7 @@ class Attachment extends Component
/**
* Populate $note_expr with the criteria for looking for notes with attachments
*/
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
{
$include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Component\Circle;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\Feed;
use App\Entity\LocalUser;
use App\Util\Common;
use App\Util\Nickname;
use Component\Circle\Controller as CircleController;
use Component\Circle\Entity\ActorCircle;
use Component\Circle\Entity\ActorCircleSubscription;
use Component\Circle\Entity\ActorTag;
use Component\Collection\Util\MetaCollectionTrait;
use Component\Tag\Tag;
use Functional as F;
use Symfony\Component\HttpFoundation\Request;
/**
* Component responsible for handling and representing ActorCircles and ActorTags
*
* @author Hugo Sales <hugo@hsal.es>
* @author Phablulo <phablulo@gmail.com>
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Circle extends Component
{
use MetaCollectionTrait;
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
protected string $slug = 'circle';
protected string $plural_slug = 'circles';
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('actor_circle_view_by_circle_id', '/circle/{circle_id<\d+>}', [CircleController\Circle::class, 'circleById']);
// View circle members by (tagger id or nickname) and tag
$r->connect('actor_circle_view_by_circle_tagger_tag', '/circle/actor/{tagger_id<\d+>/{tag<' . Tag::TAG_SLUG_REGEX . '>}}', [CircleController\Circle::class, 'circleByTaggerIdAndTag']);
$r->connect('actor_circle_view_by_circle_tagger_tag', '/circle/@{nickname<' . Nickname::DISPLAY_FMT . '>}/{tag<' . Tag::TAG_SLUG_REGEX . '>}', [CircleController\Circle::class, 'circleByTaggerNicknameAndTag']);
// View all circles by actor id or nickname
$r->connect(
id: 'actor_circles_view_by_actor_id',
uri_path: '/actor/{tag<' . Tag::TAG_SLUG_REGEX . '>}/circles',
target: [CircleController\Circles::class, 'collectionsViewByActorId'],
);
$r->connect(
id: 'actor_circles_view_by_nickname',
uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/circles',
target: [CircleController\Circles::class, 'collectionsViewByActorNickname'],
);
$r->connect('actor_circle_view_feed_by_circle_id', '/circle/{circle_id<\d+>}/feed', [CircleController\Circles::class, 'feedByCircleId']);
// View circle feed by (tagger id or nickname) and tag
$r->connect('actor_circle_view_feed_by_circle_tagger_tag', '/circle/actor/{tagger_id<\d+>/{tag<' . Tag::TAG_SLUG_REGEX . '>}}/feed', [CircleController\Circles::class, 'feedByTaggerIdAndTag']);
$r->connect('actor_circle_view_feed_by_circle_tagger_tag', '/circle/@{nickname<' . Nickname::DISPLAY_FMT . '>}/{tag<' . Tag::TAG_SLUG_REGEX . '>}/feed', [CircleController\Circles::class, 'feedByTaggerNicknameAndTag']);
return Event::next;
}
public static function cacheKeys(string $tag_single_or_multi): array
{
return [
'actor_single' => "actor-tag-feed-{$tag_single_or_multi}",
'actor_multi' => "actor-tags-feed-{$tag_single_or_multi}",
];
}
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool
{
if ($section === 'profile' && $request->get('_route') === 'settings') {
$tabs[] = [
'title' => 'Self tags',
'desc' => 'Add or remove tags on yourself',
'id' => 'settings-self-tags',
'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'),
];
}
return Event::next;
}
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): bool
{
$circles = $actor->getCircles();
foreach ($circles as $circle) {
$tag = $circle->getTag();
$targets["#{$tag}"] = $tag;
}
return Event::next;
}
// Meta Collection -------------------------------------------------------------------
private function getActorIdFromVars(array $vars): int
{
$id = $vars['request']->get('id', null);
if ($id) {
return (int) $id;
}
$nick = $vars['request']->get('nickname');
$user = LocalUser::getByNickname($nick);
return $user->getId();
}
public static function createCircle(Actor|int $tagger_id, string $tag): int
{
$tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId();
$circle = ActorCircle::create([
'tagger' => $tagger_id,
'tag' => $tag,
'description' => null, // TODO
'private' => false, // TODO
]);
DB::persist($circle);
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
return $circle->getId();
}
protected function createCollection(Actor $owner, array $vars, string $name)
{
$this->createCircle($owner, $name);
DB::persist(ActorTag::create([
'tagger' => $owner->getId(),
'tagged' => self::getActorIdFromVars($vars),
'tag' => $name,
]));
}
protected function removeItem(Actor $owner, array $vars, $items, array $collections)
{
$tagger_id = $owner->getId();
$tagged_id = $this->getActorIdFromVars($vars);
$circles_to_remove_tagged_from = DB::findBy(ActorCircle::class, ['id' => $items]);
foreach ($circles_to_remove_tagged_from as $circle) {
DB::removeBy(ActorCircleSubscription::class, ['actor_id' => $tagged_id, 'circle_id' => $circle->getId()]);
}
$tags = F\map($circles_to_remove_tagged_from, fn ($x) => $x->getTag());
foreach ($tags as $tag) {
DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]);
}
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
}
protected function addItem(Actor $owner, array $vars, $items, array $collections)
{
$tagger_id = $owner->getId();
$tagged_id = $this->getActorIdFromVars($vars);
$circles_to_add_tagged_to = DB::findBy(ActorCircle::class, ['id' => $items]);
foreach ($circles_to_add_tagged_to as $circle) {
DB::persist(ActorCircleSubscription::create(['actor_id' => $tagged_id, 'circle_id' => $circle->getId()]));
}
$tags = F\map($circles_to_add_tagged_to, fn ($x) => $x->getTag());
foreach ($tags as $tag) {
DB::persist(ActorTag::create(['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]));
}
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
}
/**
* @see MetaCollectionPlugin->shouldAddToRightPanel
*/
protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool
{
return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id']);
}
protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array
{
$tagged_id = !\is_null($vars) ? $this->getActorIdFromVars($vars) : null;
$circles = \is_null($tagged_id) ? $owner->getCircles() : F\select($owner->getCircles(), function ($x) use ($tagged_id) {
foreach ($x->getActorTags() as $at) {
if ($at->getTagged() === $tagged_id) {
return true;
}
}
return false;
});
return $ids_only ? array_map(fn ($x) => $x->getId(), $circles) : $circles;
}
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
{
DB::persist(Feed::create([
'actor_id' => $actor_id,
'url' => Router::url($route = 'actor_circles_view_by_nickname', ['nickname' => $user->getNickname()]),
'route' => $route,
'title' => _m('Circles'),
'ordering' => $ordering++,
]));
return Event::next;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Component\Circle\Controller;
use function App\Core\I18n\_m;
use App\Entity\LocalUser;
use App\Util\Exception\ClientException;
use Component\Circle\Entity\ActorCircle;
use Component\Collection\Util\Controller\CircleController;
class Circle extends CircleController
{
public function circleById(int|ActorCircle $circle_id): array
{
$circle = \is_int($circle_id) ? ActorCircle::getByPK(['id' => $circle_id]) : $circle_id;
unset($circle_id);
if (\is_null($circle)) {
throw new ClientException(_m('No such circle.'), 404);
} else {
return [
'_template' => 'collection/actors.html.twig',
'title' => _m('Circle'),
'empty_message' => _m('No members.'),
'sort_form_fields' => [],
'page' => $this->int('page') ?? 1,
'actors' => $circle->getTaggedActors(),
];
}
}
public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array
{
return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag]));
}
public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array
{
return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag]));
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Component\Circle\Controller;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\LocalUser;
use Component\Circle\Entity\ActorCircle;
use Component\Collection\Util\Controller\MetaCollectionController;
class Circles extends MetaCollectionController
{
protected string $slug = 'circle';
protected string $plural_slug = 'circles';
protected string $page_title = 'Actor circles';
public function createCollection(int $owner_id, string $name)
{
return \Component\Circle\Circle::createCircle($owner_id, $name);
}
public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string
{
return Router::url(
'actor_circle_view_by_circle_id',
['circle_id' => $collection_id],
);
}
public function getCollectionItems(int $owner_id, $collection_id): array
{
$notes = []; // TODO: Use Feed::query
return [
'_template' => 'collection/notes.html.twig',
'notes' => $notes,
];
}
public function feedByCircleId(int $circle_id)
{
// Owner id isn't used
return $this->getCollectionItems(0, $circle_id);
}
public function feedByTaggerIdAndTag(int $tagger_id, string $tag)
{
// Owner id isn't used
$circle_id = ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])->getId();
return $this->getCollectionItems($tagger_id, $circle_id);
}
public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag)
{
$tagger_id = LocalUser::getByNickname($tagger_nickname)->getId();
$circle_id = ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])->getId();
return $this->getCollectionItems($tagger_id, $circle_id);
}
public function getCollectionsByActorId(int $owner_id): array
{
return DB::findBy(ActorCircle::class, ['tagger' => $owner_id], order_by: ['id' => 'desc']);
}
public function getCollectionBy(int $owner_id, int $collection_id): ActorCircle
{
return DB::findOneBy(ActorCircle::class, ['id' => $collection_id, 'actor_id' => $owner_id]);
}
public function setCollectionName(int $actor_id, string $actor_nickname, ActorCircle $collection, string $name)
{
foreach ($collection->getActorTags(db_reference: true) as $at) {
$at->setTag($name);
}
$collection->setTag($name);
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
}
public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection)
{
foreach ($collection->getActorTags(db_reference: true) as $at) {
DB::remove($at);
}
DB::remove($collection);
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types = 1);
namespace Component\Circle\Controller;
use App\Core\Cache;
use App\Core\Controller;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Entity as E;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use Component\Circle\Entity\ActorCircle;
use Component\Circle\Entity\ActorTag;
use Component\Circle\Form\SelfTagsForm;
use Component\Tag\Tag as CompTag;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
class SelfTagsSettings extends Controller
{
/**
* Generic settings page for an Actor's self tags
*/
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
{
$actor = Common::actor();
if (!$actor->canAdmin($target)) {
throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()]));
}
$actor_self_tags = $target->getSelfTags();
[$add_form, $existing_form] = SelfTagsForm::handleTags(
$request,
$actor_self_tags,
handle_new: /**
* Handle adding tags
*/
function ($form) use ($request, $target, $details_id) {
$data = $form->getData();
$tags = $data['new-tags'];
foreach ($tags as $tag) {
$tag = CompTag::sanitize($tag);
[$actor_tag, $actor_tag_existed] = ActorTag::createOrUpdate([
'tagger' => $target->getId(), // self tag means tagger = tagger in ActorTag
'tagged' => $target->getId(),
'tag' => $tag,
]);
if (!$actor_tag_existed) {
DB::persist($actor_tag);
// Try to find the self-tag circle
$actor_circle = DB::findOneBy(
ActorCircle::class,
[
'tagger' => null, // Self-tag circle
'tag' => $tag,
],
return_null: true,
);
// It is the first time someone uses this self-tag!
if (\is_null($actor_circle)) {
DB::persist(ActorCircle::create([
'tagger' => null, // Self-tag circle
'tag' => $tag,
'private' => false, // by definition
'description' => null, // The controller can show this in every language as appropriate
]));
}
}
}
DB::flush();
Cache::delete(E\Actor::cacheKeys($target->getId())['self-tags']);
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id, '_fragment' => $details_id]);
},
handle_existing: /**
* Handle changes to the existing tags
*/
function ($form, array $form_definition) use ($request, $target, $details_id) {
$changed = false;
foreach (array_chunk($form_definition, 2) as $entry) {
$tag = CompTag::sanitize($entry[0][2]['data']);
/** @var SubmitButton $remove */
$remove = $form->get($entry[1][0]);
if ($remove->isClicked()) {
$changed = true;
DB::removeBy(
'actor_tag',
[
'tagger' => $target->getId(),
'tagged' => $target->getId(),
'tag' => $tag,
],
);
// We intentionally leave the self-tag actor circle, even if it is now empty
}
}
if ($changed) {
DB::flush();
Cache::delete(E\Actor::cacheKeys($target->getId())['self-tags']);
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id, '_fragment' => $details_id]);
}
},
remove_label: _m('Remove self tag'),
add_label: _m('Add self tag'),
);
return [
'_template' => 'self_tags_settings.fragment.html.twig',
'add_self_tags_form' => $add_form->createView(),
'existing_self_tags_form' => $existing_form?->createView(),
];
}
}

View File

@@ -19,7 +19,7 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Circle\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
@@ -49,14 +49,12 @@ class ActorCircle extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private ?int $tagger = null;
private int $tagged;
private ?int $tagger = null; // If null, is the special global self-tag circle
private string $tag;
private bool $use_canonical;
private ?string $description = null;
private ?bool $private = false;
private \DateTimeInterface $created;
private \DateTimeInterface $modified;
private ?bool $private = false;
private DateTimeInterface $created;
private DateTimeInterface $modified;
public function setId(int $id): self
{
@@ -80,20 +78,9 @@ class ActorCircle extends Entity
return $this->tagger;
}
public function setTagged(int $tagged): self
{
$this->tagged = $tagged;
return $this;
}
public function getTagged(): int
{
return $this->tagged;
}
public function setTag(string $tag): self
{
$this->tag = \mb_substr($tag, 0, 64);
$this->tag = mb_substr($tag, 0, 64);
return $this;
}
@@ -102,17 +89,6 @@ class ActorCircle extends Entity
return $this->tag;
}
public function setUseCanonical(bool $use_canonical): self
{
$this->use_canonical = $use_canonical;
return $this;
}
public function getUseCanonical(): bool
{
return $this->use_canonical;
}
public function setDescription(?string $description): self
{
$this->description = $description;
@@ -135,24 +111,24 @@ class ActorCircle extends Entity
return $this->private;
}
public function setCreated(\DateTimeInterface $created): self
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): \DateTimeInterface
public function getCreated(): DateTimeInterface
{
return $this->created;
}
public function setModified(\DateTimeInterface $modified): self
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
public function getModified(): \DateTimeInterface
public function getModified(): DateTimeInterface
{
return $this->modified;
}
@@ -160,64 +136,86 @@ class ActorCircle extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
public function getActorTag()
/**
* For use with MetaCollection trait only
*/
public function getName(): string
{
return $this->tag;
}
public function getActorTags(bool $db_reference = false): array
{
$handle = fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
if ($db_reference) {
return $handle();
}
return Cache::get(
"circle-{$this->getId()}-tagged",
$handle,
);
}
public function getTaggedActors()
{
return Cache::get(
"actor-tag-{$this->getTag()}",
fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'canonical' => $this->getTag()], limit: 1)[0], // TODO jank
"circle-{$this->getId()}-tagged-actors",
function () {
if ($this->getTagger()) {
return DB::dql('SELECT a FROM actor AS a JOIN actor_tag AS at WITH at.tagged = a.id WHERE at.tag = :tag AND at.tagger = :tagger', ['tag' => $this->getTag(), 'tagger' => $this->getTagger()]);
} else { // Self-tag
return DB::dql('SELECT a FROM actor AS a JOIN actor_tag AS at WITH at.tagged = a.id WHERE at.tag = :tag AND at.tagger = at.tagged', ['tag' => $this->getTag()]);
}
},
);
}
public function getSubscribedActors(?int $offset = null, ?int $limit = null): array
{
return Cache::get(
"circle-{$this->getId()}",
"circle-{$this->getId()}-subscribers",
fn () => DB::dql(
<<< 'EOQ'
SELECT a
FROM App\Entity\Actor a
JOIN App\Entity\ActorCircleSubscription s
FROM actor a
JOIN actor_circle_subscription s
WITH a.id = s.actor_id
ORDER BY s.created DESC, a.id DESC
EOQ,
options: ['offset' => $offset,
'limit' => $limit, ],
options: [
'offset' => $offset,
'limit' => $limit,
],
),
);
}
public function getUrl(int $type = Router::ABSOLUTE_PATH): string {
return Router::url('actor_circle', ['actor_id' => $this->getTagger(), 'tag' => $this->getTag()]);
public function getUrl(int $type = Router::ABSOLUTE_PATH): string
{
return Router::url('actor_circle_view_by_circle_id', ['circle_id' => $this->getId()], type: $type);
}
public static function schemaDef(): array
{
return [
'name' => 'actor_circle',
'description' => 'a actor can have lists of actors, to separate their feed',
'description' => 'An actor can have lists of actors, to separate their feed or quickly mention his friend',
'fields' => [
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], // An actor can be tagged by many actors
'tagger' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'description' => 'user making the tag'],
'tagged' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagged_fkey', 'not null' => true, 'description' => 'actor tagged'],
'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'ActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'actor tag'], // Join with ActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is // Many Actor Circles can reference (and probably will) an Actor Tag
'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to block canonical tags'],
'description' => ['type' => 'text', 'description' => 'description of the people tag'],
'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'], // An actor can be tagged by many actors
'tagger' => ['type' => 'int', 'default' => null, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'many to one', 'name' => 'actor_list_tagger_fkey', 'description' => 'user making the tag, null if self-tag'],
'tag' => ['type' => 'varchar', 'length' => 64, 'foreign key' => true, 'target' => 'ActorTag.tag', 'multiplicity' => 'many to one', 'not null' => true, 'description' => 'actor tag'], // Join with ActorTag // // so, Doctrine doesn't like that the target is not unique, even though the pair is // Many Actor Circles can reference (and probably will) an Actor Tag
'description' => ['type' => 'text', 'description' => 'description of the people tag'],
'private' => ['type' => 'bool', 'default' => false, 'description' => 'is this tag private'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['id'],
'primary key' => ['id'], // But we will mostly refer to them with `tagger` and `tag`
'indexes' => [
'actor_list_modified_idx' => ['modified'],
'actor_list_tagger_tag_idx' => ['tagger', 'tag'], // The actual identifier we will use the most
'actor_list_tag_idx' => ['tag'],
'actor_list_tagger_tag_idx' => ['tagger', 'tag'],
'actor_list_tagger_idx' => ['tagger'],
],
];
}
public function __toString()
{
return $this->getTag();
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@@ -17,7 +19,7 @@
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Circle\Entity;
use App\Core\Entity;
use DateTimeInterface;
@@ -45,8 +47,8 @@ class ActorCircleSubscription extends Entity
// @codeCoverageIgnoreStart
private int $actor_id;
private int $circle_id;
private \DateTimeInterface $created;
private \DateTimeInterface $modified;
private DateTimeInterface $created;
private DateTimeInterface $modified;
public function setActorId(int $actor_id): self
{
@@ -70,24 +72,24 @@ class ActorCircleSubscription extends Entity
return $this->circle_id;
}
public function setCreated(\DateTimeInterface $created): self
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): \DateTimeInterface
public function getCreated(): DateTimeInterface
{
return $this->created;
}
public function setModified(\DateTimeInterface $modified): self
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
public function getModified(): \DateTimeInterface
public function getModified(): DateTimeInterface
{
return $this->modified;
}
@@ -98,18 +100,18 @@ class ActorCircleSubscription extends Entity
public static function schemaDef(): array
{
return [
'name' => 'actor_circle_subscription',
'name' => 'actor_circle_subscription',
'fields' => [
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_circle_subscription_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'],
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_circle_subscription_actor_id_fkey', 'not null' => true, 'description' => 'foreign key to actor table'],
// An actor subscribes many circles; A Circle is subscribed by many actors.
'circle_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'ActorCircle.id', 'multiplicity' => 'one to many', 'name' => 'actor_circle_subscription_actor_circle_fkey', 'not null' => true, 'description' => 'foreign key to actor_circle'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['circle_id', 'actor_id'],
'indexes' => [
'indexes' => [
'actor_circle_subscription_actor_id_idx' => ['actor_id'],
'actor_circle_subscription_created_idx' => ['created'],
'actor_circle_subscription_created_idx' => ['created'],
],
];
}

View File

@@ -19,12 +19,12 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Circle\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Router\Router;
use App\Entity\Actor;
use Component\Tag\Tag;
use DateTimeInterface;
@@ -54,9 +54,7 @@ class ActorTag extends Entity
private int $tagger;
private int $tagged;
private string $tag;
private string $canonical;
private bool $use_canonical;
private \DateTimeInterface $modified;
private DateTimeInterface $modified;
public function setTagger(int $tagger): self
{
@@ -82,7 +80,7 @@ class ActorTag extends Entity
public function setTag(string $tag): self
{
$this->tag = \mb_substr($tag, 0, 64);
$this->tag = mb_substr($tag, 0, 64);
return $this;
}
@@ -91,35 +89,13 @@ class ActorTag extends Entity
return $this->tag;
}
public function setCanonical(string $canonical): self
{
$this->canonical = \mb_substr($canonical, 0, 64);
return $this;
}
public function getCanonical(): string
{
return $this->canonical;
}
public function setUseCanonical(bool $use_canonical): self
{
$this->use_canonical = $use_canonical;
return $this;
}
public function getUseCanonical(): bool
{
return $this->use_canonical;
}
public function setModified(\DateTimeInterface $modified): self
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
public function getModified(): \DateTimeInterface
public function getModified(): DateTimeInterface
{
return $this->modified;
}
@@ -127,18 +103,22 @@ class ActorTag extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function getByActorId(int $actor_id): array
public function getUrl(?Actor $actor = null, int $type = Router::ABSOLUTE_PATH): string
{
return Cache::getList(Actor::cacheKeys($actor_id)['tags'], fn () => DB::dql('select at from actor_tag at join actor a with a.id = at.tagger where a.id = :id', ['id' => $actor_id]));
$params = ['tag' => $this->getTag()];
if (!\is_null($actor)) {
$params['locale'] = $actor->getTopLanguage()->getLocale();
}
return Router::url('single_actor_tag', $params, type: $type);
}
public function getUrl(?Actor $actor = null): string
public function getCircle(): ActorCircle
{
$params = ['canon' => $this->getCanonical(), 'tag' => $this->getTag()];
if (!\is_null($actor)) {
$params['lang'] = $actor->getTopLanguage()->getLocale();
if ($this->getTagger() === $this->getTagged()) { // Self-tag
return DB::findOneBy(ActorCircle::class, ['tagger' => null, 'tag' => $this->getTag()]);
} else {
return DB::findOneBy(ActorCircle::class, ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
}
return Router::url('single_actor_tag', $params);
}
public static function schemaDef(): array
@@ -146,24 +126,16 @@ class ActorTag extends Entity
return [
'name' => 'actor_tag',
'fields' => [
'tagger' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagger_fkey', 'not null' => true, 'description' => 'actor making the tag'],
'tagged' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagged_fkey', 'not null' => true, 'description' => 'actor tagged'],
'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hash tag associated with this actor'],
'canonical' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'ascii slug of tag'],
'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to block canonical tags'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['tagger', 'tagged', 'tag', 'use_canonical'],
'tagger' => ['type' => 'int', 'not null' => true, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagger_fkey', 'description' => 'actor making the tag'],
'tagged' => ['type' => 'int', 'not null' => true, 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'actor_tag_tagged_fkey', 'description' => 'actor tagged'],
'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hashtag associated with this note'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
], // We will always assume the tagger's preferred language for tags and circles
'primary key' => ['tagger', 'tagged', 'tag'],
'indexes' => [
'actor_tag_modified_idx' => ['modified'],
'actor_tag_tagger_tag_idx' => ['tagger', 'tag'], // For Circles
'actor_tag_tagged_idx' => ['tagged'],
],
];
}
public function __toString(): string
{
return $this->getTag();
}
}

View File

@@ -2,13 +2,11 @@
declare(strict_types = 1);
namespace Component\Tag\Form;
namespace Component\Circle\Form;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity as E;
use App\Util\Form\ArrayTransformer;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
@@ -16,31 +14,27 @@ use Symfony\Component\HttpFoundation\Request;
abstract class SelfTagsForm
{
/**
* @param E\ActorTag[]|E\ActorTagBlock[]|E\NoteTagBlock[] $tags
*
* @return array [Form (add), ?Form (existing)]
*/
public static function handleTags(
Request $request,
array $tags,
array $actor_self_tags,
callable $handle_new,
callable $handle_existing,
string $remove_label,
string $add_label,
): array {
$form_definition = [];
foreach ($tags as $tag) {
$canon = $tag->getCanonical();
$form_definition[] = ["{$canon}:old-tag", TextType::class, ['data' => '#' . $tag->getTag(), 'label' => ' ', 'disabled' => true]];
$form_definition[] = ["{$canon}:toggle-canon", SubmitType::class, ['attr' => ['data' => $tag->getUseCanonical()], 'label' => $tag->getUseCanonical() ? _m('Set non-canonical') : _m('Set canonical')]];
$form_definition[] = [$existing_form_name = "{$canon}:remove", SubmitType::class, ['label' => $remove_label]];
foreach ($actor_self_tags as $tag) {
$tag = $tag->getTag();
$form_definition[] = ["{$tag}:old-tag", TextType::class, ['data' => $tag, 'label' => ' ', 'disabled' => true]];
$form_definition[] = [$existing_form_name = "{$tag}:remove", SubmitType::class, ['label' => $remove_label]];
}
$existing_form = !empty($form_definition) ? Form::create($form_definition) : null;
$add_form = Form::create([
['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for yourself (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]],
['new-tags-use-canon', CheckboxType::class, ['label' => _m('Use canonical'), 'help' => _m('Assume this tag is the same as similar tags'), 'required' => false, 'data' => true]],
[$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
]);

View File

@@ -4,8 +4,145 @@ declare(strict_types = 1);
namespace Component\Collection;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Entity\Actor;
use App\Util\Formatting;
use Component\Collection\Util\Parser;
use Component\Subscription\Entity\ActorSubscription;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
class Collection extends Component
{
/**
* Perform a high level query on notes or actors
*
* Supports a variety of query terms and is used both in feeds and
* in search. Uses query builders to allow for extension
*/
public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null): array
{
$note_criteria = null;
$actor_criteria = null;
if (!empty($query = trim($query))) {
[$note_criteria, $actor_criteria] = Parser::parse($query, $locale, $actor);
}
$note_qb = DB::createQueryBuilder();
$actor_qb = DB::createQueryBuilder();
// TODO consider selecting note related stuff, to avoid separate queries (though they're cached, so maybe it's okay)
$note_qb->select('note')->from('App\Entity\Note', 'note')->orderBy('note.created', 'DESC')->addOrderBy('note.id', 'DESC');
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor')->orderBy('actor.created', 'DESC')->addOrderBy('actor.id', 'DESC');
Event::handle('CollectionQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
$notes = [];
$actors = [];
if (!\is_null($note_criteria)) {
$note_qb->addCriteria($note_criteria);
$notes = $note_qb->getQuery()->execute();
}
if (!\is_null($actor_criteria)) {
$actor_qb->addCriteria($actor_criteria);
$actors = $actor_qb->getQuery()->execute();
}
// N.B.: Scope is only enforced at FeedController level
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
}
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_qb->leftJoin(ActorSubscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id')
->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
return Event::next;
}
/**
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
* notes, for different types of actors and for the content of text notes
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr)
{
if (str_contains($term, ':')) {
$term = explode(':', $term);
if (Formatting::startsWith($term[0], 'note')) {
switch ($term[0]) {
case 'notes-all':
$note_expr = $eb->neq('note.created', null);
break;
case 'note-local':
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
break;
case 'note-types':
case 'notes-include':
case 'note-filter':
if (\is_null($note_expr)) {
$note_expr = [];
}
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
$note_expr[] = $eb->neq('note.content', null);
} else {
$note_expr[] = $eb->eq('note.content', null);
}
break;
case 'note-conversation':
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
break;
case 'note-from':
case 'notes-from':
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
$type_consts = [];
if ($term[1] === 'subscribed') {
$type_consts = null;
}
foreach (explode(',', $term[1]) as $from) {
if (str_starts_with($from, 'subscribed-')) {
[, $type] = explode('-', $from);
if (\in_array($type, ['actor', 'actors'])) {
$type_consts = null;
} else {
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type));
}
}
}
if (\is_null($type_consts)) {
$note_expr = $subscribed_expr;
} elseif (!empty($type_consts)) {
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
}
break;
}
} elseif (Formatting::startsWith($term, 'actor-')) {
switch ($term[0]) {
case 'actor-types':
case 'actors-include':
case 'actor-filter':
case 'actor-local':
if (\is_null($actor_expr)) {
$actor_expr = [];
}
foreach (
[
Actor::PERSON => ['person', 'people'],
Actor::GROUP => ['group', 'groups'],
Actor::ORGANISATION => ['org', 'orgs', 'organization', 'organizations', 'organisation', 'organisations'],
Actor::BOT => ['bot', 'bots'],
] as $type => $match) {
if (array_intersect(explode(',', $term[1]), $match) !== []) {
$actor_expr[] = $eb->eq('actor.type', $type);
} else {
$actor_expr[] = $eb->neq('actor.type', $type);
}
}
break;
}
}
} else {
$note_expr = $eb->contains('note.content', $term);
}
return Event::next;
}
}

View File

@@ -7,14 +7,14 @@ namespace Component\Collection\Util\Controller;
use App\Core\Controller;
use App\Entity\Actor;
use App\Util\Common;
use Component\Feed\Feed;
use Component\Collection\Collection as CollectionModule;
class Collection extends Controller
{
public function query(string $query, ?string $language = null, ?Actor $actor = null)
public function query(string $query, ?string $locale = null, ?Actor $actor = null): array
{
$actor ??= Common::actor();
$language ??= $actor->getTopLanguage()->getLocale();
return Feed::query($query, $this->int('page') ?? 1, $language, $actor);
$actor ??= Common::actor();
$locale ??= Common::currentLanguage()->getLocale();
return CollectionModule::query($query, $this->int('page') ?? 1, $locale, $actor);
}
}

View File

@@ -77,7 +77,7 @@ abstract class MetaCollectionController extends FeedController
$collections = $this->getCollectionsByActorId($id);
$create_title = _m('Create a ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->slug)));
$collections_title = _m('Your ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->plural_slug)));
$collections_title = _m('The ' . mb_strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $this->plural_slug)));
// create collection form
$create = null;
if (Common::user()?->getId() === $id) {
@@ -127,7 +127,7 @@ abstract class MetaCollectionController extends FeedController
$this->parent = $parent;
$this->slug = $slug;
}
// there's already a injected function called path,
// there's already an injected function called path,
// that maps to Router::url(name, args), but since
// I want to preserve nicknames, I think it's better
// to use that getUrl function
@@ -159,8 +159,7 @@ abstract class MetaCollectionController extends FeedController
]);
$edit->handleRequest($this->request);
if ($edit->isSubmitted() && $edit->isValid()) {
$collection->setName($edit->getData()['name']);
DB::persist($collection);
$this->parent->setCollectionName($this->id, $this->nick, $collection, $edit->getData()['name']);
DB::flush();
throw new RedirectException();
}
@@ -181,7 +180,7 @@ abstract class MetaCollectionController extends FeedController
]);
$rm->handleRequest($this->request);
if ($rm->isSubmitted()) {
DB::remove($collection);
$this->parent->removeCollection($this->id, $this->nick, $collection);
DB::flush();
throw new RedirectException();
}

View File

@@ -35,7 +35,6 @@ use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Modules\Plugin;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\RedirectException;
@@ -45,19 +44,49 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
abstract class MetaCollectionPlugin extends Plugin
trait MetaCollectionTrait
{
protected string $slug = 'collection';
protected string $plural_slug = 'collections';
//protected string $slug = 'collection';
//protected string $plural_slug = 'collections';
/**
* create a collection owned by Actor $owner.
*
* @param Actor $owner The collection's owner
* @param array $vars Page vars sent by AppendRightPanelBlock event
* @param string $name Collection's name
*/
abstract protected function createCollection(Actor $owner, array $vars, string $name);
abstract protected function removeItems(Actor $owner, array $vars, $items, array $collections);
abstract protected function addItems(Actor $owner, array $vars, $items, array $collections);
/**
* remove item from collections.
*
* @param Actor $owner Current user
* @param array $vars Page vars sent by AppendRightPanelBlock event
* @param array $items Array of collections's ids to remove the current item from
* @param array $collections List of ids of collections owned by $owner
*/
abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections);
/**
* add item to collections.
*
* @param Actor $owner Current user
* @param array $vars Page vars sent by AppendRightPanelBlock event
* @param array $items Array of collections's ids to add the current item to
* @param array $collections List of ids of collections owned by $owner
*/
abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections);
/**
* Check the route to determine whether the widget should be added
*/
abstract protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool;
/**
* Get array of collections's owned by $actor
*
* @param Actor $owner Collection's owner
* @param ?array $vars Page vars sent by AppendRightPanelBlock event
* @param bool $ids_only if true, the function must return only the primary key or each collections
*/
abstract protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array;
/**
@@ -65,7 +94,7 @@ abstract class MetaCollectionPlugin extends Plugin
* It's compose of two forms: one to select collections to add
* the current item to, and another to create a new collection.
*/
public function onAppendRightPanelBlock($vars, Request $request, &$res): bool
public function onAppendRightPanelBlock(Request $request, $vars, &$res): bool
{
$user = Common::actor();
if (\is_null($user)) {
@@ -110,10 +139,10 @@ abstract class MetaCollectionPlugin extends Plugin
$removed = array_filter($already_selected, fn ($x) => !\in_array($x, $selected));
$added = array_filter($selected, fn ($x) => !\in_array($x, $already_selected));
if (\count($removed) > 0) {
$this->removeItems($user, $vars, $removed, $collections);
$this->removeItem($user, $vars, $removed, $collections);
}
if (\count($added) > 0) {
$this->addItems($user, $vars, $added, $collections);
$this->addItem($user, $vars, $added, $collections);
}
DB::flush();
throw new RedirectException();

View File

@@ -21,7 +21,7 @@ declare(strict_types = 1);
// }}}
namespace Component\Search\Util;
namespace Component\Collection\Util;
use App\Core\Event;
use App\Entity\Actor;
@@ -53,7 +53,7 @@ abstract class Parser
*
* @return Criteria[]
*/
public static function parse(string $input, ?string $language = null, ?Actor $actor = null, int $level = 0): array
public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array
{
if ($level === 0) {
$input = trim(preg_replace(['/\s+/', '/\s+AND\s+/', '/\s+OR\s+/'], [' ', '&', '|'], $input), ' |&');
@@ -78,17 +78,17 @@ abstract class Parser
$term = mb_substr($input, $left, $end ? null : $right - $left);
$note_res = null;
$actor_res = null;
Event::handle('SearchCreateExpression', [$eb, $term, $language, $actor, &$note_res, &$actor_res]);
Event::handle('CollectionQueryCreateExpression', [$eb, $term, $locale, $actor, &$note_res, &$actor_res]);
if (\is_null($note_res) && \is_null($actor_res)) { // @phpstan-ignore-line
throw new ServerException("No one claimed responsibility for a match term: {$term}");
}
if (!\is_null($note_res) && !empty($note_res)) { // @phpstan-ignore-line
if (!empty($note_res)) { // @phpstan-ignore-line
if (\is_array($note_res)) {
$note_res = $eb->orX(...$note_res);
}
$note_parts[] = $note_res;
}
if (!\is_null($actor_res) && !empty($actor_res)) {
if (!empty($actor_res)) {
if (\is_array($actor_res)) {
$actor_res = $eb->orX(...$actor_res);
}

View File

@@ -3,22 +3,49 @@
{% block title %}{{ title }}{% endblock %}
{% block body %}
<section class="section-widget section-padding">
<h2 class="section-title">{{ title }}</h2>
<div class="frame-section frame-section-padding">
<h1 class="frame-section-title">{{ title }}</h1>
<div>
<p>{% trans %}Sort by:{% endtrans %}</p>
<form method="GET">
{% for field in sort_form_fields %}
<label for="order_by_{{ field.value }}">{{ field.label }}</label>
<input id="order_by_{{ field.value }}" type="radio" name="order_by" value="{{ field.value }}" {% if field.checked %}checked="checked"{% endif %}>
{% endfor %}
<button type="submit" name="order_op" value="ASC">{% trans %}Ascending{% endtrans %}</button>
<button type="submit" name="order_op" value="DESC">{% trans %}Descending{% endtrans %}</button>
<details class="frame-section section-details-title">
<summary class="details-summary-title">
<strong>
{% trans %}Ordering rules{% endtrans %}
</strong>
</summary>
<form method="GET" class="section-form">
<div class="container-grid">
<section class="frame-section frame-section-padding">
<strong>{% trans %}Sort by{% endtrans %}</strong>
<hr>
<div class="container-block">
{% for field in sort_form_fields %}
<span class="container-block">
<label for="order_by_{{ field.value }}">{{ field.label }}</label>
<input id="order_by_{{ field.value }}" type="radio" name="order_by" value="{{ field.value }}" {% if field.checked %}checked="checked"{% endif %}>
</span>
{% endfor %}
</div>
</section>
<section class="frame-section frame-section-padding">
<strong class="section-title">{% trans %}Order{% endtrans %}</strong>
<hr>
<section class="container-block">
<span class="container-block">
<label for="order_op_asc">{% trans %}Ascending{% endtrans %}</label>
<input id="order_op_asc" type="radio" name="order_op" value="ASC">
</span>
<span class="container-block">
<label for="order_op_desc">{% trans %}Descending{% endtrans %}</label>
<input id="order_op_desc" type="radio" name="order_op" value="DESC" checked="checked">
</span>
</section>
</section>
</div>
<button type="submit">{% trans %}Order{% endtrans %}</button>
</form>
</div>
</details>
<div class="section-padding">
<section class="frame-section-padding">
{% if actors is defined and actors is not empty %}
{% for actor in actors %}
{% block profile_view %}{% include 'cards/profile/view.html.twig' %}{% endblock profile_view %}
@@ -26,8 +53,8 @@
{% endfor %}
<p>{% trans %}Page: %page%{% endtrans %}</p>
{% else %}
<h3>{{ empty_message }}</h3>
<h2>{{ empty_message }}</h2>
{% endif %}
</div>
</section>
</section>
</div>
{% endblock body %}

View File

@@ -3,8 +3,8 @@
{% block title %}{{ page_title | trans }}{% endblock %}
{% block body %}
<div class="section-widget section-padding">
<h2 class="section-widget-title">{{ page_title | trans }}</h2>
<div class="frame-section frame-section-padding">
<h2 class="frame-section-title">{{ page_title | trans }}</h2>
{% block collection_items %}
{% endblock collection_items %}
</div>

View File

@@ -3,14 +3,14 @@
{% block title %}{{ page_title | trans }}{% endblock %}
{% block body %}
<div class="section-widget section-padding">
<h2 class="section-widget-title">{{ page_title | trans }}</h2>
<div class="frame-section frame-section-padding">
<h2 class="frame-section-title">{{ page_title | trans }}</h2>
{% if add_collection %}
<div class="section-widget section-form">
<div class="frame-section section-form">
{{ form(add_collection) }}
</div>
{% endif %}
<div class="section-widget collections-list">
<div class="frame-section collections-list">
<h3>{{ list_title | trans }}</h3>
{% for col in collections %}
<div class="collection-item">

View File

@@ -9,21 +9,36 @@
{% endblock stylesheets %}
{% block body %}
<header class="feed-header">
{% if page_title is defined %}
<h1>{{ page_title | trans }}</h1>
{% endif %}
<nav class="feed-actions">
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
{{ block | raw }}
{% endfor %}
</nav>
</header>
{% for block in handle_event('BeforeFeed', app.request) %}
{{ block | raw }}
{% endfor %}
{# Backwards compatibility with hAtom 0.1 #}
<main class="feed" tabindex="0" role="feed">
<div class="h-feed hfeed notes">
{% if notes is defined and notes is not empty %}
{% if notes is defined %}
<header class="feed-header">
{% if page_title is defined %}
<h1 class="heading-no-margin">{{ page_title | trans }}</h1>
{% else %}
<h3 class="heading-no-margin">{{ 'Notes' | trans }}</h3>
{% endif %}
<nav class="feed-actions">
<details class="feed-actions-details">
<summary>
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
</summary>
<div class="feed-actions-details-dropdown">
<menu>
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
{{ block | raw }}
{% endfor %}
</menu>
</div>
</details>
</nav>
</header>
{% if notes is not empty %}
{# Backwards compatibility with hAtom 0.1 #}
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
{% for conversation in notes %}
{% block current_note %}
{% if conversation is instanceof('array') %}
@@ -34,11 +49,7 @@
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
{% endblock current_note %}
{% endfor %}
{% else %}
<div class="feed-empty">
{{ icon('logo', 'icon feed-background') | raw }}
</div>
{% endif %}
</div>
</main>
</section>
{% endif %}
{% endif %}
{% endblock body %}

View File

@@ -1,28 +1,26 @@
<section class="section-widget collections">
<details class="section-widget-title-details" title="Expand if you want to access more options.">
<summary class="section-title-summary">
<section class="frame-section collections">
<details class="section-details-title" title="Expand if you want to access more options.">
<summary class="details-summary-title">
<h2>{{ctitle}}</h2>
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</summary>
{% if has_collections %}
<fieldset class="section-form">
<section class="section-form">
{{ form(add_form) }}
</fieldset>
</section>
<details class="section-widget-subtitle-details section-padding"
<details class="frame-section-padding section-details-subtitle"
title="Expand if you want to access more options.">
<summary class="section-subtitle-summary">
<summary class="details-summary-subtitle">
<strong>{% trans %}Other options{% endtrans %}</strong>
{{ icon('arrow-down', 'icon icon-details-close') | raw }}
</summary>
<fieldset class="section-form">
<section class="section-form">
{{ form(create_form) }}
</fieldset>
</section>
</details>
{% else %}
<fieldset class="section-form">
<section class="section-form">
{{ form(create_form) }}
</fieldset>
</section>
{% endif %}
</details>
</section>

View File

@@ -31,6 +31,8 @@ use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
@@ -101,23 +103,39 @@ class Conversation extends FeedController
$user = Common::ensureLoggedIn();
$is_muted = ConversationMute::isMuted($conversation_id, $user);
$form = Form::create([
['mute_conversation', SubmitType::class, ['label' => $is_muted ? _m('Mute conversation') : _m('Unmute conversation')]],
['mute_conversation', SubmitType::class, ['label' => $is_muted ? _m('Unmute') : _m('Mute'), 'attr' => ['class' => '']]],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($is_muted) {
if (!$is_muted) {
DB::persist(ConversationMute::create(['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]));
} else {
DB::removeBy('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $user->getId()]);
}
DB::flush();
Cache::delete(ConversationMute::cacheKeys($conversation_id, $user->getId())['mute']);
throw new RedirectException();
// Redirect user to where they came from
// Prevent open redirect
if (!\is_null($from = $this->string('from'))) {
if (Router::isAbsolute($from)) {
Log::warning("Actor {$user->getId()} attempted to mute conversation {$conversation_id} and then get redirected to another host, or the URL was invalid ({$from})");
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
} else {
// TODO anchor on element id
throw new RedirectException(url: $from);
}
} else {
// If we don't have a URL to return to, go to the instance root
throw new RedirectException('root');
}
}
return [
'_template' => 'conversation/mute.html.twig',
'notes' => $this->query(query: "note-conversation:{$conversation_id}")['notes'] ?? [],
'is_muted' => $is_muted,
'form' => $form->createView(),
];
}

View File

@@ -226,10 +226,23 @@ class Conversation extends Component
return Event::next;
}
$from = $request->query->has('from')
? $request->query->get('from')
: $request->getPathInfo();
$mute_extra_action_url = Router::url(
'conversation_mute',
[
'conversation_id' => $note->getConversationId(),
'from' => $from . '#note-anchor-' . $note->getId(),
],
Router::ABSOLUTE_PATH,
);
$actions[] = [
'title' => ConversationMute::isMuted($note, $user) ? _m('Mute conversation') : _m('Unmute conversation'),
'title' => ConversationMute::isMuted($note, $user) ? _m('Unmute conversation') : _m('Mute conversation'),
'classes' => '',
'url' => Router::url('conversation_mute', ['conversation_id' => $note->getConversationId()]),
'url' => $mute_extra_action_url,
];
return Event::next;

View File

@@ -118,11 +118,15 @@ class ConversationMute extends Entity
return [
'name' => 'conversation_mute',
'fields' => [
'conversation_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Conversation.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'The conversation being blocked'],
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Who blocked the conversation'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'conversation_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Conversation.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'The conversation being blocked'],
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Who blocked the conversation'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
],
'primary key' => ['conversation_id', 'actor_id'],
'foreign keys' => [
'conversation_id_to_id_fkey' => ['conversation', ['conversation_id' => 'id']],
'actor_id_to_id_fkey' => ['actor', ['actor_id' => 'id']],
],
'primary key' => ['conversation_id', 'actor_id'],
];
}
}

View File

@@ -1,5 +1,21 @@
{% extends 'base.html.twig' %}
{% extends 'collection/notes.html.twig' %}
{% block body %}
{{ form(form) }}
<div class="frame-section frame-section-padding">
{% if is_muted %}
<span class="frame-section-padding alert">
<label>Do you wish to <b>unmute</b> this conversation?</label>
{{ form(form) }}
</span>
{% else %}
<span class="frame-section-padding alert">
<label>Do you wish to <b>mute</b> this conversation?</label>
{{ form(form) }}
</span>
{% endif %}
<hr>
{{ parent() }}
</div>
{% endblock body %}

View File

@@ -47,10 +47,7 @@ class Feeds extends FeedController
*/
public function public(Request $request): array
{
$data = $this->query(
query: 'note-local:true',
language: Common::actor()?->getTopLanguage()?->getLocale(),
);
$data = $this->query('note-local:true');
return [
'_template' => 'collection/notes.html.twig',
'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'),
@@ -63,13 +60,8 @@ class Feeds extends FeedController
*/
public function home(Request $request): array
{
$user = Common::ensureLoggedIn();
$actor = $user->getActor();
$data = $this->query(
query: 'note-from:subscribed-person,subscribed-group,subscribed-organization,subscribed-business',
language: $actor->getTopLanguage()->getLocale(),
actor: $actor,
);
Common::ensureLoggedIn();
$data = $this->query('note-from:subscribed-person,subscribed-group,subscribed-organisation');
return [
'_template' => 'collection/notes.html.twig',
'page_title' => _m('Home'),

View File

@@ -23,18 +23,10 @@ declare(strict_types = 1);
namespace Component\Feed;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Entity\Actor;
use App\Util\Formatting;
use Component\Feed\Controller as C;
use Component\Search\Util\Parser;
use Component\Subscription\Entity\Subscription;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
class Feed extends Component
{
@@ -44,130 +36,4 @@ class Feed extends Component
$r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']);
return Event::next;
}
/**
* Perform a high level query on notes or actors
*
* Supports a variety of query terms and is used both in feeds and
* in search. Uses query builders to allow for extension
*/
public static function query(string $query, int $page, ?string $language = null, ?Actor $actor = null): array
{
$note_criteria = null;
$actor_criteria = null;
if (!empty($query = trim($query))) {
[$note_criteria, $actor_criteria] = Parser::parse($query, $language, $actor);
}
$note_qb = DB::createQueryBuilder();
$actor_qb = DB::createQueryBuilder();
$note_qb->select('note')->from('App\Entity\Note', 'note')->orderBy('note.created', 'DESC')->addOrderBy('note.id', 'DESC');
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor')->orderBy('actor.created', 'DESC')->addOrderBy('actor.id', 'DESC');
Event::handle('SearchQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
$notes = [];
$actors = [];
if (!\is_null($note_criteria)) {
$note_qb->addCriteria($note_criteria);
}
$notes = $note_qb->getQuery()->execute();
if (!\is_null($actor_criteria)) {
$actor_qb->addCriteria($actor_criteria);
}
$actors = $actor_qb->getQuery()->execute();
// N.B.: Scope is only enforced at FeedController level
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
}
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_qb->leftJoin(Subscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id')
->leftJoin(Actor::class, 'note_actor', Expr\Join::WITH, 'note.actor_id = note_actor.id');
return Event::next;
}
/**
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
* notes, for different types of actors and for the content of text notes
*/
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr)
{
if (str_contains($term, ':')) {
$term = explode(':', $term);
if (Formatting::startsWith($term[0], 'note-')) {
switch ($term[0]) {
case 'note-local':
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
break;
case 'note-types':
case 'notes-include':
case 'note-filter':
if (\is_null($note_expr)) {
$note_expr = [];
}
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
$note_expr[] = $eb->neq('note.content', null);
} else {
$note_expr[] = $eb->eq('note.content', null);
}
break;
case 'note-conversation':
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
break;
case 'note-from':
case 'notes-from':
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
$type_consts = [];
if ($term[1] === 'subscribed') {
$type_consts = null;
}
foreach (explode(',', $term[1]) as $from) {
if (str_starts_with($from, 'subscribed-')) {
[, $type] = explode('-', $from);
if (\in_array($type, ['actor', 'actors'])) {
$type_consts = null;
} else {
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type));
}
}
}
if (\is_null($type_consts)) {
$note_expr = $subscribed_expr;
} elseif (!empty($type_consts)) {
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
}
break;
}
} elseif (Formatting::startsWith($term, 'actor-')) {
switch ($term[0]) {
case 'actor-types':
case 'actors-include':
case 'actor-filter':
case 'actor-local':
if (\is_null($actor_expr)) {
$actor_expr = [];
}
foreach (
[
Actor::PERSON => ['person', 'people'],
Actor::GROUP => ['group', 'groups'],
Actor::ORGANIZATION => ['org', 'orgs', 'organization', 'organizations', 'organisation', 'organisations'],
Actor::BUSINESS => ['business', 'businesses'],
Actor::BOT => ['bot', 'bots'],
] as $type => $match) {
if (array_intersect(explode(',', $term[1]), $match) !== []) {
$actor_expr[] = $eb->eq('actor.type', $type);
} else {
$actor_expr[] = $eb->neq('actor.type', $type);
}
}
break;
}
}
} else {
$note_expr = $eb->contains('note.content', $term);
}
return Event::next;
}
}

View File

@@ -51,10 +51,7 @@ class Feeds extends FeedController
public function network(Request $request): array
{
Common::ensureLoggedIn();
$data = $this->query(
query: 'note-local:false',
language: Common::actor()?->getTopLanguage()?->getLocale(),
);
$data = $this->query('note-local:false');
return [
'_template' => 'collection/notes.html.twig',
'page_title' => _m('Meteorites'),
@@ -71,6 +68,7 @@ class Feeds extends FeedController
public function clique(Request $request): array
{
Common::ensureLoggedIn();
// TODO: maybe make this a Collection::query
$notes = DB::dql(
<<<'EOF'
SELECT n FROM \App\Entity\Note AS n
@@ -99,10 +97,7 @@ class Feeds extends FeedController
public function federated(Request $request): array
{
Common::ensureLoggedIn();
$data = $this->query(
query: '',
language: Common::actor()?->getTopLanguage()?->getLocale(),
);
$data = $this->query('notes-all:yeah');
return [
'_template' => 'collection/notes.html.twig',
'page_title' => _m('Galaxy'),

View File

@@ -20,7 +20,7 @@ class HostMeta extends XrdController
public function setXRD()
{
if (Event::handle('StartHostMetaLinks', [&$this->xrd->links])) {
if (Event::handle('StartHostMetaLinks', [&$this->xrd->links]) !== Event::stop) {
Event::handle('EndHostMetaLinks', [&$this->xrd->links]);
}
}

View File

@@ -32,33 +32,38 @@ use App\Core\UserRoles;
use App\Entity as E;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NicknameEmptyException;
use App\Util\Exception\NicknameInvalidException;
use App\Util\Exception\NicknameNotAllowedException;
use App\Util\Exception\NicknameTakenException;
use App\Util\Exception\NicknameTooLongException;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use App\Util\Form\ActorForms;
use App\Util\Nickname;
use Component\Collection\Util\ActorControllerTrait;
use Component\Collection\Util\Controller\FeedController;
use Component\Group\Entity\GroupMember;
use Component\Group\Entity\LocalGroup;
use Component\Subscription\Entity\Subscription;
use Component\Subscription\Entity\ActorSubscription;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
class Group extends FeedController
{
use ActorControllerTrait;
public function groupViewId(Request $request, int $id)
{
return $this->handleActorById(
$id,
fn ($actor) => [
'_template' => 'group/view.html.twig',
'actor' => $actor,
],
);
}
/**
* View a group feed and give the option of creating it if it doesn't exist
* View a group feed by its nickname
*
* @param string $nickname The group's nickname to be shown
*
* @throws NicknameEmptyException
* @throws NicknameNotAllowedException
* @throws NicknameTakenException
* @throws NicknameTooLongException
* @throws ServerException
*
* @return array
*/
public function groupViewNickname(Request $request, string $nickname)
{
@@ -67,74 +72,27 @@ class Group extends FeedController
$actor = Common::actor();
$subscribe_form = null;
if (\is_null($group)) {
if (!\is_null($actor)) {
$create_form = Form::create([
['create', SubmitType::class, ['label' => _m('Create this group')]],
]);
$create_form->handleRequest($request);
if ($create_form->isSubmitted() && $create_form->isValid()) {
Log::info(
_m(
'Actor id:{actor_id} nick:{actor_nick} created the group {nickname}',
['{actor_id}' => $actor->getId(), 'actor_nick' => $actor->getNickname(), 'nickname' => $nickname],
),
);
DB::persist($group = E\Actor::create([
'nickname' => $nickname,
'type' => E\Actor::GROUP,
'is_local' => true,
'roles' => UserRoles::BOT,
]));
DB::persist(LocalGroup::create([
'group_id' => $group->getId(),
'nickname' => $nickname,
]));
DB::persist(Subscription::create([
'subscriber' => $group->getId(),
'subscribed' => $group->getId(),
]));
DB::persist(GroupMember::create([
'group_id' => $group->getId(),
'actor_id' => $actor->getId(),
'is_admin' => true,
]));
DB::flush();
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscriber']);
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
throw new RedirectException();
}
return [
'_template' => 'group/view.html.twig',
'nickname' => $nickname,
'create_form' => $create_form->createView(),
];
}
} else {
if (!\is_null($actor)
&& \is_null(Cache::get(
Subscription::cacheKeys($actor, $group)['subscribed'],
fn () => DB::findOneBy('subscription', [
'subscriber' => $actor->getId(),
'subscribed' => $group->getId(),
], return_null: true),
))
) {
$subscribe_form = Form::create([['subscribe', SubmitType::class, ['label' => _m('Subscribe to this group')]]]);
$subscribe_form->handleRequest($request);
if ($subscribe_form->isSubmitted() && $subscribe_form->isValid()) {
DB::persist(Subscription::create([
'subscriber' => $actor->getId(),
'subscribed' => $group->getId(),
]));
DB::flush();
Cache::delete(E\Actor::cacheKeys($group->getId())['subscriber']);
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
Cache::delete(Subscription::cacheKeys($actor, $group)['subscribed']);
}
if (!\is_null($group)
&& !\is_null($actor)
&& \is_null(Cache::get(
ActorSubscription::cacheKeys($actor, $group)['subscribed'],
fn () => DB::findOneBy('actor_subscription', [
'subscriber_id' => $actor->getId(),
'subscribed_id' => $group->getId(),
], return_null: true),
))
) {
$subscribe_form = Form::create([['subscribe', SubmitType::class, ['label' => _m('Subscribe to this group')]]]);
$subscribe_form->handleRequest($request);
if ($subscribe_form->isSubmitted() && $subscribe_form->isValid()) {
DB::persist(ActorSubscription::create([
'subscriber_id' => $actor->getId(),
'subscribed_id' => $group->getId(),
]));
DB::flush();
Cache::delete(E\Actor::cacheKeys($group->getId())['subscribers']);
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
Cache::delete(ActorSubscription::cacheKeys($actor, $group)['subscribed']);
}
}
@@ -158,15 +116,93 @@ class Group extends FeedController
];
}
/**
* Page that allows an actor to create a new group
*
* @throws RedirectException
* @throws ServerException
*
* @return array
*/
public function groupCreate(Request $request)
{
if (\is_null($actor = Common::actor())) {
throw new RedirectException('security_login');
}
$create_form = Form::create([
['group_nickname', TextType::class, ['label' => _m('Group nickname')]],
['group_create', SubmitType::class, ['label' => _m('Create this group!')]],
]);
$create_form->handleRequest($request);
if ($create_form->isSubmitted() && $create_form->isValid()) {
$data = $create_form->getData();
$nickname = $data['group_nickname'];
Log::info(
_m(
'Actor id:{actor_id} nick:{actor_nick} created the group {nickname}',
['{actor_id}' => $actor->getId(), 'actor_nick' => $actor->getNickname(), 'nickname' => $nickname],
),
);
DB::persist($group = E\Actor::create([
'nickname' => $nickname,
'type' => E\Actor::GROUP,
'is_local' => true,
'roles' => UserRoles::BOT,
]));
DB::persist(LocalGroup::create([
'group_id' => $group->getId(),
'nickname' => $nickname,
]));
DB::persist(ActorSubscription::create([
'subscriber_id' => $group->getId(),
'subscribed_id' => $group->getId(),
]));
DB::persist(GroupMember::create([
'group_id' => $group->getId(),
'actor_id' => $actor->getId(),
'is_admin' => true,
]));
DB::flush();
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribers']);
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
throw new RedirectException();
}
return [
'_template' => 'group/create.html.twig',
'create_form' => $create_form->createView(),
];
}
/**
* Settings page for the group with the provided nickname, checks if the current actor can administrate given group
*
* @throws ClientException
* @throws NicknameEmptyException
* @throws NicknameInvalidException
* @throws NicknameNotAllowedException
* @throws NicknameTakenException
* @throws NicknameTooLongException
* @throws NoLoggedInUser
* @throws ServerException
*
* @return array
*/
public function groupSettings(Request $request, string $nickname)
{
$group = LocalGroup::getActorByNickname($nickname);
$actor = Common::actor();
if (!\is_null($group) && $actor->canAdmin($group)) {
$local_group = LocalGroup::getByNickname($nickname);
$group_actor = $local_group->getActor();
$actor = Common::actor();
if (!\is_null($group_actor) && $actor->canAdmin($group_actor)) {
return [
'_template' => 'group/settings.html.twig',
'group' => $group,
'personal_info_form' => ActorForms::personalInfo($request, $group)->createView(),
'group' => $group_actor,
'personal_info_form' => ActorForms::personalInfo($request, $actor, $local_group)->createView(),
'open_details_query' => $this->string('open'),
];
} else {

View File

@@ -21,10 +21,18 @@ declare(strict_types = 1);
namespace Component\Group\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Entity\Actor;
use App\Util\Exception\NicknameEmptyException;
use App\Util\Exception\NicknameException;
use App\Util\Exception\NicknameInvalidException;
use App\Util\Exception\NicknameNotAllowedException;
use App\Util\Exception\NicknameTakenException;
use App\Util\Exception\NicknameTooLongException;
use App\Util\Nickname;
use DateTimeInterface;
/**
@@ -114,6 +122,29 @@ class LocalGroup extends Entity
return $res === [] ? null : $res[0];
}
/**
* Checks if desired nickname is allowed, and in case it is, it sets Actor's nickname cache to newly set nickname
*
* @param string $nickname Desired NEW nickname (do not use in local user creation)
*
* @throws NicknameEmptyException
* @throws NicknameException
* @throws NicknameInvalidException
* @throws NicknameNotAllowedException
* @throws NicknameTakenException
* @throws NicknameTooLongException
*
* @return $this
*/
public function setNicknameSanitizedAndCached(string $nickname): self
{
$nickname = Nickname::normalize($nickname, check_already_used: true, which: Nickname::CHECK_LOCAL_GROUP, check_is_allowed: true);
$this->setNickname($nickname);
$this->getActor()->setNickname($nickname);
/// XXX: cache?
return $this;
}
public static function schemaDef(): array
{
return [

View File

@@ -30,16 +30,16 @@ use App\Entity\Actor;
use App\Util\Common;
use App\Util\HTML;
use App\Util\Nickname;
use Component\Circle\Controller\SelfTagsSettings;
use Component\Group\Controller as C;
use Component\Group\Entity\LocalGroup;
use Component\Tag\Controller\Tag as TagController;
use Symfony\Component\HttpFoundation\Request;
class Group extends Component
{
public function onAddRoute(RouteLoader $r): bool
{
$r->connect(id: 'group_actor_view_id', uri_path: '/group/{id<\d+>}', target: [C\Group::class, 'groupViewId']);
$r->connect(id: 'group_create', uri_path: '/group/new', target: [C\Group::class, 'groupCreate']);
$r->connect(id: 'group_actor_view_nickname', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\Group::class, 'groupViewNickname'], options: ['is_system_path' => false]);
$r->connect(id: 'group_settings', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}/settings', target: [C\Group::class, 'groupSettings'], options: ['is_system_path' => false]);
return Event::next;
@@ -54,7 +54,7 @@ class Group extends Component
$group = $vars['actor'];
if (!\is_null($actor) && $group->isGroup() && $actor->canAdmin($group)) {
$url = Router::url('group_settings', ['nickname' => $group->getNickname()]);
$res[] = HTML::html(['hr' => '', 'a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings')], 'p' => _m('Group settings')]]);
$res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _m('Group settings')]]);
}
return Event::next;
}
@@ -68,7 +68,7 @@ class Group extends Component
'title' => 'Self tags',
'desc' => 'Add or remove tags on this group',
'id' => 'settings-self-tags',
'controller' => TagController::settingsSelfTags($request, $group, 'settings-self-tags-details'),
'controller' => SelfTagsSettings::settingsSelfTags($request, $group, 'settings-self-tags-details'),
];
}
return Event::next;

View File

@@ -0,0 +1,5 @@
{% extends 'stdgrid.html.twig' %}
{% block body %}
{{ form(create_form) }}
{% endblock body %}

View File

@@ -10,41 +10,56 @@
{% endblock stylesheets %}
{% block body %}
{% if subscribe_form is defined and subscribe_form is not null %}
{{ form(subscribe_form) }}
{% endif %}
{% if actor is defined and actor is not null %}
{% block profile_view %}
{% include 'cards/profile/view.html.twig' with { 'actor': actor } only %}
{% endblock profile_view %}
<main class="feed" tabindex="0" role="feed">
<div class="h-feed hfeed notes">
{% if notes is defined and notes is not empty %}
{% for conversation in notes %}
{% block current_note %}
{% if conversation is instanceof('array') %}
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
{% else %}
{{ noteView.macro_note(conversation) }}
{% endif %}
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
{% endblock current_note %}
{% endfor %}
{% if notes is defined %}
<article>
<header class="feed-header">
{% if page_title is defined %}
<h1 class="heading-no-margin">{{ page_title | trans }}</h1>
{% else %}
<h1 class="heading-no-margin">{{ 'Notes' | trans }}</h1>
{% endif %}
<nav class="feed-actions">
<details class="feed-actions-details">
<summary>
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
</summary>
<div class="feed-actions-details-dropdown">
<menu>
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
{{ block | raw }}
{% endfor %}
</menu>
</div>
</details>
</nav>
</header>
{% if notes is not empty %}
{# Backwards compatibility with hAtom 0.1 #}
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
{% for conversation in notes %}
{% block current_note %}
{% if conversation is instanceof('array') %}
{{ noteView.macro_note(conversation['note'], conversation['replies']) }}
{% else %}
{{ noteView.macro_note(conversation) }}
{% endif %}
<hr tabindex="0" title="{{ 'End of note and replies.' | trans }}">
{% endblock current_note %}
{% endfor %}
</section>
{% else %}
<div id="empty-notes"><h1>{% trans %}No notes here.{% endtrans %}</h1></div>
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
<strong>{% trans %}No notes yet...{% endtrans %}</strong>
</section>
{% endif %}
</div>
</main>
{% else %}
<div class="section-padding section-widget">
<p>{% trans with { '%group%': nickname } %}The group <em>%group%</em> doesn't exist.{% endtrans %}</p>
{% if create_form is defined and create_form is not null %}
<p>{% trans %}Would you like to create it?{% endtrans %}</p>
{{ form(create_form) }}
{% endif %}
</div>
</article>
{% endif %}
{% endif %}
{% endblock body %}

View File

@@ -139,7 +139,7 @@ class Language extends Controller
}
return [
'_template' => 'settings/sort_languages.html.twig',
'_template' => 'language/sort.html.twig',
'form' => $form->createView(),
];
}

View File

@@ -60,7 +60,7 @@ class Language extends Component
/**
* Populate $note_expr or $actor_expr with an expression to match a language
*/
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
{
$search_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
@@ -103,7 +103,7 @@ class Language extends Component
return Event::next;
}
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id')
->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id')

View File

@@ -1,7 +1,7 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="section-widget section-padding">
<div class="frame-section frame-section-padding">
<h3>{{ 'Put the languages in the order you\'d like to see them in your language selection dropdown, when posting' | trans}}</h3>
{{ form(form) }}
</div>

View File

@@ -77,7 +77,7 @@ class LeftPanel extends Component
*/
public function onEndShowStyles(array &$styles, string $route): bool
{
$styles[] = 'components/Left/assets/css/view.css';
$styles[] = 'components/LeftPanel/assets/css/view.css';
return Event::next;
}
}

View File

@@ -5,35 +5,49 @@
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %}
{% macro edit_feeds_form_row(child) %}
<div class="form-row">
{{ form_label(child) }}
{{ form_widget(child) }}
</div>
{% endmacro %}
{% block body %}
<div class="section-widget">
<div class="frame-section">
<form class="section-form" action="{{ path('edit_feeds') }}" method="post">
<fieldset>
<legend class="section-form-legend">{{ "Edit feed navigation links" | trans }}</legend>
<h1 class="frame-section-title">{{ "Edit feed navigation links" | trans }}</h1>
{# Since the form is not separated into individual groups, this happened #}
{{ form_start(edit_feeds) }}
{% for child in edit_feeds.children %}
{% if 'row_url' in child.vars.block_prefixes %}
<div class="section-widget section-padding">
{{ form_label(child) }}
{{ form_widget(child) }}
{% elseif 'row_title' in child.vars.block_prefixes %}
{{ form_label(child) }}
{{ form_widget(child) }}
{% elseif 'row_order' in child.vars.block_prefixes %}
{{ form_label(child) }}
{{ form_widget(child) }}
{% elseif 'row_remove' in child.vars.block_prefixes %}
{{ form_label(child) }}
{{ form_widget(child) }}
</div>
{% endif %}
{% endfor %}
{{ form_errors(edit_feeds) }}
<section class="container-grid">
{% for child in edit_feeds.children %}
{% if 'row_url' in child.vars.block_prefixes %}
<div class="frame-section frame-section-padding">
{{ _self.edit_feeds_form_row(child) }}
{% elseif 'row_title' in child.vars.block_prefixes %}
{{ _self.edit_feeds_form_row(child) }}
{% elseif 'row_order' in child.vars.block_prefixes %}
{{ _self.edit_feeds_form_row(child) }}
{% elseif 'row_remove' in child.vars.block_prefixes %}
{{ _self.edit_feeds_form_row(child) }}
</div>
{% endif %}
{% endfor %}
</section>
<div class="form-row">
{{ form_row(edit_feeds.update_exisiting) }}
{{ form_row(edit_feeds.reset) }}
</div>
<hr>
<section class="frame-section frame-section-padding">
<h2>{% trans %}Add a new feed{% endtrans %}</h2>
{{ form_rest(edit_feeds) }}
</section>
{{ form_end(edit_feeds) }}
</fieldset>
</form>
</div>

View File

@@ -1,25 +1,24 @@
{% block leftpanel %}
<label class="panel-left-icon" for="panel-left-toggle" aria-hidden="true"
tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label>
<input type="checkbox" id="panel-left-toggle" aria-hidden="true" tabindex="-1">
<label class="panel-left-icon" for="toggle-panel-left" tabindex="-1">{{ icon('menu', 'icon icon-left') | raw }}</label>
<a id="anchor-left-panel" class="anchor-hidden" tabindex="0" title="{{ 'Press tab followed by a space to access left panel' | trans }}"></a>
<input type="checkbox" id="toggle-panel-left" tabindex="0" title="{{ 'Open left panel' | trans }}">
<section class="header-panel section-panel-left">
<a id="anchor-left-panel" class="anchor-hidden" title="{{ 'Press tab to access selected region!' | trans }}"></a>
<aside class="panel-content accessibility-target">
{% if app.user %}
<section class='section-widget section-padding' title="{{ 'Your profile information.' | trans }}">
{% block profile_view %}{% include 'cards/profile/view.html.twig' with { actor: current_actor } %}{% endblock profile_view %}
{{ block("profile_current_actor", "cards/navigation/view.html.twig") }}
</section>
{% else %}
<section>
{{ block("profile_security", "cards/navigation/view.html.twig") }}
</section>
{% endif %}
<aside class="section-panel section-panel-left">
<section class="panel-content accessibility-target">
{% if app.user %}
<section class='frame-section frame-section-padding' title="{{ 'Your profile information.' | trans }}">
{% block profile_view %}{% include 'cards/profile/view.html.twig' with { actor: current_actor } %}{% endblock profile_view %}
{{ block("profile_current_actor", "cards/navigation/view.html.twig") }}
</section>
{% else %}
<section>
{{ block("profile_security", "cards/navigation/view.html.twig") }}
</section>
{% endif %}
{{ block("feeds", "cards/navigation/view.html.twig") }}
{{ block("feeds", "cards/navigation/view.html.twig") }}
{{ block("footer", "cards/navigation/view.html.twig") }}
</aside>
{{ block("footer", "cards/navigation/view.html.twig") }}
</section>
</aside>
{% endblock leftpanel %}

View File

@@ -89,11 +89,14 @@ class Notification extends Component
}
if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
// TODO: use https://symfony.com/doc/current/notifier.html
DB::persist(Entity\Notification::create([
// XXX: Unideal as in failures the rollback will leave behind a false notification,
// but most notifications (all) require flushing the objects first
// Should be okay as long as implementors bear this in mind
DB::wrapInTransaction(fn() => DB::persist(Entity\Notification::create([
'activity_id' => $activity->getId(),
'target_id' => $target->getId(),
'reason' => $reason,
]));
])));
}
} else {
// We have no authority nor responsibility of notifying remote actors of a remote actor's doing

View File

@@ -30,7 +30,6 @@ use App\Core\GSFile;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\Router;
use App\Core\Security;
use App\Core\VisibilityScope;
use App\Entity\Activity;
use App\Entity\Actor;
@@ -43,6 +42,7 @@ use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use App\Util\Form\FormFields;
use App\Util\Formatting;
use App\Util\HTML;
use Component\Attachment\Entity\ActorToAttachment;
use Component\Attachment\Entity\AttachmentToNote;
use Component\Conversation\Conversation;
@@ -74,8 +74,7 @@ class Posting extends Component
return Event::next;
}
$actor = $user->getActor();
$actor_id = $user->getId();
$actor = $user->getActor();
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
@@ -97,6 +96,8 @@ class Posting extends Component
$form_params = [];
if (!empty($in_targets)) { // @phpstan-ignore-line
// Add "none" option to the top of choices
$in_targets = array_merge([_m('Public') => 'public'], $in_targets);
$form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
}
@@ -145,13 +146,17 @@ class Posting extends Component
$extra_args = [];
Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]);
if (\array_key_exists('in', $data) && $data['in'] !== 'public') {
$target = $data['in'];
}
self::storeLocalNote(
actor: $user->getActor(),
content: $data['content'],
content_type: $content_type,
language: $data['language'],
locale: $data['language'],
scope: VisibilityScope::from($data['visibility']),
target: $data['in'] ?? $context_actor,
target: $target ?? null, // @phpstan-ignore-line
reply_to_id: $data['reply_to_id'],
attachments: $data['attachments'],
process_note_content_extra_args: $extra_args,
@@ -199,20 +204,21 @@ class Posting extends Component
Actor $actor,
?string $content,
string $content_type,
?string $language = null,
?string $locale = null,
?VisibilityScope $scope = null,
null|int|Actor $target = null,
null|Actor|int $target = null,
?int $reply_to_id = null,
array $attachments = [],
array $processed_attachments = [],
array $process_note_content_extra_args = [],
bool $notify = true,
?string $rendered = null,
string $source = 'web',
): Note {
$scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
$rendered = null;
$mentions = [];
if (!empty($content)) {
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $language, &$mentions]);
if (\is_null($rendered) && !empty($content)) {
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
}
$note = Note::create([
@@ -220,10 +226,11 @@ class Posting extends Component
'content' => $content,
'content_type' => $content_type,
'rendered' => $rendered,
'language_id' => !\is_null($language) ? Language::getByLocale($language)->getId() : null,
'language_id' => !\is_null($locale) ? Language::getByLocale($locale)->getId() : null,
'is_local' => true,
'scope' => $scope,
'reply_to' => $reply_to_id,
'source' => $source,
]);
/** @var UploadedFile[] $attachments */
@@ -252,6 +259,7 @@ class Posting extends Component
DB::persist(ActorToAttachment::create($args));
}
DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname]));
$a->livesIncrementAndGet();
}
}
@@ -262,12 +270,12 @@ class Posting extends Component
'verb' => 'create',
'object_type' => 'note',
'object_id' => $note->getId(),
'source' => 'web',
'source' => $source,
]);
DB::persist($activity);
if (!\is_null($target)) {
$target = is_numeric($target) ? Actor::getById((int) $target) : $target;
$target = \is_int($target) ? Actor::getById($target) : $target;
$mentions[] = [
'mentioned' => [$target],
'type' => match ($target->getType()) {
@@ -281,6 +289,7 @@ class Posting extends Component
$mention_ids = F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId())));
// Flush before notification
DB::flush();
if ($notify) {
@@ -299,7 +308,7 @@ class Posting extends Component
return Event::stop;
case 'text/html':
// TODO: It has to linkify and stuff as well
$rendered = Security::sanitize($content);
$rendered = HTML::sanitize($content);
return Event::stop;
default:
return Event::next;

View File

@@ -35,7 +35,7 @@ class RightPanel extends Component
*/
public function onEndShowStyles(array &$styles, string $route): bool
{
$styles[] = 'components/Right/assets/css/view.css';
$styles[] = 'components/RightPanel/assets/css/view.css';
return Event::next;
}
}

View File

@@ -1,62 +1,65 @@
{% block rightpanel %}
<label class="panel-right-icon" for="panel-right-toggle" aria-hidden="true"
tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
<input type="checkbox" id="panel-right-toggle" aria-hidden="true" tabindex="-1">
<label class="panel-right-icon" for="toggle-panel-right" tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
<a id="anchor-right-panel" class="anchor-hidden" tabindex="0" title="{{ 'Press tab followed by a space to access right panel' | trans }}"></a>
<input type="checkbox" id="toggle-panel-right" tabindex="0" title="{{ 'Open right panel' | trans }}">
<div class="header-panel section-panel-right">
<a id="anchor-right-panel" class="anchor-hidden" title="{{ 'Press tab to access selected region!' | trans }}"></a>
<aside class="panel-content accessibility-target">
<aside class="section-panel section-panel-right">
<section class="panel-content accessibility-target">
{% set prepend_right_panel = handle_event('PrependRightPanel', request) %}
{% for widget in prepend_right_panel %}
{{ widget | raw }}
{% endfor %}
{% set blocks = handle_event('AppendRightPostingBlock', request) %}
{% if blocks['post_form'] is defined %}
<section class="section-widget" title="{{ 'Create a new note.' | trans }}">
<details class="section-widget-title-details" open="open"
title="{{ 'Expand if you want to access more options.' | trans }}">
<summary class="section-title-summary">
<h2>
{% set current_path = app.request.get('_route') %}
{% if current_path == 'conversation_reply_to' %}
{{ "Reply to note" | trans }}
{% else %}
{{ "Create a note" | trans }}
{% endif %}
</h2>
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</summary>
{% set current_path = app.request.get('_route') %}
{% set blocks = handle_event('AppendRightPostingBlock', request) %}
{% if blocks['post_form'] is defined %}
<section class="frame-section" title="{{ 'Create a new note.' | trans }}">
<details class="section-details-title" open="open"
title="{{ 'Expand if you want to access more options.' | trans }}">
<summary class="details-summary-title">
<h2>
{% set current_path = app.request.get('_route') %}
{% if current_path == 'conversation_reply_to' %}
{{ "Reply to note" | trans }}
{% else %}
{{ "Create a note" | trans }}
{% endif %}
</h2>
</summary>
<div class="section-form">
<fieldset>
{{ form_start(blocks['post_form']) }}
{% if blocks['post_form'].in is defined %}
{{ form_row(blocks['post_form'].in) }}
{% endif %}
{{ form_row(blocks['post_form'].visibility) }}
{{ form_row(blocks['post_form'].content) }}
{{ form_row(blocks['post_form'].attachments) }}
<section class="section-form">
{{ form_start(blocks['post_form']) }}
{{ form_errors(blocks['post_form']) }}
{% if blocks['post_form'].in is defined %}
{{ form_row(blocks['post_form'].in) }}
{% endif %}
{{ form_row(blocks['post_form'].visibility) }}
{{ form_row(blocks['post_form'].content_type) }}
{{ form_row(blocks['post_form'].content) }}
{{ form_row(blocks['post_form'].attachments) }}
<details class="section-widget-subtitle-details">
<summary class="section-subtitle-summary">
<strong>
{{ "Additional options" | trans }}
</strong>
{{ icon('arrow-down', 'icon icon-details-close') | raw }}
</summary>
{{ form_row(blocks['post_form'].language) }}
{{ form_row(blocks['post_form'].tag_use_canonical) }}
</details>
{{ form_rest(blocks['post_form']) }}
{{ form_end(blocks['post_form']) }}
</fieldset>
</div>
</details>
</section>
{% endif %}
<details class="section-details-subtitle">
<summary class="details-summary-subtitle">
<strong>
{{ "Additional options" | trans }}
</strong>
</summary>
<section class="section-form">
{{ form_row(blocks['post_form'].language) }}
{{ form_row(blocks['post_form'].tag_use_canonical) }}
</section>
</details>
{{ form_rest(blocks['post_form']) }}
{{ form_end(blocks['post_form']) }}
</section>
</details>
</section>
{% endif %}
{% set current_path = app.request.get('_route') %}
{% for block in handle_event('AppendRightPanelBlock', {'path': current_path, 'request': request, 'vars': right_panel_vars | default }, request) %}
{{ block | raw }}
{% endfor %}
</aside>
</div>
{% set extra_blocks = get_right_panel_blocks({'path': current_path, 'request': app.request, 'vars': (right_panel_vars | default)}) %}
{% for block in extra_blocks %}
{{ block | raw }}
{% endfor %}
</section>
</aside>
{% endblock rightpanel %}

View File

@@ -48,7 +48,7 @@ class Search extends FeedController
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;
$q = $this->string('q');
$data = $this->query(query: $q, language: $language);
$data = $this->query(query: $q, locale: $language);
$notes = $data['notes'];
$actors = $data['actors'];
@@ -130,7 +130,7 @@ class Search extends FeedController
}
return [
'_template' => 'search/show.html.twig',
'_template' => 'search/view.html.twig',
'actor' => $actor,
'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)),
'search_builder_form' => $search_builder_form->createView(),

View File

@@ -29,6 +29,7 @@ use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Util\Common;
use App\Util\Exception\RedirectException;
use App\Util\Formatting;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormView;
@@ -83,7 +84,7 @@ class Search extends Component
'label' => _m('Search'),
'attr' => [
//'class' => 'button-container search-button-container',
'title' => _m('Query notes for specific tags.'),
'title' => _m('Perform search'),
],
],
];
@@ -119,9 +120,9 @@ class Search extends Component
*
* @throws RedirectException
*/
public function onAddExtraHeaderForms(Request $request, array &$elements)
public function onPrependRightPanel(Request $request, array &$elements)
{
$elements[] = self::searchForm($request);
$elements[] = Formatting::twigRenderFile('cards/search/view.html.twig', ['search' => self::searchForm($request)]);
return Event::next;
}

View File

@@ -0,0 +1,6 @@
<section class="section-form form-search" title="{{ 'Search for notes, actors, and beyond' | trans }}">
{{ form_start(search) }}
<span>{{ form_row(search.search_query) }}{{ form_row(search.submit_search) }}</span>
{{ form_rest(search) }}
{{ form_end(search) }}
</section>

View File

@@ -1,91 +0,0 @@
{% extends 'collection/notes.html.twig' %}
{% block body %}
{% if error is defined %}
<label class="alert alert-danger">
{{ error.getMessage() }}
</label>
{% endif %}
<div class="section-padding">
{{ form_start(search_form) }}
<div>
{{ form_row(search_form.search_query) }}
{{ form_row(search_form.submit_search) }}
</div>
{% if actor is not null %}
<details class="section-widget">
<summary>
<h3>
{% trans %}Save this search as a feed{% endtrans %}
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</h3>
</summary>
{{ form_row(search_form.title) }}
{{ form_row(search_form.subscribe_to_search) }}
</details>
{% endif %}
{{ form_end(search_form)}}
<hr>
<details class="section-widget">
<summary>
<h3>
{% trans %}Build a search query{% endtrans %}
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</h3>
</summary>
{{ form_start(search_builder_form) }}
{# actor options, display if first checked, with checkbox trick #}
<details class="section-widget">
<summary>
<h3>
{% trans %}People search options{% endtrans %}
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</h3>
</summary>
{{ form_row(search_builder_form.include_actors) }}
{{ form_row(search_builder_form.include_actors_people) }}
{{ form_row(search_builder_form.include_actors_groups) }}
{{ form_row(search_builder_form.include_actors_lists) }}
{{ form_row(search_builder_form.include_actors_businesses) }}
{{ form_row(search_builder_form.include_actors_organizations) }}
{{ form_row(search_builder_form.include_actors_bots) }}
{{ form_row(search_builder_form.actor_langs) }}
{{ form_row(search_builder_form.actor_tags) }}
</details>
<details class="section-widget">
<summary>
<h3>
{% trans %}Note search options{% endtrans %}
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</h3>
</summary>
{{ form_row(search_builder_form.include_notes) }}
{{ form_row(search_builder_form.include_notes_text) }}
{{ form_row(search_builder_form.include_notes_media) }}
{{ form_row(search_builder_form.include_notes_polls) }}
{{ form_row(search_builder_form.include_notes_bookmarks) }}
{{ form_row(search_builder_form.note_langs) }}
{{ form_row(search_builder_form.note_tags) }}
{{ form_row(search_builder_form.note_actor_langs) }}
{{ form_row(search_builder_form.note_actor_tags) }}
</details>
{{ form_end(search_builder_form) }}
</details>
<hr>
</div>
{# Backwards compatibility with hAtom 0.1 #}
{{ parent() }}
{% for actor in actors %}
{% include 'cards/profile/view.html.twig' with {'actor': actor} %}
{% endfor %}
{{ "Page: " ~ page }}
{% endblock body %}

View File

@@ -0,0 +1,119 @@
{% extends 'collection/notes.html.twig' %}
{% block body %}
{% if error is defined %}
<label class="alert alert-danger">
{{ error.getMessage() }}
</label>
{% endif %}
<section class="frame-section frame-section-padding">
<h2>{% trans %}Search{% endtrans %}</h2>
{{ form_start(search_form) }}
<section class="frame-section section-form">
{{ form_errors(search_form) }}
{{ form_row(search_form.search_query) }}
{% if actor is not null %}
<details class="section-details-subtitle">
<summary class="details-summary-subtitle">
<strong>{% trans %}Other options{% endtrans %}</strong>
</summary>
<div class="section-form">
<details class="section-details-subtitle">
<summary class="details-summary-subtitle">
<strong>
{% trans %}Save query as a feed{% endtrans %}
</strong>
</summary>
<div class="section-form">
{{ form_row(search_form.title) }}
{{ form_row(search_form.subscribe_to_search) }}
</div>
<hr>
</details>
</div>
</details>
{% endif %}
{{ form_row(search_form.submit_search) }}
</section>
{{ form_end(search_form)}}
<section class="frame-section">
<details class="section-details-subtitle">
<summary class="details-summary-subtitle">
<strong>{% trans %}Build a search query{% endtrans %}</strong>
</summary>
{{ form_start(search_builder_form) }}
<div class="section-form">
{# actor options, display if first checked, with checkbox trick #}
<details class="section-details-subtitle">
<summary class="details-summary-subtitle">
<strong>{% trans %}People search options{% endtrans %}</strong>
</summary>
<div class="section-form">
{{ form_row(search_builder_form.include_actors) }}
{{ form_row(search_builder_form.include_actors_people) }}
{{ form_row(search_builder_form.include_actors_groups) }}
{{ form_row(search_builder_form.include_actors_lists) }}
{{ form_row(search_builder_form.include_actors_businesses) }}
{{ form_row(search_builder_form.include_actors_organizations) }}
{{ form_row(search_builder_form.include_actors_bots) }}
{{ form_row(search_builder_form.actor_langs) }}
{{ form_row(search_builder_form.actor_tags) }}
</div>
<hr>
</details>
<details class="section-details-subtitle">
<summary class="details-summary-subtitle">
<strong>{% trans %}Note search options{% endtrans %}</strong>
</summary>
<div class="section-form">
{{ form_row(search_builder_form.include_notes) }}
{{ form_row(search_builder_form.include_notes_text) }}
{{ form_row(search_builder_form.include_notes_media) }}
{{ form_row(search_builder_form.include_notes_polls) }}
{{ form_row(search_builder_form.include_notes_bookmarks) }}
{{ form_row(search_builder_form.note_langs) }}
{{ form_row(search_builder_form.note_tags) }}
{{ form_row(search_builder_form.note_actor_langs) }}
{{ form_row(search_builder_form.note_actor_tags) }}
</div>
<hr>
</details>
</div>
{{ form_end(search_builder_form) }}
</details>
</section>
</section>
<section class="frame-section frame-section-padding">
<h2>{% trans %}Results{% endtrans %}</h2>
<div class="frame-section frame-section-padding feed-empty">
{% if notes is defined and notes is not empty %}
{{ parent() }}
{% else %}
<h3>{% trans %}No notes found{% endtrans %}</h3>
<em>{% trans %}No notes were found for the specified query...{% endtrans %}</em>
{% endif %}
</div>
<div class="frame-section frame-section-padding feed-empty">
<h3>{% trans %}Actors found{% endtrans %}</h3>
{% if actors is defined and actors is not empty %}
{% for actor in actors %}
{% include 'cards/profile/view.html.twig' with {'actor': actor} %}
{% endfor %}
{% else %}
<em>{% trans %}No Actors were found for the specified query...{% endtrans %}</em>
{% endif %}
</div>
</section>
{{ "Page: " ~ page }}
{% endblock body %}

View File

@@ -23,9 +23,19 @@ declare(strict_types = 1);
namespace Component\Subscription\Controller;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use Component\Collection\Util\ActorControllerTrait;
use Component\Collection\Util\Controller\CircleController;
use Component\Subscription\Subscription as SubscriptionComponent;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
/**
@@ -49,13 +59,109 @@ class Subscribers extends CircleController
return $this->handleActorByNickname(
$nickname,
fn ($actor) => [
'_template' => 'collection/actors.html.twig',
'title' => _m('Subscribers'),
'empty_message' => _m('No subscribers'),
'sort_options' => [],
'page' => $this->int('page') ?? 1,
'actors' => $actor->getSubscribers(),
'_template' => 'collection/actors.html.twig',
'title' => _m('Subscribers'),
'empty_message' => _m('No subscribers.'),
'sort_form_fields' => [],
'page' => $this->int('page') ?? 1,
'actors' => $actor->getSubscribers(),
],
);
}
/**
* @throws \App\Util\Exception\DuplicateFoundException
* @throws \App\Util\Exception\NoLoggedInUser
* @throws \App\Util\Exception\NotFoundException
* @throws \App\Util\Exception\ServerException
* @throws ClientException
* @throws RedirectException
*/
public function subscribersAdd(Request $request, int $object_id): array
{
$subject = Common::ensureLoggedIn();
$object = Actor::getById($object_id);
$form = Form::create(
[
['subscriber_add', SubmitType::class, ['label' => _m('Subscribe!')]],
],
);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!\is_null(SubscriptionComponent::subscribe($subject, $object))) {
DB::flush();
SubscriptionComponent::refreshSubscriptionCount($subject, $object);
}
// Redirect user to where they came from
// Prevent open redirect
if (!\is_null($from = $this->string('from'))) {
if (Router::isAbsolute($from)) {
Log::warning("Actor {$object_id} attempted to reply to a note and then get redirected to another host, or the URL was invalid ({$from})");
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
}
// TODO anchor on element id
throw new RedirectException(url: $from);
}
// If we don't have a URL to return to, go to the instance root
throw new RedirectException('root');
}
return [
'_template' => 'subscription/add_subscriber.html.twig',
'form' => $form->createView(),
'object' => $object,
];
}
/**
* @throws \App\Util\Exception\DuplicateFoundException
* @throws \App\Util\Exception\NoLoggedInUser
* @throws \App\Util\Exception\NotFoundException
* @throws \App\Util\Exception\ServerException
* @throws ClientException
* @throws RedirectException
*/
public function subscribersRemove(Request $request, int $object_id): array
{
$subject = Common::ensureLoggedIn();
$object = Actor::getById($object_id);
$form = Form::create(
[
['subscriber_remove', SubmitType::class, ['label' => _m('Unsubscribe')]],
],
);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (!\is_null(SubscriptionComponent::unsubscribe($subject, $object))) {
DB::flush();
SubscriptionComponent::refreshSubscriptionCount($subject, $object);
}
// Redirect user to where they came from
// Prevent open redirect
if (!\is_null($from = $this->string('from'))) {
if (Router::isAbsolute($from)) {
Log::warning("Actor {$object_id} attempted to subscribe an actor and then get redirected to another host, or the URL was invalid ({$from})");
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
}
// TODO anchor on element id
throw new RedirectException(url: $from);
}
// If we don't have a URL to return to, go to the instance root
throw new RedirectException('root');
}
return [
'_template' => 'subscription/remove_subscriber.html.twig',
'form' => $form->createView(),
'object' => $object,
];
}
}

View File

@@ -49,12 +49,12 @@ class Subscriptions extends CircleController
return $this->handleActorByNickname(
$nickname,
fn ($actor) => [
'_template' => 'collection/actors.html.twig',
'title' => _m('Subscribers'),
'empty_message' => _m('No subscribers'),
'sort_options' => [],
'page' => $this->int('page') ?? 1,
'actors' => $actor->getSubscribers(),
'_template' => 'collection/actors.html.twig',
'title' => _m('Subscriptions'),
'empty_message' => _m('Haven\'t subscribed anyone.'),
'sort_form_fields' => [],
'page' => $this->int('page') ?? 1,
'actors' => $actor->getSubscribers(),
],
);
}

View File

@@ -41,7 +41,7 @@ use DateTimeInterface;
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Subscription extends Entity
class ActorSubscription extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
@@ -114,10 +114,31 @@ class Subscription extends Entity
];
}
/**
* @see Entity->getNotificationTargetIds
*/
public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array
{
if (!\array_key_exists('object', $ids_already_known)) {
$target_ids = [$this->getSubscribedId()]; // The object of any subscription is the one subscribed (or unsubscribed)
} else {
$target_ids = $ids_already_known['object'];
}
// Additional actors that should know about this
if ($include_additional && \array_key_exists('additional', $ids_already_known)) {
array_push($target_ids, ...$ids_already_known['additional']);
} else {
return $target_ids;
}
return array_unique($target_ids);
}
public static function schemaDef(): array
{
return [
'name' => 'subscription',
'name' => 'actor_subscription',
'fields' => [
'subscriber_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'subscription_subscriber_fkey', 'not null' => true, 'description' => 'actor listening'],
'subscribed_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'name' => 'subscription_subscribed_fkey', 'not null' => true, 'description' => 'actor being listened to'],

View File

@@ -23,36 +23,84 @@ declare(strict_types = 1);
namespace Component\Subscription;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Util\Common;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException;
use App\Util\Nickname;
use Component\Subscription\Controller\Subscribers as SubscribersController;
use Component\Subscription\Controller\Subscriptions as SubscriptionsController;
use Symfony\Component\HttpFoundation\Request;
class Subscription extends Component
{
public function onAddRoute(RouteLoader $r): bool
{
$r->connect(id: 'actor_subscribe_add', uri_path: '/actor/subscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersAdd']);
$r->connect(id: 'actor_subscribe_remove', uri_path: '/actor/unsubscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersRemove']);
$r->connect(id: 'actor_subscriptions_id', uri_path: '/actor/{id<\d+>}/subscriptions', target: [SubscriptionsController::class, 'subscriptionsByActorId']);
$r->connect(id: 'actor_subscriptions_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/subscriptions', target: [SubscriptionsController::class, 'subscriptionsByActorNickname']);
$r->connect(id: 'actor_subscribers_id', uri_path: '/actor/{id<\d+>}/subscribers', target: [SubscribersController::class, 'subscribersByActorId']);
$r->connect(id: 'actor_subscribers_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/subscribers', target: [SubscribersController::class, 'subscribersByActorNickname']);
return Event::next;
}
/**
* Persists a new Subscription Entity from Subscriber to Subject (Actor being subscribed) and Activity
* To use after Subscribe/Unsubscribe and DB::flush()
*
* @param Actor|int|LocalUser $subject The Actor who subscribed or unsubscribed
* @param Actor|int|LocalUser $object The Actor who was subscribed or unsubscribed from
*/
public static function refreshSubscriptionCount(int|Actor|LocalUser $subject, int|Actor|LocalUser $object): array
{
$subscriber_id = \is_int($subject) ? $subject : $subject->getId();
$subscribed_id = \is_int($object) ? $object : $object->getId();
$cache_subscriber = Cache::delete(Actor::cacheKeys($subscriber_id)['subscribed']);
$cache_subscribed = Cache::delete(Actor::cacheKeys($subscribed_id)['subscribers']);
return [$cache_subscriber,$cache_subscribed];
}
/**
* Persists a new Subscription Entity from Subject to Object (Actor being subscribed) and Activity
*
* A new notification is then handled, informing all interested Actors of this action
*
* @param Actor|int|LocalUser $subject The actor performing the subscription
* @param Actor|int|LocalUser $object The target of the subscription
*
* @throws DuplicateFoundException
* @throws NotFoundException
* @throws ServerException
*
* @return null|Activity a new Activity if changes were made
*
* @see self::refreshSubscriptionCount() to delete cache after this action
*/
public static function subscribe(int|Actor|LocalUser $subscriber, int|Actor|LocalUser $subject, string $source = 'web'): ?Activity
public static function subscribe(int|Actor|LocalUser $subject, int|Actor|LocalUser $object, string $source = 'web'): ?Activity
{
$subscriber_id = \is_int($subscriber) ? $subscriber : $subscriber->getId();
$subscribed_id = \is_int($subject) ? $subject : $subject->getId();
$subscriber_id = \is_int($subject) ? $subject : $subject->getId();
$subscribed_id = \is_int($object) ? $object : $object->getId();
$opts = [
'subscriber_id' => $subscriber_id,
'subscribed_id' => $subscribed_id,
];
$subscription = DB::findOneBy(table: \Component\Subscription\Entity\Subscription::class, criteria: $opts, return_null: true);
$subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
$activity = null;
if (\is_null($subscription)) {
DB::persist(\Component\Subscription\Entity\Subscription::create($opts));
DB::persist(Entity\ActorSubscription::create($opts));
$activity = Activity::create([
'actor_id' => $subscriber_id,
'verb' => 'subscribe',
@@ -63,31 +111,40 @@ class Subscription extends Component
DB::persist($activity);
Event::handle('NewNotification', [
$actor = ($subscriber instanceof Actor ? $subscriber : Actor::getById($subscribed_id)),
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity,
['object' => [$subscribed_id]],
_m('{nickname} subscribed to {subject}.', ['{actor}' => $actor->getId(), '{subject}' => $activity->getObjectId()]),
['object' => [$activity->getObjectId()]],
_m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]),
]);
}
return $activity;
}
/**
* Removes the Subscription Entity created beforehand, by the same Actor, and on the same subject
* Removes the Subscription Entity created beforehand, by the same Actor, and on the same object
*
* Informs all interested Actors of this action, handling out the NewNotification event
*
* @param Actor|int|LocalUser $subject The actor undoing the subscription
* @param Actor|int|LocalUser $object The target of the subscription
*
* @throws DuplicateFoundException
* @throws NotFoundException
* @throws ServerException
*
* @return null|Activity a new Activity if changes were made
*
* @see self::refreshSubscriptionCount() to delete cache after this action
*/
public static function unsubscribe(int|Actor|LocalUser $subscriber, int|Actor|LocalUser $subject, string $source = 'web'): ?Activity
public static function unsubscribe(int|Actor|LocalUser $subject, int|Actor|LocalUser $object, string $source = 'web'): ?Activity
{
$subscriber_id = \is_int($subscriber) ? $subscriber : $subscriber->getId();
$subscribed_id = \is_int($subject) ? $subject : $subject->getId();
$subscriber_id = \is_int($subject) ? $subject : $subject->getId();
$subscribed_id = \is_int($object) ? $object : $object->getId();
$opts = [
'subscriber_id' => $subscriber_id,
'subscribed_id' => $subscribed_id,
];
$subscription = DB::findOneBy(table: \Component\Subscription\Entity\Subscription::class, criteria: $opts, return_null: true);
$subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
$activity = null;
if (!\is_null($subscription)) {
// Remove Subscription
@@ -102,7 +159,88 @@ class Subscription extends Component
'source' => $source,
]);
DB::persist($activity);
Event::handle('NewNotification', [
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity,
['object' => [$previous_follow_activity->getObjectId()]],
_m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]),
]);
}
return $activity;
}
/**
* Provides ``\App\templates\cards\profile\view.html.twig`` an **additional action** to be performed **on the given
* Actor** (which the profile card of is currently being rendered).
*
* In the case of ``\App\Component\Subscription``, the action added allows a **LocalUser** to **subscribe** or
* **unsubscribe** a given **Actor**.
*
* @param Actor $object The Actor on which the action is to be performed
* @param array $actions An array containing all actions added to the
* current profile, this event adds an action to it
*
* @throws DuplicateFoundException
* @throws NotFoundException
* @throws ServerException
*
* @return bool EventHook
*/
public function onAddProfileActions(Request $request, Actor $object, array &$actions): bool
{
// Action requires a user to be logged in
// We know it's a LocalUser, which has the same id as Actor
// We don't want the Actor to unfollow itself
if ((\is_null($subject = Common::user())) || ($subject->getId() === $object->getId())) {
return Event::next;
}
// Let's retrieve from here this subject came from to redirect it to previous location
$from = $request->query->has('from')
? $request->query->get('from')
: $request->getPathInfo();
// Who is the subject attempting to subscribe to?
$object_id = $object->getId();
// The id of both the subject and object
$opts = [
'subscriber_id' => $subject->getId(),
'subscribed_id' => $object_id,
];
// If subject is not subbed to object already, then route it to add subscription
// Else, route to remove subscription
$subscribe_action_url = ($not_subscribed_already = \is_null(DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true))) ? Router::url(
'actor_subscribe_add',
[
'object_id' => $object_id,
'from' => $from . '#profile-' . $object_id,
],
Router::ABSOLUTE_PATH,
) : Router::url(
'actor_subscribe_remove',
[
'object_id' => $object_id,
'from' => $from . '#profile-' . $object_id,
],
Router::ABSOLUTE_PATH,
);
// Finally, create an array with proper keys set accordingly
// to provide Profile Card template, the info it needs in order to render it properly
$action_extra_class = $not_subscribed_already ? 'add-actor-button-container' : 'remove-actor-button-container';
$title = $not_subscribed_already ? 'Subscribe ' . $object->getNickname() : 'Unsubscribe ' . $object->getNickname();
$subscribe_action = [
'url' => $subscribe_action_url,
'title' => _m($title),
'classes' => 'button-container note-actions-unset ' . $action_extra_class,
'id' => 'add-actor-button-container-' . $object_id,
];
$actions[] = $subscribe_action;
return Event::next;
}
}

View File

@@ -0,0 +1,8 @@
{% extends 'base.html.twig' %}
{% block body %}
{% block profile_view %}
{% include 'cards/profile/view.html.twig' with { actor: object } %}
{% endblock profile_view %}
{{ form(form) }}
{% endblock body %}

View File

@@ -0,0 +1,8 @@
{% extends 'base.html.twig' %}
{% block body %}
{% block profile_view %}
{% include 'cards/profile/view.html.twig' with { actor: object } %}
{% endblock profile_view %}
{{ form(form) }}
{% endblock body %}

View File

@@ -6,31 +6,34 @@ namespace Component\Tag\Controller;
use App\Core\Cache;
use App\Core\Controller;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Entity as E;
use App\Util\Common;
use App\Util\Exception\BugFoundException;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use App\Util\Formatting;
use Component\Tag\Form\SelfTagsForm;
use Component\Language\Entity\Language;
use Component\Tag\Tag as CompTag;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
class Tag extends Controller
{
private function process(string|array $canon_single_or_multi, null|string|array $tag_single_or_multi, string $key, string $query, string $template)
// TODO: Use Feed::query
// TODO: If ?canonical=something, respect
// TODO: Allow to set locale of tag being selected
private function process(null|string|array $tag_single_or_multi, string $key, string $query, string $template, bool $include_locale = false)
{
$actor = Common::actor();
$page = $this->int('page') ?: 1;
$lang = $this->string('lang');
$query_args = ['tag' => $tag_single_or_multi];
if ($include_locale) {
if (!\is_null($locale = $this->string('locale'))) {
$query_args['language_id'] = Language::getByLocale($locale)->getId();
} else {
$query_args['language_id'] = Common::actor()->getTopLanguage()->getId();
}
}
$results = Cache::pagedStream(
key: $key,
query: $query,
query_args: ['canon' => $canon_single_or_multi],
query_args: $query_args,
actor: $actor,
page: $page,
);
@@ -43,179 +46,25 @@ class Tag extends Controller
];
}
public function single_note_tag(string $canon)
public function single_note_tag(string $tag)
{
return $this->process(
canon_single_or_multi: $canon,
tag_single_or_multi: $this->string('tag'),
key: CompTag::cacheKeys($canon)['note_single'],
query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.canonical = :canon order by nt.created DESC, nt.note_id DESC',
tag_single_or_multi: $tag,
key: CompTag::cacheKeys($tag)['note_single'],
query: 'SELECT n FROM note AS n JOIN note_tag AS nt WITH n.id = nt.note_id WHERE nt.tag = :tag AND nt.language_id = :language_id ORDER BY nt.created DESC, nt.note_id DESC',
template: 'note_tag_feed.html.twig',
include_locale: true,
);
}
public function multi_note_tags(string $canons)
public function multi_note_tags(string $tags)
{
return $this->process(
canon_single_or_multi: explode(',', $canons),
tag_single_or_multi: !\is_null($this->string('tags')) ? explode(',', $this->string('tags')) : null,
key: CompTag::cacheKeys(str_replace(',', '-', $canons))['note_multi'],
query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.canonical in (:canon) order by nt.created DESC, nt.note_id DESC',
tag_single_or_multi: explode(',', $tags),
key: CompTag::cacheKeys(str_replace(',', '-', $tags))['note_multi'],
query: 'select n from note n join note_tag nt with n.id = nt.note_id where nt.tag in (:tag) AND nt.language_id = :language_id order by nt.created DESC, nt.note_id DESC',
template: 'note_tag_feed.html.twig',
include_locale: true,
);
}
public function single_actor_tag(string $canon)
{
return $this->process(
canon_single_or_multi: $canon,
tag_single_or_multi: $this->string('tag'),
key: CompTag::cacheKeys($canon)['actor_single'],
query: 'select a from actor a join actor_tag at with a.id = at.tagged where at.canonical = :canon order by at.modified DESC',
template: 'actor_tag_feed.html.twig',
);
}
public function multi_actor_tag(string $canons)
{
return $this->process(
canon_single_or_multi: explode(',', $canons),
tag_single_or_multi: !\is_null($this->string('tags')) ? explode(',', $this->string('tags')) : null,
key: CompTag::cacheKeys(str_replace(',', '-', $canons))['actor_multi'],
query: 'select a from actor a join actor_tag at with a.id = at.tagged where at.canonical = :canon order by at.modified DESC',
template: 'actor_tag_feed.html.twig',
);
}
/**
* Generic settings page for an Actor's self tags
*/
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
{
$actor = Common::actor();
if (!$actor->canAdmin($target)) {
throw new ClientException(_m('You don\'t have enough permissions to edit {nickname}\'s settings', ['{nickname}' => $target->getNickname()]));
}
$actor_tags = $target->getSelfTags();
[$add_form, $existing_form] = SelfTagsForm::handleTags(
$request,
$actor_tags,
handle_new: /**
* Handle adding tags
*/
function ($form) use ($request, $target, $details_id) {
$data = $form->getData();
$tags = $data['new-tags'];
$language = $target->getTopLanguage()->getLocale();
foreach ($tags as $tag) {
$tag = CompTag::ensureValid($tag);
$canon_tag = CompTag::canonicalTag($tag, language: $language);
$use_canon = $data['new-tags-use-canon'];
[$actor_tag, $actor_tag_existed] = E\ActorTag::createOrUpdate([
'tagger' => $target->getId(),
'tagged' => $target->getId(),
'tag' => $tag,
'canonical' => $canon_tag,
'use_canonical' => $use_canon,
]);
DB::persist($actor_tag);
$actor_circle = DB::findBy(
'actor_circle',
[
'tagger' => null,
'tagged' => $target->getId(),
'in' => ['tag' => [$tag, $canon_tag]],
'use_canonical' => $use_canon,
],
);
if (empty($actor_circle)) {
if ($actor_tag_existed) {
throw new BugFoundException('Actor tag existed but generic actor circle did not');
}
DB::persist(E\ActorCircle::create([
'tagger' => null,
'tagged' => $target->getId(),
'tag' => $use_canon ? $canon_tag : $tag,
'use_canonical' => $use_canon,
'private' => false,
'description' => null,
]));
}
}
DB::flush();
Cache::delete(E\Actor::cacheKeys($target->getId(), $target->getId())['tags']);
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id]);
},
handle_existing: /**
* Handle changes to the existing tags
*/
function ($form, array $form_definition) use ($request, $target, $details_id) {
$data = $form->getData();
$changed = false;
$language = $target->getTopLanguage()->getLocale();
foreach (array_chunk($form_definition, 3) as $entry) {
$tag = Formatting::removePrefix($entry[0][2]['data'], '#');
$canon_tag = CompTag::canonicalTag($tag, language: $language);
$use_canon = $entry[1][2]['attr']['data'];
/** @var SubmitButton $remove */
$remove = $form->get($entry[2][0]);
if ($remove->isClicked()) {
$changed = true;
DB::removeBy(
'actor_tag',
[
'tagger' => $target->getId(),
'tagged' => $target->getId(),
'tag' => $tag,
'use_canonical' => $use_canon,
],
);
DB::removeBy(
'actor_circle',
[
'tagger' => null,
'tagged' => $target->getId(),
'tag' => $use_canon ? $canon_tag : $tag,
'use_canonical' => $use_canon,
],
);
}
/** @var SubmitButton $toggle_canon */
$toggle_canon = $form->get($entry[1][0]);
if ($toggle_canon->isSubmitted()) {
$changed = true;
$actor_tag = DB::find(
'actor_tag',
[
'tagger' => $target->getId(),
'tagged' => $target->getId(),
'tag' => $tag,
'use_canonical' => $use_canon,
],
);
DB::persist($actor_tag->setUseCanonical(!$use_canon));
}
}
if ($changed) {
DB::flush();
Cache::delete(E\Actor::cacheKeys($target->getId(), $target->getId())['tags']);
throw new RedirectException($request->get('_route'), ['nickname' => $target->getNickname(), 'open' => $details_id]);
}
},
remove_label: _m('Remove self tag'),
add_label: _m('Add self tag'),
);
return [
'_template' => 'self_tags_settings.fragment.html.twig',
'add_self_tags_form' => $add_form->createView(),
'existing_self_tags_form' => $existing_form?->createView(),
];
}
}

View File

@@ -19,12 +19,15 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Tag\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\Note;
use Component\Language\Entity\Language;
use Component\Tag\Tag;
use DateTimeInterface;
@@ -39,6 +42,7 @@ use DateTimeInterface;
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
@@ -51,11 +55,11 @@ class NoteTag extends Entity
private int $note_id;
private bool $use_canonical;
private ?int $language_id = null;
private \DateTimeInterface $created;
private DateTimeInterface $created;
public function setTag(string $tag): self
{
$this->tag = \mb_substr($tag, 0, 64);
$this->tag = mb_substr($tag, 0, 64);
return $this;
}
@@ -66,7 +70,7 @@ class NoteTag extends Entity
public function setCanonical(string $canonical): self
{
$this->canonical = \mb_substr($canonical, 0, 64);
$this->canonical = mb_substr($canonical, 0, 64);
return $this;
}
@@ -108,13 +112,13 @@ class NoteTag extends Entity
return $this->language_id;
}
public function setCreated(\DateTimeInterface $created): self
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): \DateTimeInterface
public function getCreated(): DateTimeInterface
{
return $this->created;
}
@@ -132,15 +136,24 @@ class NoteTag extends Entity
public static function getByNoteId(int $note_id): array
{
return Cache::getList(self::cacheKey($note_id), fn () => DB::dql('select nt from note_tag nt join note n with n.id = nt.note_id where n.id = :id', ['id' => $note_id]));
return Cache::getList(self::cacheKey($note_id), fn () => DB::dql('SELECT nt FROM note_tag AS nt JOIN note AS n WITH n.id = nt.note_id WHERE n.id = :id', ['id' => $note_id]));
}
public function getUrl(?Actor $actor = null, int $type = Router::ABSOLUTE_PATH): string
{
$params = ['canon' => $this->getCanonical(), 'tag' => $this->getTag()];
if (!\is_null($actor)) {
$params['lang'] = $actor->getTopLanguage()->getLocale();
$params['tag'] = $this->getTag();
if (\is_null($this->getLanguageId())) {
if (!\is_null($actor)) {
$params['locale'] = $actor->getTopLanguage()->getLocale();
}
} else {
$params['locale'] = Language::getById($this->getLanguageId())->getLocale();
}
if ($this->getUseCanonical()) {
$params['canonical'] = $this->getCanonical();
}
return Router::url(id: 'single_note_tag', args: $params, type: $type);
}
@@ -150,18 +163,18 @@ class NoteTag extends Entity
'name' => 'note_tag',
'description' => 'Hash tags on notes',
'fields' => [
'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to tagged note'],
'tag' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'hash tag associated with this note'],
'canonical' => ['type' => 'varchar', 'length' => Tag::MAX_TAG_LENGTH, 'not null' => true, 'description' => 'ascii slug of tag'],
'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to tagged note'],
'use_canonical' => ['type' => 'bool', 'not null' => true, 'description' => 'whether the user wanted to use canonical tags in this note. Separate for blocks'],
'language_id' => ['type' => 'int', 'not null' => false, 'foreign key' => true, 'target' => 'Language.id', 'multiplicity' => 'many to many', 'description' => 'the language this entry refers to'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['tag', 'note_id'],
'primary key' => ['note_id', 'tag'], // No need to require language in this association because all the related tags will be in the note's language already
'indexes' => [
'note_tag_created_idx' => ['created'],
'note_tag_note_id_idx' => ['note_id'],
'note_tag_canonical_idx' => ['canonical'],
'note_tag_tag_language_id_idx' => ['tag', 'language_id'],
'note_tag_tag_created_note_id_idx' => ['tag', 'created', 'note_id'],
],
];

View File

@@ -19,7 +19,7 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace App\Entity;
namespace Component\Tag\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
@@ -46,7 +46,7 @@ class NoteTagBlock extends Entity
private string $tag;
private string $canonical;
private bool $use_canonical;
private \DateTimeInterface $modified;
private DateTimeInterface $modified;
public function setBlocker(int $blocker): self
{
@@ -61,7 +61,7 @@ class NoteTagBlock extends Entity
public function setTag(string $tag): self
{
$this->tag = \mb_substr($tag, 0, 64);
$this->tag = mb_substr($tag, 0, 64);
return $this;
}
@@ -72,7 +72,7 @@ class NoteTagBlock extends Entity
public function setCanonical(string $canonical): self
{
$this->canonical = \mb_substr($canonical, 0, 64);
$this->canonical = mb_substr($canonical, 0, 64);
return $this;
}
@@ -92,13 +92,13 @@ class NoteTagBlock extends Entity
return $this->use_canonical;
}
public function setModified(\DateTimeInterface $modified): self
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
public function getModified(): \DateTimeInterface
public function getModified(): DateTimeInterface
{
return $this->modified;
}

View File

@@ -30,18 +30,15 @@ use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\ActorCircle;
use App\Entity\ActorTag;
use App\Entity\Note;
use App\Entity\NoteTag;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Formatting;
use App\Util\Functional as GSF;
use App\Util\HTML;
use App\Util\Nickname;
use Component\Circle\Entity\ActorTag;
use Component\Language\Entity\Language;
use Component\Tag\Controller as C;
use Component\Tag\Entity\NoteTag;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
@@ -53,22 +50,20 @@ use Symfony\Component\HttpFoundation\Request;
* Component responsible for extracting tags from posted notes, as well as normalizing them
*
* @author Hugo Sales <hugo@hsal.es>
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Tag extends Component
{
public const MAX_TAG_LENGTH = 64;
public const TAG_REGEX = '/(^|\\s)(#[\\pL\\pN_\\-]{1,64})/u'; // Brion Vibber 2011-02-23 v2:classes/Notice.php:367 function saveTags
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
public const TAG_SLUG_REGEX = '[A-Za-z0-9]{1,64}';
public const MAX_TAG_LENGTH = 64;
public const TAG_REGEX = '/(^|\\s)(#[\\pL\\pN_\\-]{1,64})/u'; // Brion Vibber 2011-02-23 v2:classes/Notice.php:367 function saveTags
public const TAG_SLUG_REGEX = '[A-Za-z0-9]{1,64}';
public function onAddRoute($r): bool
{
$r->connect('single_note_tag', '/note-tag/{canon<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_note_tag']);
$r->connect('multi_note_tags', '/note-tags/{canons<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_note_tags']);
$r->connect('single_actor_tag', '/actor-tag/{canon<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_actor_tag']);
$r->connect('multi_actor_tags', '/actor-tags/{canons<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_actor_tags']);
$r->connect('single_note_tag', '/note-tag/{tag<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_note_tag']);
$r->connect('multi_note_tags', '/note-tags/{tags<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_note_tags']);
return Event::next;
}
@@ -86,7 +81,10 @@ class Tag extends Component
preg_match_all(self::TAG_REGEX, $content, $matched_tags, \PREG_SET_ORDER);
$matched_tags = array_unique(F\map($matched_tags, fn ($m) => $m[2]));
foreach ($matched_tags as $match) {
$tag = self::ensureValid($match);
$tag = self::extract($match);
if (!self::validate($tag)) {
continue; // Ignore invalid tag candidates
}
$canonical_tag = self::canonicalTag($tag, \is_null($lang_id = $note->getLanguageId()) ? null : Language::getById($lang_id)->getLocale());
DB::persist(NoteTag::create([
'tag' => $tag,
@@ -103,38 +101,54 @@ class Tag extends Component
return Event::next;
}
public function onRenderPlainTextNoteContent(string &$text, ?string $language = null): bool
public function onRenderPlainTextNoteContent(string &$text, ?string $locale = null): bool
{
$text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $language), $text);
$text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $locale), $text);
return Event::next;
}
public static function cacheKeys(string $canon_single_or_multi): array
public static function cacheKeys(string $tag_single_or_multi): array
{
return [
'note_single' => "note-tag-feed-{$canon_single_or_multi}",
'note_multi' => "note-tags-feed-{$canon_single_or_multi}",
'actor_single' => "actor-tag-feed-{$canon_single_or_multi}",
'actor_multi' => "actor-tags-feed-{$canon_single_or_multi}",
'note_single' => "note-tag-feed-{$tag_single_or_multi}",
'note_multi' => "note-tags-feed-{$tag_single_or_multi}",
'actor_single' => "actor-tag-feed-{$tag_single_or_multi}",
'actor_multi' => "actor-tags-feed-{$tag_single_or_multi}",
];
}
private static function tagLink(string $tag, ?string $language): string
private static function tagLink(string $tag, ?string $locale): string
{
$tag = self::ensureLength($tag);
$canonical = self::canonicalTag($tag, $language);
$url = Router::url('single_note_tag', !\is_null($language) ? ['canon' => $canonical, 'lang' => $language, 'tag' => $tag] : ['canon' => $canonical, 'tag' => $tag]);
return HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => $tag, 'rel' => 'tag'], $tag]], options: ['indent' => false]);
$tag = self::extract($tag);
$url = Router::url('single_note_tag', !\is_null($locale) ? ['tag' => $tag, 'locale' => $locale] : ['tag' => $tag]);
return HTML::html(['span' => ['attrs' => ['class' => 'tag'],
'#' . HTML::html(['a' => [
'attrs' => [
'href' => $url,
'rel' => 'tag', // https://microformats.org/wiki/rel-tag
],
$tag,
]], options: ['indent' => false]),
]], options: ['indent' => false, 'raw' => true]);
}
public static function ensureValid(string $tag)
public static function extract(string $tag): string
{
$tag = self::ensureLength(Formatting::removePrefix($tag, '#'));
if (preg_match(self::TAG_REGEX, '#' . $tag)) {
return $tag;
} else {
return self::ensureLength(Formatting::removePrefix($tag, '#'));
}
public static function validate(string $tag): bool
{
return preg_match(self::TAG_REGEX, '#' . $tag) === 1;
}
public static function sanitize(string $tag): string
{
$tag = self::extract($tag);
if (!self::validate($tag)) {
throw new ClientException(_m('Invalid tag given: {tag}', ['{tag}' => $tag]));
}
return $tag;
}
public static function ensureLength(string $tag): string
@@ -143,11 +157,11 @@ class Tag extends Component
}
/**
* Convert a tag to it's canonical representation, by splitting it
* Convert a tag to its canonical representation, by splitting it
* into words, stemming it in the given language (if enabled) and
* sluggifying it (turning it into an ASCII representation)
*/
public static function canonicalTag(string $tag, ?string $language): string
public static function canonicalTag(string $tag, ?string $language = null): string
{
$result = '';
foreach (Formatting::splitWords(str_replace('#', '', $tag)) as $word) {
@@ -165,17 +179,20 @@ class Tag extends Component
*
* $term /^(note|tag|people|actor)/ means we want to match only either a note or an actor
*/
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr): bool
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
{
if (!str_contains($term, ':')) {
return Event::next;
}
if (\is_null($locale)) {
$locale = Common::currentLanguage();
}
[$search_type, $search_term] = explode(':', $term);
if (str_starts_with($search_term, '#')) {
$search_term = self::ensureValid($search_term);
$canon_search_term = self::canonicalTag($search_term, $language);
$temp_note_expr = $eb->eq('note_tag.canonical', $canon_search_term);
$temp_actor_expr = $eb->eq('actor_tag.canonical', $canon_search_term);
$search_term = self::sanitize($search_term);
$canonical_search_term = self::canonicalTag($search_term, $locale);
$temp_note_expr = $eb->eq('note_tag.canonical', $canonical_search_term);
$temp_actor_expr = $eb->eq('actor_tag.canonical', $canonical_search_term);
if (Formatting::startsWith($term, ['note:', 'tag:', 'people:'])) {
$note_expr = $temp_note_expr;
} elseif (Formatting::startsWith($term, ['people:', 'actor:'])) {
@@ -183,7 +200,7 @@ class Tag extends Component
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['people', 'actor'], ['circle', 'list'], [':']], separator: ['-', '_']))) {
$null_tagger_expr = $eb->isNull('actor_circle.tagger');
$tagger_expr = \is_null($actor_expr) ? $null_tagger_expr : $eb->orX($null_tagger_expr, $eb->eq('actor_circle.tagger', $actor->getId()));
$tags = array_unique([$search_term, $canon_search_term]);
$tags = array_unique([$search_term, $canonical_search_term]);
$tag_expr = \count($tags) === 1 ? $eb->eq('actor_circle.tag', $tags[0]) : $eb->in('actor_circle.tag', $tags);
$search_expr = $eb->andX(
$tagger_expr,
@@ -196,58 +213,30 @@ class Tag extends Component
$actor_expr = $temp_actor_expr;
return Event::next;
}
return Event::stop;
}
return Event::stop;
}
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id')
->leftJoin(ActorCircle::class, 'actor_circle', Expr\Join::WITH, 'note_actor.id = actor_circle.tagged');
$actor_qb->leftJoin(ActorTag::class, 'actor_tag', Expr\Join::WITH, 'actor_tag.tagger = actor.id')
->leftJoin(ActorCircle::class, 'actor_circle', Expr\Join::WITH, 'actor.id = actor_circle.tagged');
return Event::next;
}
public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params)
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id');
$actor_qb->leftJoin(ActorTag::class, 'actor_tag', Expr\Join::WITH, 'actor_tag.tagger = actor.id');
return Event::next;
}
public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params): bool
{
$form_params[] = ['tag_use_canonical', CheckboxType::class, ['required' => false, 'data' => true, 'label' => _m('Make note tags canonical'), 'help' => _m('Canonical tags will be treated as a version of an existing tag with the same root/stem (e.g. \'#great_tag\' will be considered as a version of \'#great\', if it already exists)')]];
return Event::next;
}
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args)
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
{
if (!isset($data['tag_use_canonical'])) {
throw new ClientException;
throw new ClientException(_m('Missing Use Canonical preference for Tags.'));
}
$extra_args['tag_use_canonical'] = $data['tag_use_canonical'];
return Event::next;
}
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs)
{
if ($section === 'profile' && $request->get('_route') === 'settings') {
$tabs[] = [
'title' => 'Self tags',
'desc' => 'Add or remove tags on yourself',
'id' => 'settings-self-tags',
'controller' => C\Tag::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'),
];
}
return Event::next;
}
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets)
{
$actor_id = $actor->getId();
$tags = Cache::get(
"actor-circle-{$actor_id}",
fn () => DB::dql('select c.tag from actor_circle c where c.tagger = :tagger', ['tagger' => $actor_id]),
);
foreach ($tags as $t) {
$t = '#' . $t['tag'];
$targets[$t] = $t;
}
return Event::next;
}
}

View File

@@ -23,7 +23,7 @@
{% block profile_view %}{% include 'cards/profile/view.html.twig' %}{% endblock profile_view %}
{% endfor %}
<div class="section-widget section-padding">
<div class="frame-section frame-section-padding">
{{ "Page: " ~ page }}
</div>
{% endblock %}

View File

@@ -26,7 +26,7 @@
{% endblock current_note %}
{% endfor %}
<div class="section-widget section-padding section-widget-paging">
<div class="frame-section frame-section-padding frame-section-paging">
{{ "Page " ~ page }}
</div>
{% endblock %}

View File

@@ -1,189 +1,234 @@
{
"type": "project",
"name": "gnu/social",
"description": "Free software social networking platform.",
"license": "AGPL-3.0-only",
"require": {
"php": "^8.1",
"ext-ctype": "*",
"ext-curl": "*",
"ext-iconv": "*",
"ext-openssl": "*",
"composer/package-versions-deprecated": "1.11.*",
"doctrine/annotations": "^1.0",
"doctrine/doctrine-bundle": "^2.4",
"doctrine/doctrine-migrations-bundle": "^3.1",
"doctrine/orm": "^2.9",
"erusev/parsedown": "^1.7",
"knplabs/knp-time-bundle": "^1.17",
"lstrojny/functional-php": "^1.17",
"nyholm/psr7": "^1.4",
"odolbeau/phone-number-bundle": "^3.1",
"oro/doctrine-extensions": "^2.0",
"php-ds/php-ds": "^1.2",
"phpdocumentor/reflection-docblock": "^5.2",
"sensio/framework-extra-bundle": "6.*",
"someonewithpc/memcached-polyfill": "^1.0",
"someonewithpc/redis-polyfill": "dev-master",
"symfony/asset": "5.4.*",
"symfony/cache": "5.4.*",
"symfony/config": "5.4.*",
"symfony/console": "5.4.*",
"symfony/dom-crawler": "5.4.*",
"symfony/dotenv": "5.4.*",
"symfony/event-dispatcher": "5.4.*",
"symfony/expression-language": "5.4.*",
"symfony/filesystem": "5.4.*",
"symfony/flex": "^1.3.1",
"symfony/form": "5.4.*",
"symfony/framework-bundle": "5.4.*",
"symfony/http-client": "5.4.*",
"symfony/intl": "5.4.*",
"symfony/mailer": "5.4.*",
"symfony/messenger": "5.4.*",
"symfony/mime": "5.4.*",
"symfony/monolog-bundle": "^3.1",
"symfony/notifier": "5.4.*",
"symfony/process": "5.4.*",
"symfony/property-access": "5.4.*",
"symfony/property-info": "5.4.*",
"symfony/proxy-manager-bridge": "5.4.*",
"symfony/security-bundle": "5.4.*",
"symfony/serializer": "5.4.*",
"symfony/string": "5.4.*",
"symfony/translation": "5.4.*",
"symfony/twig-bundle": "5.4.*",
"symfony/validator": "5.4.*",
"symfony/var-exporter": "5.4.*",
"symfony/web-link": "5.4.*",
"symfony/yaml": "5.4.*",
"symfonycasts/reset-password-bundle": "^1.9",
"symfonycasts/verify-email-bundle": "^1.0",
"tgalopin/html-sanitizer-bundle": "^1.2",
"theofidry/psysh-bundle": "^4.4",
"twig/extra-bundle": "^2.12|^3.0",
"twig/markdown-extra": "^3.0",
"twig/twig": "^2.12|^3.0",
"wikimedia/composer-merge-plugin": "^2.0"
"type": "project",
"name": "gnu/social",
"description": "Free software social networking platform.",
"license": "AGPL-3.0-only",
"require": {
"php": "^8.1",
"ext-ctype": "*",
"ext-curl": "*",
"ext-iconv": "*",
"ext-openssl": "*",
"composer/package-versions-deprecated": "1.11.*",
"doctrine/annotations": "^1.0",
"doctrine/doctrine-bundle": "^2.4",
"doctrine/doctrine-migrations-bundle": "^3.1",
"doctrine/orm": "^2.9",
"erusev/parsedown": "^1.7",
"knplabs/knp-time-bundle": "^1.17",
"lstrojny/functional-php": "^1.17",
"masterminds/html5": "^2.7",
"mf2/mf2": "^0.4.6",
"odolbeau/phone-number-bundle": "^3.1",
"oro/doctrine-extensions": "^2.0",
"php-ds/php-ds": "^1.2",
"phpdocumentor/reflection-docblock": "^5.2",
"sensio/framework-extra-bundle": "^5.5",
"someonewithpc/memcached-polyfill": "^1.0",
"someonewithpc/redis-polyfill": "dev-master",
"symfony/asset": "5.4.*",
"symfony/cache": "5.4.*",
"symfony/config": "5.4.*",
"symfony/console": "5.4.*",
"symfony/dom-crawler": "5.4.*",
"symfony/dotenv": "5.4.*",
"symfony/event-dispatcher": "5.4.*",
"symfony/expression-language": "5.4.*",
"symfony/filesystem": "5.4.*",
"symfony/flex": "^1.3.1",
"symfony/form": "5.4.*",
"symfony/framework-bundle": "5.4.*",
"symfony/http-client": "5.4.*",
"symfony/intl": "5.4.*",
"symfony/mailer": "5.4.*",
"symfony/messenger": "5.4.*",
"symfony/mime": "5.4.*",
"symfony/monolog-bundle": "^3.1",
"symfony/notifier": "5.4.*",
"symfony/process": "5.4.*",
"symfony/property-access": "5.4.*",
"symfony/property-info": "5.4.*",
"symfony/proxy-manager-bridge": "5.4.*",
"symfony/security-bundle": "5.4.*",
"symfony/serializer": "5.4.*",
"symfony/string": "5.4.*",
"symfony/translation": "5.4.*",
"symfony/twig-bundle": "5.4.*",
"symfony/validator": "5.4.*",
"symfony/var-exporter": "5.4.*",
"symfony/web-link": "5.4.*",
"symfony/yaml": "5.4.*",
"symfonycasts/reset-password-bundle": "^1.9",
"symfonycasts/verify-email-bundle": "^1.0",
"tgalopin/html-sanitizer-bundle": "^1.2",
"theofidry/psysh-bundle": "^4.4",
"twig/extra-bundle": "^2.12|^3.0",
"twig/markdown-extra": "^3.0",
"twig/twig": "^2.12|^3.0",
"wikimedia/composer-merge-plugin": "^2.0"
},
"require-dev": {
"codeception/codeception": "^4.1",
"codeception/module-phpbrowser": "^2.0",
"codeception/module-symfony": "^2.1",
"doctrine/doctrine-fixtures-bundle": "^3.4",
"friendsofphp/php-cs-fixer": "^3.2.1",
"jchook/phpunit-assert-throws": "^1.0",
"niels-de-blaauw/php-doc-check": "^0.2.2",
"phpstan/phpstan": "dev-master",
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "^5.4.",
"symfony/css-selector": "^5.4.",
"symfony/debug-bundle": "^5.4.",
"symfony/error-handler": "^5.4.",
"symfony/maker-bundle": "^1.14",
"symfony/phpunit-bridge": "^5.4.",
"symfony/stopwatch": "5.4.*",
"symfony/web-profiler-bundle": "^5.4.",
"ulrichsg/getopt-php": "*",
"wp-cli/php-cli-tools": "^0.11.13",
"codeception/module-asserts": "^1.0.0"
},
"config": {
"preferred-install": {
"*": "dist"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4",
"friendsofphp/php-cs-fixer": "^3.2.1",
"jchook/phpunit-assert-throws": "^1.0",
"niels-de-blaauw/php-doc-check": "^0.2.2",
"phpstan/phpstan": "dev-master",
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "^5.4.",
"symfony/css-selector": "^5.4.",
"symfony/debug-bundle": "^5.4.",
"symfony/error-handler": "^5.4.",
"symfony/maker-bundle": "^1.14",
"symfony/phpunit-bridge": "^5.4.",
"symfony/stopwatch": "5.4.*",
"symfony/web-profiler-bundle": "^5.4.",
"ulrichsg/getopt-php": "*",
"wp-cli/php-cli-tools": "^0.11.13"
"sort-packages": true,
"allow-plugins": {
"composer/package-versions-deprecated": true,
"symfony/flex": true,
"wikimedia/composer-merge-plugin": true
}
},
"autoload": {
"files": [
"src/Core/I18n/I18n.php"
],
"psr-4": {
"App\\": "src/",
"Plugin\\": "plugins/",
"Component\\": "components/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"paragonie/random_compat": "2.*",
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php71": "*",
"symfony/polyfill-php70": "*",
"symfony/polyfill-php56": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"config": {
"preferred-install": {
"*": "dist"
},
"sort-packages": true,
"allow-plugins": {
"composer/package-versions-deprecated": true,
"symfony/flex": true,
"wikimedia/composer-merge-plugin": true
}
},
"autoload": {
"files": [
"src/Core/I18n/I18n.php"
],
"psr-4": {
"App\\": "src/",
"Plugin\\": "plugins/",
"Component\\": "components/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"paragonie/random_compat": "2.*",
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php71": "*",
"symfony/polyfill-php70": "*",
"symfony/polyfill-php56": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts",
"cp -fu bin/pre-commit .git/hooks"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "5.4.*"
},
"merge-plugin": {
"include": [
"components/*/composer.json",
"plugins/*/composer.json"
]
}
},
"repositories": [
{
"type": "package",
"package": {
"name": "niels-de-blaauw/php-doc-check",
"version": "0.2.2",
"bin": [
"bin/php-doc-check"
],
"autoload": {
"psr-4": {
"NdB\\PhpDocCheck\\": "src"
}
},
"source": {
"url": "https://github.com/someonewithpc/php-doc-check.git",
"type": "git",
"reference": "master"
}
}
},
{
"type": "package",
"package": {
"name": "ulrichsg/getopt-php",
"version": "4.0.0",
"autoload": {
"psr-4": {
"GetOpt\\": "src"
}
},
"source": {
"url": "https://github.com/someonewithpc/getopt-php.git",
"type": "git",
"reference": "master"
}
}
}
"post-install-cmd": [
"@auto-scripts",
"cp -fu bin/pre-commit .git/hooks"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "5.4.*"
},
"merge-plugin": {
"include": [
"components/*/composer.json",
"plugins/*/composer.json"
]
}
},
"repositories": [
{
"type": "package",
"package": {
"name": "niels-de-blaauw/php-doc-check",
"version": "0.2.2",
"bin": [
"bin/php-doc-check"
],
"autoload": {
"psr-4": {
"NdB\\PhpDocCheck\\": "src"
}
},
"source": {
"url": "https://github.com/someonewithpc/php-doc-check.git",
"type": "git",
"reference": "master"
}
}
},
{
"type": "package",
"package": {
"name": "ulrichsg/getopt-php",
"version": "4.0.0",
"autoload": {
"psr-4": {
"GetOpt\\": "src"
}
},
"source": {
"url": "https://github.com/someonewithpc/getopt-php.git",
"type": "git",
"reference": "master"
}
}
},
{
"type": "package",
"package": {
"name": "codeception/codeception",
"version": "4.1.30",
"autoload": {
"psr-4": {
"Codeception\\": "src/Codeception",
"Codeception\\Extension\\": "ext"
},
"files": [
"functions.php"
]
},
"require": {
"php": ">=5.6.0 <9.0",
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"codeception/lib-asserts": "^1.0 | 2.0.*@dev",
"guzzlehttp/psr7": "^1.4 | ^2.0",
"symfony/finder": ">=2.7 <6.0",
"symfony/console": ">=2.7 <6.0",
"symfony/event-dispatcher": ">=2.7 <6.0",
"symfony/yaml": ">=2.7 <6.0",
"symfony/css-selector": ">=2.7 <6.0",
"behat/gherkin": "^4.4.0",
"codeception/phpunit-wrapper": ">6.0.15 <6.1.0 | ^6.6.1 | ^7.7.1 | ^8.1.1 | ^9.0",
"codeception/stub": "^2.0 | ^3.0 | ^4.0"
},
"bin": [
"codecept"
],
"source": {
"url": "https://github.com/someonewithpc/Codeception.git",
"type": "git",
"reference": "4.1"
}
}
}
]
}

1535
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,18 @@ security:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api_apps:
pattern: ^/api/v1/apps$
security: false
api_token:
pattern: ^/oauth/token$
security: false
api:
provider: local_user
pattern: ^/api/
security: true
stateless: true
main:
entry_point: App\Security\Authenticator
guard:
@@ -53,3 +65,4 @@ security:
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/settings, roles: ROLE_USER }
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED }

Submodule docker/accessibility deleted from abcd45f8ec

View File

@@ -5,18 +5,21 @@ RUN apk update && apk add git autoconf make gcc g++ file gettext-dev icu-dev zli
ARG MAKEFLAGS="-j$(cat /proc/cpuinfo | grep processor | wc -l)"
RUN apk add --virtual .phpize-deps $PHPIZE_DEPS \
&& cd /tmp && git clone https://github.com/krakjoe/apcu && cd apcu && phpize && ./configure --enable-apcu && make install \
&& cd /tmp && git clone https://github.com/php-ds/ext-ds && cd ext-ds && phpize && ./configure && make install \
&& cd /tmp && git clone https://github.com/msgpack/msgpack-php && cd msgpack-php && phpize && ./configure && make install \
&& cd /tmp && git clone https://github.com/lz4/lz4 && cd lz4 && make install \
&& cd /tmp && git clone https://github.com/phpredis/phpredis && cd phpredis && phpize && ./configure --enable-redis-msgpack --enable-redis-lz4 --with-liblz4=yes && make install \
&& cd /tmp && git clone https://github.com/libvips/php-vips-ext && cd php-vips-ext && phpize && ./configure && make install \
&& rm -rf /usr/share/php7 \
&& rm -rf /tmp/* \
&& apk del .phpize-deps gcc g++ git autoconf > /dev/null
RUN apk add --virtual .phpize-deps $PHPIZE_DEPS
RUN docker-php-ext-install bcmath exif gd gettext gmp intl mysqli opcache pdo pdo_mysql mysqli pdo_pgsql pgsql \
&& docker-php-ext-enable ds msgpack redis apcu vips
RUN docker-php-ext-install bcmath exif gd gettext gmp intl mysqli opcache pdo pdo_mysql mysqli pdo_pgsql pgsql
RUN cd /tmp && git clone https://github.com/krakjoe/apcu && cd apcu && phpize && ./configure --enable-apcu && make install
RUN cd /tmp && git clone https://github.com/php-ds/ext-ds && cd ext-ds && phpize && ./configure && make install
RUN cd /tmp && git clone https://github.com/msgpack/msgpack-php && cd msgpack-php && phpize && ./configure && make install
RUN cd /tmp && git clone https://github.com/lz4/lz4 && cd lz4 && make install
RUN cd /tmp && git clone https://github.com/phpredis/phpredis && cd phpredis && phpize && ./configure --enable-redis-msgpack --enable-redis-lz4 --with-liblz4=yes && make install
RUN cd /tmp && git clone https://github.com/libvips/php-vips-ext && cd php-vips-ext && phpize && ./configure && make install
RUN apk add --no-cache ocaml && cd /tmp && git clone https://github.com/dlitz/texvc.git && cd texvc && make && cp texvc /usr/local/bin/texvc
RUN docker-php-ext-enable opcache ds msgpack redis apcu vips
RUN rm -rf /usr/share/php7 && rm -rf /tmp/* && apk del .phpize-deps gcc g++ autoconf > /dev/null
WORKDIR /var/www/social

3
docker/tooling/acceptance.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
SYMFONY_DEPRECATIONS_HELPER=weak vendor/bin/codecept run

20
docker/tooling/accessibility.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
rm -rf /screenshots/diff
mv -fn /screenshots/new /screenshots/old
mkdir -p /screenshots/diff
mkdir -p /screenshots/new
chmod 777 -R /screenshots
/generate_pa11y-ci-config.php
su puppet -c '/usr/local/bin/pa11y-ci -c /pa11y/config.json'
cd /screenshots/new || exit 1
for f in *; do
XC=$(compare -metric NCC "/screenshots/old/${f}" "${f}" "/screenshots/diff/${f}" 2>&1)
if [ 1 -eq "$(echo "${XC} < 0.999" | bc)" ]; then
printf '\e[33mCheck file for differences: \e]8;;%s\e\\%s\e]8;;\e\\\e[0m\n' "file:tests/screenshots/diff/${f}" "tests/screenshots/diff/${f}"
fi
done

View File

@@ -2,7 +2,7 @@ version: '3'
services:
php:
build: .
build: php
depends_on:
- db
- redis
@@ -13,6 +13,7 @@ services:
- ../social/install.sh:/var/entrypoint.d/0_social_install.sh
- ./coverage.sh:/var/tooling/coverage.sh
- ./phpstan.sh:/var/tooling/phpstan.sh
- ./acceptance.sh:/var/tooling/acceptance.sh
# Main files
- ../../:/var/www/social
- /var/www/social/docker # exclude docker folder
@@ -22,6 +23,37 @@ services:
- db.env
command: /entrypoint.sh
nginx:
image: nginx:alpine
depends_on:
- php
restart: always
tty: false
volumes:
# Nginx
- ../nginx/nginx.conf:/var/nginx/social.conf
- ../nginx/domain.sh:/var/nginx/domain.sh
# Certbot
- ../certbot/www:/var/www/certbot
- ../certbot/.files:/etc/letsencrypt
# social
- ../../public:/var/www/social/public
env_file:
- ../bootstrap/bootstrap.env
command: /bin/sh -c '/var/nginx/domain.sh; nginx -g "daemon off;"'
pa11y:
build: pa11y
depends_on:
- nginx
volumes:
- ../../tests/screenshots:/screenshots
- ./accessibility.sh:/accessibility.sh
- ./generate_pa11y-ci-config.php:/generate_pa11y-ci-config.php
- /pa11y
cap_add:
- SYS_ADMIN
db:
image: postgres:alpine
environment:

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env php
<?php
declare(strict_types = 1);
$urls = [];
foreach ([[360, 640, true], [1280, 720, true], [1280, 720, false], [2560, 1080, false]] as $viewport) {
[$x, $y, $is_mobile] = $viewport;
$gen = function (string $url, string $actions = "") use ($x, $y, $is_mobile) {
$path = "/screenshots/new/{$x}x{$y}" . ($is_mobile ? '-mobile' : '') . '-' . ($url === '' ? 'root' : str_replace('/', '-', $url)) . ".png";
$is_mobile = $is_mobile ? 'true' : 'false';
return <<<EOU
{
"url": "https://nginx/{$url}",
"screenCapture": "{$path}",
"viewport": {
"width": {$x},
"height": {$y},
"isMobile": {$is_mobile}
}{$actions}
}
EOU;
};
foreach ([
'', 'feed/public',
'doc/faq', 'doc/tos', 'doc/privacy', 'doc/source', 'doc/version',
'main/login', 'main/register',
] as $url) {
$urls[] = $gen($url);
}
$urls[] = $gen('main/login', <<<EOA
,
"actions": [
"navigate to https://nginx/main/login",
"set field #inputNicknameOrEmail to taken_user",
"set field #inputPassword to foobar",
"click element #signIn",
"wait for path to not be /login"
]
EOA);
foreach (['feed/public', 'feed/home', '@taken_user/circles',
'feed/network', 'feed/clique', 'feed/federated', 'feed/notifications',
'@taken_user/collections', '@taken_user/favourites', '@taken_user/reverse_favourites',
'directory/people', 'directory/groups', 'settings', 'main/logout'
] as $url) {
$urls[] = $gen($url);
}
}
$urls = implode(",\n", $urls);
$config = <<<EOF
{
"defaults": {
"chromeLaunchConfig": {
"ignoreHTTPSErrors": true
},
"standard": "WCAG2AAA",
"timeout": 10000
},
"concurrency": 4,
"urls": [
{$urls}
]
}
EOF;
file_put_contents('/pa11y/config.json', $config);

View File

@@ -0,0 +1,17 @@
FROM node
RUN apt-get update && apt-get install -y \
apt-transport-https ca-certificates curl fontconfig \
fonts-ipafont-gothic fonts-kacst fonts-liberation fonts-thai-tlwg \
fonts-wqy-zenhei gconf-service libgbm-dev libasound2 \
libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 \
libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \
libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 \
libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 \
libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \
libxtst6 locales lsb-release unzip xdg-utils wget imagemagick php bc \
&& apt-get clean \
&& apt-get autoremove -q \
&& npm install -g pa11y-ci
RUN useradd -ms /bin/bash puppet

View File

@@ -0,0 +1,7 @@
ChallengeResponseAuthentication no
ListenAddress 0.0.0.0
PasswordAuthentication yes
PermitEmptyPasswords yes
PermitRootLogin yes
Port 22

View File

@@ -6,3 +6,5 @@ RUN apk update \
&& apk add --no-cache $PHPIZE_DEPS runuser \
&& pecl install xdebug \
&& docker-php-ext-enable xdebug
RUN apk add --no-cache openssh sshpass

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,208 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="1366"
height="768"
viewBox="0 0 1366 768"
version="1.1"
id="svg5"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20, custom)"
sodipodi:docname="basic-layout.svg"
inkscape:export-filename="/home/booh/Work/gnu-social-documentation/basic-layou.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#070707"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:document-units="px"
showgrid="false"
showguides="true"
inkscape:zoom="0.8125"
inkscape:cx="667.07692"
inkscape:cy="422.15385"
inkscape:window-width="1920"
inkscape:window-height="1007"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g138495" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-dasharray:0.1,0.1;stroke-opacity:1;paint-order:stroke markers fill;stroke-miterlimit:4;stroke-dashoffset:0"
id="rect918"
width="1344.7627"
height="46.717339"
x="10.618625"
y="10.618625" />
<text
xml:space="preserve"
style="font-size:32px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20';fill:#ffffff"
x="626.19995"
y="43.577293"
id="text8019"><tspan
sodipodi:role="line"
id="tspan8017"
x="626.19995"
y="43.577293">Header</tspan></text>
<rect
style="fill:none;stroke:#ffffff;stroke-width:0.977782;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.0977782, 0.0977782;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
id="rect16129"
width="301.02222"
height="700.70471"
x="10.607515"
y="57.478703" />
<rect
style="fill:none;stroke:#ffffff;stroke-width:0.977782;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.0977782, 0.0977782;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
id="rect16129-3"
width="301.02222"
height="700.70471"
x="1054.3591"
y="57.335964" />
<rect
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.1, 0.1;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
id="rect49857"
width="742.72937"
height="700.56195"
x="311.62973"
y="57.478703" />
<text
xml:space="preserve"
style="font-size:21.3333px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
x="597.01569"
y="412.55966"
id="text57880"><tspan
sodipodi:role="line"
id="tspan57878"
x="597.01569"
y="412.55966"
style="fill:#ffffff">Current page</tspan></text>
<rect
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.1, 0.1;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
id="rect62142-5"
width="26"
height="26"
x="1319.381"
y="21.461538" />
<text
xml:space="preserve"
style="font-size:13.3333px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
x="50.911686"
y="36.977287"
id="text68176"><tspan
sodipodi:role="line"
id="tspan68174"
x="50.911686"
y="36.977287"
style="font-size:13.3333px;fill:#ffffff">-&gt; Opens Left panel</tspan></text>
<text
xml:space="preserve"
style="font-size:13.3333px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
x="1156.0417"
y="36.977287"
id="text88173"><tspan
sodipodi:role="line"
id="tspan88171"
x="1156.0417"
y="36.977287"
style="font-size:13.3333px;fill:#ffffff">Opens Right panel &lt;-</tspan></text>
<g
id="g98259">
<rect
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:0.1, 0.1;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
id="rect62142"
width="26"
height="26"
x="20.5"
y="20.977295" />
<path
style="fill:#ffffff;stroke:#feffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
d="m 20.5,46.977295 c 26,-26 26,-26 26,-26 l -26,26 26,-26"
id="path97234" />
<path
style="fill:#ffffff;fill-opacity:1;stroke:#feffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 46.5,46.977295 c -26,-26 -26,-26 -26,-26 l 26,26 -26,-26"
id="path97234-6" />
</g>
<g
id="g98259-2"
transform="translate(1060.3462,-63.208064)">
<path
style="fill:#ffffff;fill-opacity:1;stroke:#feffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 259.03478,110.6696 c 26,-25.999998 26,-25.999998 26,-25.999998 l -26,25.999998 26,-25.999998"
id="path97234-1" />
<path
style="fill:#ffffff;fill-opacity:1;stroke:#feffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 285.03478,110.6696 c -26,-25.999996 -26,-25.999996 -26,-25.999996 l 26,25.999996 -26,-25.999996"
id="path97234-6-2" />
<g
id="g138454"
transform="translate(0,-14.04183)">
<text
xml:space="preserve"
style="font-size:21.3333px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
x="98.718689"
y="420.67288"
id="text24170"
transform="translate(-1060.3462,63.208064)"><tspan
sodipodi:role="line"
id="tspan24168"
x="98.718689"
y="420.67288"
style="fill:#ffffff">Left panel</tspan></text>
<text
xml:space="preserve"
style="font-size:16px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
x="-1000.0276"
y="496.68094"
id="text110837"><tspan
sodipodi:role="line"
id="tspan110835"
x="-1000.0276"
y="496.68094"
style="fill:#ffffff">[.section-panel-left]</tspan></text>
</g>
<g
id="g138495"
transform="translate(0,-19.454344)">
<text
xml:space="preserve"
style="font-size:21.3333px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
x="1136.0703"
y="425.94266"
id="text24170-6"
transform="translate(-1060.3462,63.208064)"><tspan
sodipodi:role="line"
id="tspan24168-7"
x="1136.0703"
y="425.94266"
style="fill:#ffffff">Right panel</tspan></text>
<text
xml:space="preserve"
style="font-size:16px;line-height:1.25;font-family:'Mx437 IBM XGA-AI 12x20';-inkscape-font-specification:'Mx437 IBM XGA-AI 12x20'"
x="38.923981"
y="501.95071"
id="text110837-7"><tspan
sodipodi:role="line"
id="tspan110835-0"
x="38.923981"
y="501.95071"
style="fill:#ffffff">[.section-panel-right]</tspan></text>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -1,3 +1,4 @@
# Summary
- [Why a design language?](./design_language.md)
- [Layout and CSS classes](./guidelines.md)

View File

@@ -1,31 +0,0 @@
# Why a design language?
Humans have an innate understanding for common, predictable and repeatable concepts. Our brains are, in fact, hardwired
to take advantage of such phenomena, which is sometimes taken to great effects in optical illusions for example.
Patterns emerge when concepts and actions, interlinked, construct a predictable outcome. With a common design language,
we hope to achieve such predictability, and supply an innate understanding of user interaction.
The goal isn't to have one and only design language, but to encourage new themes/interfaces to take similar steps on their
design processes.
## Predictability and user experience
A good book implies meaning, perhaps through environmental storytelling, or any other thought exercise that assumes
a conscious, and rational reader capable of processing information. Not just present it.
The same is true for a good UI, it shouldn't be explained, there should be an innate understanding.
### User Interface Universal Language
Web technologies as a whole contain a set of constraints for organizing web pages. This implies that all web pages have
a common structural basis.
Users accustomed to surfing the Web know which user interactions are acceptable and which aren't.
The key puzzle is how users come to know these restrictions of their Web UI. This is the crux of any
accessible Web page, an hierarchy needs to be followed as well as common standards.
### Canons of page construction
The aforementioned comparison between books and Web pages isn't just a coincidence, given the resemblance between the
two mediums. From their presentation to fundamental theory, it's only natural to apply core book design ideas to the Web.
### User customization

View File

@@ -0,0 +1,38 @@
# Design considerations
Humans have an innate understanding for common, predictable and repeatable concepts. Our brains are, in fact, hardwired
to take advantage of such phenomena. Patterns emerge when concepts and actions, interlinked, construct a predictable outcome.
This basic idea should always be employed into the design of a user interface, because it inherently supplies an innate
understanding of user interaction.
So, just bear this in mind. Try not to reinvent HTML elements, use them properly.
The designer docs are intended to help out in the development processes of both the Core components, and Plugins.
With an emphasis on the frontend side of things, of course.
## Basic layout
_Bear in mind **any** of the following assumptions are based upon the **default theme**, your mileage may vary._
The layout is subdivided in 4 distinct areas:
- **Header**
- Left panel ~~checkbox~~ button :)
- **Left panel**
- Main instance link / header 1
- Right panel ~~checkbox~~... I mean, button...
- **Right panel**
- **Current page content**
![GNU social's basic page layout](../imgs/basic-layout.png "The basic layout of GNU social default theme")
Each one of these areas **are selectable** with CSS by **using a limited set of classes**. You can use whatever classes
you may want, but bear in mind that any external code made by someone else other than yourself may not account
for your specific class names.
### CSS classes reference
| Name | Function | Dependencies | Examples | Sub-classes |
|----------------------|----------------------------------------------------------------------------|--------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------|
| section-panel | Side panel | - Preceded by a checkbox hack (hide/show panel); | Left panel `\App\Component\LeftPanel\templates\left_panel\view.html.twig`<br/>Right `\App\Component\RightPanel\templates\right_panel\view.html.twig` | `section-panel-left`, `section-panel-right` |
| frame-section | A sub-section of a page, commonly a template block of a component / plugin | None | Login template `\App\templates\security\login.html.twig` | `frame-section-title`, `frame-section-subtitle` |
| frame-section-title | A template block's title | - Part of a `frame-section`; | Settings template `\App\templates\settings\base.html.twig` | None |
_**still in construction...**_

View File

@@ -11,6 +11,7 @@ parameters:
- plugins/ActivityPub
- plugins/Poll
- components/FreeNetwork
- tests/CodeCeption/_support/
earlyTerminatingMethodCalls:
App\Core\Log:
- unexpected_exception
@@ -24,6 +25,16 @@ parameters:
message: '/^Property App\\PHPStan\\ClassFromTableNameDynamicStaticMethodReturnTypeExtension::\$provider is never read, only written\./'
path: src/PHPStan/ClassFromTableNameDynamicStaticMethodReturnTypeExtension.php
-
message: '/Parameter \$I of method [a-zA-Z]+::[a-zA-Z_]+\(\) has invalid type AcceptanceTester\./'
paths:
- *
-
message: '/Call to method [a-zA-Z]+\(\) on an unknown class AcceptanceTester\./'
paths:
- *
# -
# message: '/has no return typehint specified/'
# paths:

View File

@@ -47,17 +47,20 @@ use App\Util\Common;
use App\Util\Exception\BugFoundException;
use App\Util\Exception\NoSuchActorException;
use App\Util\Nickname;
use Component\Collection\Util\Controller\OrderedCollection;
use Component\FreeNetwork\Entity\FreeNetworkActorProtocol;
use Component\FreeNetwork\Util\Discovery;
use Exception;
use InvalidArgumentException;
use const PHP_URL_HOST;
use Plugin\ActivityPub\Controller\Inbox;
use Plugin\ActivityPub\Controller\Outbox;
use Plugin\ActivityPub\Entity\ActivitypubActivity;
use Plugin\ActivityPub\Entity\ActivitypubActor;
use Plugin\ActivityPub\Entity\ActivitypubObject;
use Plugin\ActivityPub\Util\HTTPSignature;
use Plugin\ActivityPub\Util\Model;
use Plugin\ActivityPub\Util\OrderedCollectionController;
use Plugin\ActivityPub\Util\Response\ActorResponse;
use Plugin\ActivityPub\Util\Response\NoteResponse;
use Plugin\ActivityPub\Util\TypeResponse;
@@ -127,7 +130,7 @@ class ActivityPub extends Plugin
$r->connect(
'activitypub_actor_outbox',
'/actor/{gsactor_id<\d+>}/outbox.json',
[Inbox::class, 'handle'],
[Outbox::class, 'viewOutboxByActorId'],
options: ['accept' => self::$accept_headers, 'format' => self::$accept_headers[0]],
);
return Event::next;
@@ -185,16 +188,21 @@ class ActivityPub extends Plugin
case 'actor_view_id':
case 'actor_view_nickname':
$response = ActorResponse::handle($vars['actor']);
return Event::stop;
break;
case 'note_view':
$response = NoteResponse::handle($vars['note']);
return Event::stop;
break;
case 'activitypub_actor_outbox':
$response = new TypeResponse($vars['type']);
break;
default:
if (Event::handle('ActivityPubActivityStreamsTwoResponse', [$route, $vars, &$response]) === Event::stop) {
return Event::stop;
if (Event::handle('ActivityPubActivityStreamsTwoResponse', [$route, $vars, &$response]) !== Event::stop) {
if (is_subclass_of($vars['controller'][0], OrderedCollection::class)) {
$response = new TypeResponse(OrderedCollectionController::fromControllerVars($vars)['type']);
}
}
return Event::next;
}
return Event::stop;
}
/**

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* ActivityPub implementation for GNU social
*
* @package GNUsocial
* @category ActivityPub
*
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Plugin\ActivityPub\Controller;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Entity\Activity;
use App\Util\Exception\ClientException;
use Exception;
use Plugin\ActivityPub\Util\OrderedCollectionController;
use Symfony\Component\HttpFoundation\Request;
/**
* ActivityPub Outbox Handler
*
* @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Outbox extends OrderedCollectionController
{
/**
* Create an Inbox Handler to receive something from someone.
*/
public function viewOutboxByActorId(Request $request, int $gsactor_id): array
{
try {
$user = DB::findOneBy('local_user', ['id' => $gsactor_id]);
} catch (Exception $e) {
throw new ClientException(_m('No such actor.'), 404, $e);
}
$this->actor_id = $gsactor_id;
Log::debug('ActivityPub Outbox: Received a GET request.');
$activities = DB::findBy(Activity::class, ['actor_id' => $user->getId()], order_by: ['created' => 'DESC']);
foreach ($activities as $act) {
$this->ordered_items[] = Router::url('activity_view', ['id' => $act->getId()], ROUTER::ABSOLUTE_URL);
}
$this->route = 'activitypub_actor_outbox';
$this->route_args = ['gsactor_id' => $user->getId(), 'page' => $this->int('page') ?? 0];
return $this->handle($request);
}
}

View File

@@ -61,6 +61,20 @@ use Plugin\ActivityPub\Util\Model;
*/
class Actor extends Model
{
private static array $_gs_actor_type_to_as2_actor_type = [
GSActor::PERSON => 'Person',
GSActor::GROUP => 'Group',
GSActor::ORGANISATION => 'Organization',
GSActor::BOT => 'Application',
];
private static array $_as2_actor_type_to_gs_actor_type = [
'Person' => GSActor::PERSON,
'Group' => GSActor::GROUP,
'Organization' => GSActor::ORGANISATION,
'Application' => GSActor::BOT,
'Service' => null,
];
/**
* Create an Entity from an ActivityStreams 2.0 JSON string
* This will persist a new GSActor, ActivityPubRSA, and ActivityPubActor
@@ -77,8 +91,8 @@ class Actor extends Model
'fullname' => !empty($person->get('name')) ? $person->get('name') : null,
'created' => new DateTime($person->get('published') ?? 'now'),
'bio' => $person->get('summary'),
'is_local' => false,
'type' => GSActor::PERSON,
'is_local' => false, // duh!
'type' => self::$_as2_actor_type_to_gs_actor_type[$person->get('type')],
'roles' => UserRoles::USER,
'modified' => new DateTime(),
];
@@ -184,7 +198,7 @@ class Actor extends Model
$uri = $object->getUri(Router::ABSOLUTE_URL);
$attr = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Person',
'type' => self::$_gs_actor_type_to_as2_actor_type[$object->getType()],
'id' => $uri,
'inbox' => Router::url('activitypub_actor_inbox', ['gsactor_id' => $object->getId()], Router::ABSOLUTE_URL),
'outbox' => Router::url('activitypub_actor_outbox', ['gsactor_id' => $object->getId()], Router::ABSOLUTE_URL),

View File

@@ -44,7 +44,6 @@ use App\Core\Log;
use App\Core\Router\Router;
use App\Core\VisibilityScope;
use App\Entity\Note as GSNote;
use App\Entity\NoteTag;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
@@ -57,6 +56,7 @@ use Component\Attachment\Entity\AttachmentToNote;
use Component\Conversation\Conversation;
use Component\FreeNetwork\FreeNetwork;
use Component\Language\Entity\Language;
use Component\Tag\Entity\NoteTag;
use Component\Tag\Tag;
use DateTime;
use DateTimeInterface;
@@ -254,7 +254,7 @@ class Note extends Model
break;
case 'Hashtag':
$match = ltrim($ap_tag->get('name'), '#');
$tag = Tag::ensureValid($match);
$tag = Tag::extract($match);
$canonical_tag = $ap_tag->get('canonical') ?? Tag::canonicalTag($tag, \is_null($lang_id = $obj->getLanguageId()) ? null : Language::getById($lang_id)->getLocale());
DB::persist(NoteTag::create([
'tag' => $tag,

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* ActivityPub implementation for GNU social
*
* @package GNUsocial
* @category ActivityPub
*
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Plugin\ActivityPub\Util;
use ActivityPhp\Type\Core\OrderedCollection;
use ActivityPhp\Type\Core\OrderedCollectionPage;
use App\Core\Router\Router;
use Component\Collection\Util\Controller\CircleController;
use Component\Collection\Util\Controller\FeedController;
use Component\Collection\Util\Controller\OrderedCollection as GSOrderedCollection;
use Symfony\Component\HttpFoundation\Request;
/**
* Provides a response in application/ld+json to GSActivity
*
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
abstract class OrderedCollectionController extends GSOrderedCollection
{
protected array $ordered_items = [];
protected string $route;
protected array $route_args = [];
protected int $actor_id;
public static function fromControllerVars(array $vars): array
{
$route = $vars['request']->get('_route');
$route_args = array_merge($vars['request']->query->all(), $vars['request']->attributes->get('_route_params'));
unset($route_args['is_system_path'], $route_args['template'], $route_args['_format'], $route_args['accept'], $route_args['p']);
if (is_subclass_of($vars['controller'][0], FeedController::class)) {
$notes = [];
foreach ($vars['notes'] as $note_replies) {
$notes[] = Router::url('note_view', ['id' => $note_replies['note']->getId()], type: Router::ABSOLUTE_URL);
}
$type = self::setupType(route: $route, route_args: $route_args, ordered_items: $notes);
} elseif (is_subclass_of($vars['controller'][0], CircleController::class)) {
$actors = [];
foreach ($vars['actors'] as $actor) {
$actors[] = Router::url('actor_view_id', ['id' => $actor->getId()], type: Router::ABSOLUTE_URL);
}
$type = self::setupType(route: $route, route_args: $route_args, ordered_items: $actors);
} else {
$type = self::setupType(route: $route, route_args: $route_args, ordered_items: []);
}
return ['type' => $type];
}
protected static function setupType(string $route, array $route_args = [], array $ordered_items = []): OrderedCollectionPage|OrderedCollection
{
$page = $route_args['page'] ?? 0;
$type = $page === 0 ? new OrderedCollection() : new OrderedCollectionPage();
$type->set('@context', 'https://www.w3.org/ns/activitystreams');
$type->set('items', $ordered_items);
$type->set('orderedItems', $ordered_items);
$type->set('totalItems', \count($ordered_items));
if ($page === 0) {
$route_args['page'] = 1;
$type->set('first', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL));
} else {
$type->set('partOf', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL));
if ($page + 1 < $total_pages = 1) { // TODO: do proper pagination
$route_args['page'] = ($page + 1 == 1 ? 2 : $page + 1);
$type->set('next', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL));
}
if ($page > 1) {
$route_args['page'] = ($page - 1 <= 0 ? 1 : $page - 1);
$type->set('prev', Router::url($route, $route_args, type: ROUTER::ABSOLUTE_URL));
}
}
return $type;
}
public function handle(Request $request): array
{
$type = self::setupType($this->route, $this->route_args, $this->ordered_items, $this->actor_id);
return ['type' => $type];
}
}

View File

@@ -49,7 +49,7 @@ class TypeResponse extends JsonResponse
* @param null|AbstractObject|string $json
* @param int $status The response status code
*/
public function __construct(string|AbstractObject|null $json = null, int $status = 202)
public function __construct(string|AbstractObject|null $json = null, int $status = 200)
{
parent::__construct(
data: \is_object($json) ? $json->toJson() : $json,

View File

@@ -1,7 +1,5 @@
{
"require": {
"landrok/activitypub": "^0.5.6",
"masterminds/html5": "^2.7",
"mf2/mf2": "^0.4.6"
"landrok/activitypub": "^0.5.6"
}
}

View File

@@ -1,181 +0,0 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* Actor Circles for GNU social
*
* @package GNUsocial
* @category Plugin
*
* @author Phablulo <phablulo@gmail.com>
* @copyright 2018-2019, 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Plugin\ActorCircles;
use App\Core\DB\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\Feed;
use App\Entity\LocalUser;
use App\Util\Nickname;
use Component\Collection\Util\MetaCollectionPlugin;
use Plugin\ActorCircles\Controller as C;
use Plugin\ActorCircles\Entity as E;
use Symfony\Component\HttpFoundation\Request;
class ActorCircles extends MetaCollectionPlugin
{
protected string $slug = 'circle';
protected string $plural_slug = 'circles';
private function getActorIdFromVars(array $vars): int
{
$id = $vars['request']->get('id', null);
if ($id) {
return (int) $id;
}
$nick = $vars['request']->get('nickname');
$actor = DB::findOneBy(Actor::class, ['nickname' => $nick]);
return $actor->getId();
}
protected function createCollection(Actor $owner, array $vars, string $name)
{
$actor_id = $this->getActorIdFromVars($vars);
$col = E\ActorCircles::create([
'name' => $name,
'actor_id' => $owner->getId(),
]);
DB::persist($col);
DB::persist(E\ActorCirclesEntry::create([
'actor_id' => $actor_id,
'circle_id' => $col->getId(),
]));
}
protected function removeItems(Actor $owner, array $vars, $items, array $collections)
{
$actor_id = $this->getActorIdFromVars($vars);
// can only delete what you own
$items = array_filter($items, fn ($x) => \in_array($x, $collections));
DB::dql(<<<'EOF'
DELETE FROM \Plugin\ActorCircles\Entity\ActorCirclesEntry AS entry
WHERE entry.actor_id = :actor_id AND entry.circle_id IN (:ids)
EOF, [
'actor_id' => $actor_id,
'ids' => $items,
]);
}
protected function addItems(Actor $owner, array $vars, $items, array $collections)
{
$actor_id = $this->getActorIdFromVars($vars);
foreach ($items as $id) {
// prevent user from putting something in a collection (s)he doesn't own:
if (\in_array($id, $collections)) {
DB::persist(E\ActorCirclesEntry::create([
'actor_id' => $actor_id,
'circle_id' => $id,
]));
}
}
}
/**
* @see MetaCollectionPlugin->shouldAddToRightPanel
*/
protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool
{
return
$vars['path'] === 'actor_view_nickname'
|| $vars['path'] === 'actor_view_id'
|| $vars['path'] === 'group_actor_view_nickname'
|| $vars['path'] === 'group_actor_view_id';
}
protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array
{
if (\is_null($vars)) {
$res = DB::findBy(E\ActorCircles::class, ['actor_id' => $owner->getId()]);
} else {
$actor_id = $this->getActorIdFromVars($vars);
$res = DB::dql(
<<<'EOF'
SELECT entry.circle_id FROM \Plugin\ActorCircles\Entity\ActorCirclesEntry AS entry
INNER JOIN \Plugin\ActorCircles\Entity\ActorCircles AS circle
WITH circle.id = entry.circle_id
WHERE circle.actor_id = :owner_id AND entry.actor_id = :actor_id
EOF,
[
'owner_id' => $owner->getId(),
'actor_id' => $actor_id,
],
);
}
if (!$ids_only) {
return $res;
}
return array_map(fn ($x) => $x['circle_id'], $res);
}
public function onAddRoute(RouteLoader $r): bool
{
// View all circles by actor id and nickname
$r->connect(
id: 'actor_circles_view_by_actor_id',
uri_path: '/actor/{id<\d+>}/circles',
target: [C\Circles::class, 'collectionsViewByActorId'],
);
$r->connect(
id: 'actor_circles_view_by_nickname',
uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/circles',
target: [C\Circles::class, 'collectionsViewByActorNickname'],
);
// View notes from a circle by actor id and nickname
$r->connect(
id: 'actor_circles_notes_view_by_actor_id',
uri_path: '/actor/{id<\d+>}/circles/{cid<\d+>}',
target: [C\Circles::class, 'collectionsEntryViewNotesByActorId'],
);
$r->connect(
id: 'actor_circles_notes_view_by_nickname',
uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/circles/{cid<\d+>}',
target: [C\Circles::class, 'collectionsEntryViewNotesByNickname'],
);
return Event::next;
}
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
{
DB::persist(Feed::create([
'actor_id' => $actor_id,
'url' => Router::url($route = 'actor_circles_view_by_nickname', ['nickname' => $user->getNickname()]),
'route' => $route,
'title' => _m('Circles'),
'ordering' => $ordering++,
]));
return Event::next;
}
}

View File

@@ -1,84 +0,0 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Plugin\ActorCircles\Controller;
use App\Core\DB\DB;
use App\Core\Router\Router;
use Component\Collection\Util\Controller\MetaCollectionController;
use Plugin\ActorCircles\Entity\ActorCircles;
class Circles extends MetaCollectionController
{
protected string $slug = 'circle';
protected string $plural_slug = 'circles';
protected string $page_title = 'Actor circles';
public function createCollection(int $owner_id, string $name)
{
DB::persist(ActorCircles::create([
'name' => $name,
'actor_id' => $owner_id,
]));
}
public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string
{
if (\is_null($owner_nickname)) {
return Router::url(
'actor_circles_notes_view_by_actor_id',
['id' => $owner_id, 'cid' => $collection_id],
);
}
return Router::url(
'actor_circles_notes_view_by_nickname',
['nickname' => $owner_nickname, 'cid' => $collection_id],
);
}
public function getCollectionItems(int $owner_id, $collection_id): array
{
$notes = DB::dql(
<<<'EOF'
SELECT n FROM \App\Entity\Note as n WHERE n.actor_id in (
SELECT entry.actor_id FROM \Plugin\ActorCircles\Entity\ActorCirclesEntry as entry
inner join \Plugin\ActorCircles\Entity\ActorCircles as ac
with ac.id = entry.circle_id
WHERE ac.id = :circle_id
)
ORDER BY n.created DESC, n.id DESC
EOF,
['circle_id' => $collection_id],
);
return [
'_template' => 'collection/notes.html.twig',
'notes' => array_values($notes),
];
}
public function getCollectionsByActorId(int $owner_id): array
{
return DB::findBy(ActorCircles::class, ['actor_id' => $owner_id], order_by: ['id' => 'desc']);
}
public function getCollectionBy(int $owner_id, int $collection_id): ActorCircles
{
return DB::findOneBy(ActorCircles::class, ['id' => $collection_id, 'actor_id' => $owner_id]);
}
}

View File

@@ -1,66 +0,0 @@
<?php
declare(strict_types = 1);
namespace Plugin\ActorCircles\Entity;
use App\Core\Entity;
class ActorCirclesEntry extends Entity
{
// These tags are meant to be literally included and will be populated with the appropriate fields, setters and getters by `bin/generate_entity_fields`
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private int $actor_id;
private int $circle_id;
public function setId(int $id): self
{
$this->id = $id;
return $this;
}
public function getId(): int
{
return $this->id;
}
public function setActorId(int $actor_id): self
{
$this->actor_id = $actor_id;
return $this;
}
public function getActorId(): int
{
return $this->actor_id;
}
public function setCircleId(int $circle_id): self
{
$this->circle_id = $circle_id;
return $this;
}
public function getCircleId(): int
{
return $this->circle_id;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function schemaDef()
{
return [
'name' => 'actor_circles_entry',
'fields' => [
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'unique identifier'],
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to attachment table'],
'circle_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'actor_circles_a.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'foreign key to collection table'],
],
'primary key' => ['id'],
];
}
}

View File

@@ -34,20 +34,24 @@ namespace Plugin\AttachmentCollections;
use App\Core\DB\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Plugin;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\Feed;
use App\Entity\LocalUser;
use App\Util\Nickname;
use Component\Collection\Util\MetaCollectionPlugin;
use Component\Collection\Util\MetaCollectionTrait;
use Plugin\AttachmentCollections\Controller\AttachmentCollections as AttachmentCollectionsController;
use Plugin\AttachmentCollections\Entity\AttachmentCollection;
use Plugin\AttachmentCollections\Entity\AttachmentCollectionEntry;
use Symfony\Component\HttpFoundation\Request;
class AttachmentCollections extends MetaCollectionPlugin
class AttachmentCollections extends Plugin
{
use MetaCollectionTrait;
protected string $slug = 'collection';
protected string $plural_slug = 'collections';
protected function createCollection(Actor $owner, array $vars, string $name)
{
$col = AttachmentCollection::create([
@@ -61,7 +65,7 @@ class AttachmentCollections extends MetaCollectionPlugin
'collection_id' => $col->getId(),
]));
}
protected function removeItems(Actor $owner, array $vars, $items, array $collections)
protected function removeItem(Actor $owner, array $vars, array $items, array $collections)
{
return DB::dql(<<<'EOF'
DELETE FROM \Plugin\AttachmentCollections\Entity\AttachmentCollectionEntry AS entry
@@ -79,7 +83,7 @@ class AttachmentCollections extends MetaCollectionPlugin
]);
}
protected function addItems(Actor $owner, array $vars, $items, array $collections)
protected function addItem(Actor $owner, array $vars, array $items, array $collections)
{
foreach ($items as $id) {
// prevent user from putting something in a collection (s)he doesn't own:

View File

@@ -77,4 +77,15 @@ class AttachmentCollections extends MetaCollectionController
{
return DB::findOneBy(AttachmentCollection::class, ['id' => $collection_id]);
}
public function setCollectionName(int $actor_id, string $actor_nickname, AttachmentCollection $collection, string $name)
{
$collection->setName($name);
DB::persist($collection);
}
public function removeCollection(int $actor_id, string $actor_nickname, AttachmentCollection $collection)
{
DB::remove($collection);
}
}

View File

@@ -2,9 +2,9 @@
{% block collection_items %}
{% for key, attachment in attachments %}
<section class="section-widget section-padding">
<section class="frame-section frame-section-padding">
{% include '/cards/attachments/view.html.twig' with {'attachment': attachment, 'note': bare_notes[key], 'title': attachment.getBestTitle(bare_notes[key])} only %}
<a class="section-widget-button-like"
<a class="frame-section-button-like"
href="{{ attachment.getDownloadUrl(bare_notes[key]) }}"> {{ 'Download link' | trans }}</a>
</section>
{% else %}

View File

@@ -28,10 +28,11 @@ use App\Core\Event;
use App\Core\Modules\Plugin;
use App\Util\Common;
use App\Util\Formatting;
use Symfony\Component\HttpFoundation\Request;
class AttachmentShowRelated extends Plugin
{
public function onAppendRightPanelBlock($vars, $request, &$res): bool
public function onAppendRightPanelBlock(Request $request, $vars, &$res): bool
{
if ($vars['path'] === 'note_attachment_show') {
$related_notes = DB::dql('select n from attachment_to_note an '

View File

@@ -1,6 +1,6 @@
{% import '/cards/note/view.html.twig' as noteView %}
<section class="section-widget section-padding">
<section class="frame-section frame-section-padding">
<div class="section-title">
<h2 class="heading-no-margin">
{{ 'Notes related' | trans }}

View File

@@ -1,4 +1,4 @@
<section class="section-widget section-padding">
<section class="frame-section frame-section-padding">
<div class="section-title">
<h2 class="heading-no-margin">
{{ 'Attachment tags' | trans }}

View File

@@ -22,6 +22,7 @@ declare(strict_types = 1);
namespace Plugin\DeleteNote;
use ActivityPhp\Type\AbstractObject;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
@@ -51,6 +52,14 @@ use Symfony\Component\HttpFoundation\Request;
*/
class DeleteNote extends NoteHandlerPlugin
{
public static function cacheKeys(int|Note $note_id): array
{
$note_id = \is_int($note_id) ? $note_id : $note_id->getId();
return [
'activity' => "deleted-note-activity-{$note_id}",
];
}
/**
* **Checks actor permissions for the DeleteNote action, deletes given Note
* and creates respective Activity and Notification**
@@ -85,6 +94,7 @@ class DeleteNote extends NoteHandlerPlugin
// Undertaker believes the actor can terminate this note
$activity = $note->delete(actor: $actor, source: 'web');
Cache::delete(self::cacheKeys($note)['activity']);
// Undertaker successful
Event::handle('NewNotification', [$actor, $activity, [], _m('{nickname} deleted note {note_id}.', ['nickname' => $actor->getNickname(), 'note_id' => $activity->getObjectId()])]);
@@ -106,8 +116,13 @@ class DeleteNote extends NoteHandlerPlugin
{
$actor = \is_int($actor) ? Actor::getById($actor) : $actor;
$note = \is_int($note) ? Note::getById($note) : $note;
// Try and find if note was already deleted
if (\is_null(DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => 'note', 'object_id' => $note->getId()], return_null: true))) {
// Try to find if note was already deleted
if (\is_null(
Cache::get(
self::cacheKeys($note)['activity'],
fn () => DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => 'note', 'object_id' => $note->getId()], return_null: true),
),
)) {
// If none found, then undertaker has a job to do
return self::undertaker($actor, $note);
} else {
@@ -145,8 +160,12 @@ class DeleteNote extends NoteHandlerPlugin
if (\is_null($actor = Common::actor())) {
return Event::next;
}
// Only add action if note wasn't already deleted!
if (\is_null(DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => 'note', 'object_id' => $note->getId()], return_null: true))
if (
// Only add action if note wasn't already deleted!
\is_null(Cache::get(
self::cacheKeys($note)['activity'],
fn () => DB::findOneBy(Activity::class, ['verb' => 'delete', 'object_type' => 'note', 'object_id' => $note->getId()], return_null: true),
))
// And has permissions
&& $actor->canAdmin($note->getActor())) {
$delete_action_url = Router::url('delete_note_action', ['note_id' => $note->getId()]);

View File

@@ -82,7 +82,7 @@ class Favourite extends FeedController
// Prevent open redirect
if (!\is_null($from = $this->string('from'))) {
if (Router::isAbsolute($from)) {
Log::warning("Actor {$actor_id} attempted to reply to a note and then get redirected to another host, or the URL was invalid ({$from})");
Log::warning("Actor {$actor_id} attempted to favourite a note and then get redirected to another host, or the URL was invalid ({$from})");
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
} else {
// TODO anchor on element id
@@ -161,12 +161,12 @@ class Favourite extends FeedController
];
}
public function favouritesByActorId(Request $request, int $id)
public function favouritesViewByActorId(Request $request, int $id)
{
$notes = DB::dql(
<<< 'EOF'
select n from note n
join favourite f with n.id = f.note_id
join note_favourite f with n.id = f.note_id
where f.actor_id = :id
order by f.created DESC
EOF,
@@ -180,10 +180,10 @@ class Favourite extends FeedController
];
}
public function favouritesByActorNickname(Request $request, string $nickname)
public function favouritesViewByActorNickname(Request $request, string $nickname)
{
$user = DB::findOneBy('local_user', ['nickname' => $nickname]);
return self::favouritesByActorId($request, $user->getId());
return self::favouritesViewByActorId($request, $user->getId());
}
/**
@@ -193,12 +193,12 @@ class Favourite extends FeedController
*
* @return array template
*/
public function reverseFavouritesByActorId(Request $request, int $id): array
public function reverseFavouritesViewByActorId(Request $request, int $id): array
{
$notes = DB::dql(
<<< 'EOF'
select n from note n
join favourite f with n.id = f.note_id
join note_favourite f with n.id = f.note_id
where f.actor_id != :id
and n.actor_id = :id
order by f.created DESC
@@ -213,9 +213,9 @@ class Favourite extends FeedController
];
}
public function reverseFavouritesByActorNickname(Request $request, string $nickname)
public function reverseFavouritesViewByActorNickname(Request $request, string $nickname)
{
$user = DB::findOneBy('local_user', ['nickname' => $nickname]);
return self::reverseFavouritesByActorId($request, $user->getId());
return self::reverseFavouritesViewByActorId($request, $user->getId());
}
}

View File

@@ -21,8 +21,11 @@ declare(strict_types = 1);
namespace Plugin\Favourite\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Entity\Note;
use DateTimeInterface;
@@ -82,22 +85,39 @@ class NoteFavourite extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function cacheKeys(int|Note $note_id, int|Actor|LocalUser|null $actor_id = null): array
{
$note_id = \is_int($note_id) ? $note_id : $note_id->getId();
$actor_id = \is_null($actor_id) ? null : (\is_int($actor_id) ? $actor_id : $actor_id->getId());
return [
'favourite' => "note-favourite-{$note_id}-{$actor_id}",
'favourites' => "note-favourites-{$note_id}",
'favourites-actors' => "note-favourites-actors-{$note_id}",
];
}
public static function getNoteFavourites(Note $note): array
{
return DB::findBy('note_favourite', ['note_id' => $note->getId()]);
return Cache::getList(
self::cacheKeys($note)['favourites'],
fn () => DB::findBy('note_favourite', ['note_id' => $note->getId()]),
);
}
public static function getNoteFavouriteActors(Note $note): array
{
return DB::dql(
<<<'EOF'
select a from actor as a
inner join note_favourite as nf
with nf.note_id = :note_id
where a.id = nf.actor_id
order by nf.created DESC
EOF,
['note_id' => $note->getId()],
return Cache::getList(
self::cacheKeys($note)['favourites-actors'],
fn () => DB::dql(
<<<'EOF'
select a from actor a
inner join note_favourite nf
with a.id = nf.actor_id
where nf.note_id = :note_id
order by nf.created DESC
EOF,
['note_id' => $note->getId()],
),
);
}

View File

@@ -23,6 +23,7 @@ declare(strict_types = 1);
namespace Plugin\Favourite;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
@@ -56,10 +57,14 @@ class Favourite extends NoteHandlerPlugin
public static function favourNote(int $note_id, int $actor_id, string $source = 'web'): ?Activity
{
$opts = ['note_id' => $note_id, 'actor_id' => $actor_id];
$note_already_favoured = DB::findOneBy('note_favourite', $opts, return_null: true);
$activity = null;
$note_already_favoured = Cache::get(
FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite'],
fn () => DB::findOneBy('note_favourite', $opts, return_null: true),
);
$activity = null;
if (\is_null($note_already_favoured)) {
DB::persist(FavouriteEntity::create($opts));
Cache::delete(FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite']);
$activity = Activity::create([
'actor_id' => $actor_id,
'verb' => 'favourite',
@@ -86,11 +91,15 @@ class Favourite extends NoteHandlerPlugin
*/
public static function unfavourNote(int $note_id, int $actor_id, string $source = 'web'): ?Activity
{
$note_already_favoured = DB::findOneBy('note_favourite', ['note_id' => $note_id, 'actor_id' => $actor_id], return_null: true);
$activity = null;
$note_already_favoured = Cache::get(
FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite'],
fn () => DB::findOneBy('note_favourite', ['note_id' => $note_id, 'actor_id' => $actor_id], return_null: true),
);
$activity = null;
if (!\is_null($note_already_favoured)) {
DB::remove($note_already_favoured);
$favourite_activity = DB::findBy('activity', ['verb' => 'favourite', 'object_type' => 'note', 'object_id' => $note_id], order_by: ['created' => 'DESC'])[0];
Cache::delete(FavouriteEntity::cacheKeys($note_id, $actor_id)['favourite']);
$favourite_activity = DB::findBy('activity', ['verb' => 'favourite', 'object_type' => 'note', 'actor_id' => $actor_id, 'object_id' => $note_id], order_by: ['created' => 'DESC'])[0];
$activity = Activity::create([
'actor_id' => $actor_id,
'verb' => 'undo', // 'undo_favourite',
@@ -123,7 +132,12 @@ class Favourite extends NoteHandlerPlugin
// If note is favourite, "is_favourite" is 1
$opts = ['note_id' => $note->getId(), 'actor_id' => $user->getId()];
$is_favourite = !\is_null(DB::findOneBy('note_favourite', $opts, return_null: true));
$is_favourite = !\is_null(
Cache::get(
FavouriteEntity::cacheKeys($note->getId(), $user->getId())['favourite'],
fn () => DB::findOneBy('note_favourite', $opts, return_null: true),
),
);
// Generating URL for favourite action route
$args = ['id' => $note->getId()];
@@ -195,8 +209,8 @@ class Favourite extends NoteHandlerPlugin
$r->connect(id: 'favourites_reverse_view_by_actor_id', uri_path: '/actor/{id<\d+>}/reverse_favourites', target: [Controller\Favourite::class, 'favouritesReverseViewByActorId']);
// View all favourites by nickname
$r->connect(id: 'favourites_view_by_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/favourites', target: [Controller\Favourite::class, 'favouritesByActorNickname']);
$r->connect(id: 'favourites_reverse_view_by_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/reverse_favourites', target: [Controller\Favourite::class, 'reverseFavouritesByActorNickname']);
$r->connect(id: 'favourites_view_by_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/favourites', target: [Controller\Favourite::class, 'favouritesViewByActorNickname']);
$r->connect(id: 'favourites_reverse_view_by_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/reverse_favourites', target: [Controller\Favourite::class, 'reverseFavouritesViewByActorNickname']);
return Event::next;
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* ActivityPub implementation for GNU social
*
* @package OAuth2
* @category API
*
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Plugin\IndieAuth\Controller;
use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\Log;
use App\Util\Common;
use Plugin\IndieAuth\Entity\OAuth2Client;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* App Management Endpoint
*
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Apps extends Controller
{
public function onPost(): JsonResponse
{
Log::debug('OAuth2 Apps: Received a POST request.');
Log::debug('OAuth2 Apps: Request content: ', [$body = $this->request->getContent()]);
$args = json_decode($body, true);
$identifier = hash('md5', random_bytes(16));
// Random string Length should be between 43 and 128
$secret = Common::base64url_encode(hash('sha256', random_bytes(57)));
DB::persist($app = OAuth2Client::create([
'identifier' => $identifier,
'secret' => $secret,
'redirect_uris' => $args['redirect_uris'],
'grants' => 'client_credentials authorization_code',
'scopes' => $args['scopes'],
'active' => true,
'allow_plain_text_pkce' => false,
'client_name' => $args['client_name'],
'website' => $args['website'],
]));
Log::debug('OAuth2 Apps: Created App: ', [$app]);
DB::flush();
// Success
return new JsonResponse([
'name' => $app->getClientName(),
'website' => $app->getWebsite(),
'redirect_uri' => $app->getRedirectUris()[0],
'client_id' => $app->getIdentifier(),
'client_secret' => $app->getSecret(),
], status: 200, headers: ['content_type' => 'application/json; charset=utf-8']);
}
}

Some files were not shown because too many files have changed in this diff Show More