Compare commits

..

1 Commits
v3 ... chaotic

551 changed files with 10756 additions and 21980 deletions

View File

@ -37,22 +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 --build > /dev/null 2>&1
@cd docker/tooling && docker-compose up -d > /dev/null 2>&1
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
accessibility: .PHONY
@cd docker/accessibility && docker-compose up
test: tooling-docker
docker exec $(call translate-container-name,tooling_php_1) /var/tooling/coverage.sh $(call args,'')
cs-fixer: tooling-docker
@bin/php-cs-fixer $${CS_FIXER_FILE}
bin/php-cs-fixer
doc-check: tooling-docker
bin/php-doc-check
@ -60,63 +54,13 @@ 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:
sudo rm -rf file/*
flush-redis-cache:
docker exec -it $(call translate-container-name,$(strip $(DIR))_redis_1) sh -c 'redis-cli flushall'
install-plugins:
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) /var/www/social/bin/install_plugins.sh
update-dependencies:
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c 'cd /var/www/social && composer update'
update-autocode:
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) sh -c 'cd /var/www/social && bin/update_autocode'
backup-actors:
docker exec -it $(call translate-container-name,$(strip $(DIR))_db_1) \
sh -c 'su postgres -c "mkdir -p /tmp/backup"' && \
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) \
sh -c "cd /var/www/social && bin/console doctrine:query:sql \"\
copy actor to '/tmp/backup/actor.csv';\
copy local_user to '/tmp/backup/local_user.csv';\
copy local_group to '/tmp/backup/local_group.csv';\
\
copy activitypub_actor to '/tmp/backup/ap_actor.csv';\
copy activitypub_rsa to '/tmp/backup/ap_rsa.csv';\
\
copy actor_subscription to '/tmp/backup/actor_subscription.csv';\
copy group_member to '/tmp/backup/group_member.csv';\
\
copy feed to '/tmp/backup/feed.csv';\
copy (SELECT 'ALTER SEQUENCE ' || c.relname || ' RESTART WITH ' || nextval(c.relname::regclass) || ';'\
FROM pg_class c WHERE c.relkind = 'S') to '/tmp/backup/sequences';\"" && \
mkdir -p /tmp/social-sql-backup && \
docker cp $(call translate-container-name,$(strip $(DIR))_db_1):/tmp/backup/. /tmp/social-sql-backup
restore-actors:
docker cp /tmp/social-sql-backup/. $(call translate-container-name,$(strip $(DIR))_db_1):/tmp/backup
docker exec -it $(call translate-container-name,$(strip $(DIR))_db_1) sh -c 'chown postgres /tmp/backup' && \
docker exec -it $(call translate-container-name,$(strip $(DIR))_php_1) \
sh -c "cd /var/www/social && bin/console doctrine:query:sql \"\
copy actor from '/tmp/backup/actor.csv';\
copy local_user from '/tmp/backup/local_user.csv';\
copy local_group from '/tmp/backup/local_group.csv';\
\
copy activitypub_actor from '/tmp/backup/ap_actor.csv';\
copy activitypub_rsa from '/tmp/backup/ap_rsa.csv';\
\
copy actor_subscription from '/tmp/backup/actor_subscription.csv';\
copy group_member from '/tmp/backup/group_member.csv';\
\
copy feed from '/tmp/backup/feed.csv';\
`cat /tmp/social-sql-backup/sequences`\""
force-nuke-everything: down remove-var remove-file up flush-redis-cache database-force-nuke install-plugins
force-delete-content: backup-actors force-nuke-everything restore-actors
force-nuke-everything: remove-var flush-redis-cache database-force-nuke

4
bin/configure vendored
View File

@ -352,8 +352,8 @@ SOCIAL_DBMS=${DBMS}
SOCIAL_DB=${DB_NAME}
SOCIAL_USER=${DB_USER}
SOCIAL_PASSWORD=${DB_PASSWORD}
CONFIG_DOMAIN=${DOMAIN}
CONFIG_NODE_NAME=${NODE_NAME}
SOCIAL_DOMAIN=${DOMAIN}
SOCIAL_NODE_NAME=${NODE_NAME}
SOCIAL_ADMIN_EMAIL=${EMAIL}
SOCIAL_SITE_PROFILE=${PROFILE}
MAILER_DSN=${MAILER_DSN}

View File

@ -1,4 +1,4 @@
#!/usr/bin/env php
#!/usr/local/bin/php
<?php
use Symfony\Component\Yaml\Yaml;
@ -20,23 +20,25 @@ const types = [
'text' => 'string',
'varchar' => 'string',
'phone_number' => 'PhoneNumber',
'float' => 'float', // TODO REMOVE THIS
];
$files = array_merge(glob(ROOT . '/src/Entity/*.php'),
array_merge(glob(ROOT . '/components/*/Entity/*.php'),
glob(ROOT . '/plugins/*/Entity/*.php')));
$nullable_no_defaults_warning = [];
$classes = [];
foreach ($files as $file) {
require_once $file;
$class = str_replace(['/', 'src', 'components', 'plugins'], ['\\', 'App', 'Component', 'Plugin'], substr($file, strlen(ROOT) + 1, -4));
if (!method_exists($class, 'schemaDef')) {
continue;
$declared = get_declared_classes();
foreach ($declared as $dc) {
if (preg_match('/(App|(Component|Plugin)\\\\[^\\\\]+)\\\\Entity/', $dc) && !in_array($dc, $classes)) {
$class = $dc;
$classes[] = $class;
break;
}
}
$no_ns_class = preg_replace('/.*?\\\\/', '', $class);
@ -45,32 +47,16 @@ foreach ($files as $file) {
$fields_code = [];
$methods_code = [];
foreach ($fields as $field) {
$field_schema = $schema['fields'][$field];
$nullable = ($field_schema['not null'] ?? false) ? '' : '?';
$type = types[$field_schema['type']];
$nullable = !@$schema['fields'][$field]['not null'] ? '?' : '';
$type = types[$schema['fields'][$field]['type']];
$type = $type !== '' ? $nullable . $type : $type;
$method_name = str_replace([' ', 'actor'], ['', 'Actor'], ucwords(str_replace('_', ' ', $field)));
$length = $field_schema['length'] ?? null;
$default = @$schema['fields'][$field]['default'];
$field_setter = "\${$field}";
if (\is_int($length)) {
if ($nullable === '?') {
$field_setter = "\is_null(\${$field}) ? null : \mb_substr(\${$field}, 0, $length)";
} else {
$field_setter = "\mb_substr(\${$field}, 0, $length)";
}
}
if (($nullable === '?' || \array_key_exists('default', $field_schema)) && $type != '\DateTimeInterface') {
if (!\array_key_exists('default', $field_schema)) {
$nullable_no_defaults_warning[] = "{$class}::{$field}";
}
$default = $field_schema['default'] ?? null;
if (\is_string($default)) {
if (isset($default) && $nullable != '?' && $type != '\DateTimeInterface') {
if (is_string($default)) {
$default = "'{$default}'";
} elseif (\is_null($default)) {
$default = "null";
} elseif ($type === 'bool' || $type === '?bool') {
} elseif ($type == 'bool') {
$default = $default ? 'true' : 'false';
}
@ -80,13 +66,13 @@ foreach ($files as $file) {
}
$methods_code[] = " public function set{$method_name}({$type} \${$field}): self" .
"\n {\n \$this->{$field} = {$field_setter};\n return \$this;\n }" . "\n\n" .
"\n {\n \$this->{$field} = \${$field};\n return \$this;\n }" . "\n\n" .
" public function get{$method_name}()" . ($type !== '' ? ": {$type}" : '') .
"\n {\n return \$this->{$field};\n }" . "\n";
}
$fields_code = implode("\n", $fields_code);
$methods_code = implode("\n", $methods_code);
$methods_code = implode("\n", $methods_code) . "\n";
$begin = '// {{{ Autocode';
$end = '// }}} Autocode';
@ -105,13 +91,7 @@ foreach ($files as $file) {
}
$in_file = file_get_contents($file);
$out_file = preg_replace("%\\s*{$begin}.*{$end}%smu", $code, $in_file);
$out_file = preg_replace("/\\s*{$begin}[^\\/]*{$end}/m", $code, $in_file);
file_put_contents($file, $out_file);
}
if (!empty($nullable_no_defaults_warning)) {
echo "Warning: The following don't have a default value, but we're assigning it `null`. Doctrine might not like this, so update it\n";
foreach ($nullable_no_defaults_warning as $n) {
echo " {$n}\n";
}
}

View File

@ -1,11 +0,0 @@
#!/bin/sh
for plugin in plugins/*; do
install="${plugin}/bin/install.sh"
if [ -x "${install}" ]; then
( # subshell, to clear options/environment
set -x
"${install}"
)
fi
done

View File

@ -2,8 +2,8 @@
. bin/translate_container_name.sh
if [ "$#" -eq 0 ] || [ -z "$*" ]; then
docker exec "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-cs-fixer -n --config=".php-cs-fixer.php" fix
if [ $# -eq 1 ]; then
docker exec -it "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-cs-fixer --ansi -n --config=".php-cs-fixer.php" fix $1
else
docker exec "$(translate_container_name tooling_php_1)" sh -c "/var/www/social/vendor/bin/php-cs-fixer -q -n --config=\".php-cs-fixer.php\" fix --path-mode=intersection -- $*"
fi
docker exec -it "$(translate_container_name tooling_php_1)" /var/www/social/vendor/bin/php-cs-fixer -n --ansi --config=".php-cs-fixer.php" fix
fi

View File

@ -2,25 +2,32 @@
root="$(git rev-parse --show-toplevel)"
# get the list of changed files that didn't get only partially added
# get the list of changed files
staged_files="$(git status --porcelain | sed -rn "s/^[^ ][ ] (.*)/\1/p")"
if (! (: "${SKIP_ALL?}") 2>/dev/null) && (! (: "${SKIP_CS_FIX?}") 2>/dev/null); then
echo "Running php-cs-fixer on edited files"
files=""
for staged in ${staged_files}; do
# work only with existing files
if [ -f "${staged}" ] && expr "${staged}" : '^.*\.php$' > /dev/null; then
# use php-cs-fixer and get flag of correction
CS_FIXER_FILE="${staged}" make cs-fixer
git add "${staged}"
files="${staged} ${files}"
fi
done
if [ -n "${files}" ]; then
prev="${PWD}"
cd "${root}" && make cs-fixer "${files}" || exit 1
cd "${prev}" || exit 1
fi
fi
if (! (: "${SKIP_ALL?}") 2>/dev/null) && (! (: "${SKIP_DOC_CHECK?}") 2>/dev/null); then
if echo "${staged_files}" | grep -F ".php" > /dev/null 2>&1; then
echo "Running php-doc-checker"
make doc-check < /dev/tty
echo "Running php-doc-checker"
if echo "${staged_files}" | grep -F ".php"; then
prev="${PWD}"
cd "${root}" && make doc-check || exit 1
cd "${prev}" || exit 1
fi
fi

View File

@ -22,7 +22,6 @@ declare(strict_types = 1);
namespace Component\Attachment;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
@ -39,10 +38,10 @@ class Attachment extends Component
{
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('note_attachment_show', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}', [C\Attachment::class, 'attachmentShowWithNote']);
$r->connect('note_attachment_view', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/view', [C\Attachment::class, 'attachmentViewWithNote']);
$r->connect('note_attachment_download', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/download', [C\Attachment::class, 'attachmentDownloadWithNote']);
$r->connect('note_attachment_thumbnail', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/thumbnail/{size<big|medium|small>}', [C\Attachment::class, 'attachmentThumbnailWithNote']);
$r->connect('attachment_show', '/object/attachment/{id<\d+>}', [C\Attachment::class, 'attachment_show']);
$r->connect('attachment_view', '/object/attachment/{id<\d+>}/view', [C\Attachment::class, 'attachment_view']);
$r->connect('attachment_download', '/object/attachment/{id<\d+>}/download', [C\Attachment::class, 'attachment_download']);
$r->connect('attachment_thumbnail', '/object/attachment/{id<\d+>}/thumbnail/{size<big|medium|small>}', [C\Attachment::class, 'attachment_thumbnail']);
return Event::next;
}
@ -57,34 +56,26 @@ class Attachment extends Component
return Event::stop;
}
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
public function onNoteDeleteRelated(Note &$note): bool
{
Cache::delete("note-attachments-{$note->getId()}");
E\AttachmentToNote::removeWhereNoteId($note->getId());
foreach ($note->getAttachments() as $attachment) {
$attachment->kill();
}
DB::wrapInTransaction(fn () => E\AttachmentToNote::removeWhereNoteId($note->getId()));
Cache::delete("note-attachments-{$note->getId()}");
return Event::next;
}
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
if (!\in_array('attachment_to_note', $note_qb->getAllAliases())) {
$note_qb->leftJoin(
join: E\AttachmentToNote::class,
alias: 'attachment_to_note',
conditionType: Expr\Join::WITH,
condition: 'note.id = attachment_to_note.note_id',
);
}
$note_qb->leftJoin(E\AttachmentToNote::class, 'attachment_to_note', Expr\Join::WITH, 'note.id = attachment_to_note.note_id');
return Event::next;
}
/**
* Populate $note_expr with the criteria for looking for notes with attachments
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr)
{
$include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {

View File

@ -28,14 +28,14 @@ use App\Core\DB\DB;
use App\Core\Event;
use App\Core\GSFile;
use function App\Core\I18n\_m;
use App\Entity\Note;
use App\Core\Log;
use App\Core\Router\Router;
use Component\Attachment\Entity\AttachmentThumbnail;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NoSuchFileException;
use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException;
use Component\Attachment\Entity\AttachmentThumbnail;
use Component\Attachment\Entity\AttachmentToNote;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -46,28 +46,22 @@ class Attachment extends Controller
/**
* Generic function that handles getting a representation for an attachment
*/
private function attachment(int $attachment_id, Note|int $note, callable $handle)
private function attachment(int $id, callable $handle)
{
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
$note = \is_int($note) ? Note::getById($note) : $note;
// Before anything, two very important things!
// first: ensure this attachment is associated with this note
if (DB::count(AttachmentToNote::class, ['attachment_id' => $attachment->getId(), 'note_id' => $note->getId()]) <= 0) {
if ($id <= 0) { // This should never happen coming from the router, but let's bail if it does
// @codeCoverageIgnoreStart
Log::critical("Attachment controller called with {$id}, which should not be possible");
throw new ClientException(_m('No such attachment.'), 404);
}
// second: ensure proper scope
if (!$note->isVisibleTo(Common::actor())) {
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
}
$res = null;
if (Event::handle('AttachmentFileInfo', [$attachment, $note, &$res]) !== Event::stop) {
// If no one else claims this attachment, use the default representation
try {
$res = GSFile::getAttachmentFileInfo($attachment_id);
} catch (NoSuchFileException $e) {
// Continue below
// @codeCoverageIgnoreEnd
} else {
$res = null;
if (Event::handle('AttachmentFileInfo', [$id, &$res]) != Event::stop) {
// If no one else claims this attachment, use the default representation
try {
$res = GSFile::getAttachmentFileInfo($id);
} catch (NoSuchFileException $e) {
// Continue below
}
}
}
@ -79,9 +73,6 @@ class Attachment extends Controller
throw new ServerException('This attachment is not stored locally.');
// @codeCoverageIgnoreEnd
} else {
$res['attachment'] = $attachment;
$res['note'] = $note;
$res['title'] = $attachment->getBestTitle($note);
return $handle($res);
}
}
@ -90,17 +81,16 @@ class Attachment extends Controller
/**
* The page where the attachment and it's info is shown
*/
public function attachmentShowWithNote(Request $request, int $note_id, int $attachment_id)
public function attachment_show(Request $request, int $id)
{
try {
return $this->attachment($attachment_id, $note_id, function ($res) use ($note_id, $attachment_id) {
$attachment = DB::findOneBy('attachment', ['id' => $id]);
return $this->attachment($id, function ($res) use ($id, $attachment) {
return [
'_template' => 'attachment/view.html.twig',
'download' => $res['attachment']->getDownloadUrl(note: $note_id),
'title' => $res['title'],
'attachment' => $res['attachment'],
'note' => $res['note'],
'right_panel_vars' => ['attachment_id' => $attachment_id, 'note_id' => $note_id],
'_template' => '/cards/attachments/show.html.twig',
'download' => Router::url('attachment_download', ['id' => $id]),
'attachment' => $attachment,
'right_panel_vars' => ['attachment_id' => $id],
];
});
} catch (NotFoundException) {
@ -111,29 +101,27 @@ class Attachment extends Controller
/**
* Display the attachment inline
*/
public function attachmentViewWithNote(Request $request, int $note_id, int $attachment_id)
public function attachment_view(Request $request, int $id)
{
return $this->attachment(
$attachment_id,
$note_id,
$id,
fn (array $res) => GSFile::sendFile(
$res['filepath'],
$res['mimetype'],
GSFile::ensureFilenameWithProperExtension($res['title'], $res['mimetype']) ?? $res['filename'],
GSFile::ensureFilenameWithProperExtension($res['filename'], $res['mimetype']) ?? $res['filename'],
HeaderUtils::DISPOSITION_INLINE,
),
);
}
public function attachmentDownloadWithNote(Request $request, int $note_id, int $attachment_id)
public function attachment_download(Request $request, int $id)
{
return $this->attachment(
$attachment_id,
$note_id,
$id,
fn (array $res) => GSFile::sendFile(
$res['filepath'],
$res['mimetype'],
GSFile::ensureFilenameWithProperExtension($res['title'], $res['mimetype']) ?? $res['filename'],
GSFile::ensureFilenameWithProperExtension($res['filename'], $res['mimetype']) ?? $res['filename'],
HeaderUtils::DISPOSITION_ATTACHMENT,
),
);
@ -142,27 +130,16 @@ class Attachment extends Controller
/**
* Controller to produce a thumbnail for a given attachment id
*
* @param int $attachment_id Attachment ID
* @param int $id Attachment ID
*
* @throws \App\Util\Exception\DuplicateFoundException
* @throws ClientException
* @throws NotFoundException
* @throws ServerException
*/
public function attachmentThumbnailWithNote(Request $request, int $note_id, int $attachment_id, string $size = 'small'): Response
public function attachment_thumbnail(Request $request, int $id, string $size = 'small'): Response
{
$attachment = DB::findOneBy('attachment', ['id' => $attachment_id]);
$note = Note::getById($note_id);
// Before anything, two very important things!
// first: ensure this attachment is associated with this note
if (DB::count(AttachmentToNote::class, ['attachment_id' => $attachment->getId(), 'note_id' => $note->getId()]) <= 0) {
throw new ClientException(_m('No such attachment.'), 404);
}
// second: ensure proper scope
if (!$note->isVisibleTo(Common::actor())) {
throw new ClientException(_m('You don\'t have permissions to view this attachment.'), 401);
}
$attachment = DB::findOneBy('attachment', ['id' => $id]);
$crop = Common::config('thumbnail', 'smart_crop');

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@ -41,7 +39,7 @@ class ActorToAttachment extends Entity
// @codeCoverageIgnoreStart
private int $attachment_id;
private int $actor_id;
private DateTimeInterface $modified;
private \DateTimeInterface $modified;
public function setAttachmentId(int $attachment_id): self
{
@ -79,6 +77,10 @@ class ActorToAttachment extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
/**
* @param int $attachment_id
* @return mixed
*/
public static function removeWhereAttachmentId(int $attachment_id): mixed
{
return DB::dql(
@ -90,6 +92,11 @@ class ActorToAttachment extends Entity
);
}
/**
* @param int $actor_id
* @param int $attachment_id
* @return mixed
*/
public static function removeWhere(int $attachment_id, int $actor_id): mixed
{
return DB::dql(
@ -102,6 +109,10 @@ class ActorToAttachment extends Entity
);
}
/**
* @param int $actor_id
* @return mixed
*/
public static function removeWhereActorId(int $actor_id): mixed
{
return DB::dql(

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
@ -28,14 +26,13 @@ use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\GSFile;
use App\Entity\Note;
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;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NoSuchFileException;
use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException;
use DateTimeInterface;
@ -60,13 +57,13 @@ class Attachment extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private int $lives = 1;
private ?string $filehash = null;
private ?string $mimetype = null;
private ?string $filename = null;
private ?int $size = null;
private ?int $width = null;
private ?int $height = null;
private int $lives = 1;
private ?string $filehash;
private ?string $mimetype;
private ?string $filename;
private ?int $size;
private ?int $width;
private ?int $height;
private DateTimeInterface $modified;
public function setId(int $id): self
@ -80,20 +77,25 @@ class Attachment extends Entity
return $this->id;
}
public function setLives(int $lives): self
{
$this->lives = $lives;
return $this;
}
/**
* @return int
*/
public function getLives(): int
{
return $this->lives;
}
/**
* @param int $lives
*/
public function setLives(int $lives): void
{
$this->lives = $lives;
}
public function setFilehash(?string $filehash): self
{
$this->filehash = \is_null($filehash) ? null : mb_substr($filehash, 0, 64);
$this->filehash = $filehash;
return $this;
}
@ -104,7 +106,7 @@ class Attachment extends Entity
public function setMimetype(?string $mimetype): self
{
$this->mimetype = \is_null($mimetype) ? null : mb_substr($mimetype, 0, 255);
$this->mimetype = $mimetype;
return $this;
}
@ -115,7 +117,7 @@ class Attachment extends Entity
public function setFilename(?string $filename): self
{
$this->filename = \is_null($filename) ? null : mb_substr($filename, 0, 191);
$this->filename = $filename;
return $this;
}
@ -174,36 +176,44 @@ class Attachment extends Entity
public function getMimetypeMajor(): ?string
{
$mime = $this->getMimetype();
return \is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
return is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
}
public function getMimetypeMinor(): ?string
{
$mime = $this->getMimetype();
return \is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
return is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
}
/**
* @return int
*/
public function livesIncrementAndGet(): int
{
++$this->lives;
return $this->lives;
}
/**
* @return int
*/
public function livesDecrementAndGet(): int
{
--$this->lives;
return $this->lives;
}
public const FILEHASH_ALGO = 'sha256';
const FILEHASH_ALGO = 'sha256';
/**
* Delete a file if safe, removes dependencies, cleanups and flushes
*
* @return bool
*/
public function kill(): bool
{
if ($this->livesDecrementAndGet() <= 0) {
return DB::wrapInTransaction(fn () => $this->delete());
return $this->delete();
}
return true;
}
@ -213,7 +223,7 @@ class Attachment extends Entity
*/
public function deleteStorage(): bool
{
if (!\is_null($filepath = $this->getPath())) {
if (!is_null($filepath = $this->getPath())) {
if (file_exists($filepath)) {
if (@unlink($filepath) === false) {
// @codeCoverageIgnoreStart
@ -224,7 +234,8 @@ class Attachment extends Entity
$this->setFilename(null);
$this->setSize(null);
// Important not to null neither width nor height
DB::wrapInTransaction(fn () => DB::persist($this));
DB::persist($this);
DB::flush();
}
} else {
// @codeCoverageIgnoreStart
@ -237,8 +248,6 @@ class Attachment extends Entity
/**
* Attachment delete always removes dependencies, cleanups and flushes
* WARNING: Wrap this function in a transaction!
*
* @see kill() It's more likely that you want to use that rather than call delete directly
*/
protected function delete(): bool
@ -252,7 +261,7 @@ class Attachment extends Entity
// Collect files starting with the one associated with this attachment
$files = [];
if (!\is_null($filepath = $this->getPath())) {
if (!is_null($filepath = $this->getPath())) {
$files[] = $filepath;
}
@ -297,21 +306,25 @@ class Attachment extends Entity
/**
* TODO: Maybe this isn't the best way of handling titles
*
* @param null|Note $note
*
* @throws DuplicateFoundException
* @throws NotFoundException
* @throws ServerException
*
* @return string
*/
public function getBestTitle(?Note $note = null): string
{
// If we have a note, then the best title is the title itself
if (!\is_null(($note))) {
if (!is_null(($note))) {
$title = Cache::get('attachment-title-' . $this->getId() . '-' . $note->getId(), function () use ($note) {
try {
$attachment_to_note = DB::findOneBy('attachment_to_note', [
'attachment_id' => $this->getId(),
'note_id' => $note->getId(),
]);
if (!\is_null($attachment_to_note->getTitle())) {
if (!is_null($attachment_to_note->getTitle())) {
return $attachment_to_note->getTitle();
}
} catch (NotFoundException) {
@ -319,13 +332,14 @@ class Attachment extends Entity
Event::handle('AttachmentGetBestTitle', [$this, $note, &$title]);
return $title;
}
return null;
});
if ($title != null) {
return $title;
}
}
// Else
if (!\is_null($filename = $this->getFilename())) {
if (!is_null($filename = $this->getFilename())) {
// A filename would do just as well
return $filename;
} else {
@ -339,51 +353,38 @@ class Attachment extends Entity
*/
public function getThumbnails()
{
return DB::findBy(
AttachmentThumbnail::class,
['attachment_id' => $this->id],
order_by: ['size' => 'ASC', 'mimetype' => 'ASC'],
);
return DB::findBy('attachment_thumbnail', ['attachment_id' => $this->id]);
}
public function getPath()
{
$filename = $this->getFilename();
return \is_null($filename) ? null : Common::config('attachments', 'dir') . \DIRECTORY_SEPARATOR . $filename;
return is_null($filename) ? null : Common::config('attachments', 'dir') . DIRECTORY_SEPARATOR . $filename;
}
public function getUrl(Note|int $note, int $type = Router::ABSOLUTE_URL): string
public function getUrl(int $type = Router::ABSOLUTE_URL): string
{
return Router::url(id: 'note_attachment_view', args: ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId()], type: $type);
}
public function getShowUrl(Note|int $note, int $type = Router::ABSOLUTE_URL): string
{
return Router::url(id: 'note_attachment_show', args: ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId()], type: $type);
}
public function getDownloadUrl(Note|int $note, int $type = Router::ABSOLUTE_URL): string
{
return Router::url(id: 'note_attachment_download', args: ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId()], type: $type);
return Router::url(id: 'attachment_view', args: ['id' => $this->getId()], type: $type);
}
/**
* @param null|string $size
* @param bool $crop
*
* @throws ClientException
* @throws NotFoundException
* @throws ServerException
*
* @return AttachmentThumbnail
*/
public function getThumbnail(?string $size = null, bool $crop = false): ?AttachmentThumbnail
{
try {
return AttachmentThumbnail::getOrCreate(attachment: $this, size: $size, crop: $crop);
} catch (NoSuchFileException) {
return null;
}
return AttachmentThumbnail::getOrCreate(attachment: $this, size: $size, crop: $crop);
}
public function getThumbnailUrl(Note|int $note, ?string $size = null): string
public function getThumbnailUrl(?string $size = null)
{
return Router::url('note_attachment_thumbnail', ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getId(), 'size' => $size ?? Common::config('thumbnail', 'default_size')]);
return Router::url('attachment_thumbnail', ['id' => $this->getId(), 'size' => $size ?? Common::config('thumbnail', 'default_size')]);
}
public static function schemaDef(): array
@ -392,7 +393,7 @@ class Attachment extends Entity
'name' => 'attachment',
'fields' => [
'id' => ['type' => 'serial', 'not null' => true],
'lives' => ['type' => 'int', 'default' => 1, 'not null' => true, 'description' => 'RefCount, starts with 1'],
'lives' => ['type' => 'int', 'not null' => true, 'description' => 'RefCount'],
'filehash' => ['type' => 'varchar', 'length' => 64, 'description' => 'sha256 of the file contents, if the file is stored locally'],
'mimetype' => ['type' => 'varchar', 'length' => 255, 'description' => 'resource mime type 127+1+127 as per rfc6838#section-4.2'],
'filename' => ['type' => 'varchar', 'length' => 191, 'description' => 'file name of resource when available'],

View File

@ -30,7 +30,6 @@ use App\Core\Event;
use App\Core\GSFile;
use App\Core\Log;
use App\Core\Router\Router;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NotFoundException;
@ -67,11 +66,12 @@ class AttachmentThumbnail extends Entity
'medium' => self::SIZE_MEDIUM,
'big' => self::SIZE_BIG,
];
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $attachment_id;
private ?string $mimetype = null;
private int $size = 0;
private ?string $mimetype;
private int $size = self::SIZE_SMALL;
private string $filename;
private int $width;
private int $height;
@ -90,7 +90,7 @@ class AttachmentThumbnail extends Entity
public function setMimetype(?string $mimetype): self
{
$this->mimetype = \is_null($mimetype) ? null : mb_substr($mimetype, 0, 129);
$this->mimetype = $mimetype;
return $this;
}
@ -99,20 +99,20 @@ class AttachmentThumbnail extends Entity
return $this->mimetype;
}
public function getSize(): int
{
return $this->size;
}
public function setSize(int $size): self
{
$this->size = $size;
return $this;
}
public function getSize(): int
{
return $this->size;
}
public function setFilename(string $filename): self
{
$this->filename = mb_substr($filename, 0, 191);
$this->filename = $filename;
return $this;
}
@ -121,28 +121,6 @@ class AttachmentThumbnail extends Entity
return $this->filename;
}
public function setWidth(int $width): self
{
$this->width = $width;
return $this;
}
public function getWidth(): int
{
return $this->width;
}
public function setHeight(int $height): self
{
$this->height = $height;
return $this;
}
public function getHeight(): int
{
return $this->height;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
@ -154,6 +132,28 @@ class AttachmentThumbnail extends Entity
return $this->modified;
}
public function getWidth(): int
{
return $this->width;
}
public function setWidth(int $width): self
{
$this->width = $width;
return $this;
}
public function getHeight(): int
{
return $this->height;
}
public function setHeight(int $height): self
{
$this->height = $height;
return $this;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
@ -180,7 +180,7 @@ class AttachmentThumbnail extends Entity
if (isset($this->attachment) && !\is_null($this->attachment)) {
return $this->attachment;
} else {
return $this->attachment = DB::findOneBy(Attachment::class, ['id' => $this->attachment_id]);
return $this->attachment = DB::findOneBy('attachment', ['id' => $this->attachment_id]);
}
}
@ -205,7 +205,7 @@ class AttachmentThumbnail extends Entity
try {
return Cache::get(
self::getCacheKey($attachment->getId(), $size_int),
fn () => DB::findOneBy(self::class, ['attachment_id' => $attachment->getId(), 'size' => $size_int]),
fn () => DB::findOneBy('attachment_thumbnail', ['attachment_id' => $attachment->getId(), 'size' => $size_int]),
);
} catch (NotFoundException) {
if (\is_null($attachment->getWidth()) || \is_null($attachment->getHeight())) {
@ -214,7 +214,7 @@ class AttachmentThumbnail extends Entity
[$predicted_width, $predicted_height] = self::predictScalingValues($attachment->getWidth(), $attachment->getHeight(), $size, $crop);
if (\is_null($attachment->getPath()) || !file_exists($attachment->getPath())) {
// Before we quit, check if there's any other thumb
$alternative_thumbs = DB::findBy(self::class, ['attachment_id' => $attachment->getId()]);
$alternative_thumbs = DB::findBy('attachment_thumbnail', ['attachment_id' => $attachment->getId()]);
usort($alternative_thumbs, fn ($l, $r) => $r->getSize() <=> $l->getSize());
if (empty($alternative_thumbs)) {
throw new NotStoredLocallyException();
@ -254,14 +254,14 @@ class AttachmentThumbnail extends Entity
}
}
public function getPath(): string
public function getPath()
{
return Common::config('thumbnail', 'dir') . \DIRECTORY_SEPARATOR . $this->getFilename();
}
public function getUrl(Note|int $note): string
public function getUrl()
{
return Router::url('note_attachment_thumbnail', ['note_id' => \is_int($note) ? $note : $note->getId(), 'attachment_id' => $this->getAttachmentId(), 'size' => self::sizeIntToStr($this->getSize())]);
return Router::url('attachment_thumbnail', ['id' => $this->getAttachmentId(), 'size' => self::sizeIntToStr($this->getSize())]);
}
/**
@ -278,10 +278,9 @@ class AttachmentThumbnail extends Entity
}
}
Cache::delete(self::getCacheKey($this->getAttachmentId(), $this->getSize()));
DB::remove($this);
if ($flush) {
DB::wrapInTransaction(fn () => DB::remove($this));
} else {
DB::remove($this);
DB::flush();
}
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@ -39,20 +37,9 @@ class AttachmentToLink extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $link_id;
private int $attachment_id;
private DateTimeInterface $modified;
public function setLinkId(int $link_id): self
{
$this->link_id = $link_id;
return $this;
}
public function getLinkId(): int
{
return $this->link_id;
}
private int $link_id;
private \DateTimeInterface $modified;
public function setAttachmentId(int $attachment_id): self
{
@ -65,6 +52,17 @@ class AttachmentToLink extends Entity
return $this->attachment_id;
}
public function setLinkId(int $link_id): self
{
$this->link_id = $link_id;
return $this;
}
public function getLinkId(): int
{
return $this->link_id;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
@ -79,6 +77,10 @@ class AttachmentToLink extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
/**
* @param int $attachment_id
* @return mixed
*/
public static function removeWhereAttachmentId(int $attachment_id): mixed
{
return DB::dql(
@ -90,6 +92,11 @@ class AttachmentToLink extends Entity
);
}
/**
* @param int $link_id
* @param int $attachment_id
* @return mixed
*/
public static function removeWhere(int $link_id, int $attachment_id): mixed
{
return DB::dql(
@ -102,6 +109,10 @@ class AttachmentToLink extends Entity
);
}
/**
* @param int $link_id
* @return mixed
*/
public static function removeWhereLinkId(int $link_id): mixed
{
return DB::dql(

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@ -21,6 +19,7 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use DateTimeInterface;
@ -45,8 +44,8 @@ class AttachmentToNote extends Entity
// @codeCoverageIgnoreStart
private int $attachment_id;
private int $note_id;
private ?string $title = null;
private DateTimeInterface $modified;
private ?string $title;
private \DateTimeInterface $modified;
public function setAttachmentId(int $attachment_id): self
{
@ -95,6 +94,11 @@ class AttachmentToNote extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
/**
* @param int $note_id
* @param int $attachment_id
* @return mixed
*/
public static function removeWhere(int $note_id, int $attachment_id): mixed
{
return DB::dql(
@ -107,6 +111,10 @@ class AttachmentToNote extends Entity
);
}
/**
* @param int $note_id
* @return mixed
*/
public static function removeWhereNoteId(int $note_id): mixed
{
return DB::dql(
@ -118,6 +126,10 @@ class AttachmentToNote extends Entity
);
}
/**
* @param int $attachment_id
* @return mixed
*/
public static function removeWhereAttachmentId(int $attachment_id): mixed
{
return DB::dql(

View File

@ -29,8 +29,6 @@ use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Util\Common;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentThumbnail;
use Component\Avatar\Controller as C;
use Component\Avatar\Exception\NoAvatarException;
use Symfony\Component\HttpFoundation\Request;
@ -43,8 +41,8 @@ class Avatar extends Component
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'avatar_view']);
$r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'default_avatar_view']);
$r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?full}', [Controller\Avatar::class, 'avatar_view']);
$r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?full}', [Controller\Avatar::class, 'default_avatar_view']);
$r->connect('avatar_settings', '/settings/avatar', [Controller\Avatar::class, 'settings_avatar']);
return Event::next;
}
@ -104,7 +102,7 @@ class Avatar extends Component
/**
* Get the cached avatar associated with the given Actor id, or the current user if not given
*/
public static function getUrl(int $actor_id, string $size = 'medium', int $type = Router::ABSOLUTE_PATH): string
public static function getUrl(int $actor_id, string $size = 'full', int $type = Router::ABSOLUTE_PATH): string
{
try {
return self::getAvatar($actor_id)->getUrl($size, $type);
@ -113,13 +111,13 @@ class Avatar extends Component
}
}
public static function getDimensions(int $actor_id, string $size = 'medium')
public static function getDimensions(int $actor_id, string $size = 'full')
{
try {
$attachment = self::getAvatar($actor_id)->getAttachment();
return ['width' => (int) $attachment->getWidth(), 'height' => (int) $attachment->getHeight()];
return ['width' => $attachment->getWidth(), 'height' => $attachment->getHeight()];
} catch (NoAvatarException) {
return ['width' => (int) (Common::config('thumbnail', 'small')), 'height' => (int) (Common::config('thumbnail', 'small'))];
return ['width' => Common::config('thumbnail', 'small'), 'height' => Common::config('thumbnail', 'small')];
}
}
@ -129,7 +127,7 @@ class Avatar extends Component
* Returns the avatar file's hash, mimetype, title and path.
* Ensures exactly one cached value exists
*/
public static function getAvatarFileInfo(int $actor_id, string $size = 'medium'): array
public static function getAvatarFileInfo(int $actor_id, string $size = 'full'): array
{
$res = Cache::get(
"avatar-file-info-{$actor_id}-{$size}",
@ -153,12 +151,8 @@ class Avatar extends Component
'title' => 'default_avatar.svg',
];
} else {
$res = $res[0]; // A user must always only have one avatar.
if ($size === 'full') {
$res['filepath'] = Attachment::getByPK(['id' => $res['id']])->getPath();
} else {
$res['filepath'] = AttachmentThumbnail::getOrCreate(Attachment::getByPK(['id' => $res['id']]), $size)->getPath();
}
$res = $res[0]; // A user must always only have one avatar.
$res['filepath'] = DB::findOneBy('attachment', ['id' => $res['id']])->getPath();
return $res;
}
}

View File

@ -33,6 +33,7 @@ use function App\Core\I18n\_m;
use App\Core\Log;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NotFoundException;
use App\Util\TemporaryFile;
use Component\Avatar\Entity\Avatar as AvatarEntity;
use Exception;
@ -56,8 +57,13 @@ class Avatar extends Controller
*/
public function avatar_view(Request $request, int $actor_id, string $size): Response
{
$res = \Component\Avatar\Avatar::getAvatarFileInfo($actor_id, $size);
return M::sendFile($res['filepath'], $res['mimetype'], $res['title']);
switch ($size) {
case 'full':
$res = \Component\Avatar\Avatar::getAvatarFileInfo($actor_id);
return M::sendFile($res['filepath'], $res['mimetype'], $res['title']);
default:
throw new Exception('Not implemented');
}
}
/**
@ -79,14 +85,15 @@ class Avatar extends Controller
$user = Common::user();
$actor_id = $user->getId();
if ($data['remove'] == true) {
if (\is_null($avatar = DB::findOneBy(AvatarEntity::class, ['actor_id' => $actor_id], return_null: true))) {
$form->addError(new FormError(_m('No avatar set, so cannot delete.')));
} else {
try {
$avatar = DB::findOneBy('avatar', ['actor_id' => $actor_id]);
$avatar->delete();
Event::handle('AvatarUpdate', [$user->getId()]);
} catch (NotFoundException) {
$form->addError(new FormError(_m('No avatar set, so cannot delete')));
}
} else {
$attachment = null;
$title = $data['avatar']?->getClientOriginalName() ?? null;
if (isset($data['hidden'])) {
// Cropped client side
$matches = [];
@ -106,18 +113,15 @@ class Avatar extends Controller
$file = $data['avatar'];
$attachment = GSFile::storeFileAsAttachment($file);
} else {
throw new ClientException(_m('Invalid form.'));
throw new ClientException('Invalid form');
}
// Delete current avatar if there's one
if (!\is_null($avatar = DB::findOneBy(AvatarEntity::class, ['actor_id' => $actor_id], return_null: true))) {
$avatar->delete();
}
$avatar = DB::find('avatar', ['actor_id' => $actor_id]);
$avatar?->delete();
DB::persist($attachment);
DB::persist(AvatarEntity::create([
'actor_id' => $actor_id,
'attachment_id' => $attachment->getId(),
'title' => $title,
]));
// Can only get new id after inserting
DB::flush();
DB::persist(AvatarEntity::create(['actor_id' => $actor_id, 'attachment_id' => $attachment->getId()]));
DB::flush();
Event::handle('AvatarUpdate', [$user->getId()]);
}

View File

@ -26,11 +26,9 @@ namespace Component\Avatar\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\Router\Router;
use App\Util\Common;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentThumbnail;
use App\Util\Common;
use DateTimeInterface;
/**
@ -53,7 +51,7 @@ class Avatar extends Entity
// @codeCoverageIgnoreStart
private int $actor_id;
private int $attachment_id;
private ?string $title = null;
private ?string $title;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@ -79,17 +77,16 @@ class Avatar extends Entity
return $this->attachment_id;
}
public function setTitle(?string $title): self
{
$this->title = \is_null($title) ? null : mb_substr($title, 0, 191);
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): void
{
$this->title = $title;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
@ -117,7 +114,7 @@ class Avatar extends Entity
private ?Attachment $attachment = null;
public function getUrl(string $size = 'medium', int $type = Router::ABSOLUTE_PATH): string
public function getUrl(string $size = 'full', int $type = Router::ABSOLUTE_PATH): string
{
$actor_id = $this->getActorId();
return Cache::get("avatar-url-{$actor_id}-{$size}-{$type}", fn () => Router::url('avatar_actor', ['actor_id' => $actor_id, 'size' => $size], $type));
@ -125,15 +122,10 @@ class Avatar extends Entity
public function getAttachment(): Attachment
{
$this->attachment ??= DB::findOneBy('attachment', ['id' => $this->getAttachmentId()]);
$this->attachment ??= DB::findOneBy('attachment', ['id' => $this->attachment_id]);
return $this->attachment;
}
public function getAttachmentThumbnail(string $size): ?AttachmentThumbnail
{
return AttachmentThumbnail::getOrCreate($this->getAttachment(), $size);
}
public static function getFilePathStatic(string $filename): string
{
return Common::config('avatar', 'dir') . $filename;
@ -149,11 +141,10 @@ class Avatar extends Entity
*/
public function delete(): bool
{
$actor_id = $this->getActorId();
DB::remove($this);
$attachment = $this->getAttachment();
DB::wrapInTransaction(fn () => DB::removeBy(static::class, ['actor_id' => $actor_id]));
$attachment->kill();
Event::handle('AvatarUpdate', [$actor_id]);
DB::flush();
return true;
}

View File

@ -1,166 +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 Component\Blog\Controller;
use App\Core\ActorLocalRoles;
use App\Core\Controller;
use App\Core\Event;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\VisibilityScope;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use App\Util\Form\FormFields;
use Component\Posting\Posting;
use InvalidArgumentException;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints\Length;
class Post extends Controller
{
/**
* Creates and handles Blog post creation form
*
* @throws \App\Util\Exception\DuplicateFoundException
* @throws \App\Util\Exception\NoLoggedInUser
* @throws \App\Util\Exception\ServerException
* @throws ClientException
* @throws RedirectException
*/
public function makePost(Request $request)
{
$actor = Common::ensureLoggedIn()->getActor();
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
$placeholder = $placeholder_strings[array_rand($placeholder_strings)];
$initial_content = '';
Event::handle('PostingInitialContent', [&$initial_content]);
$available_content_types = [
_m('Plain Text') => 'text/plain',
];
Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
if (!\is_int($this->int('in'))) {
throw new InvalidArgumentException('You must specify an In group/org.');
}
$context_actor = Actor::getById($this->int('in'));
if (!$context_actor->isGroup()) {
throw new InvalidArgumentException('Only group blog posts are supported for now.');
}
$in_targets = ["!{$context_actor->getNickname()}" => $context_actor->getId()];
$form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
$visibility_options = [
_m('Public') => VisibilityScope::EVERYWHERE->value,
_m('Local') => VisibilityScope::LOCAL->value,
_m('Addressee') => VisibilityScope::ADDRESSEE->value,
];
if (!\is_null($context_actor) && $context_actor->isGroup()) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
if ($actor->canModerate($context_actor)) {
if ($context_actor->getRoles() & ActorLocalRoles::PRIVATE_GROUP) {
$visibility_options = array_merge([_m('Group') => VisibilityScope::GROUP->value], $visibility_options);
} else {
$visibility_options[_m('Group')] = VisibilityScope::GROUP->value;
}
}
}
$form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'choices' => $visibility_options]];
$form_params[] = ['title', TextType::class, ['label' => _m('Title:'), 'constraints' => [new Length(['max' => 129])], 'required' => true]];
$form_params[] = ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]];
$form_params[] = ['attachments', FileType::class, ['label' => _m('Attachments:'), 'multiple' => true, 'required' => false, 'invalid_message' => _m('Attachment not valid.')]];
$form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings'));
if (\count($available_content_types) > 1) {
$form_params[] = ['content_type', ChoiceType::class,
[
'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false,
'data' => $available_content_types[array_key_first($available_content_types)],
'choices' => $available_content_types,
],
];
}
Event::handle('PostingAddFormEntries', [$request, $actor, &$form_params]);
$form_params[] = ['post_note', SubmitType::class, ['label' => _m('Post')]];
$form = Form::create($form_params);
$form->handleRequest($request);
if ($form->isSubmitted()) {
try {
if ($form->isValid()) {
$data = $form->getData();
Event::handle('PostingModifyData', [$request, $actor, &$data, $form_params, $form]);
if (empty($data['content']) && empty($data['attachments'])) {
// TODO Display error: At least one of `content` and `attachments` must be provided
throw new ClientException(_m('You must enter content or provide at least one attachment to post a note.'));
}
if (\is_null(VisibilityScope::tryFrom($data['visibility']))) {
throw new ClientException(_m('You have selected an impossible visibility.'));
}
$content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)];
$extra_args = [];
Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]);
[,$note,] = Posting::storeLocalPage(
actor: $actor,
content: $data['content'],
content_type: $content_type,
locale: $data['language'],
scope: VisibilityScope::from($data['visibility']),
targets: [(int) $data['in']],
reply_to: $data['reply_to_id'],
attachments: $data['attachments'],
process_note_content_extra_args: $extra_args,
title: $data['title'],
);
return new RedirectResponse($note->getConversationUrl());
}
} catch (FormSizeFileException $e) {
throw new ClientException(_m('Invalid file size given'), previous: $e);
}
}
return [
'_template' => 'blog/make_post.html.twig',
'blog_entry_form' => $form->createView(),
];
}
}

View File

@ -1,10 +0,0 @@
{% extends 'stdgrid.html.twig' %}
{% block title %}{% trans %}Create a blog post{% endtrans %}{% endblock %}
{% block body %}
{{ parent() }}
<section class="frame-section frame-section-padding">
<h1>{% trans %}Create a blog post{% endtrans %}</h1>
{{ form(blog_entry_form) }}
</section>
{% endblock body %}

View File

@ -44,12 +44,12 @@ class ForeignLink
private int $user_id;
private int $foreign_id;
private int $service;
private ?string $credentials = null;
private int $noticesync = 1;
private int $friendsync = 2;
private int $profilesync = 1;
private ?DateTimeInterface $last_noticesync = null;
private ?DateTimeInterface $last_friendsync = null;
private ?string $credentials;
private int $noticesync = 1;
private int $friendsync = 2;
private int $profilesync = 1;
private ?DateTimeInterface $last_noticesync;
private ?DateTimeInterface $last_friendsync;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@ -88,7 +88,7 @@ class ForeignLink
public function setCredentials(?string $credentials): self
{
$this->credentials = \is_null($credentials) ? null : mb_substr($credentials, 0, 191);
$this->credentials = $credentials;
return $this;
}

View File

@ -43,7 +43,7 @@ class ForeignService
// @codeCoverageIgnoreStart
private int $id;
private string $name;
private ?string $description = null;
private ?string $description;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@ -60,7 +60,7 @@ class ForeignService
public function setName(string $name): self
{
$this->name = mb_substr($name, 0, 32);
$this->name = $name;
return $this;
}
@ -71,7 +71,7 @@ class ForeignService
public function setDescription(?string $description): self
{
$this->description = \is_null($description) ? null : mb_substr($description, 0, 191);
$this->description = $description;
return $this;
}

View File

@ -44,7 +44,7 @@ class ForeignUser
private int $id;
private int $service;
private string $uri;
private ?string $nickname = null;
private ?string $nickname;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@ -72,7 +72,7 @@ class ForeignUser
public function setUri(string $uri): self
{
$this->uri = mb_substr($uri, 0, 191);
$this->uri = $uri;
return $this;
}
@ -83,7 +83,7 @@ class ForeignUser
public function setNickname(?string $nickname): self
{
$this->nickname = \is_null($nickname) ? null : mb_substr($nickname, 0, 191);
$this->nickname = $nickname;
return $this;
}

View File

@ -1,234 +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 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 const SLUG = 'circle';
protected const 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' && \in_array($request->get('_route'), ['person_actor_settings', 'group_actor_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']);
}
/**
* Retrieves an array of Collections owned by an Actor.
* In this case, Collections of those within Actor's own circle of Actors, aka ActorCircle.
*
* Differs from the overwritten method in MetaCollectionsTrait, since retrieved Collections come from the $owner
* itself, and from every Actor that is a part of its ActorCircle.
*
* @param Actor $owner the Actor, and by extension its own circle of Actors
* @param null|array $vars Page vars sent by AppendRightPanelBlock event
* @param bool $ids_only true if only the Collections ids are to be returned
*/
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

@ -1,69 +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 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
{
/**
* Render an existing ActorCircle with the given id as a Collection of Actors
*
* @param ActorCircle|int $circle_id the desired ActorCircle id
*
* @throws \App\Util\Exception\ServerException
* @throws ClientException
*/
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

@ -1,107 +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 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 const SLUG = 'circle';
protected const 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

@ -1,118 +0,0 @@
<?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
* TODO: We should have $actor->setSelfTags(), $actor->addSelfTags(), $actor->removeSelfTags()
*/
public static function settingsSelfTags(Request $request, E\Actor $target, string $details_id)
{
$actor = Common::actor();
if (!$actor->canModerate($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

@ -1,163 +0,0 @@
<?php
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_order_by = [], array $actor_order_by = []): 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');
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor');
Event::handle('CollectionQueryAddJoins', [&$note_qb, &$actor_qb, $note_criteria, $actor_criteria]);
// Handle ordering
$note_order_by = !empty($note_order_by) ? $note_order_by : ['note.created' => 'DESC', 'note.id' => 'DESC'];
$actor_order_by = !empty($actor_order_by) ? $actor_order_by : ['actor.created' => 'DESC', 'actor.id' => 'DESC'];
foreach ($note_order_by as $field => $order) {
$note_qb->addOrderBy($field, $order);
}
foreach ($actor_order_by as $field => $order) {
$actor_qb->addOrderBy($field, $order);
}
$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_aliases = $note_qb->getAllAliases();
if (!\in_array('subscription', $note_aliases)) {
$note_qb->leftJoin(ActorSubscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed_id');
}
if (!\in_array('note_actor', $note_aliases)) {
$note_qb->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 === 'organisation' ? 'group' : $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', 'org', 'orgs', 'organisation', 'organisations', 'organization', 'organizations'],
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

@ -1,9 +0,0 @@
<?php
declare(strict_types = 1);
namespace Component\Collection\Util\Controller;
class CircleController extends OrderedCollection
{
}

View File

@ -1,20 +0,0 @@
<?php
declare(strict_types = 1);
namespace Component\Collection\Util\Controller;
use App\Core\Controller;
use App\Entity\Actor;
use App\Util\Common;
use Component\Collection\Collection as CollectionModule;
class Collection extends Controller
{
public function query(string $query, ?string $locale = null, ?Actor $actor = null, array $note_order_by = [], array $actor_order_by = []): array
{
$actor ??= Common::actor();
$locale ??= Common::currentLanguage()->getLocale();
return CollectionModule::query($query, $this->int('page') ?? 1, $locale, $actor, $note_order_by, $actor_order_by);
}
}

View File

@ -1,9 +0,0 @@
<?php
declare(strict_types = 1);
namespace Component\Collection\Util\Controller;
class OrderedCollection extends Collection
{
}

View File

@ -1,195 +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/>.
// }}}
/**
* Collections 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 Component\Collection\Util;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\RedirectException;
use App\Util\Formatting;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
trait MetaCollectionTrait
{
//protected const SLUG = 'collection';
//protected const 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);
/**
* 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;
/**
* Append Collections widget to the right panel.
* 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(Request $request, $vars, &$res): bool
{
$user = Common::actor();
if (\is_null($user)) {
return Event::next;
}
if (!$this->shouldAddToRightPanel($user, $vars, $request)) {
return Event::next;
}
$collections = $this->getCollectionsBy($user);
// form: add to collection
$choices = [];
foreach ($collections as $col) {
$choices[$col->getName()] = $col->getId();
}
$collections = array_map(fn ($x) => $x->getId(), $collections);
$already_selected = $this->getCollectionsBy($user, $vars, true);
$add_form = Form::create([
['collections', ChoiceType::class, [
'choices' => $choices,
'multiple' => true,
'required' => false,
'choice_attr' => function ($id) use ($already_selected) {
if (\in_array($id, $already_selected)) {
return ['selected' => 'selected'];
}
return [];
},
]],
['add', SubmitType::class, [
'label' => _m('Add to ' . static::PLURAL_SLUG),
'attr' => [
'title' => _m('Add to ' . static::PLURAL_SLUG),
],
]],
]);
$add_form->handleRequest($request);
if ($add_form->isSubmitted() && $add_form->isValid()) {
$selected = $add_form->getData()['collections'];
$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->removeItem($user, $vars, $removed, $collections);
}
if (\count($added) > 0) {
$this->addItem($user, $vars, $added, $collections);
}
DB::flush();
throw new RedirectException();
}
// form: add to new collection
$create_form = Form::create([
['name', TextType::class, [
'label' => _m('Add to a new ' . static::SLUG),
'attr' => [
'placeholder' => _m('New ' . static::SLUG . ' name'),
'required' => 'required',
],
'data' => '',
]],
['create', SubmitType::class, [
'label' => _m('Create a new ' . static::SLUG),
'attr' => [
'title' => _m('Create a new ' . static::SLUG),
],
]],
]);
$create_form->handleRequest($request);
if ($create_form->isSubmitted() && $create_form->isValid()) {
$name = $create_form->getData()['name'];
$this->createCollection($user, $vars, $name);
DB::flush();
throw new RedirectException();
}
$res[] = Formatting::twigRenderFile(
'collection/widget_add_to.html.twig',
[
'ctitle' => _m('Add to ' . static::PLURAL_SLUG),
'user' => $user,
'has_collections' => \count($collections) > 0,
'add_form' => $add_form->createView(),
'create_form' => $create_form->createView(),
],
);
return Event::next;
}
public function onEndShowStyles(array &$styles, string $route): bool
{
$styles[] = 'components/Collection/assets/css/widget.css';
$styles[] = 'components/Collection/assets/css/pages.css';
return Event::next;
}
}

View File

@ -1,70 +0,0 @@
{% extends 'stdgrid.html.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block body %}
<section class="frame-section frame-section-padding">
<header class="feed-header">
{% if actors_feed_title is defined %}
{{ actors_feed_title.getHtml() }}
{% endif %}
</header>
{% set prepend_actors_collection = handle_event('PrependActorsCollection', request) %}
{% for widget in prepend_actors_collection %}
{{ widget | raw }}
{% endfor %}
<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>
</details>
<section class="frame-section frame-section-padding">
<h2>{% trans %}Results{% endtrans %}</h2>
{% if actors is defined and actors is not empty %}
{% for actor in actors %}
{% block profile_view %}{% include 'cards/blocks/profile.html.twig' %}{% endblock profile_view %}
<hr>
{% endfor %}
<span class="frame-section-button-like">{% trans %}Page: %page%{% endtrans %}</span>
{% else %}
<span>{{ empty_message }}</span>
{% endif %}
</section>
</section>
{% endblock body %}

View File

@ -1,11 +0,0 @@
{% extends '/collection/notes.html.twig' %}
{% block title %}{% trans %}%page_title%{% endtrans %}{% endblock %}
{% block body %}
<div class="frame-section frame-section-padding">
<h2 class="frame-section-title">{% trans %}%page_title%{% endtrans %}</h2>
{% block collection_items %}
{% endblock collection_items %}
</div>
{% endblock body %}

View File

@ -1,34 +0,0 @@
{% extends 'stdgrid.html.twig' %}
{% block title %}{% trans %}%page_title%{% endtrans %}{% endblock %}
{% block body %}
<div class="frame-section frame-section-padding">
<h2 class="frame-section-title">{% trans %}%page_title%{% endtrans %}</h2>
{% if add_collection %}
<div class="frame-section section-form">
{{ form(add_collection) }}
</div>
{% endif %}
<div class="frame-section collections-list">
<h3>{% trans %}%list_title%{% endtrans %}</h3>
{% for col in collections %}
<div class="collection-item">
<a class="name" href="{{ fn.getUrl(col.id) }}">{{ col.name }}</a>
<details title="Expand if you want to edit the collection's name">
<summary>
<span class="collection-action">{{ icon('edit') | raw }}</span>
</summary>
{{ form(fn.editForm(col)) }}
</details>
<details title="Expand if you want to delete the collection">
<summary>
<span class="collection-action">{{ icon('delete') | raw }}</span>
</summary>
{{ form(fn.rmForm(col)) }}
</details>
</div>
{% endfor %}
</div>
</div>
{% endblock body %}

View File

@ -1,58 +0,0 @@
{% extends 'stdgrid.html.twig' %}
{% import '/cards/macros/note/factory.html.twig' as NoteFactory %}
{% block title %}{% if page_title is defined %}{% trans %}%page_title%{% endtrans %}{% endif %}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/feeds.css') }}" type="text/css">
{% endblock stylesheets %}
{% block body %}
{% for block in handle_event('BeforeFeed', app.request) %}
{{ block | raw }}
{% endfor %}
{% if notes is defined %}
<header class="feed-header">
{% set current_path = app.request.get('_route') %}
{% if notes_feed_title is defined %}
{{ notes_feed_title.getHtml() }}
{% endif %}
<nav class="feed-actions" title="{% trans %}Actions that change how the feed behaves{% endtrans %}">
<details class="feed-actions-details" role="group">
<summary>
{{ icon('filter', 'icon icon-feed-actions') | raw }} {# button-container #}
</summary>
<menu class="feed-actions-details-dropdown" role="toolbar">
{% for block in handle_event('AddFeedActions', app.request, notes is defined and notes is not empty) %}
{{ block | raw }}
{% endfor %}
</menu>
</details>
</nav>
</header>
{% if notes is not empty %}
{# Backwards compatibility with hAtom 0.1 #}
<section class="feed h-feed hfeed notes" role="feed" aria-busy="false" title="{% trans %}Feed content{% endtrans %}">
{% for conversation in notes %}
{% block current_note %}
{% if conversation is instanceof('array') %}
{% set args = conversation | merge({'type': 'vanilla_full'}) %}
{{ NoteFactory.constructor(args) }}
{# {% else %}
{% set args = { 'type': 'vanilla_full', 'note': conversation, 'extra': { 'depth': 0 } } %}
{{ NoteFactory.constructor(args) }}#}
{% endif %}
<hr class="hr-replies-end" role="separator" aria-label="{% trans %}Marks the end of previous conversation's initial note{% endtrans %}">
{% endblock current_note %}
{% endfor %}
</section>
{% else %}
<section class="feed h-feed hfeed notes" tabindex="0" role="feed">
<span>{% trans %}No notes here...{% endtrans %}</span>
</section>
{% endif %}
{% endif %}
{% endblock body %}

View File

@ -1,27 +0,0 @@
<section class="frame-section collections">
<details class="section-details-title" title="Expand if you want to access more options.">
<summary class="details-summary-title">
<span>{{ctitle}}</span>
</summary>
{% if has_collections %}
<section class="section-form">
{{ form(add_form) }}
</section>
<details class="frame-section-padding section-details-subtitle"
title="Expand if you want to access more options.">
<summary class="details-summary-subtitle">
<strong>{% trans %}Other options{% endtrans %}</strong>
</summary>
<section class="section-form">
{{ form(create_form) }}
</section>
</details>
{% else %}
<section class="section-form">
{{ form(create_form) }}
</section>
{% endif %}
</details>
</section>

View File

@ -20,133 +20,42 @@ declare(strict_types = 1);
/**
* @author Hugo Sales <hugo@hsal.es>
* @author Eliseu Amaro <mail@eliseuama.ro>
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Component\Conversation\Controller;
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;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController;
use Component\Conversation\Entity\ConversationMute;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Component\Feed\Feed;
use Component\Feed\Util\FeedController;
use Symfony\Component\HttpFoundation\Request;
class Conversation extends FeedController
{
/**
* Render conversation page.
*
* @param int $conversation_id To identify what Conversation is to be rendered
*
* @throws \App\Util\Exception\ServerException
*
* @return array Array containing keys: 'notes' (all known notes in the given Conversation), 'should_format' (boolean, stating if onFormatNoteList events may or not format given notes), 'page_title' (used as the title header)
*/
public function showConversation(Request $request, int $conversation_id): array
{
$page_title = _m('Conversation');
return [
'_template' => 'collection/notes.html.twig',
'notes' => $this->query(
query: "note-conversation:{$conversation_id}",
note_order_by: ['note.created' => 'ASC', 'note.id' => 'ASC'],
)['notes'] ?? [],
'should_format' => false,
'page_title' => $page_title,
'notes_feed_title' => (new Heading(1, [], $page_title)),
];
}
/**
* Controller for the note reply non-JS page
*
* Leverages the `PostingModifyData` event to add the `reply_to_id` field from the GET variable 'reply_to_id'
*
* @throws ClientException
* @throws NoLoggedInUser
* @throws NoSuchNoteException
* @throws ServerException
* Render conversation page
*
* @return array
*/
public function addReply(Request $request)
public function showConversation(Request $request, int $conversation_id)
{
$user = Common::ensureLoggedIn();
$note_id = $this->int('reply_to_id', new ClientException(_m('Malformed query.')));
$note = Note::ensureCanInteract(Note::getByPK($note_id), $user);
$conversation_id = $note->getConversationId();
return $this->showConversation($request, $conversation_id);
}
/**
* Creates form view for Muting Conversation extra action.
*
* @param int $conversation_id The Conversation id that this action targets
*
* @throws \App\Util\Exception\NoLoggedInUser
* @throws \App\Util\Exception\RedirectException
* @throws \App\Util\Exception\ServerException
*
* @return array Array containing templating where the form is to be rendered, and the form itself
*/
public function muteConversation(Request $request, int $conversation_id)
{
$user = Common::ensureLoggedIn();
$is_muted = ConversationMute::isMuted($conversation_id, $user);
$form = Form::create([
['mute_conversation', SubmitType::class, ['label' => $is_muted ? _m('Unmute') : _m('Mute'), 'attr' => ['class' => '']]],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
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']);
// 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');
}
}
// TODO:
// if note is root -> just link
// if note is a reply -> link from above plus anchor
/* $notes = DB::findBy(
table: 'note',
criteria: ['conversation_id' => $conversation_id],
order_by: ['created' => 'DESC', 'id' => 'DESC']
);*/
$data = Feed::query(query: "note-conversation:{$conversation_id}", page: $this->int('p') ?? 1);
$notes = $data['notes'];
return [
'_template' => 'conversation/mute.html.twig',
'notes' => $this->query(
query: "note-conversation:{$conversation_id}",
note_order_by: ['note.created' => 'ASC', 'note.id' => 'ASC'],
)['notes'] ?? [],
'is_muted' => $is_muted,
'form' => $form->createView(),
'_template' => 'feed/feed.html.twig',
'notes' => $notes,
'should_format' => false,
'page_title' => _m('Conversation'),
];
}
}

View File

@ -1,9 +1,7 @@
<?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
@ -18,45 +16,49 @@ declare(strict_types = 1);
//
// 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\Subscription\Controller;
/**
* @author Hugo Sales <hugo@hsal.es>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
use function App\Core\I18n\_m;
use App\Entity\Actor;
namespace Component\Conversation\Controller;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\NoSuchNoteException;
use App\Util\Exception\ServerException;
use Component\Collection\Util\Controller\CircleController;
use Component\Feed\Util\FeedController;
use Symfony\Component\HttpFoundation\Request;
/**
* Collection of an actor's subscriptions
*/
class Subscriptions extends CircleController
class Reply extends FeedController
{
/**
* Controller for the note reply non-JS page
*
* @throws ClientException
* @throws NoLoggedInUser
* @throws NoSuchNoteException
* @throws ServerException
*
* @return array
*/
public function subscriptionsByActorId(Request $request, int $id): array
public function addReply(Request $request, int $note_id, int $actor_id)
{
$actor = Actor::getById($id);
if (\is_null($actor)) {
throw new ClientException(_m('No such actor.'), 404);
}
return $this->subscriptionsByActor($request, $actor);
}
$user = Common::ensureLoggedIn();
$note = Note::getByPK($note_id);
if (\is_null($note) || !$note->isVisibleTo($user)) {
throw new NoSuchNoteException();
}
public function subscriptionsByActor(Request $request, Actor $actor)
{
return [
'_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(),
'_template' => 'reply/add_reply.html.twig',
'note' => $note,
];
}
}

View File

@ -1,7 +1,9 @@
<?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
@ -16,47 +18,29 @@ declare(strict_types = 1);
//
// 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/>.
// }}}
/**
* @author Hugo Sales <hugo@hsal.es>
* @author Eliseu Amaro <mail@eliseuama.ro>
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
// }}}
namespace Component\Conversation;
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\Note;
use App\Util\Common;
use App\Util\Formatting;
use Component\Conversation\Controller\Reply as ReplyController;
use Component\Conversation\Entity\Conversation as ConversationEntity;
use Component\Conversation\Entity\ConversationMute;
use Functional as F;
use Symfony\Component\HttpFoundation\Request;
class Conversation extends Component
{
public function onAddRoute(RouteLoader $r): bool
{
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
$r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']);
$r->connect('conversation_reply_to', '/conversation/reply', [Controller\Conversation::class, 'addReply']);
return Event::next;
}
/**
* **Assigns** the given local Note it's corresponding **Conversation**.
* **Assigns** the given local Note it's corresponding **Conversation**
*
* **If a _$parent_id_ is not given**, then the Actor is not attempting a reply,
* therefore, we can assume (for now) that we need to create a new Conversation and assign it
@ -64,9 +48,6 @@ class Conversation extends Component
*
* **On the other hand**, given a _$parent_id_, the Actor is attempting to post a reply. Meaning that,
* this Note conversation_id should be same as the parent Note
*
* @param \App\Entity\Note $current_note Local Note currently being assigned a Conversation
* @param null|int $parent_id If present, it's a reply
*/
public static function assignLocalConversation(Note $current_note, ?int $parent_id): void
{
@ -83,27 +64,20 @@ class Conversation extends Component
} else {
// It's a reply for sure
// Set reply_to property in newly created Note to parent's id
$current_note->setReplyTo($parent_id);
// Parent will have a conversation of its own, the reply should have the same one
$parent_note = Note::getById($parent_id);
$current_note->setConversationId($parent_note->getConversationId());
}
DB::persist($current_note);
DB::merge($current_note);
DB::flush();
}
/**
* HTML rendering event that adds a reply link as a note
* action, if a user is logged in.
*
* @param \App\Entity\Note $note The Note being rendered
* @param array $actions Contains keys 'url' (linking 'conversation_reply_to'
* route), 'title' (used as title for aforementioned url),
* 'classes' (CSS styling classes used to visually inform the user of action context),
* 'id' (HTML markup id used to redirect user to this anchor upon performing the action)
*
* @throws \App\Util\Exception\ServerException
*
* @return bool EventHook
* action, if a user is logged in
*/
public function onAddNoteActions(Request $request, Note $note, array &$actions): bool
{
@ -111,19 +85,15 @@ class Conversation extends Component
return Event::next;
}
$from = $request->query->has('from')
? $request->query->get('from')
: $request->getPathInfo();
// Generating URL for reply action route
$args = ['note_id' => $note->getId(), 'actor_id' => $note->getActor()->getId()];
$type = Router::ABSOLUTE_PATH;
$reply_action_url = Router::url('reply_add', $args, $type);
$reply_action_url = Router::url(
'conversation_reply_to',
[
'reply_to_id' => $note->getId(),
'from' => $from,
'_fragment' => 'note-anchor-' . $note->getId(),
],
Router::ABSOLUTE_PATH,
);
$query_string = $request->getQueryString();
// Concatenating get parameter to redirect the user to where he came from
$reply_action_url .= !\is_null($query_string) ? '?from=' : '&from=';
$reply_action_url .= mb_substr($query_string, 2);
$reply_action = [
'url' => $reply_action_url,
@ -133,179 +103,79 @@ class Conversation extends Component
];
$actions[] = $reply_action;
return Event::next;
}
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
{
// If Actor is adding a reply, get parent's Note id
// Else it's null
$extra_args['reply_to'] = $request->get('_route') === 'reply_add' ? (int) $request->get('note_id') : null;
return Event::next;
}
/**
* Append on note information about user actions.
*
* @param array $vars Contains information related to Note currently being rendered
* @param array $result Contains keys 'actors', and 'action'. Needed to construct a string, stating who ($result['actors']), has already performed a reply ($result['action']), in the given Note (vars['note'])
*
* @return bool EventHook
* Append on note information about user actions
*/
public function onAppendCardNote(array $vars, array &$result): bool
{
if (str_contains($vars['request']->getPathInfo(), 'conversation')) {
return Event::next;
// if note is the original, append on end "user replied to this"
// if note is the reply itself: append on end "in response to user in conversation"
$check_user = !\is_null(Common::user());
$note = $vars['note'];
$complementary_info = '';
$reply_actor = [];
$note_replies = $note->getReplies();
// Get actors who replied
foreach ($note_replies as $reply) {
$reply_actor[] = Actor::getByPK($reply->getActorId());
}
// The current Note being rendered
$note = $vars['note'];
// Will have actors array, and action string
// Actors are the subjects, action is the verb (in the final phrase)
$reply_actors = F\map(
$note->getReplies(),
fn (Note $reply) => Actor::getByPK($reply->getActorId()),
);
if (empty($reply_actors)) {
if (\count($reply_actor) < 1) {
return Event::next;
}
// Filter out multiple replies from the same actor
$reply_actors = array_unique($reply_actors, \SORT_REGULAR);
$result[] = ['actors' => $reply_actors, 'action' => 'replied to'];
$reply_actor = array_unique($reply_actor, \SORT_REGULAR);
// Add to complementary info
foreach ($reply_actor as $actor) {
$reply_actor_url = $actor->getUrl();
$reply_actor_nickname = $actor->getNickname();
if ($check_user && $actor->getId() === (Common::actor())->getId()) {
// If the reply is yours
$you_translation = _m('You');
$prepend = "<a href={$reply_actor_url}>{$you_translation}</a>, " . ($prepend = &$complementary_info);
$complementary_info = $prepend;
} else {
// If the repeat is from someone else
$complementary_info .= "<a href={$reply_actor_url}>{$reply_actor_nickname}</a>, ";
}
}
$complementary_info = rtrim(trim($complementary_info), ',');
$complementary_info .= _m(' replied to this note.');
$result[] = Formatting::twigRenderString($complementary_info, []);
return Event::next;
}
private function getReplyToIdFromRequest(Request $request): ?int
public function onAddRoute(RouteLoader $r): bool
{
if (!\is_array($request->get('post_note')) || !\array_key_exists('_next', $request->get('post_note'))) {
return null;
}
$next = parse_url($request->get('post_note')['_next']);
if (!\array_key_exists('query', $next)) {
return null;
}
parse_str($next['query'], $query);
if (!\array_key_exists('reply_to_id', $query)) {
return null;
}
return (int) $query['reply_to_id'];
$r->connect('reply_add', '/object/note/new?to={actor_id<\d+>}&reply_to={note_id<\d+>}', [ReplyController::class, 'addReply']);
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
return Event::next;
}
/**
* Informs **\App\Component\Posting::onAppendRightPostingBlock**, of the **current page context** in which the given
* Actor is in. This is valuable when posting within a group route, allowing \App\Component\Posting to create a
* Note **targeting** that specific Group.
*
* @param \App\Entity\Actor $actor The Actor currently attempting to post a Note
* @param null|\App\Entity\Actor $context_actor The 'owner' of the current route (e.g. Group or Actor), used to target it
*
* @return bool EventHook
*/
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): bool
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor $context_actor)
{
$to_note_id = $this->getReplyToIdFromRequest($request);
if (!\is_null($to_note_id)) {
$to_query = $request->get('actor_id');
if (!\is_null($to_query)) {
// Getting the actor itself
$context_actor = Actor::getById(Note::getById((int) $to_note_id)->getActorId());
return Event::stop;
}
return Event::next;
}
/**
* Posting event to add extra information to Component\Posting form data
*
* @param array $data Transport data to be filled with reply_to_id
*
* @throws \App\Util\Exception\ClientException
* @throws \App\Util\Exception\NoSuchNoteException
*
* @return bool EventHook
*/
public function onPostingModifyData(Request $request, Actor $actor, array &$data): bool
{
$to_note_id = $this->getReplyToIdFromRequest($request);
if (!\is_null($to_note_id)) {
Note::ensureCanInteract(Note::getById($to_note_id), $actor);
$data['reply_to_id'] = $to_note_id;
}
return Event::next;
}
/**
* Add minimal Note card to RightPanel template
*/
public function onPrependPostingForm(Request $request, array &$elements): bool
{
$elements[] = Formatting::twigRenderFile('cards/blocks/note_compact_wrapper.html.twig', ['note' => Note::getById((int) $request->query->get('reply_to_id'))]);
return Event::next;
}
/**
* Event launched when deleting given Note, it's deletion implies further changes to object related to this Note.
* Please note, **replies are NOT deleted**, their reply_to is only set to null since this Note no longer exists.
*
* @param \App\Entity\Note $note Note being deleted
* @param \App\Entity\Actor $actor Actor that performed the delete action
*
* @return bool EventHook
*/
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
{
// Ensure we have the most up to date replies
Cache::delete(Note::cacheKeys($note->getId())['replies']);
DB::wrapInTransaction(fn () => F\each($note->getReplies(), fn (Note $note) => $note->setReplyTo(null)));
Cache::delete(Note::cacheKeys($note->getId())['replies']);
return Event::next;
}
/**
* Adds extra actions related to Conversation Component, that act upon/from the given Note.
*
* @param \App\Entity\Note $note Current Note being rendered
* @param array $actions Containing 'url' (Controller connected route), 'title' (used in anchor link containing the url), ?'classes' (CSS classes required for styling, if needed)
*
* @throws \App\Util\Exception\ServerException
*
* @return bool EventHook
*/
public function onAddExtraNoteActions(Request $request, Note $note, array &$actions): bool
{
if (\is_null($user = Common::user())) {
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,
'_fragment' => 'note-anchor-' . $note->getId(),
],
Router::ABSOLUTE_PATH,
);
$actions[] = [
'title' => ConversationMute::isMuted($note, $user) ? _m('Unmute conversation') : _m('Mute conversation'),
'classes' => '',
'url' => $mute_extra_action_url,
];
return Event::next;
}
/**
* Prevents new Notifications to appear for muted conversations
*
* @param Activity $activity Notification Activity
*
* @return bool EventHook
*/
public function onNewNotificationShould(Activity $activity, Actor $actor): bool
{
if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) {
$context_actor = Actor::getById((int) $to_query);
return Event::stop;
}
return Event::next;

View File

@ -23,7 +23,6 @@ declare(strict_types = 1);
namespace Component\Conversation\Entity;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Router\Router;
@ -47,6 +46,7 @@ class Conversation extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private string $uri;
private int $initial_note_id;
public function setId(int $id): self

View File

@ -1,132 +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 Component\Conversation\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Entity\Note;
use DateTimeInterface;
/**
* Entity class for Conversations Mutes
*
* @category DB
* @package GNUsocial
*
* @author Hugo Sales <hugo@hsal.es>
* @copyright 2022 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class ConversationMute extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $conversation_id;
private int $actor_id;
private DateTimeInterface $created;
public function setConversationId(int $conversation_id): self
{
$this->conversation_id = $conversation_id;
return $this;
}
public function getConversationId(): int
{
return $this->conversation_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 setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): DateTimeInterface
{
return $this->created;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function cacheKeys(int $conversation_id, int $actor_id): array
{
return [
'mute' => "conversation-mute-{$conversation_id}-{$actor_id}",
];
}
/**
* Check if a conversation referenced by $object is muted form $actor
*/
public static function isMuted(Activity|Note|int $object, Actor|LocalUser $actor): bool
{
$conversation_id = null;
if (\is_int($object)) {
$conversation_id = $object;
} elseif ($object instanceof Note) {
$conversation_id = $object->getConversationId();
} elseif ($object instanceof Activity) {
$conversation_id = Note::getById($object->getObjectId())->getConversationId();
}
return Cache::get(
self::cacheKeys($conversation_id, $actor->getId())['mute'],
fn () => (bool) DB::count('conversation_mute', ['conversation_id' => $conversation_id, 'actor_id' => $actor->getId()]),
);
}
public static function schemaDef(): array
{
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'],
],
'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']],
],
];
}
}

View File

@ -1,21 +0,0 @@
{% extends 'collection/notes.html.twig' %}
{% block body %}
<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

@ -0,0 +1,18 @@
{% extends 'stdgrid.html.twig' %}
{% import "/cards/note/view.html.twig" as noteView %}
{% block title %}{{ 'Reply to ' | trans }}{{ note.getActorNickname() }}{{ '\'s note.' | trans }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %}
{% block body %}
{{ parent() }}
<div class="page">
<div class="main">
{{ noteView.macro_note_minimal(note) }}
</div>
</div>
{% endblock body %}

View File

@ -23,17 +23,142 @@ 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 Component\Feed\Controller as C;
use App\Entity\Actor;
use App\Entity\Subscription;
use App\Util\Formatting;
use Component\Search\Util\Parser;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
class Feed extends Component
{
public function onAddRoute(RouteLoader $r): bool
/**
* 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
{
$r->connect('feed_public', '/feed/public', [C\Feeds::class, 'public']);
$r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']);
$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')->orderBy('note.id', 'DESC');
$actor_qb->select('actor')->from('App\Entity\Actor', 'actor')->orderBy('actor.created', 'DESC')->orderBy('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();
// TODO: Enforce scoping on the notes before returning
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
}
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb)
{
$note_qb->leftJoin(Subscription::class, 'subscription', Expr\Join::WITH, 'note.actor_id = subscription.subscribed')
->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', $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

@ -30,36 +30,29 @@ declare(strict_types = 1);
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Component\Collection\Util\Controller;
namespace Component\Feed\Util;
use App\Core\Controller;
use App\Core\Event;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common;
use Functional as F;
abstract class FeedController extends OrderedCollection
abstract class FeedController extends Controller
{
/**
* Post-processing of the result of a feed controller, to remove any
* Post process the result of a feed controller, to remove any
* notes or actors the user specified, as well as format the raw
* list of notes into a usable format
*/
protected function postProcess(array $result): array
public static function post_process(array $result): array
{
$actor = Common::actor();
if (\array_key_exists('notes', $result)) {
$notes = $result['notes'];
self::enforceScope($notes, $actor, $result['actor'] ?? null);
Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]);
Event::handle('FormatNoteList', [$notes, &$result['notes'], &$result['request']]);
Event::handle('FormatNoteList', [$notes, &$result['notes']]);
}
return $result;
}
private static function enforceScope(array &$notes, ?Actor $actor, ?Actor $in = null): void
{
$notes = F\select($notes, fn (Note $n) => $n->isVisibleTo($actor, $in));
}
}

View File

@ -0,0 +1,42 @@
{% extends 'stdgrid.html.twig' %}
{% import '/cards/note/view.html.twig' as noteView %}
{% block title %}{% if page_title is defined %}{{ page_title | trans }}{% endif %}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %}
{% block body %}
<header class="feed-header">
{% if page_title is defined %}
<h1>{{ page_title | trans }} {{ 'feed' | trans }}</h1>
{% endif %}
<nav class="feed-actions" role=navigation>
{% for block in handle_event('AddFeedActions', app.request) %}
{{ block | raw }}
{% endfor %}
</nav>
</header>
{# 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 %}
{% 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 %}
{% else %}
<div id="empty-notes"><h1>{% trans %}No notes here.{% endtrans %}</h1></div>
{% endif %}
</div>
</main>
{% endblock body %}

View File

@ -1,54 +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 Component\Feed\tests\Controller;
use App\Core\Router\Router;
use App\Util\GNUsocialTestCase;
use Component\Feed\Controller\Feeds;
use Jchook\AssertThrows\AssertThrows;
class FeedsTest extends GNUsocialTestCase
{
use AssertThrows;
public function testPublic()
{
// This calls static::bootKernel(), and creates a "client" that is acting as the browser
$client = static::createClient();
$crawler = $client->request('GET', Router::url('feed_public'));
$this->assertResponseIsSuccessful();
}
public function testHome()
{
// This calls static::bootKernel(), and creates a "client" that is acting as the browser
$client = static::createClient();
$crawler = $client->request('GET', Router::url('feed_home'));
$this->assertResponseStatusCodeSame(302);
}
// TODO: It would be nice to actually test whether the feeds are respecting scopes and spitting
// out the expected notes... The ActivityPub plugin have a somewhat obvious way of testing it so,
// for now, having that, might fill that need, let's see
}

View File

@ -37,7 +37,8 @@ namespace Component\FreeNetwork\Controller;
use App\Core\DB\DB;
use function App\Core\I18n\_m;
use App\Util\Common;
use Component\Collection\Util\Controller\FeedController;
use Component\Feed\Feed;
use Component\Feed\Util\FeedController;
use Symfony\Component\HttpFoundation\Request;
class Feeds extends FeedController
@ -51,9 +52,13 @@ class Feeds extends FeedController
public function network(Request $request): array
{
Common::ensureLoggedIn();
$data = $this->query('note-local:false');
$data = Feed::query(
query: 'note-local:false',
page: $this->int('p'),
language: Common::actor()?->getTopLanguage()?->getLocale(),
);
return [
'_template' => 'collection/notes.html.twig',
'_template' => 'feed/feed.html.twig',
'page_title' => _m('Meteorites'),
'should_format' => true,
'notes' => $data['notes'],
@ -68,7 +73,6 @@ 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
@ -82,7 +86,7 @@ class Feeds extends FeedController
EOF,
);
return [
'_template' => 'collection/notes.html.twig',
'_template' => 'feed/feed.html.twig',
'page_title' => _m('Planetary System'),
'should_format' => true,
'notes' => $notes,
@ -97,9 +101,13 @@ class Feeds extends FeedController
public function federated(Request $request): array
{
Common::ensureLoggedIn();
$data = $this->query('notes-all:yeah');
$data = Feed::query(
query: '',
page: $this->int('p'),
language: Common::actor()?->getTopLanguage()?->getLocale(),
);
return [
'_template' => 'collection/notes.html.twig',
'_template' => 'feed/feed.html.twig',
'page_title' => _m('Galaxy'),
'should_format' => true,
'notes' => $data['notes'],

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
/**
* @author James Walker <james@status.net>
* @author Craig Andrews <candrews@integralblue.com>
@ -10,13 +8,17 @@ declare(strict_types = 1);
namespace Component\FreeNetwork\Controller;
use App\Core\Controller;
use App\Core\Event;
use Component\FreeNetwork\Util\Discovery;
use Component\FreeNetwork\Util\XrdController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use XML_XRD;
class HostMeta extends XrdController
{
protected string $default_mimetype = Discovery::JRD_MIMETYPE;
protected string $default_mimetype = Discovery::XRD_MIMETYPE;
public function setXRD()
{

View File

@ -1,6 +1,4 @@
<?php
declare(strict_types = 1);
// 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
@ -32,6 +30,7 @@ use App\Util\Common;
use Component\FreeNetwork\Util\Discovery;
use Component\FreeNetwork\Util\XrdController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class OwnerXrd extends XrdController
{

View File

@ -1,6 +1,4 @@
<?php
declare(strict_types = 1);
/*
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
@ -71,7 +69,7 @@ class Webfinger extends XrdController
$this->xrd->subject = $this->resource;
foreach ($this->target->getAliases() as $alias) {
if ($alias != $this->xrd->subject && !\in_array($alias, $this->xrd->aliases)) {
if ($alias != $this->xrd->subject && !in_array($alias, $this->xrd->aliases)) {
$this->xrd->aliases[] = $alias;
}
}

View File

@ -32,11 +32,17 @@ declare(strict_types = 1);
namespace Component\FreeNetwork\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Entity\Actor;
use Component\FreeNetwork\Util\Discovery;
use DateTimeInterface;
use Exception;
use Plugin\ActivityPub\Util\DiscoveryHints;
use Plugin\ActivityPub\Util\Explorer;
/**
* Table Definition for free_network_actor_protocol
@ -47,27 +53,27 @@ use DateTimeInterface;
class FreeNetworkActorProtocol extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
// @codeCoverageIgnoreStart;
private int $actor_id;
private ?string $protocol = null;
private string $addr;
private ?string $protocol;
private ?string $addr;
private DateTimeInterface $created;
private DateTimeInterface $modified;
public function getActorId(): int
{
return $this->actor_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 setProtocol(?string $protocol): self
{
$this->protocol = \is_null($protocol) ? null : mb_substr($protocol, 0, 32);
$this->protocol = $protocol;
return $this;
}
@ -87,20 +93,14 @@ class FreeNetworkActorProtocol extends Entity
return $this->addr;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): DateTimeInterface
{
return $this->created;
}
public function setModified(DateTimeInterface $modified): self
public function setCreated(DateTimeInterface $created): self
{
$this->modified = $modified;
$this->created = $created;
return $this;
}
@ -109,14 +109,19 @@ class FreeNetworkActorProtocol extends Entity
return $this->modified;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function protocolSucceeded(string $protocol, int|Actor $actor_id, string $addr): void
{
$actor_id = \is_int($actor_id) ? $actor_id : $actor_id->getId();
$actor_id = is_int($actor_id) ? $actor_id : $actor_id->getId();
$attributed_protocol = self::getByPK(['actor_id' => $actor_id]);
if (\is_null($attributed_protocol)) {
if (is_null($attributed_protocol)) {
$attributed_protocol = self::create([
'actor_id' => $actor_id,
'protocol' => $protocol,
@ -125,14 +130,14 @@ class FreeNetworkActorProtocol extends Entity
} else {
$attributed_protocol->setProtocol($protocol);
}
DB::persist($attributed_protocol);
DB::wrapInTransaction(fn() => DB::persist($attributed_protocol));
}
public static function canIActor(string $protocol, int|Actor $actor_id): bool
{
$actor_id = \is_int($actor_id) ? $actor_id : $actor_id->getId();
$actor_id = is_int($actor_id) ? $actor_id : $actor_id->getId();
$attributed_protocol = self::getByPK(['actor_id' => $actor_id])?->getProtocol();
if (\is_null($attributed_protocol)) {
if (is_null($attributed_protocol)) {
// If it is not attributed, you can go ahead.
return true;
} else {
@ -144,9 +149,9 @@ class FreeNetworkActorProtocol extends Entity
public static function canIAddr(string $protocol, string $target): bool
{
// Normalize $addr, i.e. add 'acct:' if missing
$addr = Discovery::normalize($target);
$addr = Discovery::normalize($target);
$attributed_protocol = self::getByPK(['addr' => $addr])?->getProtocol();
if (\is_null($attributed_protocol)) {
if (is_null($attributed_protocol)) {
// If it is not attributed, you can go ahead.
return true;
} else {
@ -162,7 +167,7 @@ class FreeNetworkActorProtocol extends Entity
'fields' => [
'actor_id' => ['type' => 'int', 'not null' => true],
'protocol' => ['type' => 'varchar', 'length' => 32, 'description' => 'the protocol plugin that should handle federation of this actor'],
'addr' => ['type' => 'text', 'not null' => true, 'description' => 'webfinger acct'],
'addr' => ['type' => 'text', 'not null' => true, 'description' => 'webfinger acct'],
'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'],
],

View File

@ -1,6 +1,4 @@
<?php
declare(strict_types = 1);
/**
* StatusNet, the distributed open-source microblogging tool
*
@ -52,7 +50,7 @@ use Throwable;
*/
class WebfingerReconstructionException extends ServerException
{
public function __construct(string $message = '', int $code = 500, ?Throwable $previous = null)
public function __construct(string $message = '', int $code = 500, Throwable $previous = null)
{
// We could log an entry here with the search parameters
parent::__construct(_m('WebFinger URI generation failed.'));

View File

@ -44,7 +44,6 @@ use App\Util\Exception\NicknameTakenException;
use App\Util\Exception\NicknameTooLongException;
use App\Util\Exception\NoSuchActorException;
use App\Util\Exception\ServerException;
use App\Util\Formatting;
use App\Util\Nickname;
use Component\FreeNetwork\Controller\Feeds;
use Component\FreeNetwork\Controller\HostMeta;
@ -54,8 +53,9 @@ use Component\FreeNetwork\Util\Discovery;
use Component\FreeNetwork\Util\WebfingerResource;
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceActor;
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceNote;
use Doctrine\Common\Collections\ExpressionBuilder;
use Exception;
use Plugin\ActivityPub\Entity\ActivitypubActivity;
use Plugin\ActivityPub\Util\TypeResponse;
use const PREG_SET_ORDER;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
@ -78,13 +78,6 @@ class FreeNetwork extends Component
public const OAUTH_ACCESS_TOKEN_REL = 'http://apinamespace.org/oauth/access_token';
public const OAUTH_REQUEST_TOKEN_REL = 'http://apinamespace.org/oauth/request_token';
public const OAUTH_AUTHORIZE_REL = 'http://apinamespace.org/oauth/authorize';
private static array $protocols = [];
public function onInitializeComponent(): bool
{
Event::handle('AddFreeNetworkProtocol', [&self::$protocols]);
return Event::next;
}
public function onAddRoute(RouteLoader $m): bool
{
@ -161,7 +154,7 @@ class FreeNetwork extends Component
$parts = explode('@', mb_substr(urldecode($resource), 5)); // 5 is strlen of 'acct:'
if (\count($parts) === 2) {
[$nick, $domain] = $parts;
if ($domain !== Common::config('site', 'server')) {
if ($domain !== $_ENV['SOCIAL_DOMAIN']) {
throw new ServerException(_m('Remote profiles not supported via WebFinger yet.'));
}
@ -178,7 +171,7 @@ class FreeNetwork extends Component
// This means $resource is a valid url
$resource_parts = parse_url($resource);
// TODO: Use URLMatcher
if ($resource_parts['host'] === Common::config('site', 'server')) {
if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
$str = $resource_parts['path'];
// actor_view_nickname
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
@ -216,8 +209,9 @@ class FreeNetwork extends Component
return Event::stop; // We got our target, stop handler execution
}
if (!\is_null($note = DB::findOneBy(Note::class, ['url' => $resource], return_null: true))) {
$target = new WebfingerResourceNote($note);
$APNote = ActivitypubActivity::getByPK(['object_uri' => $resource]);
if ($APNote instanceof ActivitypubActivity) {
$target = new WebfingerResourceNote(Note::getByPK(['id' => $APNote->getObjectId()]));
return Event::stop; // We got our target, stop handler execution
}
@ -276,7 +270,7 @@ class FreeNetwork extends Component
* @throws ClientException
* @throws ServerException
*/
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?Response &$response = null): bool
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?TypeResponse &$response = null): bool
{
if (!\in_array($route, ['freenetwork_hostmeta', 'freenetwork_hostmeta_format', 'freenetwork_webfinger', 'freenetwork_webfinger_format', 'freenetwork_ownerxrd'])) {
return Event::next;
@ -306,9 +300,6 @@ class FreeNetwork extends Component
Discovery::XRD_MIMETYPE => new Response(content: $vars['xrd']->to('xml'), headers: $headers),
Discovery::JRD_MIMETYPE, Discovery::JRD_MIMETYPE_OLD => new JsonResponse(data: $vars['xrd']->to('json'), headers: $headers, json: true),
};
$response->headers->set('cache-control', 'no-store, no-cache, must-revalidate');
return Event::stop;
}
@ -388,7 +379,7 @@ class FreeNetwork extends Component
$actor = null;
$resource_parts = explode($preMention, $target);
if ($resource_parts[1] === Common::config('site', 'server')) {
if ($resource_parts[1] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
$actor = LocalUser::getByPK(['nickname' => $resource_parts[0]])->getActor();
} else {
Event::handle('FreeNetworkFindMentions', [$target, &$actor]);
@ -417,7 +408,7 @@ class FreeNetwork extends Component
// This means $resource is a valid url
$resource_parts = parse_url($url);
// TODO: Use URLMatcher
if ($resource_parts['host'] === Common::config('site', 'server')) {
if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
$str = $resource_parts['path'];
// actor_view_nickname
$renick = '/\/@(' . Nickname::DISPLAY_FMT . ')\/?/m';
@ -498,39 +489,17 @@ class FreeNetwork extends Component
public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
{
foreach (self::$protocols as $protocol) {
$protocol::freeNetworkDistribute($sender, $activity, $targets, $reason);
$protocols = [];
Event::handle('AddFreeNetworkProtocol', [&$protocols]);
$delivered = [];
foreach ($protocols as $protocol) {
$protocol::freeNetworkDistribute($sender, $activity, $targets, $reason, $delivered);
}
$failed_targets = array_udiff($targets, $delivered, fn (Actor $a, Actor $b): int => $a->getId() <=> $b->getId());
// TODO: Implement failed queues
return false;
}
public static function mentionTagToName(string $nickname, string $uri): string
{
return '@' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
}
public static function groupTagToName(string $nickname, string $uri): string
{
return '!' . $nickname . '@' . parse_url($uri, \PHP_URL_HOST);
}
/**
* Add fediverse: query expression
* // TODO: adding WebFinger would probably be nice
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
{
if (Formatting::startsWith($term, ['fediverse:'])) {
foreach (self::$protocols as $protocol) {
// 10 is strlen of `fediverse:`
if ($protocol::freeNetworkGrabRemote(mb_substr($term, 10))) {
break;
}
}
}
return Event::next;
}
public function onPluginVersion(array &$versions): bool
{
$versions[] = [

View File

@ -1,6 +1,4 @@
<?php
declare(strict_types = 1);
/**
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
@ -71,7 +69,7 @@ class LinkHeader
$this->type = null;
// remove uri-reference from header
$str = mb_substr($str, mb_strlen($uri_reference[0]));
$str = substr($str, strlen($uri_reference[0]));
// parse link-params
$params = explode(';', $str);
@ -80,7 +78,7 @@ class LinkHeader
if (empty($param)) {
continue;
}
[$param_name, $param_value] = explode('=', $param, 2);
list($param_name, $param_value) = explode('=', $param, 2);
$param_name = trim($param_name);
$param_value = preg_replace('(^"|"$)', '', trim($param_value));
@ -112,17 +110,18 @@ class LinkHeader
$headers = $response->getHeader('Link');
if ($headers) {
// Can get an array or string, so try to simplify the path
if (!\is_array($headers)) {
if (!is_array($headers)) {
$headers = [$headers];
}
foreach ($headers as $header) {
$lh = new self($header);
if ((\is_null($rel) || $lh->rel == $rel) && (\is_null($type) || $lh->type == $type)) {
if ((is_null($rel) || $lh->rel == $rel) && (is_null($type) || $lh->type == $type)) {
return $lh->href;
}
}
}
return null;
}
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util\LrddMethod;
// This file is part of GNU social - https://www.gnu.org/software/social
@ -41,27 +39,29 @@ class LrddMethodHostMeta extends LRDDMethod
/**
* For RFC6415 and HTTP URIs, fetch the host-meta file
* and look for LRDD templates
*
* @param mixed $uri
*/
public function discover($uri)
{
// This is allowed for RFC6415 but not the 'WebFinger' RFC7033.
$try_schemes = ['https', 'http'];
$scheme = mb_strtolower(parse_url($uri, \PHP_URL_SCHEME));
$scheme = mb_strtolower(parse_url($uri, PHP_URL_SCHEME));
switch ($scheme) {
case 'acct':
// We can't use parse_url data for this, since the 'host'
// entry is only set if the scheme has '://' after it.
$parts = explode('@', parse_url($uri, \PHP_URL_PATH), 2);
$parts = explode('@', parse_url($uri, PHP_URL_PATH), 2);
if (!Discovery::isAcct($uri) || \count($parts) != 2) {
if (!Discovery::isAcct($uri) || count($parts) != 2) {
throw new Exception('Bad resource URI: ' . $uri);
}
[, $domain] = $parts;
break;
case 'http':
case 'https':
$domain = mb_strtolower(parse_url($uri, \PHP_URL_HOST));
$domain = mb_strtolower(parse_url($uri, PHP_URL_HOST));
$try_schemes = [$scheme];
break;
default:

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util\LrddMethod;
// This file is part of GNU social - https://www.gnu.org/software/social
@ -40,7 +38,10 @@ class LrddMethodLinkHtml extends LRDDMethod
* For HTTP IDs, fetch the URL and look for <link> elements
* in the HTML response.
*
* @param mixed $uri
*
* @todo fail out of WebFinger URIs faster
*
*/
public function discover($uri)
{
@ -64,7 +65,7 @@ class LrddMethodLinkHtml extends LRDDMethod
preg_match('/<head(\s[^>]*)?>(.*?)<\/head>/is', $html, $head_matches);
if (\count($head_matches) != 3) {
if (count($head_matches) != 3) {
return [];
}
[, , $head_html] = $head_matches;
@ -77,23 +78,23 @@ class LrddMethodLinkHtml extends LRDDMethod
$link_type = null;
preg_match('/\srel=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $rel_matches);
if (\count($rel_matches) > 3) {
if (count($rel_matches) > 3) {
$link_rel = $rel_matches[3];
} elseif (\count($rel_matches) > 1) {
} elseif (count($rel_matches) > 1) {
$link_rel = $rel_matches[1];
}
preg_match('/\shref=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $href_matches);
if (\count($href_matches) > 3) {
if (count($href_matches) > 3) {
$link_uri = $href_matches[3];
} elseif (\count($href_matches) > 1) {
} elseif (count($href_matches) > 1) {
$link_uri = $href_matches[1];
}
preg_match('/\stype=(("|\')([^\\2]*?)\\2|[^"\'\s]+)/i', $link_html, $type_matches);
if (\count($type_matches) > 3) {
if (count($type_matches) > 3) {
$link_type = $type_matches[3];
} elseif (\count($type_matches) > 1) {
} elseif (count($type_matches) > 1) {
$link_type = $type_matches[1];
}

View File

@ -1,6 +1,4 @@
<?php
declare(strict_types = 1);
// 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
@ -38,17 +36,19 @@ class LrddMethodWebfinger extends LRDDMethod
/**
* Simply returns the WebFinger URL over HTTPS at the uri's domain:
* https://{domain}/.well-known/webfinger?resource={uri}
*
* @param mixed $uri
*/
public function discover($uri)
{
$parts = explode('@', parse_url($uri, \PHP_URL_PATH), 2);
$parts = explode('@', parse_url($uri, PHP_URL_PATH), 2);
if (!Discovery::isAcct($uri) || \count($parts) != 2) {
if (!Discovery::isAcct($uri) || count($parts) != 2) {
throw new Exception('Bad resource URI: ' . $uri);
}
[, $domain] = $parts;
if (!filter_var($domain, \FILTER_VALIDATE_IP)
&& !filter_var(gethostbyname($domain), \FILTER_VALIDATE_IP)) {
if (!filter_var($domain, FILTER_VALIDATE_IP)
&& !filter_var(gethostbyname($domain), FILTER_VALIDATE_IP)) {
throw new Exception('Bad resource host.');
}
@ -56,7 +56,7 @@ class LrddMethodWebfinger extends LRDDMethod
Discovery::LRDD_REL,
'https://' . $domain . '/.well-known/webfinger?resource={uri}',
Discovery::JRD_MIMETYPE,
true, // isTemplate
true // isTemplate
);
return [$link];

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util;
use App\Core\Entity;
@ -42,6 +40,8 @@ abstract class WebfingerResource
/**
* List of alternative IDs of a certain Actor
*
* @return array
*/
public function getAliases(): array
{
@ -53,7 +53,7 @@ abstract class WebfingerResource
// you've run HTTPS all the time!
if (Common::config('fix', 'legacy_http')) {
foreach ($aliases as $alias => $id) {
if (!mb_strtolower(parse_url($alias, \PHP_URL_SCHEME)) === 'https') {
if (!strtolower(parse_url($alias, PHP_URL_SCHEME)) === 'https') {
continue;
}
$aliases[preg_replace('/^https:/i', 'http:', $alias, 1)] = $id;

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util\WebfingerResource;
use App\Core\Event;
@ -26,9 +24,9 @@ use XML_XRD_Element_Link;
*/
class WebfingerResourceActor extends WebFingerResource
{
public const PROFILEPAGE = 'http://webfinger.net/rel/profile-page';
const PROFILEPAGE = 'http://webfinger.net/rel/profile-page';
public function __construct(?Actor $object = null)
public function __construct(Actor $object = null)
{
// The type argument above verifies that it's our class
parent::__construct($object);
@ -51,9 +49,8 @@ class WebfingerResourceActor extends WebFingerResource
/**
* Reconstruct WebFinger acct: from object
*
* @return array|false|mixed|string|string[]|null
* @throws WebfingerReconstructionException
*
* @return null|array|false|mixed|string|string[]
*/
public function reconstructAcct()
{
@ -61,7 +58,7 @@ class WebfingerResourceActor extends WebFingerResource
if (Event::handle('StartWebFingerReconstruction', [$this->object, &$acct])) {
// TODO: getUri may not always give us the correct host on remote users?
$host = parse_url($this->object->getUri(Router::ABSOLUTE_URL), \PHP_URL_HOST);
$host = parse_url($this->object->getUri(Router::ABSOLUTE_URL), PHP_URL_HOST);
if (empty($this->object->getNickname()) || empty($host)) {
throw new WebFingerReconstructionException(print_r($this->object, true));
}
@ -78,11 +75,8 @@ class WebfingerResourceActor extends WebFingerResource
if (Event::handle('StartWebFingerProfileLinks', [$xrd, $this->object])) {
// Profile page, can give more metadata from Link header or HTML parsing
$xrd->links[] = new XML_XRD_Element_Link(
self::PROFILEPAGE,
$this->object->getUrl(Router::ABSOLUTE_URL),
'text/html',
);
$xrd->links[] = new XML_XRD_Element_Link(self::PROFILEPAGE,
$this->object->getUrl(Router::ABSOLUTE_URL), 'text/html');
// // XFN
// $xrd->links[] = new XML_XRD_Element_Link('http://gmpg.org/xfn/11',

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util\WebfingerResource;
use App\Core\Event;
@ -24,7 +22,7 @@ use XML_XRD_Element_Link;
*/
class WebfingerResourceNote extends WebfingerResource
{
public function __construct(?Note $object = null)
public function __construct(Note $object = null)
{
// The type argument above verifies that it's our class
parent::__construct($object);
@ -32,37 +30,29 @@ class WebfingerResourceNote extends WebfingerResource
/**
* Update given XRD with self's data
*
* @param XML_XRD $xrd
*/
public function updateXRD(XML_XRD $xrd)
{
if (Event::handle('StartWebFingerNoticeLinks', [$xrd, $this->object])) {
if ($this->object->isLocal()) {
$xrd->links[] = new XML_XRD_Element_Link(
'alternate',
common_local_url(
'ApiStatusesShow',
$xrd->links[] = new XML_XRD_Element_Link('alternate',
common_local_url('ApiStatusesShow',
['id' => $this->object->id,
'format' => 'atom', ],
),
'application/atom+xml',
);
'format' => 'atom', ]),
'application/atom+xml');
$xrd->links[] = new XML_XRD_Element_Link(
'alternate',
common_local_url(
'ApiStatusesShow',
$xrd->links[] = new XML_XRD_Element_Link('alternate',
common_local_url('ApiStatusesShow',
['id' => $this->object->id,
'format' => 'json', ],
),
'application/json',
);
'format' => 'json', ]),
'application/json');
} else {
try {
$xrd->links[] = new XML_XRD_Element_Link(
'alternate',
$xrd->links[] = new XML_XRD_Element_Link('alternate',
$this->object->getUrl(),
'text/html',
);
'text/html');
} catch (InvalidUrlException $e) {
// don't do a fallback in webfinger
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
namespace Component\FreeNetwork\Util;
use App\Core\Controller;
@ -37,4 +35,4 @@ abstract class XrdController extends Controller
$this->setXRD();
return ['xrd' => $this->xrd, 'default_mimetype' => $this->default_mimetype];
}
}
}

View File

@ -23,161 +23,122 @@ declare(strict_types = 1);
namespace Component\Group\Controller;
use App\Core\ActorLocalRoles;
use App\Core\Cache;
use App\Core\Controller;
use App\Core\Controller\ActorController;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Entity\Actor;
use App\Entity as E;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
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\Exception\NotFoundException;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use App\Util\Form\ActorForms;
use App\Util\Nickname;
use Component\Group\Entity\GroupMember;
use Component\Group\Entity\LocalGroup;
use Component\Subscription\Entity\ActorSubscription;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
class Group extends Controller
class Group extends ActorController
{
/**
* Page that allows an actor to create a new group
*
* @throws RedirectException
* @throws ServerException
*/
public function groupCreate(Request $request): array
public function groupViewId(Request $request, int $id)
{
if (\is_null($actor = Common::actor())) {
throw new RedirectException('security_login');
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
*/
public function groupViewNickname(Request $request, string $nickname)
{
Nickname::validate($nickname, which: Nickname::CHECK_LOCAL_GROUP); // throws
$group = Actor::getByNickname($nickname, type: Actor::GROUP);
if (\is_null($group)) {
$actor = Common::actor();
if (!\is_null($actor)) {
$form = Form::create([
['create', SubmitType::class, ['label' => _m('Create this group')]],
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $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],
),
);
$group = Actor::create([
'nickname' => $nickname,
'type' => Actor::GROUP,
'is_local' => true,
]);
DB::persist($group);
DB::persist(E\Subscription::create([
'subscriber' => $group->getId(),
'subscribed' => $group->getId(),
]));
DB::persist(E\Subscription::create([
'subscriber' => $actor->getId(),
'subscribed' => $group->getId(),
]));
DB::persist(E\GroupMember::create([
'group_id' => $group->getId(),
'actor_id' => $actor->getId(),
'is_admin' => true,
]));
DB::flush();
Cache::delete(Actor::cacheKeys($actor->getId())['subscriber']);
Cache::delete(Actor::cacheKeys($actor->getId())['subscribed']);
throw new RedirectException;
}
return [
'_template' => 'group/view.html.twig',
'nickname' => $nickname,
'create_form' => $form->createView(),
];
}
}
$create_form = self::getGroupCreateForm($request, $actor);
$notes = !\is_null($group) ? DB::dql(
<<<'EOF'
select n from note n
join activity a with n.id = a.object_id
join group_inbox gi with a.id = gi.activity_id
where a.object_type = 'note' and gi.group_id = :group_id
EOF,
['group_id' => $group->getId()],
) : [];
return [
'_template' => 'group/create.html.twig',
'create_form' => $create_form->createView(),
'_template' => 'group/view.html.twig',
'actor' => $group,
'nickname' => $group?->getNickname() ?? $nickname,
'notes' => $notes,
];
}
/**
* Settings page for the group with the provided nickname, checks if the current actor can administrate given group
*
* @throws ClientException
* @throws DuplicateFoundException
* @throws NicknameEmptyException
* @throws NicknameException
* @throws NicknameInvalidException
* @throws NicknameNotAllowedException
* @throws NicknameTakenException
* @throws NicknameTooLongException
* @throws NotFoundException
* @throws ServerException
*/
public function groupSettings(Request $request, int $id): array
public function groupSettings(Request $request, string $nickname)
{
$local_group = DB::findOneBy(LocalGroup::class, ['actor_id' => $id]);
$group_actor = $local_group->getActor();
$actor = Common::actor();
if (!\is_null($group_actor) && $actor->canModerate($group_actor)) {
$group = Actor::getByNickname($nickname, type: Actor::GROUP);
$actor = Common::actor();
if (!\is_null($group) && $actor->canAdmin($group)) {
return [
'_template' => 'group/settings.html.twig',
'group' => $group_actor,
'personal_info_form' => ActorForms::personalInfo(request: $request, scope: $actor, target: $group_actor)->createView(),
'group' => $group,
'personal_info_form' => ActorForms::personalInfo($request, $group)->createView(),
'open_details_query' => $this->string('open'),
];
} else {
throw new ClientException(_m('You do not have permission to edit settings for the group "{group}"', ['{group}' => $id]), code: 404);
throw new ClientException(_m('You do not have permission to edit settings for the group "{group}"', ['{group}' => $nickname]), code: 404);
}
}
/**
* Create a new Group FormInterface getter
*
* @throws RedirectException
* @throws ServerException
*/
public static function getGroupCreateForm(Request $request, E\Actor $actor): FormInterface
{
$create_form = Form::create([
['group_nickname', TextType::class, ['label' => _m('Group nickname')]],
['group_type', ChoiceType::class, ['label' => _m('Type:'), 'multiple' => false, 'expanded' => false, 'choices' => [
_m('Group') => 'group',
_m('Organisation') => 'organisation',
]]],
['group_scope', ChoiceType::class, ['label' => _m('Is this a private group:'), 'multiple' => false, 'expanded' => false, 'choices' => [
_m('No') => 'public',
_m('Yes') => 'private',
]]],
['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 = Nickname::normalize(
nickname: $data['group_nickname'],
check_already_used: true,
which: Nickname::CHECK_LOCAL_GROUP,
check_is_allowed: true,
);
$roles = ActorLocalRoles::VISITOR; // Can send direct messages to other actors
if ($data['group_scope'] === 'private') {
$roles |= ActorLocalRoles::PRIVATE_GROUP;
}
Log::info(
_m(
'Actor id:{actor_id} nick:{actor_nick} created the ' . ($roles & ActorLocalRoles::PRIVATE_GROUP ? 'private' : 'public') . ' 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' => $roles,
]));
DB::persist(LocalGroup::create([
'actor_id' => $group->getId(),
'type' => $data['group_type'],
'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(),
// Group Owner
'roles' => ActorLocalRoles::OPERATOR | ActorLocalRoles::MODERATOR | ActorLocalRoles::PARTICIPANT | ActorLocalRoles::VISITOR,
]));
DB::flush();
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribers']);
Cache::delete(E\Actor::cacheKeys($actor->getId())['subscribed']);
throw new RedirectException();
}
return $create_form;
}
}

View File

@ -1,130 +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 Component\Group\Controller;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity as E;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\ServerException;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController;
use Component\Group\Entity\LocalGroup;
use Component\Subscription\Entity\ActorSubscription;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
class GroupFeed extends FeedController
{
/**
* @throws ServerException
*/
public function groupView(Request $request, Actor $group): array
{
$actor = Common::actor();
$subscribe_form = null;
if (!\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']);
}
}
$notes = DB::dql(<<<'EOF'
SELECT n FROM \App\Entity\Note AS n
WHERE n.id IN (
SELECT act.object_id FROM \App\Entity\Activity AS act
WHERE act.object_type = 'note' AND act.id IN
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id)
)
EOF, ['id' => $group->getId()]);
return [
'_template' => 'group/view.html.twig',
'actor' => $group,
'nickname' => $group->getNickname(),
'notes' => $notes,
'notes_feed_title' => (new Heading(1, [], $group->getNickname() . '\'s feed')),
'subscribe_form' => $subscribe_form?->createView(),
];
}
/**
* @throws ClientException
* @throws ServerException
*/
public function groupViewId(Request $request, int $id): array
{
$group = Actor::getById($id);
if (\is_null($group) || !$group->isGroup()) {
throw new ClientException(_m('No such group.'), 404);
}
if ($group->getIsLocal()) {
return [
'_redirect' => Router::url('group_actor_view_nickname', ['nickname' => $group->getNickname()]),
'actor' => $group,
];
}
return $this->groupView($request, $group);
}
/**
* View a group feed by its nickname
*
* @param string $nickname The group's nickname to be shown
*
* @throws ClientException
* @throws ServerException
*/
public function groupViewNickname(Request $request, string $nickname): array
{
$group = LocalGroup::getActorByNickname($nickname);
if (\is_null($group)) {
throw new ClientException(_m('No such group.'), 404);
}
return $this->groupView($request, $group);
}
}

View File

@ -1,174 +0,0 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/soci
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as publ
// 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 Li
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Component\Group\Entity;
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;
/**
* Entity for local groups
*
* @category DB
* @package GNUsocial
*
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet Inc.
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @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 LocalGroup extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $actor_id;
private string $nickname;
private string $type = 'group';
private DateTimeInterface $created;
private DateTimeInterface $modified;
public function setActorId(int $actor_id): self
{
$this->actor_id = $actor_id;
return $this;
}
public function getActorId(): int
{
return $this->actor_id;
}
public function setNickname(string $nickname): self
{
$this->nickname = mb_substr($nickname, 0, 64);
return $this;
}
public function getNickname(): string
{
return $this->nickname;
}
public function setType(string $type): self
{
$this->type = mb_substr($type, 0, 64);
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): DateTimeInterface
{
return $this->created;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
public function getModified(): DateTimeInterface
{
return $this->modified;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public function getActor()
{
return DB::findOneBy(Actor::class, ['id' => $this->actor_id]);
}
public static function getByNickname(string $nickname): ?self
{
return DB::findOneBy(self::class, ['nickname' => $nickname]);
}
public static function getActorByNickname(string $nickname): ?Actor
{
return DB::findOneBy(Actor::class, ['nickname' => $nickname, 'type' => Actor::GROUP]);
}
/**
* 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 [
'name' => 'local_group',
'description' => 'Record for a user group on the local site, with some additional info not in user_group',
'fields' => [
'actor_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Group.id', 'multiplicity' => 'one to one', 'name' => 'local_group_group_id_fkey', 'not null' => true, 'description' => 'group represented'],
'nickname' => ['type' => 'varchar', 'not null' => true, 'length' => 64, 'description' => 'group represented'],
'type' => ['type' => 'varchar', 'not null' => true, 'default' => 'group', 'length' => 64, 'description' => 'Group or Organisation'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'modified' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['actor_id'],
'unique keys' => [
'local_group_nickname_key' => ['nickname'],
],
];
}
}

View File

@ -26,56 +26,49 @@ 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\Util\Common;
use App\Util\HTML;
use App\Util\Nickname;
use Component\Group\Controller as C;
use Component\Group\Entity\LocalGroup;
use Component\Notification\Notification;
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\GroupFeed::class, 'groupViewId']);
$r->connect(id: 'group_actor_view_nickname', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\GroupFeed::class, 'groupViewNickname']);
$r->connect(id: 'group_create', uri_path: '/group/new', target: [C\Group::class, 'groupCreate']);
$r->connect(id: 'group_actor_settings', uri_path: '/group/{id<\d+>}/settings', target: [C\Group::class, 'groupSettings']);
$r->connect(id: 'group_actor_view_id', uri_path: '/group/{id<\d+>}', target: [C\Group::class, 'groupViewId']);
$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;
}
/**
* Enqueues a notification for an Actor (such as person or group) which means
* it shows up in their home feed and such.
*/
public function onNewNotificationWithTargets(Actor $sender, Activity $activity, array $targets = [], ?string $reason = null): bool
{
foreach ($targets as $target) {
if ($target->isGroup()) {
// The Group announces to its subscribers
Notification::notify($target, $activity, $target->getSubscribers(), $reason);
}
}
return Event::next;
}
/**
* Add an <a href=group_actor_settings> to the profile card for groups, if the current actor can access them
* Add an <a href=group_settings> to the profile card for groups, if the current actor can access them
*/
public function onAppendCardProfile(array $vars, array &$res): bool
{
$actor = Common::actor();
$group = $vars['actor'];
if (!\is_null($actor) && $group->isGroup()) {
if ($actor->canModerate($group)) {
$url = Router::url('group_actor_settings', ['id' => $group->getId()]);
$res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _m('Group settings')]]);
}
$res[] = HTML::html(['a' => ['attrs' => ['href' => Router::url('blog_post', ['in' => $group->getId()]), 'title' => _m('Make a new blog post'), 'class' => 'profile-extra-actions'], _m('Post in blog')]]);
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')]]);
}
return Event::next;
}
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs)
{
if ($section === 'profile' && $request->get('_route') === 'group_settings') {
$nickname = $request->get('nickname');
$group = Actor::getByNickname($nickname, type: Actor::GROUP);
$tabs[] = [
'title' => 'Self tags',
'desc' => 'Add or remove tags on this group',
'id' => 'settings-self-tags',
'controller' => TagController::settingsSelfTags($request, $group, 'settings-self-tags-details'),
];
}
return Event::next;
}
@ -85,46 +78,27 @@ class Group extends Component
*/
private function getGroupFromContext(Request $request): ?Actor
{
if (\is_array($request->get('post_note')) && \array_key_exists('_next', $request->get('post_note'))) {
$next = parse_url($request->get('post_note')['_next']);
$match = Router::match($next['path']);
$route = $match['_route'];
$identifier = $match['id'] ?? $match['nickname'] ?? null;
} else {
$route = $request->get('_route');
$identifier = $request->get('id') ?? $request->get('nickname');
}
if (str_starts_with($route, 'group_actor_view_')) {
switch ($route) {
case 'group_actor_view_nickname':
return LocalGroup::getActorByNickname($identifier);
case 'group_actor_view_id':
return Actor::getById($identifier);
if (str_starts_with($request->get('_route'), 'group_actor_view_')) {
if (!\is_null($id = $request->get('id'))) {
return Actor::getById((int) $id);
} elseif (!\is_null($nickname = $request->get('nickname'))) {
return Actor::getByNickname($nickname, type: Actor::GROUP);
}
}
return null;
}
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): bool
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets)
{
$group = $this->getGroupFromContext($request);
if (!\is_null($group)) {
$nick = "!{$group->getNickname()}";
$targets[$nick] = $group->getId();
$nick = '!' . $group->getNickname();
$targets[$nick] = $nick;
}
return Event::next;
}
/**
* Indicates the context in which Posting's form is to be presented. Passing on $context_actor to Posting's
* onAppendRightPostingBlock event, the Group a given $actor is currently browsing.
*
* Makes it possible to automagically fill in the targets (aka the Group which this $request route is connected to)
* in the Posting's form.
*
* @param null|Actor $context_actor Actor group, if current route is part of an existing Group set of routes
*/
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): bool
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor $context_actor)
{
$ctx = $this->getGroupFromContext($request);
if (!\is_null($ctx)) {

View File

@ -1,10 +0,0 @@
<details class="frame-section section-details-title">
<summary class="details-summary-title">
<strong>
{% trans %}Create a group{% endtrans %}
</strong>
</summary>
<form method="POST" class="section-form">
{{ form(create_form) }}
</form>
</details>

View File

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

View File

@ -1,10 +1,16 @@
{% extends 'base.html.twig' %}
{% import 'cards/macros/settings.html.twig' as macros %}
{% import 'settings/macros.html.twig' as macros %}
{% block stylesheets %}
{{ parent() }}
<link rel="preload" href="{{ asset('assets/default_theme/css/pages/settings.css') }}" as="style" type="text/css">
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/settings.css') }}">
{% endblock stylesheets %}
{% block body %}
<nav class='section-settings'>
<h1>Settings</h1>
<h2>Settings</h2>
<ul>
<li>
{% set profile_tabs = [{'title': 'Personal Info', 'desc': 'Nickname, Homepage, Bio, Self Tags and more.', 'id': 'settings-personal-info', 'form': personal_info_form}] %}

View File

@ -1,16 +1,47 @@
{% extends 'collection/notes.html.twig' %}
{% extends 'stdgrid.html.twig' %}
{% import '/cards/note/view.html.twig' as noteView %}
{% set nickname = nickname|escape %}
{% block title %}{{ nickname }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/css/pages/feeds.css') }}" type="text/css">
{% endblock stylesheets %}
{% block body %}
{% if actor is defined and actor is not null %}
{% block profile_view %}
{% include 'cards/blocks/profile.html.twig' with { 'actor': actor } only %}
{% include 'cards/profile/view.html.twig' with { 'actor': actor } only %}
{% endblock profile_view %}
<hr>
{% if notes is defined %}
{{ parent() }}
{% endif %}
<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 %}
{% else %}
<div id="empty-notes"><h1>{% trans %}No notes here.{% endtrans %}</h1></div>
{% 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>
{% endif %}
{% endblock body %}

View File

@ -28,13 +28,13 @@ use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity\ActorLanguage;
use App\Entity\Language as LangEntity;
use App\Util\Common;
use App\Util\Exception\NoLoggedInUser;
use App\Util\Exception\RedirectException;
use App\Util\Exception\ServerException;
use App\Util\Form\FormFields;
use Component\Language\Entity\ActorLanguage;
use Component\Language\Entity\Language as LangEntity;
use Functional as F;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -134,12 +134,12 @@ class Language extends Controller
// Stay on same page, but force update and prevent resubmission
throw new RedirectException('settings_sort_languages');
} else {
throw new RedirectException('person_actor_settings', ['id' => $user->getId(), 'open' => 'settings-language-details', '_fragment' => 'settings-language-details']);
throw new RedirectException('settings', ['open' => 'account', '_fragment' => 'save_account_info_languages']);
}
}
return [
'_template' => 'language/sort.html.twig',
'_template' => 'settings/sort_languages.html.twig',
'form' => $form->createView(),
];
}

View File

@ -25,11 +25,11 @@ use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Entity\Actor;
use App\Entity\ActorLanguage;
use App\Entity\Note;
use App\Util\Formatting;
use App\Util\Functional as GSF;
use Component\Language\Controller as C;
use Component\Language\Entity\ActorLanguage;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
@ -38,20 +38,20 @@ use Symfony\Component\HttpFoundation\Request;
class Language extends Component
{
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(RouteLoader $r)
{
$r->connect('settings_sort_languages', '/settings/sort_languages', [C\Language::class, 'sortLanguages']);
return Event::next;
}
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request): bool
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request)
{
if (\is_null($actor)) {
return Event::next;
}
$notes = F\select(
$notes,
fn (Note $n) => \is_null($n->getLanguageId()) || \in_array($n->getLanguageId(), ActorLanguage::getActorRelatedLanguagesIds($actor)),
fn (Note $n) => \in_array($n->getLanguageId(), ActorLanguage::getActorRelatedLanguagesIds($actor)),
);
return Event::next;
@ -60,7 +60,7 @@ class Language extends Component
/**
* Populate $note_expr or $actor_expr with an expression to match a language
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
public function onSearchCreateExpression(ExpressionBuilder $eb, string $term, ?string $language, ?Actor $actor, &$note_expr, &$actor_expr)
{
$search_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
@ -90,40 +90,26 @@ class Language extends Component
$note_expr = $temp_note_expr;
$actor_expr = $temp_actor_expr;
return Event::stop;
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['note', 'post'], ['lang', 'language'], [':']], separator: ['-', '_']))) {
} elseif (Formatting::startsWith($term, GSF::cartesianProduct(['-', '_'], ['note', 'post'], ['lang', 'language'], [':']))) {
$note_expr = $temp_note_expr;
return Event::stop;
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['note', 'post'], ['author', 'actor', 'people', 'person'], ['lang', 'language'], [':']], separator: ['-', '_']))) {
} elseif (Formatting::startsWith($term, GSF::cartesianProduct(['-', '_'], ['note', 'post'], ['author', 'actor', 'people', 'person'], ['lang', 'language'], [':']))) {
$note_expr = $temp_note_actor_expr;
return Event::stop;
} elseif (Formatting::startsWith($term, GSF::cartesianProduct([['actor', 'people', 'person'], ['lang', 'language'], [':']], separator: ['-', '_']))) {
} elseif (Formatting::startsWith($term, GSF::cartesianProduct(['-', '_'], ['actor', 'people', 'person'], ['lang', 'language'], [':']))) {
$actor_expr = $temp_actor_expr;
return Event::stop;
}
return Event::next;
}
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
public function onSearchQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
{
$note_aliases = $note_qb->getAllAliases();
if (!\in_array('note_language', $note_aliases)) {
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id');
}
if (!\in_array('actor_language', $note_aliases)) {
$note_qb->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id');
}
if (!\in_array('note_actor_language', $note_aliases)) {
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_actor_language', Expr\Join::WITH, 'note_actor_language.id = actor_language.language_id');
}
$actor_aliases = $note_qb->getAllAliases();
if (!\in_array('actor_language', $actor_aliases)) {
$actor_qb->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'actor.id = actor_language.actor_id');
}
if (!\in_array('language', $actor_aliases)) {
$actor_qb->leftJoin('Component\Language\Entity\Language', 'language', Expr\Join::WITH, 'actor_language.language_id = language.id');
}
$note_qb->leftJoin('App\Entity\Language', 'note_language', Expr\Join::WITH, 'note.language_id = note_language.id')
->leftJoin('App\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'note.actor_id = actor_language.actor_id')
->leftJoin('App\Entity\Language', 'note_actor_language', Expr\Join::WITH, 'note_actor_language.id = actor_language.language_id');
$actor_qb->leftJoin('App\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'actor.id = actor_language.actor_id')
->leftJoin('App\Entity\Language', 'language', Expr\Join::WITH, 'actor_language.language_id = language.id');
return Event::next;
}
}

View File

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

View File

@ -33,7 +33,7 @@ use App\Entity\Feed;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use Component\Collection\Util\Controller\FeedController;
use Component\Feed\Util\FeedController;
use Functional as F;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;

View File

@ -42,12 +42,7 @@ class LeftPanel extends Component
return Event::next;
}
/**
* @throws \App\Util\Exception\DuplicateFoundException
* @throws \App\Util\Exception\ServerException
* @throws ClientException
*/
public function onAppendFeed(Actor $actor, string $title, string $route, array $route_params): bool
public function onAppendFeed(Actor $actor, string $title, string $route, array $route_params)
{
$cache_key = Feed::cacheKey($actor);
$feeds = Feed::getFeeds($actor);
@ -72,4 +67,17 @@ class LeftPanel extends Component
return Event::stop;
}
}
/**
* Output our dedicated stylesheet
*
* @param array $styles stylesheets path
*
* @return bool hook value; true means continue processing, false means stop
*/
public function onEndShowStyles(array &$styles, string $route): bool
{
$styles[] = 'components/Left/assets/css/view.css';
return Event::next;
}
}

View File

@ -2,52 +2,38 @@
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('assets/default_theme/pages/feeds.css') }}" type="text/css">
<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="frame-section">
<div class="section-widget">
<form class="section-form" action="{{ path('edit_feeds') }}" method="post">
<h1 class="frame-section-title">{% trans %}Edit feed navigation links{% endtrans %}</h1>
<fieldset>
<legend class="section-form-legend">{{ "Edit feed navigation links" | trans }}</legend>
{# Since the form is not separated into individual groups, this happened #}
{{ form_start(edit_feeds) }}
{{ 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>
{% 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_end(edit_feeds) }}
</fieldset>
</form>
</div>

View File

@ -1,24 +1,25 @@
{% block leftpanel %}
<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="{% trans %}Press tab followed by a space to access left panel{% endtrans %}"></a>
<input type="checkbox" id="toggle-panel-left" tabindex="0" title="{% trans %}Open left panel{% endtrans %}">
<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">
<aside class="section-panel section-panel-left">
<section class="panel-content accessibility-target">
{% if app.user %}
<section class='frame-section frame-section-padding' title="{% trans %}Your profile information{% endtrans %}">
{% block profile_view %}{% include 'cards/blocks/profile.html.twig' with { actor: current_actor } %}{% endblock profile_view %}
{{ block("profile_current_actor", "cards/blocks/navigation.html.twig") }}
</section>
{% else %}
<section>
{{ block("profile_security", "cards/blocks/navigation.html.twig") }}
</section>
{% endif %}
<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 %}
{{ block("feeds", "cards/blocks/navigation.html.twig") }}
{{ block("feeds", "cards/navigation/view.html.twig") }}
{{ block("footer", "cards/blocks/navigation.html.twig") }}
{{ block("footer", "cards/navigation/view.html.twig") }}
</aside>
</section>
</aside>
{% endblock leftpanel %}

View File

@ -49,9 +49,9 @@ class Link extends Entity
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $id;
private ?string $url = null;
private ?string $url_hash = null;
private ?string $mimetype = null;
private ?string $url;
private ?string $url_hash;
private ?string $mimetype;
private DateTimeInterface $modified;
public function setId(int $id): self
@ -65,20 +65,20 @@ class Link extends Entity
return $this->id;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): self
{
$this->url = $url;
return $this;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrlHash(?string $url_hash): self
{
$this->url_hash = \is_null($url_hash) ? null : mb_substr($url_hash, 0, 64);
$this->url_hash = $url_hash;
return $this;
}
@ -89,7 +89,7 @@ class Link extends Entity
public function setMimetype(?string $mimetype): self
{
$this->mimetype = \is_null($mimetype) ? null : mb_substr($mimetype, 0, 50);
$this->mimetype = $mimetype;
return $this;
}
@ -98,6 +98,18 @@ class Link extends Entity
return $this->mimetype;
}
public function getMimetypeMajor(): ?string
{
$mime = $this->getMimetype();
return \is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
}
public function getMimetypeMinor(): ?string
{
$mime = $this->getMimetype();
return \is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
@ -126,7 +138,7 @@ class Link extends Entity
{
if (Common::isValidHttpUrl($url)) {
// If the URL is a local one, do not create a Link to it
if (parse_url($url, \PHP_URL_HOST) === Common::config('site', 'server')) {
if (parse_url($url, \PHP_URL_HOST) === $_ENV['SOCIAL_DOMAIN']) {
Log::warning("It was attempted to create a Link to a local location {$url}.");
// Forbidden
throw new InvalidArgumentException(message: "A Link can't point to a local location ({$url}), it must be a remote one", code: 400);
@ -160,18 +172,6 @@ class Link extends Entity
}
}
public function getMimetypeMajor(): ?string
{
$mime = $this->getMimetype();
return \is_null($mime) ? $mime : GSFile::mimetypeMajor($mime);
}
public function getMimetypeMinor(): ?string
{
$mime = $this->getMimetype();
return \is_null($mime) ? $mime : GSFile::mimetypeMinor($mime);
}
public static function schemaDef(): array
{
return [

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@ -42,7 +40,7 @@ class NoteToLink extends Entity
// @codeCoverageIgnoreStart
private int $link_id;
private int $note_id;
private DateTimeInterface $modified;
private \DateTimeInterface $modified;
public function setLinkId(int $link_id): self
{
@ -76,7 +74,6 @@ class NoteToLink extends Entity
{
return $this->modified;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
@ -96,6 +93,10 @@ class NoteToLink extends Entity
return parent::create($args, $obj);
}
/**
* @param int $note_id
* @return mixed
*/
public static function removeWhereNoteId(int $note_id): mixed
{
return DB::dql(
@ -107,6 +108,11 @@ class NoteToLink extends Entity
);
}
/**
* @param int $link_id
* @param int $note_id
* @return mixed
*/
public static function removeWhere(int $link_id, int $note_id): mixed
{
return DB::dql(
@ -119,6 +125,10 @@ class NoteToLink extends Entity
);
}
/**
* @param int $link_id
* @return mixed
*/
public static function removeWhereLinkId(int $link_id): mixed
{
return DB::dql(

View File

@ -26,7 +26,6 @@ namespace Component\Link;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common;
use App\Util\HTML;
@ -38,17 +37,13 @@ class Link extends Component
/**
* Extract URLs from $content and create the appropriate Link and NoteToLink entities
*/
public function onProcessNoteContent(Note $note, string $content, string $content_type, array $process_note_content_extra_args = []): bool
public function onProcessNoteContent(Note $note, string $content): bool
{
$ignore = $process_note_content_extra_args['ignoreLinks'] ?? [];
if (Common::config('attachments', 'process_links')) {
$matched_urls = [];
preg_match_all($this->getURLRegex(), $content, $matched_urls);
$matched_urls = array_unique($matched_urls[1]);
foreach ($matched_urls as $match) {
if (\in_array($match, $ignore)) {
continue;
}
try {
$link_id = Entity\Link::getOrCreate($match)->getId();
DB::persist(NoteToLink::create(['link_id' => $link_id, 'note_id' => $note->getId()]));
@ -264,9 +259,9 @@ class Link extends Component
return HTML::html(['a' => ['attrs' => $attrs, $url]], options: ['indent' => false]);
}
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
public function onNoteDeleteRelated(Note &$note): bool
{
DB::wrapInTransaction(fn () => NoteToLink::removeWhereNoteId($note->getId()));
NoteToLink::removeWhereNoteId($note->getId());
return Event::next;
}
}

View File

@ -57,7 +57,7 @@ class Feed extends Controller
)
EOF, ['id' => $user->getId()]);
return [
'_template' => 'collection/notes.html.twig',
'_template' => 'feed/feed.html.twig',
'page_title' => _m('Notifications'),
'should_format' => true,
'notes' => $notes,

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 Component\Notification\Entity;
use App\Core\Entity;
/**
* Entity for note attentions
*
* @category DB
* @package GNUsocial
*
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2022 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Attention extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $note_id;
private int $target_id;
public function setNoteId(int $note_id): self
{
$this->note_id = $note_id;
return $this;
}
public function getNoteId(): int
{
return $this->note_id;
}
public function setTargetId(int $target_id): self
{
$this->target_id = $target_id;
return $this;
}
public function getTargetId(): int
{
return $this->target_id;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public static function schemaDef(): array
{
return [
'name' => 'note_attention',
'description' => 'Note attentions to actors (that are not a mention)',
'fields' => [
'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'note_id to give attention'],
'target_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'actor_id for feed receiver'],
],
'primary key' => ['note_id', 'target_id'],
'indexes' => [
'attention_note_id_idx' => ['note_id'],
'attention_target_id_idx' => ['target_id'],
],
];
}
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@ -47,9 +45,9 @@ class Notification extends Entity
// @codeCoverageIgnoreStart
private int $activity_id;
private int $target_id;
private ?string $reason = null;
private DateTimeInterface $created;
private DateTimeInterface $modified;
private ?string $reason;
private \DateTimeInterface $created;
private \DateTimeInterface $modified;
public function setActivityId(int $activity_id): self
{
@ -75,7 +73,7 @@ class Notification extends Entity
public function setReason(?string $reason): self
{
$this->reason = \is_null($reason) ? null : mb_substr($reason, 0, 191);
$this->reason = $reason;
return $this;
}
@ -109,6 +107,9 @@ class Notification extends Entity
// @codeCoverageIgnoreEnd
// }}} Autocode
/**
* @return Actor
*/
public function getTarget(): Actor
{
return Actor::getById($this->getTargetId());
@ -121,26 +122,21 @@ class Notification extends Entity
*/
public static function getNotificationTargetIdsByActivity(int|Activity $activity_id): array
{
$notifications = DB::findBy(self::class, ['activity_id' => \is_int($activity_id) ? $activity_id : $activity_id->getId()]);
$targets = [];
$notifications = DB::findBy('notification', ['activity_id' => is_int($activity_id) ? $activity_id : $activity_id->getId()]);
$targets = [];
foreach ($notifications as $notification) {
$targets[] = $notification->getTargetId();
}
return $targets;
}
/**
* @param int|Activity $activity_id
* @return array
*/
public function getNotificationTargetsByActivity(int|Activity $activity_id): array
{
return DB::findBy(Actor::class, ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]);
}
public static function getAllActivitiesTargetedAtActor(Actor $actor): array
{
return DB::dql(<<<'EOF'
SELECT act FROM \App\Entity\Activity AS act
WHERE act.object_type = 'note' AND act.id IN
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id)
EOF, ['id' => $actor->getId()]);
return DB::findBy('actor', ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]);
}
public static function schemaDef(): array
@ -158,7 +154,7 @@ class Notification extends Entity
'primary key' => ['activity_id', 'target_id'],
'indexes' => [
'attention_activity_id_idx' => ['activity_id'],
'attention_target_id_idx' => ['target_id'],
'attention_target_id_idx' => ['target_id'],
],
];
}

View File

@ -1,7 +1,5 @@
<?php
declare(strict_types = 1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
@ -40,7 +38,7 @@ class UserNotificationPrefs extends Entity
// @codeCoverageIgnoreStart
private int $user_id;
private string $transport;
private ?int $target_actor_id = null;
private ?int $target_actor_id;
private bool $activity_by_subscribed = true;
private bool $mention = true;
private bool $reply = true;
@ -49,9 +47,9 @@ class UserNotificationPrefs extends Entity
private bool $nudge = false;
private bool $dm = true;
private bool $post_on_status_change = false;
private ?bool $enable_posting = true;
private DateTimeInterface $created;
private DateTimeInterface $modified;
private ?bool $enable_posting;
private \DateTimeInterface $created;
private \DateTimeInterface $modified;
public function setUserId(int $user_id): self
{
@ -66,7 +64,7 @@ class UserNotificationPrefs extends Entity
public function setTransport(string $transport): self
{
$this->transport = mb_substr($transport, 0, 191);
$this->transport = $transport;
return $this;
}

View File

@ -26,17 +26,13 @@ use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Modules\Component;
use App\Core\Queue\Queue;
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\Exception\ServerException;
use Component\FreeNetwork\FreeNetwork;
use Component\Notification\Controller\Feed;
use Exception;
use Throwable;
class Notification extends Component
{
@ -46,14 +42,11 @@ class Notification extends Component
return Event::next;
}
/**
* @throws ServerException
*/
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): bool
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
{
DB::persist(\App\Entity\Feed::create([
'actor_id' => $actor_id,
'url' => Router::url($route = 'feed_notifications'),
'url' => Router::url($route = 'feed_notifications', ['nickname' => $user->getNickname()]),
'route' => $route,
'title' => _m('Notifications'),
'ordering' => $ordering++,
@ -62,89 +55,41 @@ class Notification extends Component
}
/**
* Enqueues a notification for an Actor (such as person or group) which means
* Enqueues a notification for an Actor (user or group) which means
* it shows up in their home feed and such.
*/
public function onNewNotification(Actor $sender, Activity $activity, array $ids_already_known = [], ?string $reason = null): bool
{
$targets = $activity->getNotificationTargets(ids_already_known: $ids_already_known, sender_id: $sender->getId());
if (Event::handle('NewNotificationWithTargets', [$sender, $activity, $targets, $reason]) === Event::next) {
self::notify($sender, $activity, $targets, $reason);
}
$targets = $activity->getNotificationTargets($ids_already_known, sender_id: $sender->getId());
$this->notify($sender, $activity, $targets, $reason);
return Event::next;
}
public function onQueueNotificationLocal(Actor $sender, Activity $activity, Actor $target, ?string $reason, array &$retry_args): bool
{
// TODO: use https://symfony.com/doc/current/notifier.html
return Event::stop;
}
public function onQueueNotificationRemote(Actor $sender, Activity $activity, array $targets, ?string $reason, array &$retry_args): bool
{
if (FreeNetwork::notify($sender, $activity, $targets, $reason)) {
return Event::stop;
} else {
return Event::next;
}
}
/**
* Bring given Activity to Targets's attention
*
* @return true if successful, false otherwise
*/
public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
public function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
{
$remote_targets = [];
foreach ($targets as $target) {
if ($target->getIsLocal()) {
if ($target->hasBlocked($activity->getActor())) {
Log::info("Not saving reply to actor {$target->getId()} from sender {$sender->getId()} because of a block.");
continue;
}
if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
if ($sender->getId() === $target->getId()
|| $activity->getActorId() === $target->getId()) {
// The target already knows about this, no need to bother with a notification
if ($target->isGroup()) {
// FIXME: Make sure we check (for both local and remote) users are in the groups they send to!
} else {
if ($target->hasBlocked($activity->getActor())) {
Log::info("Not saving reply to actor {$target->getId()} from sender {$sender->getId()} because of a block.");
continue;
}
}
Queue::enqueue(
payload: [$sender, $activity, $target, $reason],
queue: 'notification_local',
priority: true,
);
// TODO: use https://symfony.com/doc/current/notifier.html
} else {
// We have no authority nor responsibility of notifying remote actors of a remote actor's doing
if ($sender->getIsLocal()) {
$remote_targets[] = $target;
}
}
// 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
try {
DB::wrapInTransaction(fn () => DB::persist(Entity\Notification::create([
'activity_id' => $activity->getId(),
'target_id' => $target->getId(),
'reason' => $reason,
])));
} catch (Exception|Throwable $e) {
// We do our best not to record duplicated notifications, but it's not insane that can happen
Log::error('It was attempted to record an invalid notification!', [$e]);
$remote_targets[] = $target;
}
}
if ($remote_targets !== []) {
Queue::enqueue(
payload: [$sender, $activity, $remote_targets, $reason],
queue: 'notification_remote',
priority: false,
);
}
FreeNetwork::notify($sender, $activity, $remote_targets, $reason);
return true;
return Event::next;
}
}

View File

@ -1,87 +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 Component\Person\Controller;
use function App\Core\I18n\_m;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity as E;
use App\Entity\LocalUser;
use App\Util\Exception\ClientException;
use App\Util\Exception\ServerException;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController;
use Symfony\Component\HttpFoundation\Request;
class PersonFeed extends FeedController
{
/**
* @throws ClientException
* @throws ServerException
*/
public function personViewId(Request $request, int $id): array
{
$person = Actor::getById($id);
if (\is_null($person) || !$person->isPerson()) {
throw new ClientException(_m('No such person.'), 404);
}
if ($person->getIsLocal()) {
return [
'_redirect' => Router::url('person_actor_view_nickname', ['nickname' => $person->getNickname()]),
'actor' => $person,
];
}
return $this->personView($request, $person);
}
/**
* View a group feed by its nickname
*
* @param string $nickname The group's nickname to be shown
*
* @throws ClientException
* @throws ServerException
*/
public function personViewNickname(Request $request, string $nickname): array
{
$user = LocalUser::getByNickname($nickname);
if (\is_null($user)) {
throw new ClientException(_m('No such person.'), 404);
}
$person = Actor::getById($user->getId());
return $this->personView($request, $person);
}
public function personView(Request $request, Actor $person): array
{
return [
'_template' => 'actor/view.html.twig',
'actor' => $person,
'nickname' => $person->getNickname(),
'notes' => E\Note::getAllNotesByActor($person),
'page_title' => _m('{nickname}\'s profile', ['{nickname}' => $person->getNickname()]),
'notes_feed_title' => (new Heading(level: 2, classes: ['section-title'], text: 'Notes by ' . $person->getNickname())),
];
}
}

View File

@ -1,165 +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 Component\Person\tests\Controller;
use App\Core\DB\DB;
use App\Core\Router\Router;
use App\Entity\LocalUser;
use App\Util\GNUsocialTestCase;
use Jchook\AssertThrows\AssertThrows;
class PersonSettingsTest extends GNUsocialTestCase
{
use AssertThrows;
/**
* @covers \App\Controller\PersonSettings::allSettings
* @covers \App\Controller\PersonSettings::personalInfo
*/
public function testPersonalInfo()
{
$client = static::createClient();
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_personal_info_test_user']);
$client->loginUser($user);
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
$this->assertResponseIsSuccessful();
$crawler = $client->submitForm('Save personal info', [
'save_personal_info[nickname]' => 'form_test_user_new_nickname',
'save_personal_info[full_name]' => 'Form User',
'save_personal_info[homepage]' => 'https://gnu.org',
'save_personal_info[bio]' => 'I was born at a very young age',
'save_personal_info[location]' => 'right here',
// 'save_personal_info[phone_number]' => '+351908555842', // from fakenumber.net
]);
$changed_user = DB::findOneBy(LocalUser::class, ['id' => $user->getId()]);
$actor = $changed_user->getActor();
static::assertSame($changed_user->getNickname(), 'form_test_user_new_nickname');
static::assertSame($actor->getNickname(), 'form_test_user_new_nickname');
static::assertSame($actor->getFullName(), 'Form User');
static::assertSame($actor->getHomepage(), 'https://gnu.org');
static::assertSame($actor->getBio(), 'I was born at a very young age');
static::assertSame($actor->getLocation(), 'right here');
// static::assertSame($changed_user->getPhoneNumber()->getNationalNumber(), '908555842');
}
/**
* @covers \App\Controller\PersonSettings::account
* @covers \App\Controller\PersonSettings::allSettings
*/
public function testEmail()
{
$client = static::createClient();
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
$client->loginUser($user);
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
$this->assertResponseIsSuccessful();
$crawler = $client->submitForm('Save email info', [
'save_email[outgoing_email_sanitized]' => 'outgoing@provider.any',
'save_email[incoming_email_sanitized]' => 'incoming@provider.any',
]);
$changed_user = DB::findOneBy(LocalUser::class, ['id' => $user->getId()]);
static::assertSame($changed_user->getOutgoingEmail(), 'outgoing@provider.any');
static::assertSame($changed_user->getIncomingEmail(), 'incoming@provider.any');
}
/**
* @covers \App\Controller\PersonSettings::account
* @covers \App\Controller\PersonSettings::allSettings
*/
public function testCorrectPassword()
{
$client = static::createClient();
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
$client->loginUser($user);
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
$this->assertResponseIsSuccessful();
$crawler = $client->submitForm('Save new password', [
'save_password[old_password]' => 'foobar',
'save_password[password][first]' => 'this is some test password',
'save_password[password][second]' => 'this is some test password',
]);
$changed_user = DB::findOneBy(LocalUser::class, ['id' => $user->getId()]);
static::assertTrue($changed_user->checkPassword('this is some test password'));
}
/**
* @covers \App\Controller\PersonSettings::account
* @covers \App\Controller\PersonSettings::allSettings
*/
public function testAccountWrongPassword()
{
$client = static::createClient();
$user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
$client->loginUser($user);
$client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
$this->assertResponseIsSuccessful();
$crawler = $client->submitForm('Save new password', [
'save_password[old_password]' => 'some wrong password',
'save_password[password][first]' => 'this is some test password',
'save_password[password][second]' => 'this is some test password',
]);
$this->assertResponseStatusCodeSame(500); // 401 in future
$this->assertSelectorTextContains('.stacktrace', 'AuthenticationException');
}
// TODO: First actually implement this functionality
// /**
// * @covers \App\Controller\PersonSettings::allSettings
// * @covers \App\Controller\PersonSettings::notifications
// */
// public function testNotifications()
// {
// $client = static::createClient();
// $user = DB::findOneBy(LocalUser::class, ['nickname' => 'form_account_test_user']);
// $client->loginUser($user);
//
// $client->request('GET', Router::url('person_actor_settings', ['id' => $user->getId()]));
// $this->assertResponseIsSuccessful();
// $crawler = $client->submitForm('Save notification settings for Email', [
// 'save_email[activity_by_subscribed]' => false,
// 'save_email[mention]' => true,
// 'save_email[reply]' => false,
// 'save_email[subscription]' => true,
// 'save_email[favorite]' => false,
// 'save_email[nudge]' => true,
// 'save_email[dm]' => false,
// 'save_email[enable_posting]' => true,
// ]);
// $settings = DB::findOneBy('user_notification_prefs', ['user_id' => $user->getId(), 'transport' => 'email']);
// static::assertSame($settings->getActivityBySubscribed(), false);
// static::assertSame($settings->getMention(), true);
// static::assertSame($settings->getReply(), false);
// static::assertSame($settings->getSubscription(), true);
// static::assertSame($settings->getFavorite(), false);
// static::assertSame($settings->getNudge(), true);
// static::assertSame($settings->getDm(), false);
// static::assertSame($settings->getEnablePosting(), true);
// }
}

View File

@ -1,69 +0,0 @@
<?php
declare(strict_types = 1);
namespace Component\Posting\Controller;
use App\Core;
use App\Core\Controller;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\VisibilityScope;
use App\Util\Common;
use App\Util\Exception\ClientException;
use Component\Posting\Form;
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
class Posting extends Controller
{
public function onPost(Request $request): RedirectResponse
{
$actor = Common::actor();
$form = Form\Posting::create($request);
$form->handleRequest($request);
if ($form->isSubmitted()) {
try {
if ($form->isValid()) {
$data = $form->getData();
Event::handle('PostingModifyData', [$request, $actor, &$data, $form]);
if (empty($data['content']) && empty($data['attachments'])) {
// TODO Display error: At least one of `content` and `attachments` must be provided
throw new ClientException(_m('You must enter content or provide at least one attachment to post a note.'));
}
if (\is_null(VisibilityScope::tryFrom($data['visibility']))) {
throw new ClientException(_m('You have selected an impossible visibility.'));
}
$extra_args = [];
Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form]);
if (\array_key_exists('in', $data) && $data['in'] !== 'public') {
$target = $data['in'];
}
\Component\Posting\Posting::storeLocalNote(
actor: $actor,
content: $data['content'],
content_type: $data['content_type'],
locale: $data['language'],
scope: VisibilityScope::from($data['visibility']),
targets: isset($target) ? [$target] : [],
reply_to: \array_key_exists('reply_to_id', $data) ? $data['reply_to_id'] : null,
attachments: $data['attachments'],
process_note_content_extra_args: $extra_args,
);
return Core\Form::forceRedirect($form, $request);
}
} catch (FormSizeFileException $e) {
throw new ClientException(_m('Invalid file size given'), previous: $e);
}
}
throw new ClientException(_m('Invalid form submission'));
}
}

View File

@ -1,103 +0,0 @@
<?php
declare(strict_types = 1);
namespace Component\Posting\Form;
use App\Core\ActorLocalRoles;
use App\Core\Event;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Router\Router;
use App\Core\VisibilityScope;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Form\FormFields;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints\Length;
class Posting
{
public static function create(Request $request)
{
$actor = Common::actor();
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
$placeholder = $placeholder_strings[array_rand($placeholder_strings)];
$initial_content = '';
Event::handle('PostingInitialContent', [&$initial_content]);
$available_content_types = [
_m('Plain Text') => 'text/plain',
];
Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
$in_targets = [];
Event::handle('PostingFillTargetChoices', [$request, $actor, &$in_targets]);
$context_actor = null;
Event::handle('PostingGetContextActor', [$request, $actor, &$context_actor]);
$form_params = [];
if (!empty($in_targets)) { // @phpstan-ignore-line
// Add "none" option to the first of choices
$in_targets = array_merge([_m('Public') => 'public'], $in_targets);
// Make the context actor the first In target option
if (!\is_null($context_actor)) {
foreach ($in_targets as $it_nick => $it_id) {
if ($it_id === $context_actor->getId()) {
unset($in_targets[$it_nick]);
$in_targets = array_merge([$it_nick => $it_id], $in_targets);
break;
}
}
}
$form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
}
$visibility_options = [
_m('Public') => VisibilityScope::EVERYWHERE->value,
_m('Local') => VisibilityScope::LOCAL->value,
_m('Addressee') => VisibilityScope::ADDRESSEE->value,
];
if (!\is_null($context_actor) && $context_actor->isGroup()) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
if ($actor->canModerate($context_actor)) {
if ($context_actor->getRoles() & ActorLocalRoles::PRIVATE_GROUP) {
$visibility_options = array_merge([_m('Group') => VisibilityScope::GROUP->value], $visibility_options);
} else {
$visibility_options[_m('Group')] = VisibilityScope::GROUP->value;
}
}
}
$form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'choices' => $visibility_options]];
$form_params[] = ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]];
$form_params[] = ['attachments', FileType::class, ['label' => _m('Attachments:'), 'multiple' => true, 'required' => false, 'invalid_message' => _m('Attachment not valid.')]];
$form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings'));
if (\count($available_content_types) > 1) {
$form_params[] = ['content_type', ChoiceType::class,
[
'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false,
'data' => $available_content_types[array_key_first($available_content_types)],
'choices' => $available_content_types,
],
];
} else {
$form_params[] = ['content_type', HiddenType::class, ['data' => $available_content_types[array_key_first($available_content_types)]]];
}
Event::handle('PostingAddFormEntries', [$request, $actor, &$form_params]);
$form_params[] = ['post_note', SubmitType::class, ['label' => _m('Post')]];
return Form::create($form_params, form_options: ['action' => Router::url(\Component\Posting\Posting::route)]);
}
}

View File

@ -23,112 +23,137 @@ declare(strict_types = 1);
namespace Component\Posting;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\Form;
use App\Core\GSFile;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\VisibilityScope;
use App\Core\Security;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\GroupInbox;
use App\Entity\Language;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\BugFoundException;
use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
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\Attachment;
use Component\Attachment\Entity\AttachmentToNote;
use Component\Conversation\Conversation;
use Component\Language\Entity\Language;
use Component\Notification\Entity\Attention;
use Functional as F;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints\Length;
class Posting extends Component
{
public const route = 'posting_form_action';
public function onAddRoute(RouteLoader $r): bool
{
$r->connect(self::route, '/form/posting', Controller\Posting::class);
return Event::next;
}
/**
* HTML render event handler responsible for adding and handling
* the result of adding the note submission form, only if a user is logged in
*
* @throws BugFoundException
* @throws ClientException
* @throws DuplicateFoundException
* @throws RedirectException
* @throws ServerException
*/
public function onAddMainRightPanelBlock(Request $request, array &$res): bool
public function onAppendRightPostingBlock(Request $request, array &$res): bool
{
if (\is_null($user = Common::user()) || preg_match('(feed|conversation|group|view)', $request->get('_route')) === 0) {
if (\is_null($user = Common::user())) {
return Event::next;
}
$res['post_form'] = Form\Posting::create($request)->createView();
$actor = $user->getActor();
$actor_id = $user->getId();
return Event::next;
}
$placeholder_strings = ['How are you feeling?', 'Have something to share?', 'How was your day?'];
Event::handle('PostingPlaceHolderString', [&$placeholder_strings]);
$placeholder = $placeholder_strings[array_rand($placeholder_strings)];
/**
* @throws ClientException
* @throws DuplicateFoundException
* @throws ServerException
*/
public static function storeLocalPage(
Actor $actor,
?string $content,
string $content_type,
?string $locale = null,
?VisibilityScope $scope = null,
array $targets = [],
null|int|Note $reply_to = null,
array $attachments = [],
array $processed_attachments = [],
array $process_note_content_extra_args = [],
bool $flush_and_notify = true,
?string $rendered = null,
string $source = 'web',
?string $title = null,
): array {
[$activity, $note, $attention_ids] = self::storeLocalNote(
actor: $actor,
content: $content,
content_type: $content_type,
locale: $locale,
scope: $scope,
targets: $targets,
reply_to: $reply_to,
attachments: $attachments,
processed_attachments: $processed_attachments,
process_note_content_extra_args: $process_note_content_extra_args,
flush_and_notify: false,
rendered: $rendered,
source: $source,
);
$note->setType('page');
$note->setTitle($title);
$initial_content = '';
Event::handle('PostingInitialContent', [&$initial_content]);
if ($flush_and_notify) {
// Flush before notification
DB::flush();
Event::handle('NewNotification', [$actor, $activity, ['object' => $attention_ids], _m('{nickname} created a page {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]);
$available_content_types = [
_m('Plain Text') => 'text/plain',
];
Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
$in_targets = [];
Event::handle('PostingFillTargetChoices', [$request, $actor, &$in_targets]);
$context_actor = null;
Event::handle('PostingGetContextActor', [$request, $actor, &$context_actor]);
$form_params = [];
if (!empty($in_targets)) {
$form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]];
}
return [$activity, $note, $attention_ids];
$form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [_m('Public') => 'public', _m('Instance') => 'instance', _m('Private') => 'private']]];
$form_params[] = ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]];
$form_params[] = ['attachments', FileType::class, ['label' => _m('Attachments:'), 'multiple' => true, 'required' => false, 'invalid_message' => _m('Attachment not valid.')]];
$form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language:'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings'));
if (\count($available_content_types) > 1) {
$form_params[] = ['content_type', ChoiceType::class,
[
'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false,
'data' => $available_content_types[array_key_first($available_content_types)],
'choices' => $available_content_types,
],
];
}
Event::handle('PostingAddFormEntries', [$request, $actor, &$form_params]);
$form_params[] = ['post_note', SubmitType::class, ['label' => _m('Post')]];
$form = Form::create($form_params);
$form->handleRequest($request);
if ($form->isSubmitted()) {
try {
if ($form->isValid()) {
$data = $form->getData();
if (empty($data['content']) && empty($data['attachments'])) {
// TODO Display error: At least one of `content` and `attachments` must be provided
throw new ClientException(_m('You must enter content or provide at least one attachment to post a note'));
}
$content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)];
$extra_args = [];
Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]);
self::storeLocalNote(
$user->getActor(),
$data['content'],
$content_type,
$data['language'],
$data['attachments'],
target: $data['in'] ?? null,
process_note_content_extra_args: $extra_args,
);
throw new RedirectException();
}
} catch (FormSizeFileException $sizeFileException) {
throw new FormSizeFileException();
}
}
$res['post_form'] = $form->createView();
return Event::next;
}
/**
@ -136,46 +161,30 @@ class Posting extends Component
* $actor_id, possibly as a reply to note $reply_to and with flag
* $is_local. Sanitizes $content and $attachments
*
* @param Actor $actor The Actor responsible for the creation of this Note
* @param null|string $content The raw text content
* @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...)
* @param null|string $locale Note's written text language, set by the default Actor language or upon filling
* @param null|VisibilityScope $scope The visibility of this Note
* @param array $targets Actor|int[]: In Group/To Person or Bot, registers an attention between note and target
* @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself
* @param array $attachments UploadedFile[] to be stored as GSFiles associated to this note
* @param array $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note
* @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
* @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification
* @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent
* @param string $source The source of this Note
* @param array $attachments Array of UploadedFile to be stored as GSFiles associated to this note
* @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note
* @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
*
* @throws ClientException
* @throws DuplicateFoundException
* @throws ServerException
*
* @return array [Activity, Note, int[]] Activity, Note, Attention Ids
* @return Entity|mixed
*/
public static function storeLocalNote(
Actor $actor,
?string $content,
string $content_type,
?string $locale = null,
?VisibilityScope $scope = null,
array $targets = [],
null|int|Note $reply_to = null,
?string $language = null,
array $attachments = [],
array $processed_attachments = [],
?string $target = null,
array $process_note_content_extra_args = [],
bool $flush_and_notify = true,
?string $rendered = null,
string $source = 'web',
): array {
$scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
$reply_to_id = \is_null($reply_to) ? null : (\is_int($reply_to) ? $reply_to : $reply_to->getId());
$mentions = [];
if (\is_null($rendered) && !empty($content)) {
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
) {
$rendered = null;
$mentions = [];
if (!empty($content)) {
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $language, &$mentions]);
}
$note = Note::create([
@ -183,11 +192,8 @@ class Posting extends Component
'content' => $content,
'content_type' => $content_type,
'rendered' => $rendered,
'language_id' => !\is_null($locale) ? Language::getByLocale($locale)->getId() : null,
'language_id' => !\is_null($language) ? Language::getByLocale($language)->getId() : null,
'is_local' => true,
'scope' => $scope,
'reply_to' => $reply_to_id,
'source' => $source,
]);
/** @var UploadedFile[] $attachments */
@ -203,17 +209,11 @@ class Posting extends Component
}
DB::persist($note);
Conversation::assignLocalConversation($note, $reply_to_id);
// Update replies cache
if (!\is_null($reply_to_id)) {
Cache::incr(Note::cacheKeys($reply_to_id)['replies-count']);
// Not having them cached doesn't mean replies don't exist, but don't push it to the
// list, as that means they need to be refetched, or some would be missed
if (Cache::exists(Note::cacheKeys($reply_to_id)['replies'])) {
Cache::listPushRight(Note::cacheKeys($reply_to_id)['replies'], $note);
}
}
// Assign conversation to this note
// AddExtraArgsToNoteContent already added the info we need
$reply_to = $process_note_content_extra_args['reply_to'];
Conversation::assignLocalConversation($note, $reply_to);
// Need file and note ids for the next step
$note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL));
@ -221,11 +221,8 @@ class Posting extends Component
Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]);
}
// These are note attachments now, and not just attachments, ensure these relations are ensured
if ($processed_attachments !== []) {
foreach ($processed_attachments as [$a, $fname]) {
// Most attachments should already be associated with its author, but maybe it didn't make sense
//for this attachment, or it's simply a repost of an attachment by a different actor
if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {
DB::persist(ActorToAttachment::create($args));
}
@ -238,36 +235,42 @@ class Posting extends Component
'verb' => 'create',
'object_type' => 'note',
'object_id' => $note->getId(),
'source' => $source,
'source' => 'web',
]);
DB::persist($activity);
$attention_ids = [];
foreach ($targets as $target) {
$target_id = \is_int($target) ? $target : $target->getId();
DB::persist(Attention::create(['note_id' => $note->getId(), 'target_id' => $target_id]));
$attention_ids[$target_id] = true;
}
$attention_ids = array_keys($attention_ids);
if ($flush_and_notify) {
// Flush before notification
DB::flush();
Event::handle('NewNotification', [
$actor,
$activity,
[
'note-attention' => $attention_ids,
'object' => F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId()))),
],
_m('{nickname} created a note {note_id}.', [
'{nickname}' => $actor->getNickname(),
'{note_id}' => $activity->getObjectId(),
]),
]);
if (!\is_null($target)) {
switch ($target[0]) {
case '!':
$mentions[] = [
'mentioned' => [Actor::getByNickname(mb_substr($target, 1), type: Actor::GROUP)],
'type' => 'group',
'text' => mb_substr($target, 1),
];
break;
default:
throw new ClientException(_m('Unkown target type give in \'in\' field: ' . $target));
}
}
return [$activity, $note, $attention_ids];
$mentioned = [];
foreach (F\unique(F\flat_map($mentions, fn (array $m) => $m['mentioned'] ?? []), fn (Actor $a) => $a->getId()) as $m) {
if (!\is_null($m)) {
$mentioned[] = $m->getId();
if ($m->isGroup()) {
DB::persist(GroupInbox::create([
'group_id' => $m->getId(),
'activity_id' => $activity->getId(),
]));
}
}
}
DB::flush();
Event::handle('NewNotification', [$actor, $activity, ['object' => $mentioned], "{$actor->getNickname()} created note {$note->getUrl()}"]);
return $note;
}
public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = [])
@ -279,7 +282,7 @@ class Posting extends Component
return Event::stop;
case 'text/html':
// TODO: It has to linkify and stuff as well
$rendered = HTML::sanitize($content);
$rendered = Security::sanitize($content);
return Event::stop;
default:
return Event::next;

View File

@ -19,21 +19,23 @@ declare(strict_types = 1);
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
namespace Component\Person;
namespace Component\RightPanel;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Util\Nickname;
use Component\Person\Controller as C;
class Person extends Component
class RightPanel extends Component
{
public function onAddRoute(RouteLoader $r): bool
/**
* Output our dedicated stylesheet
*
* @param array $styles stylesheets path
*
* @return bool hook value; true means continue processing, false means stop
*/
public function onEndShowStyles(array &$styles, string $route): bool
{
$r->connect(id: 'person_actor_view_id', uri_path: '/person/{id<\d+>}', target: [C\PersonFeed::class, 'personViewId']);
$r->connect(id: 'person_actor_view_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\PersonFeed::class, 'personViewNickname'], options: ['is_system_path' => false]);
$r->connect(id: 'person_actor_settings', uri_path: '/person/{id<\d+>}/settings', target: [C\PersonSettings::class, 'allSettings']);
$styles[] = 'components/Right/assets/css/view.css';
return Event::next;
}
}

View File

@ -1,97 +1,57 @@
{% macro posting(form) %}
<section class="section-form">
{{ form_start(form) }}
{{ form_errors(form) }}
{% if form.in is defined %}
{{ form_row(form.in) }}
{% endif %}
{{ form_row(form.visibility) }}
{{ form_row(form.content_type) }}
{{ form_row(form.content) }}
{{ form_row(form.attachments) }}
<details class="section-details-subtitle frame-section">
<summary class="details-summary-subtitle">
<strong>
{% trans %}Additional options{% endtrans %}
</strong>
</summary>
<section class="section-form">
{{ form_row(form.language) }}
{{ form_row(form.tag_use_canonical) }}
</section>
</details>
{{ form_rest(form) }}
{{ form_end(form) }}
</section>
{% endmacro %}
{% macro posting_section_vanilla(widget) %}
<section class="frame-section" title="{% trans %}Create a new note{% endtrans %}">
<details class="section-details-title" open="open"
title="{% trans %}Expand if you want to access more options{% endtrans %}">
<summary class="details-summary-title">
<span>
{% trans %}Create a note{% endtrans %}
</span>
</summary>
{% import _self as forms %}
{{ forms.posting(widget) }}
</details>
</section>
{% endmacro %}
{% macro posting_section_reply(widget, extra) %}
<section class="frame-section" title="{% trans %}Create a new note{% endtrans %}">
<details class="section-details-title" open="open"
title="{% trans %}Expand if you want to access more options{% endtrans %}">
<summary class="details-summary-title">
<span>
{% trans %}Reply to note{% endtrans %}
</span>
</summary>
{% for block in extra %}
<section class="posting-extra">
{{ block | raw }}
</section>
{% endfor %}
{% import _self as forms %}
{{ forms.posting(widget) }}
</details>
</section>
{% endmacro %}
{% block rightpanel %}
{% import _self as this %}
<label class="panel-right-icon" for="toggle-panel-right"
<label class="panel-right-icon" for="panel-right-toggle" aria-hidden="true"
tabindex="-1">{{ icon('chevron-left', 'icon icon-right') | raw }}</label>
<a id="anchor-right-panel" class="anchor-hidden" tabindex="0"
title="{% trans %}Press tab followed by a space to access right panel{% endtrans %}"></a>
<input type="checkbox" id="toggle-panel-right" tabindex="0" title="{% trans %}Open right panel{% endtrans %}"
{% if app.request.get('_route') == 'conversation_reply_to' %}checked{% endif %}>
<input type="checkbox" id="panel-right-toggle" aria-hidden="true" tabindex="-1">
<aside class="section-panel section-panel-right">
{% set var_list = {'path': app.request.get('_route'), 'request': app.request, 'vars': right_panel_vars | default } %}
{% set blocks = add_right_panel_block('prepend', var_list) %}
{% set blocks = blocks|merge(add_right_panel_block('main', var_list)) %}
{% set blocks = blocks|merge(add_right_panel_block('append', var_list)) %}
<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">
<section class="panel-content accessibility-target">
{% for widget in blocks %}
{% if widget is iterable and widget.vars.id == 'post_note' %}
{% if app.request.get('_route') == 'conversation_reply_to' %}
{% set extra = handle_event('PrependPostingForm', request) %}
{{ this.posting_section_reply(widget, extra) }}
{% else %}
{{ this.posting_section_vanilla(widget) }}
{% endif %}
{% else %}
{{ widget | raw }}
{% endif %}
{% 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>
{{ "Create a note" | trans }}
</h2>
{{ icon('arrow-down', 'icon icon-details-open') | raw }}
</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) }}
<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 %}
{% set current_path = app.request.get('_route') %}
{% for block in handle_event('AppendRightPanelBlock', {'path': current_path, 'vars': right_panel_vars | default }, request) %}
{{ block | raw }}
{% endfor %}
</section>
</aside>
</aside>
</div>
{% endblock rightpanel %}

View File

@ -30,8 +30,8 @@ use App\Util\Exception\BugFoundException;
use App\Util\Exception\RedirectException;
use App\Util\Form\FormFields;
use App\Util\Formatting;
use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController;
use Component\Feed\Feed;
use Component\Feed\Util\FeedController;
use Component\Search as Comp;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -49,7 +49,7 @@ class Search extends FeedController
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;
$q = $this->string('q');
$data = $this->query(query: $q, locale: $language);
$data = Feed::query(query: $q, page: $this->int('p'), language: $language);
$notes = $data['notes'];
$actors = $data['actors'];
@ -131,13 +131,11 @@ class Search extends FeedController
}
return [
'_template' => 'search/view.html.twig',
'_template' => 'search/show.html.twig',
'actor' => $actor,
'search_form' => Comp\Search::searchForm($request, query: $q, add_subscribe: !\is_null($actor)),
'search_builder_form' => $search_builder_form->createView(),
'notes' => $notes ?? [],
'notes_feed_title' => (new Heading(level: 3, classes: ['section-title'], text: 'Notes found')),
'actors_feed_title' => (new Heading(level: 3, classes: ['section-title'], text: 'Actors found')),
'actors' => $actors ?? [],
'page' => 1, // TODO paginate
];

View File

@ -28,9 +28,7 @@ use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Util\Common;
use App\Util\Exception\ClientException;
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;
@ -64,22 +62,13 @@ class Search extends Component
if ($add_subscribe) {
$form_definition[] = [
'title', TextType::class,
[
'label' => _m('Subscribe to search query'),
'help' => _m('By subscribing to a search query, a new feed link will be added to left panel\'s feed navigation menu'),
'required' => false,
'attr' => [
'title' => _m('Title for this new feed in your left panel'),
'placeholder' => _m('Input desired title...'),
],
],
'title', TextType::class, ['label' => _m('Title'), 'required' => false, 'attr' => ['title' => _m('Title for this new feed in your left panel')]],
];
$form_definition[] = [
'subscribe_to_search',
SubmitType::class,
[
'label' => _m('Subscribe'),
'label' => _m('Subscribe to this search'),
'attr' => [
'title' => _m('Add this search as a feed in your feeds section of the left panel'),
],
@ -94,7 +83,7 @@ class Search extends Component
'label' => _m('Search'),
'attr' => [
//'class' => 'button-container search-button-container',
'title' => _m('Perform search'),
'title' => _m('Query notes for specific tags.'),
],
],
];
@ -110,11 +99,8 @@ class Search extends Component
/** @var SubmitButton $subscribe */
$subscribe = $form->get('subscribe_to_search');
if ($subscribe->isClicked()) {
if (!\is_null($data['title'])) {
Event::handle('AppendFeed', [$actor, $data['title'], 'search', ['q' => $data['search_query']]]);
} else {
throw new ClientException(_m('Empty title is not allowed.'));
}
// TODO ensure title is set
Event::handle('AppendFeed', [$actor, $data['title'], 'search', ['q' => $data['search_query']]]);
$redirect = true;
}
}
@ -133,9 +119,9 @@ class Search extends Component
*
* @throws RedirectException
*/
public function onPrependRightPanelBlock(Request $request, array &$elements): bool
public function onAddExtraHeaderForms(Request $request, array &$elements)
{
$elements[] = Formatting::twigRenderFile('cards/search/view.html.twig', ['search' => self::searchForm($request)]);
$elements[] = self::searchForm($request);
return Event::next;
}

View File

@ -21,7 +21,7 @@ declare(strict_types = 1);
// }}}
namespace Component\Collection\Util;
namespace Component\Search\Util;
use App\Core\Event;
use App\Entity\Actor;
@ -50,11 +50,10 @@ abstract class Parser
* recognises either spaces (currently `or`, should be fuzzy match), `OR` or `|` (`or`) and `AND` or `&` (`and`)
*
* TODO: Better fuzzy match, implement exact match with quotes and nesting with parens
* TODO: Proper parser, tokenize better. Mostly a rewrite
*
* @return array{?Criteria, ?Criteria} [?$note_criteria, ?$actor_criteria]
* @return Criteria[]
*/
public static function parse(string $input, ?string $locale = null, ?Actor $actor = null, int $level = 0): array
public static function parse(string $input, ?string $language = null, ?Actor $actor = null, int $level = 0): array
{
if ($level === 0) {
$input = trim(preg_replace(['/\s+/', '/\s+AND\s+/', '/\s+OR\s+/'], [' ', '&', '|'], $input), ' |&');
@ -79,18 +78,17 @@ abstract class Parser
$term = mb_substr($input, $left, $end ? null : $right - $left);
$note_res = null;
$actor_res = null;
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}");
// It's okay if the term doesn't exist, just perform a regular search
Event::handle('SearchCreateExpression', [$eb, $term, $language, $actor, &$note_res, &$actor_res]);
if (\is_null($note_res) && \is_null($actor_res)) {
throw new ServerException("No one claimed responsibility for a match term: {$term}");
}
if (!empty($note_res)) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
if (!\is_null($note_res) && !empty($note_res)) {
if (\is_array($note_res)) {
$note_res = $eb->orX(...$note_res);
}
$note_parts[] = $note_res;
}
if (!empty($actor_res)) { // @phpstan-ignore-line currently an open bug. See https://web.archive.org/web/20220226131651/https://github.com/phpstan/phpstan/issues/6234
if (!\is_null($actor_res) && !empty($note_res)) {
if (\is_array($actor_res)) {
$actor_res = $eb->orX(...$actor_res);
}
@ -105,10 +103,9 @@ abstract class Parser
$last_op = $delimiter;
}
$match = true;
break;
continue 2;
}
}
// TODO
if (!$match) {
++$right;
}
@ -120,7 +117,7 @@ abstract class Parser
self::connectParts($note_parts, $note_criteria_arr, $last_op, $eb, force: true);
$note_criteria = new Criteria($eb->orX(...$note_criteria_arr));
}
if (!empty($actor_parts)) { // @phpstan-ignore-line weird, but this whole thing needs a rewrite
if (!empty($actor_parts)) {
self::connectParts($actor_parts, $actor_criteria_arr, $last_op, $eb, force: true);
$actor_criteria = new Criteria($eb->orX(...$actor_criteria_arr));
}

View File

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

View File

@ -0,0 +1,91 @@
{% extends 'feed/feed.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

@ -1,130 +0,0 @@
{% extends 'collection/notes.html.twig' %}
{% block search_query_simple %}
<section>
<h1 class="section-title">{% trans %}Search{% endtrans %}</h1>
{{ form_start(search_form) }}
{{ form_errors(search_form) }}
{{ form_row(search_form.search_query) }}
{% if actor is not null %}
<details class="section-details-subtitle frame-section">
<summary class="details-summary-subtitle">
<strong>
{% trans %}Extra options{% endtrans %}
</strong>
</summary>
<div class="section-form">
{{ form_row(search_form.title) }}
{{ form_row(search_form.subscribe_to_search) }}
</div>
</details>
{% endif %}
{{ form_row(search_form.submit_search) }}
{{ form_rest(search_form) }}
{{ form_end(search_form)}}
</section>
{% endblock search_query_simple %}
{% block search_query_advanced %}
{{ form_start(search_builder_form) }}
<details class="section-details section-details-title frame-section">
<summary class="details-summary-title">
<span>{% trans %}Advanced search{% endtrans %}</span>
</summary>
<section class="frame-section-padding">
<details class="section-details-subtitle frame-section">
<summary class="details-summary-subtitle">
<strong>{% trans %}People search options{% endtrans %}</strong>
</summary>
<div class="section-form">
<div class="section-checkbox-flex">
{{ 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) }}
</div>
<hr>
{{ form_row(search_builder_form.actor_tags) }}
<hr>
{{ form_row(search_builder_form.actor_langs) }}
</div>
</details>
<details class="section-details-subtitle frame-section">
<summary class="details-summary-subtitle">
<strong>{% trans %}Note search options{% endtrans %}</strong>
</summary>
<div class="section-form">
<div class="section-checkbox-flex">
{{ 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) }}
</div>
<hr>
{{ form_row(search_builder_form.note_tags) }}
<hr>
{{ form_row(search_builder_form.note_langs) }}
<hr>
{{ form_row(search_builder_form.note_actor_tags) }}
<hr>
{{ form_row(search_builder_form.note_actor_langs) }}
</div>
</details>
{{ form_rest(search_builder_form) }}
</section>
</details>
{{ form_end(search_builder_form) }}
{% endblock search_query_advanced %}
{% block search %}
<section class="frame-section frame-section-padding">
{% if error is defined %}
<label class="alert alert-danger">
{{ error.getMessage() }}
</label>
{% endif %}
{{ block('search_query_simple') }}
<hr>
{{ block('search_query_advanced') }}
</section>
{% endblock search %}
{% block body %}
{{ block('search') }}
<div class="frame-section frame-section-padding">
<h1 class="section-title">{% trans %}Results{% endtrans %}</h1>
<section>
{% 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 %}
</section>
<hr>
<section>
<h3>{% trans %}Actors found{% endtrans %}</h3>
{% if actors is defined and actors is not empty %}
{% for actor in actors %}
{% include 'cards/blocks/profile.html.twig' with {'actor': actor} %}
{% endfor %}
{% else %}
<em>{% trans %}No Actors were found for the specified query...{% endtrans %}</em>
{% endif %}
</section>
<div class="frame-section-button-like">
{{ "Page: " ~ page }}
</div>
</div>
{% endblock body %}

View File

@ -1,169 +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 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 App\Util\Exception\ServerException;
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;
/**
* Collection of an actor's subscribers
*/
class Subscribers extends CircleController
{
/**
* @throws ServerException
*/
public function subscribersByActor(Request $request, Actor $actor): array
{
return [
'_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 ClientException
* @throws ServerException
*/
public function subscribersByActorId(Request $request, int $id): array
{
$actor = Actor::getById($id);
if (\is_null($actor)) {
throw new ClientException(_m('No such actor.'), 404);
}
return $this->subscribersByActor($request, $actor);
}
/**
* @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

@ -1,155 +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 Component\Subscription\Entity;
use App\Core\Entity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use Component\Group\Entity\LocalGroup;
use DateTimeInterface;
/**
* Entity for subscription
*
* @category DB
* @package GNUsocial
*
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet Inc.
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @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 ActorSubscription extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $subscriber_id;
private int $subscribed_id;
private DateTimeInterface $created;
private DateTimeInterface $modified;
public function setSubscriberId(int $subscriber_id): self
{
$this->subscriber_id = $subscriber_id;
return $this;
}
public function getSubscriberId(): int
{
return $this->subscriber_id;
}
public function setSubscribedId(int $subscribed_id): self
{
$this->subscribed_id = $subscribed_id;
return $this;
}
public function getSubscribedId(): int
{
return $this->subscribed_id;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): DateTimeInterface
{
return $this->created;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
public function getModified(): DateTimeInterface
{
return $this->modified;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public function getSubscriber(): Actor
{
return Actor::getById($this->getSubscriberId());
}
public function getSubscribed(): Actor
{
return Actor::getById($this->getSubscribedId());
}
public static function cacheKeys(LocalUser|LocalGroup|Actor $subject, LocalUser|LocalGroup|Actor $target): array
{
return [
'subscribed' => "subscription-{$subject->getId()}-{$target->getId()}",
];
}
/**
* @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' => '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'],
'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' => ['subscriber_id', 'subscribed_id'],
'indexes' => [
'subscription_subscriber_idx' => ['subscriber_id', 'created'],
'subscription_subscribed_idx' => ['subscribed_id', 'created'],
],
];
}
}

View File

@ -1,243 +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 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 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_subscribers_id', uri_path: '/actor/{id<\d+>}/subscribers', target: [SubscribersController::class, 'subscribersByActorId']);
return Event::next;
}
/**
* 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 $subject, int|Actor|LocalUser $object, string $source = 'web'): ?Activity
{
$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: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
$activity = null;
if (\is_null($subscription)) {
DB::persist(Entity\ActorSubscription::create($opts));
$activity = Activity::create([
'actor_id' => $subscriber_id,
'verb' => 'subscribe',
'object_type' => 'actor',
'object_id' => $subscribed_id,
'source' => $source,
]);
DB::persist($activity);
Event::handle('NewNotification', [
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity,
['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 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 $subject, int|Actor|LocalUser $object, string $source = 'web'): ?Activity
{
$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: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
$activity = null;
if (!\is_null($subscription)) {
// Remove Subscription
DB::remove($subscription);
$previous_follow_activity = DB::findBy('activity', ['verb' => 'subscribe', 'object_type' => 'actor', 'object_id' => $subscribed_id], order_by: ['created' => 'DESC'])[0];
// Store Activity
$activity = Activity::create([
'actor_id' => $subscriber_id,
'verb' => 'undo',
'object_type' => 'activity',
'object_id' => $previous_follow_activity->getId(),
'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;
}
}

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