Porównaj commity

...

11 Commity

Autor SHA1 Wiadomość Data
Carl Schwan ee1b2945bb
More stuff to commit
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
2022-09-12 13:43:13 +02:00
Carl Schwan a2fca565a1
More stuff
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
2022-09-12 13:42:43 +02:00
Carl Schwan 9f49b14657
Add media api
See https://docs.joinmastodon.org/methods/statuses/media/

Signed-off-by: Carl Schwan <carl@carlschwan.eu>
2022-08-09 15:38:26 +02:00
Carl Schwan e821566eca
fixup! More stuff around timeline handling and following new account 2022-08-09 12:48:27 +02:00
Carl Schwan d6aa923b8d
fixup! More stuff around timeline handling and following new account 2022-08-09 12:23:47 +02:00
Carl Schwan f21a5a70d8
More stuff around timeline handling and following new account
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
2022-08-09 12:23:47 +02:00
Carl Schwan b023f93deb
More testing now mention put post in the timeline
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
2022-08-09 12:23:47 +02:00
Carl Schwan b00831783d
More tests and fixes
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
2022-08-09 12:23:47 +02:00
Carl Schwan d53fc73448
More work
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
2022-08-09 12:23:47 +02:00
Carl Schwan 95f3fc3a5a
fixup! Rewrite DB with Doctrine 2022-08-09 12:23:47 +02:00
Carl Schwan ff816897dd
Rewrite DB with Doctrine
Signed-off-by: Carl Schwan <carl@carlschwan.eu>
2022-08-09 12:23:43 +02:00
79 zmienionych plików z 7606 dodań i 2087 usunięć

2
.gitignore vendored
Wyświetl plik

@ -8,3 +8,5 @@ cypress/screenshots
cypress/snapshots
.php-cs-fixer.cache
tests/.phpunit.result.cache

Wyświetl plik

@ -77,4 +77,9 @@
<contactsmenu>
<provider>OCA\Social\Providers\ContactsMenuProvider</provider>
</contactsmenu>
<settings>
<personal>OCA\Social\Settings\Personal</personal>
<personal-section>OCA\Social\Settings\PersonalSection</personal-section>
</settings>
</info>

Wyświetl plik

@ -67,8 +67,8 @@ return [
['name' => 'OStatus#getLink', 'url' => '/api/v1/ostatus/link/{local}/{account}', 'verb' => 'GET'],
// OAuth
['name' => 'OAuth#nodeinfo', 'url' => '/.well-known/nodeinfo', 'verb' => 'GET'],
['name' => 'OAuth#nodeinfo2', 'url' => '/.well-known/nodeinfo/2.0', 'verb' => 'GET'],
['name' => 'OAuth#index', 'url' => '/.well-known/nodeinfo', 'verb' => 'GET'],
['name' => 'OAuth#show', 'url' => '/.well-known/nodeinfo/2.{version}{extension}', 'verb' => 'GET'],
['name' => 'OAuth#apps', 'url' => '/api/v1/apps', 'verb' => 'POST'],
['name' => 'OAuth#authorize', 'url' => '/oauth/authorize', 'verb' => 'GET'],
['name' => 'OAuth#token', 'url' => '/oauth/token', 'verb' => 'POST'],
@ -82,6 +82,16 @@ return [
['name' => 'Api#timelines', 'url' => '/api/v1/timelines/{timeline}/', 'verb' => 'GET'],
['name' => 'Api#notifications', 'url' => '/api/v1/notifications', 'verb' => 'GET'],
['name' => 'MediaApi#uploadMedia', 'url' => '/api/v1/media', 'verb' => 'POST'],
['name' => 'MediaApi#updateMedia', 'url' => '/api/v1/media/{id}', 'verb' => 'PUT'],
['name' => 'MediaApi#deleteMedia', 'url' => '/api/v1/media/{id}', 'verb' => 'DELETE'],
['name' => 'StatusApi#publishStatus', 'url' => '/api/v1/statuses', 'verb' => 'POST'],
['name' => 'StatusApi#getStatus', 'url' => '/api/v1/statuses/{id}', 'verb' => 'GET'],
['name' => 'StatusApi#deleteStatus', 'url' => '/api/v1/statuses/{id}', 'verb' => 'DELETE'],
['name' => 'StatusApi#contextStatus', 'url' => '/api/v1/statuses/{id}/context', 'verb' => 'GET'],
['name' => 'StatusApi#reblogedBy', 'url' => '/api/v1/statuses/{id}/reblogged_by', 'verb' => 'GET'],
// Api for local front-end
// TODO: front-end should be using the new ApiController
['name' => 'Local#streamHome', 'url' => '/api/v1/stream/home', 'verb' => 'GET'],

Wyświetl plik

@ -24,7 +24,8 @@
},
"require": {
"gumlet/php-image-resize": "2.0.*",
"friendica/json-ld": "^1.0"
"friendica/json-ld": "^1.0",
"landrok/activitypub": "^0.5.8"
},
"require-dev": {
"phpunit/phpunit": "^9.5",

2314
composer.lock wygenerowano

Plik diff jest za duży Load Diff

Wyświetl plik

@ -31,22 +31,19 @@ declare(strict_types=1);
namespace OCA\Social\AppInfo;
use Closure;
use OCA\Social\Entity\Account;
use OCA\Social\Notification\Notifier;
use OCA\Social\Search\UnifiedSearchProvider;
use OCA\Social\Service\ConfigService;
use OCA\Social\Service\UpdateService;
use OCA\Social\Serializer\AccountSerializer;
use OCA\Social\Serializer\SerializerFactory;
use OCA\Social\Service\Feed\RedisFeedProvider;
use OCA\Social\Service\Feed\IFeedProvider;
use OCA\Social\WellKnown\WebfingerHandler;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\QueryException;
use OCP\IDBConnection;
use OCP\IServerContainer;
use OC\DB\SchemaWrapper;
use OCP\DB\ISchemaWrapper;
use Throwable;
use Psr\Container\ContainerInterface;
require_once __DIR__ . '/../../vendor/autoload.php';
@ -69,6 +66,15 @@ class Application extends App implements IBootstrap {
public function register(IRegistrationContext $context): void {
$context->registerSearchProvider(UnifiedSearchProvider::class);
$context->registerWellKnownHandler(WebfingerHandler::class);
$context->registerNotifierService(Notifier::class);
/** @var SerializerFactory $serializerFactory */
$serializerFactory = $this->getContainer()->get(SerializerFactory::class);
$serializerFactory->registerSerializer(Account::class, AccountSerializer::class);
$context->registerService(IFeedProvider::class, function (ContainerInterface $container): IFeedProvider {
return $container->get(RedisFeedProvider::class);
});
}
@ -76,43 +82,5 @@ class Application extends App implements IBootstrap {
* @param IBootContext $context
*/
public function boot(IBootContext $context): void {
$manager = $context->getServerContainer()
->getNotificationManager();
$manager->registerNotifierService(Notifier::class);
try {
$context->injectFn(Closure::fromCallable([$this, 'checkUpgradeStatus']));
} catch (Throwable $e) {
}
}
/**
* Register Navigation Tab
*
* @param IServerContainer $container
*/
protected function checkUpgradeStatus(IServerContainer $container) {
$upgradeChecked = $container->getConfig()
->getAppValue(Application::APP_NAME, 'update_checked', '');
if ($upgradeChecked === '0.3') {
return;
}
try {
$configService = $container->query(ConfigService::class);
$updateService = $container->query(UpdateService::class);
} catch (QueryException $e) {
return;
}
/** @var ISchemaWrapper $schema */
$schema = new SchemaWrapper($container->get(IDBConnection::class));
if ($schema->hasTable('social_a2_stream')) {
$updateService->checkUpdateStatus();
}
$configService->setAppValue('update_checked', '0.3');
}
}

Wyświetl plik

@ -33,9 +33,11 @@ namespace OCA\Social\Command;
use Exception;
use OC\Core\Command\Base;
use OCA\Social\Service\AccountFinder;
use OCA\Social\Service\AccountService;
use OCA\Social\Service\CacheActorService;
use OCA\Social\Service\ConfigService;
use OCA\Social\Service\FollowOption;
use OCA\Social\Service\FollowService;
use OCA\Social\Service\MiscService;
use Symfony\Component\Console\Input\InputArgument;
@ -44,23 +46,18 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class AccountFollowing extends Base {
private AccountService $accountService;
private AccountFinder $accountFinder;
private CacheActorService $cacheActorService;
private FollowService $followService;
private ConfigService $configService;
private MiscService $miscService;
public function __construct(
AccountService $accountService, CacheActorService $cacheActorService,
FollowService $followService, ConfigService $configService, MiscService $miscService
AccountFinder $accountFinder, FollowService $followService, ConfigService $configService
) {
parent::__construct();
$this->accountService = $accountService;
$this->cacheActorService = $cacheActorService;
$this->accountFinder = $accountFinder;
$this->followService = $followService;
$this->configService = $configService;
$this->miscService = $miscService;
}
protected function configure() {
@ -80,16 +77,16 @@ class AccountFollowing extends Base {
$userId = $input->getArgument('userId');
$account = $input->getArgument('account');
$actor = $this->accountService->getActor($userId);
$sourceAccount = $this->accountFinder->getAccountByNextcloudId($userId);
if ($input->getOption('local')) {
$local = $this->cacheActorService->getFromLocalAccount($account);
$account = $local->getAccount();
$targetAccount = $this->accountFinder->getAccountByNextcloudId($account);
}
if ($input->getOption('unfollow')) {
$this->followService->unfollowAccount($actor, $account);
$this->followService->unfollow($sourceAccount, $targetAccount, FollowOption::default());
} else {
$this->followService->followAccount($actor, $account);
$this->followService->follow($sourceAccount, $targetAccount, FollowOption::default());
}
}
}

Wyświetl plik

@ -30,6 +30,8 @@ declare(strict_types=1);
namespace OCA\Social\Controller;
use OCA\Social\Entity\Account;
use OCA\Social\Serializer\SerializerFactory;
use OCA\Social\Tools\Traits\TNCLogger;
use OCA\Social\Tools\Traits\TNCDataResponse;
use OCA\Social\Tools\Traits\TAsync;
@ -57,6 +59,8 @@ use OCA\Social\Service\StreamService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\Response;
use OCP\DB\ORM\IEntityManager;
use OCP\DB\ORM\IEntityRepository;
use OCP\IRequest;
class ActivityPubController extends Controller {
@ -76,13 +80,14 @@ class ActivityPubController extends Controller {
private StreamService $streamService;
private ConfigService $configService;
private MiscService $miscService;
private IEntityManager $entityManager;
public function __construct(
IRequest $request, SocialPubController $socialPubController, FediverseService $fediverseService,
CacheActorService $cacheActorService, SignatureService $signatureService,
StreamQueueService $streamQueueService, ImportService $importService, AccountService $accountService,
FollowService $followService, StreamService $streamService, ConfigService $configService,
MiscService $miscService
MiscService $miscService, IEntityManager $entityManager, SerializerFactory $serializerFactory
) {
parent::__construct(Application::APP_NAME, $request);
@ -97,11 +102,12 @@ class ActivityPubController extends Controller {
$this->streamService = $streamService;
$this->configService = $configService;
$this->miscService = $miscService;
$this->entityManager = $entityManager;
}
/**
* returns information about an Actor, based on the username.
* Returns the actor information
*
* This method should be called when a remote ActivityPub server require information
* about a local Social account
@ -111,9 +117,6 @@ class ActivityPubController extends Controller {
* @NoCSRFRequired
* @PublicPage
*
* @param string $username
*
* @return Response
* @throws UrlCloudException
* @throws SocialAppConfigException
*/
@ -122,15 +125,17 @@ class ActivityPubController extends Controller {
return $this->socialPubController->actor($username);
}
try {
$actor = $this->cacheActorService->getFromLocalAccount($username);
$actor->setDisplayW3ContextSecurity(true);
return $this->directSuccess($actor);
} catch (Exception $e) {
/** @var IEntityRepository<Account> $accountRepository */
$accountRepository = $this->entityManager->getRepository(Account::class);
$account = $accountRepository->findOneBy([
'userName' => $username,
]);
if ($account === null || !$account->isLocal()) {
http_response_code(404);
exit();
}
return $account->toJsonLd($this->request);
}

Wyświetl plik

@ -43,7 +43,7 @@ use OCA\Social\Model\Client\Options\TimelineOptions;
use OCA\Social\Model\Client\SocialClient;
use OCA\Social\Service\AccountService;
use OCA\Social\Service\CacheActorService;
use OCA\Social\Service\ClientService;
use OCA\Social\Service\ApplicationService;
use OCA\Social\Service\ConfigService;
use OCA\Social\Service\FollowService;
use OCA\Social\Service\InstanceService;
@ -65,7 +65,7 @@ class ApiController extends Controller {
private IUserSession $userSession;
private InstanceService $instanceService;
private ClientService $clientService;
private ApplicationService $clientService;
private AccountService $accountService;
private CacheActorService $cacheActorService;
private FollowService $followService;
@ -77,10 +77,10 @@ class ApiController extends Controller {
private ?Person $viewer = null;
public function __construct(
IRequest $request, IUserSession $userSession, InstanceService $instanceService,
ClientService $clientService, AccountService $accountService, CacheActorService $cacheActorService,
FollowService $followService, StreamService $streamService, ConfigService $configService,
MiscService $miscService
IRequest $request, IUserSession $userSession, InstanceService $instanceService,
ApplicationService $clientService, AccountService $accountService, CacheActorService $cacheActorService,
FollowService $followService, StreamService $streamService, ConfigService $configService,
MiscService $miscService
) {
parent::__construct(Application::APP_NAME, $request);

Wyświetl plik

@ -7,11 +7,37 @@ declare(strict_types=1);
namespace OCA\Social\Controller;
use OCP\AppFramework\Controller;
use OCA\Social\Entity\MediaAttachment;
use OCA\Social\Service\AccountFinder;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\DB\ORM\IEntityManager;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\IL10N;
use OCP\AppFramework\Controller;
use OCP\Files\IMimeTypeDetector;
use OCP\Image;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Util;
use Psr\Log\LoggerInterface;
class MediaApiController extends Controller {
private IL10N $l10n;
private IMimeTypeDetector $mimeTypeDetector;
private IAppData $appData;
private IUserSession $userSession;
private AccountFinder $accountFinder;
private IEntityManager $entityManager;
private IURLGenerator $generator;
private LoggerInterface $logger;
public const IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
@ -24,22 +50,208 @@ class MediaApiController extends Controller {
'image/webp',
];
private IMimeTypeDetector $mimeTypeDetector;
public function __construct(
string $appName,
IRequest $request,
IL10N $l10n,
IMimeTypeDetector $mimeTypeDetector,
IAppData $appData,
IUserSession $userSession,
AccountFinder $accountFinder,
IEntityManager $entityManager,
IURLGenerator $generator,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->l10n = $l10n;
$this->mimeTypeDetector = $mimeTypeDetector;
$this->appData = $appData;
$this->userSession = $userSession;
$this->accountFinder = $accountFinder;
$this->entityManager = $entityManager;
$this->generator = $generator;
$this->logger = $logger;
}
/**
* Creates an attachment to be used with a new status.
*
* @NoAdminRequired
*/
public function uploadMedia(): DataResponse {
// TODO
return new DataResponse([
'id' => 1,
'url' => '',
'preview_url' => '',
'remote_url' => null,
'text_url' => '',
'description' => '',
public function uploadMedia(?string $description, ?string $focus = ''): DataResponse {
try {
$file = $this->getUploadedFile('file');
if (!isset($file['tmp_name'], $file['name'], $file['type'])) {
return new DataResponse(['error' => 'No uploaded file'], Http::STATUS_BAD_REQUEST);
}
if (!in_array($file['type'], MediaAttachment::IMAGE_MIME_TYPES, true)) {
return new DataResponse(['error' => 'Image type not supported'], Http::STATUS_BAD_REQUEST);
}
$account = $this->accountFinder->getCurrentAccount($this->userSession->getUser());
$meta = [];
$this->processFocus($focus, $meta);
$newFileResource = fopen($file['tmp_name'], 'rb');
if (!is_resource($newFileResource)) {
return new DataResponse(['error' => 'Image type not supported'], Http::STATUS_BAD_REQUEST);
}
$image = new Image();
$image->loadFromFileHandle($newFileResource);
$meta['original'] = [
"width" => $image->width(),
"height" => $image->height(),
"size" => $image->width() . "x" . $image->height(),
"aspect" => $image->width() / $image->height(),
];
$attachment = MediaAttachment::create();
$attachment->setMimetype($file['type']);
$attachment->setAccount($account);
$attachment->setDescription($description ?? '');
$attachment->setMeta($meta);
$this->entityManager->persist($attachment);
$this->entityManager->flush();
try {
$folder = $this->appData->getFolder('media-attachments');
} catch (NotFoundException $e) {
$folder = $this->appData->newFolder('media-attachments');
}
assert($attachment->getId() !== '');
$folder->newFile($attachment->getId(), $image->data());
return new DataResponse($attachment->toMastodonApi($this->generator));
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new DataResponse([
"error" => "Validation failed: File content type is invalid, File is invalid",
], 500);
}
}
/**
* @NoAdminRequired
*/
public function updateMedia(string $id, ?string $description, ?string $focus = ''): Response {
try {
$account = $this->accountFinder->getCurrentAccount($this->userSession->getUser());
$attachementRepository = $this->entityManager->getRepository(MediaAttachment::class);
$attachement = $attachementRepository->findOneBy([
'id' => $id,
]);
if ($attachement->getAccount()->getId() !== $account->getId()) {
throw new NotFoundResponse();
}
$attachement->setDescription($description ?? '');
$this->entityManager->persist($attachement);
$this->entityManager->flush();
return new DataResponse($attachement->toMastodonApi($this->generator));
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new DataResponse([
"error" => "Validation failed: File content type is invalid, File is invalid",
], 500);
}
}
private function processFocus(string $focus, array &$meta): void {
if ($focus === '') {
return;
}
try {
[$x, $y] = explode(',', $focus);
$meta['focus'] = ['x' => $x, 'y' => $y];
} catch (\Exception $e) {
return;
}
}
private function getUploadedFile(string $key): array {
$file = $this->request->getUploadedFile($key);
$error = null;
$phpFileUploadErrors = [
UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'),
UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'),
UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'),
UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'),
UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'),
UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'),
];
if (empty($file)) {
$error = $this->l10n->t('No file uploaded or file size exceeds maximum of %s', [Util::humanFileSize(Util::uploadLimit())]);
}
if (!empty($file) && array_key_exists('error', $file) && $file['error'] !== UPLOAD_ERR_OK) {
$error = $phpFileUploadErrors[$file['error']];
}
if ($error !== null) {
throw new \Exception($error);
}
return $file;
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function getMedia(string $shortcode, string $extension): DataDownloadResponse {
try {
$folder = $this->appData->getFolder('media-attachments');
} catch (NotFoundException $e) {
$folder = $this->appData->newFolder('media-attachments');
}
$attachementRepository = $this->entityManager->getRepository(MediaAttachment::class);
$attachement = $attachementRepository->findOneBy([
'shortcode' => $shortcode,
]);
$file = $folder->getFile($attachement->getId());
return new DataDownloadResponse(
$file->getContent(),
(string) Http::STATUS_OK,
$this->getSecureMimeType($file->getMimeType())
);
}
/**
* @NoAdminRequired
*/
public function deleteMedia(string $id): DataResponse {
try {
$folder = $this->appData->getFolder('media-attachments');
} catch (NotFoundException $e) {
$folder = $this->appData->newFolder('media-attachments');
}
$attachementRepository = $this->entityManager->getRepository(MediaAttachment::class);
$attachement = $attachementRepository->findOneBy([
'id' => $id,
]);
$file = $folder->getFile($attachement->getId());
$file->delete();
$this->entityManager->remove($attachement);
$this->entityManager->flush();
return new DataResponse(['removed']);
}
/**
* Allow all supported mimetypes
* Use mimetype detector for the other ones
*
* @param string $mimetype
* @return string
*/
private function getSecureMimeType(string $mimetype): string {
if (in_array($mimetype, self::IMAGE_MIME_TYPES)) {
return $mimetype;
}
return $this->mimeTypeDetector->getSecureMimeType($mimetype);
}
}

Wyświetl plik

@ -31,6 +31,7 @@ declare(strict_types=1);
namespace OCA\Social\Controller;
use OCA\Social\Service\AccountFinder;
use OCA\Social\Tools\Traits\TNCDataResponse;
use OCA\Social\Tools\Traits\TArrayTools;
use Exception;
@ -49,13 +50,13 @@ use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\DB\ORM\IEntityManager;
use OCP\IConfig;
use OCP\IInitialStateService;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IGroupManager;
use OCP\Server;
use OCP\IUserSession;
/**
* Class NavigationController
@ -69,40 +70,43 @@ class NavigationController extends Controller {
private ?string $userId = null;
private IConfig $config;
private IURLGenerator $urlGenerator;
private AccountService $accountService;
private AccountFinder $accountFinder;
private DocumentService $documentService;
private ConfigService $configService;
private MiscService $miscService;
private IL10N $l10n;
private CheckService $checkService;
private IInitialStateService $initialStateService;
private IInitialState $initialState;
private IUserSession $userSession;
public function __construct(
IL10N $l10n,
IRequest $request,
?string $userId,
IConfig $config,
IInitialStateService $initialStateService,
IInitialState $initialState,
IURLGenerator $urlGenerator,
AccountService $accountService,
AccountFinder $accountFinder,
DocumentService $documentService,
ConfigService $configService,
CheckService $checkService,
MiscService $miscService
MiscService $miscService,
IUserSession $userSession
) {
parent::__construct(Application::APP_NAME, $request);
$this->userId = $userId;
$this->l10n = $l10n;
$this->config = $config;
$this->initialStateService = $initialStateService;
$this->initialState = $initialState;
$this->urlGenerator = $urlGenerator;
$this->checkService = $checkService;
$this->accountService = $accountService;
$this->accountFinder = $accountFinder;
$this->documentService = $documentService;
$this->configService = $configService;
$this->miscService = $miscService;
$this->userSession = $userSession;
}
@ -118,33 +122,31 @@ class NavigationController extends Controller {
public function navigate(string $path = ''): TemplateResponse {
$serverData = [
'public' => false,
'firstrun' => false,
'setup' => false,
'isAdmin' => Server::get(IGroupManager::class)
->isAdmin($this->userId),
'cliUrl' => $this->getCliUrl()
];
try {
$serverData['cloudAddress'] = $this->configService->getCloudUrl();
} catch (SocialAppConfigException $e) {
$this->checkService->checkInstallationStatus(true);
$cloudAddress = $this->setupCloudAddress();
if ($cloudAddress !== '') {
$serverData['cloudAddress'] = $cloudAddress;
} else {
$serverData['setup'] = true;
// TODO redirect to admin page
//$this->checkService->checkInstallationStatus(true);
//$cloudAddress = $this->setupCloudAddress();
//if ($cloudAddress !== '') {
// $serverData['cloudAddress'] = $cloudAddress;
//} else {
// $serverData['setup'] = true;
if ($serverData['isAdmin']) {
$cloudAddress = $this->request->getParam('cloudAddress');
if ($cloudAddress !== null) {
$this->configService->setCloudUrl($cloudAddress);
} else {
$this->initialStateService->provideInitialState(Application::APP_NAME, 'serverData', $serverData);
return new TemplateResponse(Application::APP_NAME, 'main');
}
}
}
// if ($serverData['isAdmin']) {
// $cloudAddress = $this->request->getParam('cloudAddress');
// if ($cloudAddress !== null) {
// $this->configService->setCloudUrl($cloudAddress);
// } else {
// $this->initialState->provideInitialState( 'serverData', $serverData);
// return new TemplateResponse(Application::APP_NAME, 'main');
// }
// }
//}
}
try {
@ -153,26 +155,9 @@ class NavigationController extends Controller {
$this->configService->setSocialUrl();
}
if ($serverData['isAdmin']) {
$checks = $this->checkService->checkDefault();
$serverData['checks'] = $checks;
}
$account = $this->accountFinder->getCurrentAccount($this->userSession->getUser());
/*
* Create social user account if it doesn't exist yet
*/
try {
$this->accountService->createActor($this->userId, $this->userId);
$serverData['firstrun'] = true;
} catch (AccountAlreadyExistsException $e) {
// we do nothing
} catch (NoUserException $e) {
// well, should not happens
} catch (SocialAppConfigException $e) {
// neither.
}
$this->initialStateService->provideInitialState(Application::APP_NAME, 'serverData', $serverData);
$this->initialState->provideInitialState('serverData', $serverData);
return new TemplateResponse(Application::APP_NAME, 'main');
}
@ -194,7 +179,7 @@ class NavigationController extends Controller {
return '';
}
private function getCliUrl() {
private function getCliUrl(): string {
$url = rtrim($this->urlGenerator->getBaseUrl(), '/');
$frontControllerActive =
($this->config->getSystemValue('htaccess.IgnoreFrontController', false) === true

Wyświetl plik

@ -30,23 +30,24 @@ declare(strict_types=1);
namespace OCA\Social\Controller;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Instance;
use OCA\Social\Repository\InstanceRepository;
use OCA\Social\Tools\Traits\TNCDataResponse;
use Exception;
use OCA\Social\AppInfo\Application;
use OCA\Social\Entity\Application;
use OCA\Social\Exceptions\ClientException;
use OCA\Social\Exceptions\ClientNotFoundException;
use OCA\Social\Exceptions\InstanceDoesNotExistException;
use OCA\Social\Model\Client\SocialClient;
use OCA\Social\Service\AccountService;
use OCA\Social\Service\CacheActorService;
use OCA\Social\Service\ClientService;
use OCA\Social\Service\ApplicationService;
use OCA\Social\Service\ConfigService;
use OCA\Social\Service\InstanceService;
use OCA\Social\Service\MiscService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\Response;
use OCP\DB\ORM\IEntityManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
@ -59,17 +60,24 @@ class OAuthController extends Controller {
private InstanceService $instanceService;
private AccountService $accountService;
private CacheActorService $cacheActorService;
private ClientService $clientService;
private ApplicationService $clientService;
private ConfigService $configService;
private MiscService $miscService;
private IEntityManager $entityManager;
public function __construct(
IRequest $request, IUserSession $userSession, IURLGenerator $urlGenerator,
InstanceService $instanceService, AccountService $accountService,
CacheActorService $cacheActorService, ClientService $clientService, ConfigService $configService,
MiscService $miscService
IRequest $request,
IUserSession $userSession,
IURLGenerator $urlGenerator,
InstanceService $instanceService,
AccountService $accountService,
CacheActorService $cacheActorService,
ApplicationService $clientService,
ConfigService $configService,
MiscService $miscService,
IEntityManager $entityManager
) {
parent::__construct(Application::APP_NAME, $request);
parent::__construct('social', $request);
$this->userSession = $userSession;
$this->urlGenerator = $urlGenerator;
@ -79,9 +87,7 @@ class OAuthController extends Controller {
$this->clientService = $clientService;
$this->configService = $configService;
$this->miscService = $miscService;
$body = file_get_contents('php://input');
$this->miscService->log('[OAuthController] input: ' . $body, 0);
$this->entityManager = $entityManager;
}
@ -89,11 +95,11 @@ class OAuthController extends Controller {
* @NoCSRFRequired
* @PublicPage
*/
public function nodeinfo(): DataResponse {
public function index(): DataResponse {
$nodeInfo = [
'links' => [
'rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0',
'href' => $this->urlGenerator->linkToRouteAbsolute('social.OAuth.nodeinfo2')
'href' => $this->urlGenerator->linkToRouteAbsolute('social.OAuth.show')
]
];
@ -105,32 +111,24 @@ class OAuthController extends Controller {
* @NoCSRFRequired
* @PublicPage
*/
public function nodeinfo2(): Response {
try {
$local = $this->instanceService->getLocal();
$name = $local->getTitle();
$version = $local->getVersion();
$usage = $local->getUsage();
$openReg = $local->isRegistrations();
} catch (InstanceDoesNotExistException $e) {
$name = 'Nextcloud Social';
$version = $this->configService->getAppValue('installed_version');
$usage = [];
$openReg = false;
}
public function show(): DataResponse {
$query = $this->entityManager->createQuery('SELECT COUNT(a) FROM \OCA\Social\Entity\Account a');
$query->setCacheable(true);
$countUser = $query->getSingleScalarResult();
$nodeInfo = [
"version" => "2.0",
"software" => [
"name" => $name,
"version" => $version
"name" => 'Nextcloud Social',
"version" => $this->configService->getAppValue('installed_version'),
],
"protocols" => [
"activitypub"
],
"usage" => $usage,
"openRegistrations" => $openReg
"usage" => [
"total" => (int)$countUser,
],
"openRegistrations" => false,
];
return new DataResponse($nodeInfo, Http::STATUS_OK);
@ -151,10 +149,10 @@ class OAuthController extends Controller {
$redirect_uris = [$redirect_uris];
}
$client = new SocialClient();
$client = new Application();
$client->setAppWebsite($website);
$client->setAppRedirectUris($redirect_uris);
$client->setAppScopes($client->getScopesFromString($scopes));
$client->setAppScopes(Application::getScopesFromString($scopes));
$client->setAppName($client_name);
$this->clientService->createApp($client);
@ -181,6 +179,11 @@ class OAuthController extends Controller {
): DataResponse {
try {
$user = $this->userSession->getUser();
$accountRepository = $this->entityManager->getRepository(Account::class);
$accountRepository->findBy([
''
]);
$account = $this->accountService->getActorFromUserId($user->getUID());
if ($response_type !== 'code') {

Wyświetl plik

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Controller;
use OCA\Social\Entity\Account;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCA\Social\AppInfo\Application as App;
use OCP\DB\ORM\IEntityManager;
use OCP\DB\ORM\IEntityRepository;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\IUserSession;
/**
* Controller responsible to set up social
*/
class SetupController extends Controller {
private IUserSession $userSession;
private IEntityManager $entityManager;
private IEntityRepository $accountRepository;
private IURLGenerator $generator;
public function __construct(IRequest $request, IUserSession $userSession, IEntityManager $entityManager, IURLGenerator $generator) {
parent::__construct(App::APP_NAME, $request);
$this->userSession = $userSession;
$this->entityManager = $entityManager;
$this->accountRepository = $entityManager->getRepository(Account::class);
$this->generator = $generator;
}
/**
* Display the account creation page
*
* @NoAdminRequired
* @NoCSRFRequired
*/
public function setupUser(): Response {
$account = $this->accountRepository->findOneBy([
'userId' => $this->userSession->getUser()->getUID(),
]);
if ($account !== null) {
return new RedirectResponse($this->generator->linkToRoute('social.Navigation.timeline'));
}
return new TemplateResponse(App::APP_NAME, 'setup-user');
}
/**
* @NoAdminRequired
*/
public function createAccount(string $userName): DataResponse {
}
}

Wyświetl plik

@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Controller;
use OCA\Social\Entity\MediaAttachment;
use OCA\Social\Service\AccountFinder;
use OCA\Social\Service\PostServiceStatus;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\DB\ORM\IEntityManager;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\IL10N;
use OCP\AppFramework\Controller;
use OCP\Files\IMimeTypeDetector;
use OCP\Image;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Util;
use Psr\Log\LoggerInterface;
class StatusApiController extends Controller {
private IL10N $l10n;
private IMimeTypeDetector $mimeTypeDetector;
private IAppData $appData;
private IUserSession $userSession;
private AccountFinder $accountFinder;
private IEntityManager $entityManager;
private IURLGenerator $generator;
private LoggerInterface $logger;
private PostServiceStatus $postServiceStatus;
public function __construct(
string $appName,
IRequest $request,
IL10N $l10n,
IUserSession $userSession,
AccountFinder $accountFinder,
IEntityManager $entityManager,
IURLGenerator $generator,
LoggerInterface $logger,
PostServiceStatus $postServiceStatus
) {
parent::__construct($appName, $request);
$this->l10n = $l10n;
$this->userSession = $userSession;
$this->accountFinder = $accountFinder;
$this->entityManager = $entityManager;
$this->generator = $generator;
$this->logger = $logger;
$this->postServiceStatus = $postServiceStatus;
}
/**
* Publish new status
* @NoAdminRequired
*/
public function publishStatus(
?string $status,
array $media_ids,
?bool $sensitive,
?string $spoiler_text
): DataResponse {
if ($sensitive === null) {
$sensitive = false;
}
$account = $this->accountFinder->getCurrentAccount($this->userSession->getUser());
$status = $this->postServiceStatus->create($account, [
'text' => $status,
'spoilerText' => $spoiler_text,
'sensitive' => $sensitive,
]);
return new DataResponse($status->toMastodonApi());
}
/**
* View specific status
* @NoAdminRequired
*/
public function getStatus(string $id): DataResponse {
$statusRepository = $this->entityManager->getRepository(Status::class);
$status = $statusRepository->findOneBy([
'id' => $id,
]);
if ($status === null) {
return new DataResponse(["error" => "Record not found"]);
}
$account = $this->accountFinder->getCurrentAccount($this->userSession->getUser());
if (!$this->canRead($account, $status)) {
return new DataResponse(["error" => "Record not found"]);
}
return new DataResponse($status->toMastodonApi());
}
/**
* Delete specific status
* @NoAdminRequired
*/
public function deleteStatus(string $id): DataResponse {
$statusRepository = $this->entityManager->getRepository(Status::class);
$status = $statusRepository->findOneBy([
'id' => $id,
]);
if ($status === null) {
return new DataResponse(["error" => "Record not found"]);
}
$account = $this->accountFinder->getCurrentAccount($this->userSession->getUser());
if ($status->getAccount()->getId() !== $account->getId()) {
return new DataResponse(["error" => "Record not found"]);
}
$this->entityManager->delete($status);
$this->entityManager->flush();
return new DataResponse($status->toMastodonApi());
}
/**
* Context of a specific status
* @NoAdminRequired
*/
public function contextStatus(string $id): DataResponse {
$statusRepository = $this->entityManager->getRepository(Status::class);
$status = $statusRepository->findOneBy([
'id' => $id,
]);
if ($status === null) {
return new DataResponse(["error" => "Record not found"]);
}
$account = $this->accountFinder->getCurrentAccount($this->userSession->getUser());
if (!$this->canRead($account, $status)) {
return new DataResponse(["error" => "Record not found"]);
}
return new DataResponse([
'ancestors' => [],
'descendants' => [],
]);
}
public function reblogedBy(string $id): DataResponse {
$statusRepository = $this->entityManager->getRepository(Status::class);
$status = $statusRepository->findOneBy([
'id' => $id,
]);
if ($status === null) {
return new DataResponse(["error" => "Record not found"]);
}
$account = $this->accountFinder->getCurrentAccount($this->userSession->getUser());
if (!$this->canRead($account, $status)) {
return new DataResponse(["error" => "Record not found"]);
}
return new DataResponse([]);
}
private function canRead(Account $accout, Status $status): bool {
return true;
}
}

Wyświetl plik

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Controller;
use OCA\Social\Entity\MediaAttachment;
use OCA\Social\Service\AccountFinder;
use OCA\Social\Service\PostServiceStatus;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\DB\ORM\IEntityManager;
use OCP\Files\IAppData;
use OCP\Files\NotFoundException;
use OCP\IL10N;
use OCP\AppFramework\Controller;
use OCP\Files\IMimeTypeDetector;
use OCP\Image;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Util;
use Psr\Log\LoggerInterface;
class TimelineApiController extends Controller {
private IL10N $l10n;
private IMimeTypeDetector $mimeTypeDetector;
private IAppData $appData;
private IUserSession $userSession;
private AccountFinder $accountFinder;
private IEntityManager $entityManager;
private IURLGenerator $generator;
private LoggerInterface $logger;
private PostServiceStatus $postServiceStatus;
public function __construct(
string $appName,
IRequest $request,
IL10N $l10n,
IUserSession $userSession,
AccountFinder $accountFinder,
IEntityManager $entityManager,
IURLGenerator $generator,
LoggerInterface $logger,
) {
parent::__construct($appName, $request);
$this->l10n = $l10n;
$this->userSession = $userSession;
$this->accountFinder = $accountFinder;
$this->entityManager = $entityManager;
$this->generator = $generator;
$this->logger = $logger;
}
/**
* Public timeline
*
* @params bool $local Show only local statuses? Defaults to false.
* @params bool $remote Show only remote statuses? Defaults to false.
* @params bool $only_media Show only statuses with media attached? Defaults to false.
* @params string $max_id Return results older than this id
* @params string $since_id Return results newer than this id
* @params string $min_id Return results immediately newer than this id
* @params int $limit Maximum number of results to return. Defaults to 20.
*/
public function publicTimeline(
bool $local = null,
bool $remote = null,
bool $only_media = null,
string $max_id = null,
string $since_id = null,
string $min_id = null,
int $limit = null,
): DataResponse {
if ($local === null) {
$local = false;
}
if ($remote === null) {
$remote = false;
}
if ($only_media === null) {
$only_media = false;
}
if ($limit === null || $limit > 100) {
$limit = 20;
}
$statusRepository = $this->entityManager->getRepository(Status::class);
$statusRepository->createQuery('SELECT s FROM \OCA\Social\Entity\Status s WHERE s.visibility = :visibility');
}
}

Wyświetl plik

@ -1,164 +0,0 @@
<?php
declare(strict_types=1);
/**
* Nextcloud - Social Support
*
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
* @author Maxence Lange <maxence@artificial-owl.com>
* @copyright 2018, Maxence Lange <maxence@artificial-owl.com>
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Social\Db;
use OCA\Social\Tools\Traits\TArrayTools;
use DateTime;
use Exception;
use OCA\Social\Exceptions\ClientNotFoundException;
use OCA\Social\Model\Client\SocialClient;
use OCA\Social\Service\ClientService;
use OCP\DB\QueryBuilder\IQueryBuilder;
/**
* Class ClientAppRequest
*
* @package OCA\Social\Db
*/
class ClientRequest extends ClientRequestBuilder {
use TArrayTools;
/**
* Insert a new OAuth client in the database.
* @throws \OCP\DB\Exception
*/
public function saveApp(SocialClient $client): void {
$qb = $this->getClientInsertSql();
$qb->setValue('app_name', $qb->createNamedParameter($client->getAppName()))
->setValue('app_website', $qb->createNamedParameter($client->getAppWebsite()))
->setValue(
'app_redirect_uris', $qb->createNamedParameter(json_encode($client->getAppRedirectUris()))
)
->setValue('app_client_id', $qb->createNamedParameter($client->getAppClientId()))
->setValue('app_client_secret', $qb->createNamedParameter($client->getAppClientSecret()))
->setValue('app_scopes', $qb->createNamedParameter(json_encode($client->getAppScopes())));
try {
$dt = new DateTime('now');
$qb->setValue('last_update', $qb->createNamedParameter($dt, IQueryBuilder::PARAM_DATE));
$qb->setValue('creation', $qb->createNamedParameter($dt, IQueryBuilder::PARAM_DATE));
} catch (Exception $e) {
}
$qb->executeStatement();
$client->setId($qb->getLastInsertId());
}
/**
* @param SocialClient $client
*/
public function authClient(SocialClient $client): void {
$qb = $this->getClientUpdateSql();
$qb->set('auth_code', $qb->createNamedParameter($client->getAuthCode()));
$qb->set('auth_scopes', $qb->createNamedParameter(json_encode($client->getAuthScopes())));
$qb->set('auth_account', $qb->createNamedParameter($client->getAuthAccount()));
$qb->set('auth_user_id', $qb->createNamedParameter($client->getAuthUserId()));
$qb->limitToId($client->getId());
$qb->executeStatement();
}
/**
* @param SocialClient $client
*/
public function updateToken(SocialClient $client): void {
$qb = $this->getClientUpdateSql();
$qb->set('token', $qb->createNamedParameter($client->getToken()));
$qb->set('auth_code', $qb->createNamedParameter(''));
$qb->limitToId($client->getId());
$qb->execute();
}
/**
* @param SocialClient $client
*/
public function updateTime(SocialClient $client): void {
$now = new DateTime('now');
$client->setLastUpdate($now->getTimestamp());
$qb = $this->getClientUpdateSql();
$qb->set('last_update', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATE));
$qb->limitToId($client->getId());
$qb->execute();
}
/**
* @param string $clientId
*
* @return SocialClient
* @throws ClientNotFoundException
*/
public function getFromClientId(string $clientId): SocialClient {
$qb = $this->getClientSelectSql();
$qb->limitToAppClientId($clientId);
return $this->getClientFromRequest($qb);
}
/**
* @param string $token
*
* @return SocialClient
* @throws ClientNotFoundException
*/
public function getFromToken(string $token): SocialClient {
$qb = $this->getClientSelectSql();
$qb->limitToToken($token);
return $this->getClientFromRequest($qb);
}
/**
* @throws Exception
*/
public function deprecateToken() {
$qb = $this->getClientDeleteSql();
$date = new DateTime();
$date->setTimestamp(time() - ClientService::TIME_TOKEN_TTL);
$qb->limitToDBFieldDateTime('last_update', $date, true);
$qb->execute();
}
}

Wyświetl plik

@ -1,154 +0,0 @@
<?php
declare(strict_types=1);
/**
* Nextcloud - Social Support
*
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
* @author Maxence Lange <maxence@artificial-owl.com>
* @copyright 2018, Maxence Lange <maxence@artificial-owl.com>
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Social\Db;
use OCA\Social\Tools\Exceptions\RowNotFoundException;
use OCA\Social\Tools\Traits\TArrayTools;
use Exception;
use OCA\Social\Exceptions\ClientNotFoundException;
use OCA\Social\Model\Client\SocialClient;
/**
* Class ClientRequestBuilder
*
* @package OCA\Social\Db
*/
class ClientRequestBuilder extends CoreRequestBuilder {
use TArrayTools;
/**
* Base of the Sql Insert request
*
* @return SocialQueryBuilder
*/
protected function getClientInsertSql(): SocialQueryBuilder {
$qb = $this->getQueryBuilder();
$qb->insert(self::TABLE_CLIENT);
return $qb;
}
/**
* Base of the Sql Update request
*
* @return SocialQueryBuilder
*/
protected function getClientUpdateSql(): SocialQueryBuilder {
$qb = $this->getQueryBuilder();
$qb->update(self::TABLE_CLIENT);
return $qb;
}
/**
* Base of the Sql Select request for Shares
*
* @return SocialQueryBuilder
*/
protected function getClientSelectSql(): SocialQueryBuilder {
$qb = $this->getQueryBuilder();
/** @noinspection PhpMethodParametersCountMismatchInspection */
$qb->select(
'cl.id', 'cl.app_name', 'cl.app_website', 'cl.app_redirect_uris', 'cl.app_client_id',
'cl.app_client_secret', 'cl.app_scopes', 'cl.auth_scopes', 'cl.auth_account', 'cl.auth_user_id',
'cl.auth_code', 'cl.token', 'cl.last_update', 'cl.creation'
)
->from(self::TABLE_CLIENT, 'cl');
$this->defaultSelectAlias = 'cl';
$qb->setDefaultSelectAlias('cl');
return $qb;
}
/**
* Base of the Sql Delete request
*
* @return SocialQueryBuilder
*/
protected function getClientDeleteSql(): SocialQueryBuilder {
$qb = $this->getQueryBuilder();
$qb->delete(self::TABLE_CLIENT);
return $qb;
}
/**
* @param SocialQueryBuilder $qb
*
* @return SocialClient
* @throws ClientNotFoundException
*/
public function getClientFromRequest(SocialQueryBuilder $qb): SocialClient {
/** @var SocialClient $result */
try {
$result = $qb->getRow([$this, 'parseClientSelectSql']);
} catch (RowNotFoundException $e) {
throw new ClientNotFoundException($e->getMessage());
}
return $result;
}
/**
* @param SocialQueryBuilder $qb
*
* @return SocialClient[]
*/
public function getClientsFromRequest(SocialQueryBuilder $qb): array {
/** @var SocialClient[] $result */
$result = $qb->getRows([$this, 'parseClientSelectSql']);
return $result;
}
/**
* @param array $data
*
* @return SocialClient
* @throws Exception
*/
public function parseClientSelectSql(array $data): SocialClient {
$item = new SocialClient();
$item->importFromDatabase($data);
return $item;
}
}

Wyświetl plik

@ -0,0 +1,14 @@
<?php
namespace OCA\Social\Db;
use OCP\AppFramework\Db\QBMapper;
/**
* @extends QBMapper<Status>
*/
class StatusMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'status', Status::class);
}
}

Wyświetl plik

@ -0,0 +1,580 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Mapping as ORM;
use OCA\Social\Service\FollowOption;
use OCA\Social\InstanceUtils;
use OCP\IRequest;
/**
* @ORM\Entity
* @ORM\Table(name="social_account")
*/
class Account {
const REPRESENTATIVE_ID = '-99';
const TYPE_APPLICATION = 'Application';
const TYPE_PERSON = 'Person';
const TYPE_GROUP = 'Group';
const TYPE_ORGANIZATION = 'Organization';
const TYPE_SERVICE = 'Service';
/**
* @ORM\Id
* @ORM\Column(type="bigint")
* @ORM\GeneratedValue
*/
private ?string $id = null;
/**
* Username of the user e.g. alice from alice@cloud.social
*
* @ORM\Column(name="user_name", nullable=false)
*/
private string $userName = "";
/**
* Internal userId of the user
*
* Only set for local users.
*
* @ORM\Column(name="user_id", nullable=true, unique=true)
*/
private ?string $userId = null;
/**
* Display name: e.g. "Alice Müller"
* @ORM\Column(nullable=true)
*/
private ?string $name = null;
/**
* @ORM\ManyToOne
* @ORM\JoinColumn(name="domain", referencedColumnName="domain", nullable=true)
*/
private ?Instance $instance = null;
/**
* @ORM\Column(name="private_key", type="text", nullable=false)
*/
private string $privateKey = "";
/**
* @ORM\Column(name="public_key", type="text", nullable=false)
*/
private string $publicKey = "";
/**
* @ORM\Column(name="created_at", type="datetime", nullable=false)
*/
private \DateTime $createdAt;
/**
* @ORM\Column(name="updated_at", type="datetime", nullable=false)
*/
private \DateTime $updatedAt;
/**
* @ORM\Column(type="string", nullable=false)
*/
private string $uri = "";
/**
* @ORM\Column(type="string", nullable=false)
*/
private string $url = "";
/**
* @ORM\Column(type="boolean", nullable=false)
*/
private bool $locked = false;
/**
* @ORM\Column(name="avatar_remote_url", type="string", nullable=false)
*/
private string $avatarRemoteUrl = "";
/**
* @ORM\Column(name="header_remote_url", type="string", nullable=false)
*/
private string $headerRemoteUrl = "";
/**
* @ORM\Column(name="last_webfingered_at", type="datetime", nullable=true)
*/
private ?\DateTimeInterface $lastWebfingeredAt = null;
/**
* @ORM\Column(name="inbox_url", type="string", nullable=false)
*/
private string $inboxUrl = "";
/**
* @ORM\Column(name="outbox_url", type="string", nullable=false)
*/
private string $outboxUrl = "";
/**
* @ORM\Column(name="shared_inbox_url", type="string", nullable=false)
*/
private string $sharedInboxUrl = "";
/**
* @ORM\Column(name="followers_url", type="string", nullable=false)
*/
private string $followersUrl = "";
/**
* @ORM\Column(name="protocol", type="string", nullable=false)
*/
private string $protocol = "ostatus";
/**
* @ORM\Column(type="boolean", nullable=false)
*/
private bool $memorial = false;
/**
* @ORM\Column(type="json", nullable=false)
*/
private array $fields = [];
/**
* @ORM\Column(type="string", nullable=false)
*/
private string $actorType = self::TYPE_PERSON;
/**
* @ORM\Column(nullable=false)
*/
private bool $discoverable = true;
/**
* @ORM\OneToMany(targetEntity="Follow", mappedBy="account", fetch="EXTRA_LAZY", cascade={"persist", "remove"})
* @var Collection<Follow>
*/
private Collection $follow;
/**
* @ORM\OneToMany(targetEntity="Follow", mappedBy="targetAccount", fetch="EXTRA_LAZY", cascade={"persist", "remove"})
* @var Collection<Follow>
*/
private Collection $followedBy;
/**
* @ORM\OneToMany(targetEntity="FollowRequest", mappedBy="account", fetch="EXTRA_LAZY", cascade={"persist", "remove"})
* @var Collection<FollowRequest>
*/
private Collection $followRequest;
/**
* @ORM\OneToMany(targetEntity="FollowRequest", mappedBy="targetAccount", fetch="EXTRA_LAZY", cascade={"persist", "remove"})
* @var Collection<FollowRequest>
*/
private Collection $followRequestFrom;
/**
* @ORM\OneToMany(targetEntity="Follow", mappedBy="account", fetch="EXTRA_LAZY", cascade={"persist", "remove"})
* @var Collection<Block>
*/
private Collection $block;
/**
* @ORM\OneToMany(targetEntity="Follow", mappedBy="targetAccount", fetch="EXTRA_LAZY", cascade={"persist", "remove"})
* @var Collection<Block>
*/
private Collection $blockedBy;
public function __construct() {
$this->block = new ArrayCollection();
$this->blockedBy = new ArrayCollection();
$this->follow = new ArrayCollection();
$this->followRequest = new ArrayCollection();
$this->followRequestFrom = new ArrayCollection();
$this->followedBy = new ArrayCollection();
$this->updatedAt = new \DateTime();
$this->createdAt = new \DateTime();
}
static public function newLocal(string $userId = null, string $userName = null, string $displayName = null): self {
$account = new Account();
if ($userId !== null) {
$account->setUserId($userId);
if ($userName !== null) {
$account->setUserName($userName);
} else {
$account->setUserName($userId);
}
if ($displayName !== null) {
$account->setName($displayName);
} else {
$account->setName($account->getUserName());
}
}
$account->generateKeys();
return $account;
}
public function generateKeys(): self {
if (!$this->isLocal() || ($this->publicKey !== '' && $this->privateKey !== '')) {
return $this;
}
$res = openssl_pkey_new([
"digest_alg" => "rsa",
"private_key_bits" => 2048,
"private_key_type" => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($res, $privateKey);
$publicKey = openssl_pkey_get_details($res)['key'];
$this->setPublicKey($publicKey);
$this->setPrivateKey($privateKey);
return $this;
}
public function getId(): string {
return $this->id;
}
public function setRepresentative(): self {
$this->userId = '__self';
return $this;
}
public function getUserId(): ?string {
return $this->userId;
}
public function setUserId(string $userId): self {
$this->userId = $userId;
return $this;
}
public function getUserName(): string {
return $this->userName;
}
public function setUserName(string $userName): self {
$this->userName = $userName;
return $this;
}
public function getName(): string {
return $this->name;
}
public function setName(string $displayName): self {
$this->name = $displayName;
return $this;
}
public function getInstance(): ?Instance {
return $this->instance;
}
public function setInstance(Instance $instance): self {
$this->instance = $instance;
return $this;
}
public function getPrivateKey(): string {
return $this->privateKey;
}
public function setPrivateKey(string $privateKey): self {
$this->privateKey = $privateKey;
return $this;
}
public function getPublicKey(): string {
return $this->publicKey;
}
public function setPublicKey(string $publicKey): self {
$this->publicKey = $publicKey;
return $this;
}
public function getCreatedAt(): \DateTime {
return $this->createdAt;
}
public function setCreatedAt(\DateTime $createdAt): self {
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): \DateTime {
return $this->updatedAt;
}
public function setUpdatedAt(\DateTime $updatedAt): self {
$this->updatedAt = $updatedAt;
return $this;
}
public function getUri(): string {
return $this->uri;
}
public function setUri(string $uri): self {
$this->uri = $uri;
return $this;
}
public function getUrl(): string {
return $this->url;
}
public function setUrl(string $url): self {
$this->url = $url;
return $this;
}
public function isLocked(): bool {
return $this->locked;
}
public function setLocked(bool $locked): self {
$this->locked = $locked;
return $this;
}
public function getAvatarRemoteUrl(): string {
return $this->avatarRemoteUrl;
}
public function setAvatarRemoteUrl(string $avatarRemoteUrl): self {
$this->avatarRemoteUrl = $avatarRemoteUrl;
return $this;
}
public function getHeaderRemoteUrl(): string {
return $this->headerRemoteUrl;
}
public function setHeaderRemoteUrl(string $headerRemoteUrl): self {
$this->headerRemoteUrl = $headerRemoteUrl;
return $this;
}
public function getLastWebfingeredAt(): ?\DateTimeInterface {
return $this->lastWebfingeredAt;
}
public function setLastWebfingeredAt(?\DateTimeInterface $lastWebfingeredAt): self {
$this->lastWebfingeredAt = $lastWebfingeredAt;
return $this;
}
public function getInboxUrl(): string {
return $this->inboxUrl;
}
public function setInboxUrl(string $inboxUrl): self {
$this->inboxUrl = $inboxUrl;
return $this;
}
public function getOutboxUrl(): string {
return $this->outboxUrl;
}
public function setOutboxUrl(string $outboxUrl): self {
$this->outboxUrl = $outboxUrl;
return $this;
}
public function getSharedInboxUrl(): string {
return $this->sharedInboxUrl;
}
public function setSharedInboxUrl(string $sharedInboxUrl): self {
$this->sharedInboxUrl = $sharedInboxUrl;
return $this;
}
public function getFollowersUrl(): string {
return $this->followersUrl;
}
public function setFollowersUrl(string $followersUrl): self {
$this->followersUrl = $followersUrl;
return $this;
}
public function getProtocol(): string {
return $this->protocol;
}
public function setProtocol(string $protocol): self {
$this->protocol = $protocol;
return $this;
}
public function isMemorial(): bool {
return $this->memorial;
}
public function setMemorial(bool $memorial): self {
$this->memorial = $memorial;
return $this;
}
public function getFields(): array {
return $this->fields;
}
public function setFields(array $fields): self {
$this->fields = $fields;
return $this;
}
public function getActorType(): string {
return $this->actorType;
}
public function setActorType(string $actorType): self {
$this->actorType = $actorType;
return $this;
}
public function isDiscoverable(): bool {
return $this->discoverable;
}
public function setDiscoverable(bool $discoverable): self {
$this->discoverable = $discoverable;
return $this;
}
public function getFollow(): Collection {
return $this->follow;
}
public function setFollow(Collection $follow): self {
$this->follow = $follow;
return $this;
}
public function getFollowedBy(): Collection {
return $this->followedBy;
}
public function setFollowedBy(Collection $followedBy): void {
$this->followedBy = $followedBy;
}
public function getBlock(): Collection {
return $this->block;
}
public function setBlock(Collection $block): self {
$this->block = $block;
return $this;
}
public function getBlockedBy(): Collection {
return $this->blockedBy;
}
public function setBlockedBy(Collection $blockedBy): void {
$this->blockedBy = $blockedBy;
}
public function isLocal(): bool {
return $this->getInstance() === null;
}
public function getDomain(): ?string {
return $this->getInstance() !== null ? $this->getInstance()->getDomain() : null;
}
public function getAccountName(): string {
return $this->isLocal() ? $this->getUserName() : $this->getUserName() . '@' . $this->getDomain();
}
public function possiblyStale(): bool {
return $this->lastWebfingeredAt === null || $this->lastWebfingeredAt->diff((new \DateTime('now')))->days > 1;
}
/**
* Check whether this account follow the $targetAccount
*/
public function following(Account $targetAccount): bool {
$criteria = Criteria::create();
$criteria->where(Criteria::expr()->eq('account', $targetAccount));
return !$this->follow->matching($criteria)->isEmpty();
}
/**
* Check whether this account created a follow request to $targetAccount
*/
public function followRequested(Account $targetAccount): bool {
$criteria = Criteria::create();
$criteria->where(Criteria::expr()->eq('account', $targetAccount));
return !$this->followRequest->matching($criteria)->isEmpty();
}
/**
* Add a new follower to this account
*/
public function follow(Account $account, bool $notify = false, bool $showReblogs = true): Follow {
$follow = new Follow();
$follow->setTargetAccount($account);
$follow->setAccount($this);
$follow->setNotify($notify);
$follow->setShowReblogs($showReblogs);
$this->followedBy->add($follow);
return $follow;
}
public function getFollowRequest() {
return $this->followRequest;
}
public function setFollowRequest($followRequest): void {
$this->followRequest = $followRequest;
}
public function getFollowRequestFrom(): Collection {
return $this->followRequestFrom;
}
public function setFollowRequestFrom(Collection $followRequestFrom): self {
$this->followRequestFrom = $followRequestFrom;
return $this;
}
public function requestFollow(Account $targetAccount, bool $notify = false, bool $showReblogs = true): FollowRequest {
$followRequest = new Follow();
$followRequest->setTargetAccount($targetAccount);
$followRequest->setAccount($this);
$followRequest->setNotify($notify);
$followRequest->setShowReblogs($showReblogs);
$this->followRequest->add($followRequest);
return $followRequest;
}
public function toMastodonApi(): array {
return [
'id' => $this->id,
'username' => $this->userName,
'acct' => $this->userName,
'display_name' => $this->name ?? $this->userName,
// TODO more
];
}
}

Wyświetl plik

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
// Nextcloud Social
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Entity;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="social_application")
*/
class Application {
/**
* @ORM\Id
* @ORM\Column
* @ORM\GeneratedValue
*/
private int $id = 0;
/**
* @ORM\Column(name="app_name")
*/
private string $appName = '';
/**
* @ORM\Column(name="app_website")
*/
private string $appWebsite = '';
/**
* @ORM\Column(name="app_redirect_uris")
*/
private array $appRedirectUris = [];
/**
* @ORM\Column(name="app_client_id")
*/
private string $appClientId = '';
/**
* @ORM\Column(name="app_client_secret")
*/
private string $appClientSecret = '';
/**
* @ORM\Column(name="app_scopes")
*/
private array $appScopes = [];
/**
* @ORM\Column(name="auth_scopes")
*/
private array $authScopes = [];
/**
* @ORM\Column(name="auth_account")
*/
private string $authAccount = '';
/**
* @ORM\Column(name="auth_user_id")
*/
private string $authUserId = '';
/**
* @ORM\Column(name="auth_code")
*/
private string $authCode = '';
/**
* @ORM\Column(name="last_update")
*/
private int $lastUpdate = -1;
/**
* @ORM\Column
*/
private string $token = '';
/**
* @ORM\Column
*/
private DateTimeInterface $creation;
public function __construct() {
$this->lastUpdate = (new \DateTime('now'))->getTimestamp();
$this->creation = new \DateTime('now');
}
/**
* @return list<string>
*/
static public function getScopesFromString(string $scopes): array {
return explode(' ', $scopes);
}
public function getId(): int {
return $this->id;
}
public function getAppName(): string {
return $this->appName;
}
public function setAppName(string $appName): void {
$this->appName = $appName;
}
public function getAppWebsite(): string {
return $this->appWebsite;
}
public function setAppWebsite(string $appWebsite): void {
$this->appWebsite = $appWebsite;
}
public function getAppRedirectUris(): array {
return $this->appRedirectUris;
}
public function setAppRedirectUris(array $appRedirectUris): void {
$this->appRedirectUris = $appRedirectUris;
}
public function getAppClientId(): string {
return $this->appClientId;
}
public function setAppClientId(string $appClientId): void {
$this->appClientId = $appClientId;
}
public function getAppClientSecret(): string {
return $this->appClientSecret;
}
public function setAppClientSecret(string $appClientSecret): void {
$this->appClientSecret = $appClientSecret;
}
public function getAppScopes(): array {
return $this->appScopes;
}
public function setAppScopes(array $appScopes): void {
$this->appScopes = $appScopes;
}
public function getAuthScopes(): array {
return $this->authScopes;
}
public function setAuthScopes(array $authScopes): void {
$this->authScopes = $authScopes;
}
public function getAuthAccount(): string {
return $this->authAccount;
}
public function setAuthAccount(string $authAccount): void {
$this->authAccount = $authAccount;
}
public function getAuthUserId(): string {
return $this->authUserId;
}
public function setAuthUserId(string $authUserId): void {
$this->authUserId = $authUserId;
}
public function getAuthCode(): string {
return $this->authCode;
}
public function setAuthCode(string $authCode): void {
$this->authCode = $authCode;
}
public function getLastUpdate(): int {
return $this->lastUpdate;
}
public function setLastUpdate(int $lastUpdate): void {
$this->lastUpdate = $lastUpdate;
}
public function getToken(): string {
return $this->token;
}
public function setToken(string $token): void {
$this->token = $token;
}
public function getCreation(): DateTimeInterface {
return $this->creation;
}
public function setCreation(DateTimeInterface $creation): void {
$this->creation = $creation;
}
}

Wyświetl plik

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Entity;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="social_block")
*/
class Block {
/**
* @ORM\Id
* @ORM\Column(type="bigint")
* @ORM\GeneratedValue
*/
private string $id = "-1";
/**
* @ORM\Column(name="created_at")
*/
private DateTimeInterface $createdAt;
/**
* @ORM\Column(name="updated_at")
*/
private DateTimeInterface $updatedAt;
/**
* @ORM\ManyToOne
* @ORM\JoinColumn(nullable=false)
*/
private Account $account;
/**
* @ORM\ManyToOne
* @ORM\JoinColumn(nullable=false)
*/
private Account $targetAccount;
/**
* @ORM\Column
*/
private string $uri = "";
public function __construct() {
$this->updatedAt = new \DateTime();
$this->createdAt = new \DateTime();
$this->account = new Account();
$this->targetAccount = new Account();
}
}

Wyświetl plik

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Entity;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="social_follow")
*/
class Follow {
/**
* @ORM\Id
* @ORM\Column(type="bigint")
* @ORM\GeneratedValue
*/
private ?string $id = null;
/**
* @ORM\Column(name="created_at", type="datetime", nullable=false)
*/
private \DateTime $createdAt;
/**
* @ORM\Column(name="updated_at", type="datetime", nullable=false)
*/
private \DateTime $updatedAt;
/**
* @ORM\ManyToOne(cascade={"persist", "remove"})
* @ORM\JoinColumn(nullable=false)
*/
private ?Account $account = null;
/**
* @ORM\ManyToOne(cascade={"persist", "remove"})
* @ORM\JoinColumn(nullable=false)
*/
private ?Account $targetAccount = null;
/**
* @ORM\Column
*/
private bool $showReblogs = true;
/**
* @ORM\Column
*/
private string $uri = "";
/**
* @ORM\Column
*/
private bool $notify = false;
public function __construct() {
$this->updatedAt = new \DateTime();
$this->createdAt = new \DateTime();
$this->account = new Account();
$this->targetAccount = new Account();
}
public function getId(): string {
return $this->id;
}
public function getCreatedAt():\DateTimeInterface {
return $this->createdAt;
}
public function setCreatedAt(\DateTime $createdAt): void {
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): \DateTimeInterface {
return $this->updatedAt;
}
public function setUpdatedAt(\DateTime $updatedAt): void {
$this->updatedAt = $updatedAt;
}
public function getAccount(): Account {
return $this->account;
}
public function setAccount(Account $account): void {
$this->account = $account;
}
public function getTargetAccount(): Account {
return $this->targetAccount;
}
public function setTargetAccount(Account $targetAccount): void {
$this->targetAccount = $targetAccount;
}
public function isShowReblogs(): bool {
return $this->showReblogs;
}
public function setShowReblogs(bool $showReblogs): void {
$this->showReblogs = $showReblogs;
}
public function getUri(): string {
return $this->uri;
}
public function setUri(string $uri): void {
$this->uri = $uri;
}
public function isNotify(): bool {
return $this->notify;
}
public function setNotify(bool $notify): void {
$this->notify = $notify;
}
}

Wyświetl plik

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Entity;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="social_request")
*/
class FollowRequest {
/**
* @ORM\Id
* @ORM\Column(type="bigint")
* @ORM\GeneratedValue
*/
private ?string $id = null;
/**
* @ORM\Column(name="created_at", type="datetime", nullable=false)
*/
private \DateTime $createdAt;
/**
* @ORM\Column(name="updated_at", type="datetime", nullable=false)
*/
private \DateTime $updatedAt;
/**
* @ORM\ManyToOne(cascade={"persist", "remove"})
* @ORM\JoinColumn(nullable=false)
*/
private ?Account $account = null;
/**
* @ORM\ManyToOne(cascade={"persist", "remove"})
* @ORM\JoinColumn(nullable=false)
*/
private ?Account $targetAccount = null;
/**
* @ORM\Column
*/
private bool $showReblogs = true;
/**
* @ORM\Column
*/
private string $uri = "";
/**
* @ORM\Column
*/
private bool $notify = false;
public function __construct() {
$this->updatedAt = new \DateTime();
$this->createdAt = new \DateTime();
$this->account = new Account();
$this->targetAccount = new Account();
}
public function getId(): string {
return $this->id;
}
public function getCreatedAt():\DateTimeInterface {
return $this->createdAt;
}
public function setCreatedAt(\DateTime $createdAt): void {
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): \DateTimeInterface {
return $this->updatedAt;
}
public function setUpdatedAt(\DateTime $updatedAt): void {
$this->updatedAt = $updatedAt;
}
public function getAccount(): Account {
return $this->account;
}
public function setAccount(Account $account): void {
$this->account = $account;
}
public function getTargetAccount(): Account {
return $this->targetAccount;
}
public function setTargetAccount(Account $targetAccount): void {
$this->targetAccount = $targetAccount;
}
public function isShowReblogs(): bool {
return $this->showReblogs;
}
public function setShowReblogs(bool $showReblogs): void {
$this->showReblogs = $showReblogs;
}
public function getUri(): string {
return $this->uri;
}
public function setUri(string $uri): void {
$this->uri = $uri;
}
public function isNotify(): bool {
return $this->notify;
}
public function setNotify(bool $notify): void {
$this->notify = $notify;
}
}

Wyświetl plik

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="social_instance")
*/
class Instance {
/**
* @ORM\Id
* @ORM\Column
*/
private string $domain = "";
/**
* @ORM\Column(type="integer")
*/
private int $accountsCount = -1;
public function getDomain(): string {
return $this->domain;
}
public function setDomain(string $domain): void {
$this->domain = $domain;
}
public function getAccountsCount(): int {
return $this->accountsCount;
}
public function setAccountsCount(int $accountsCount): void {
$this->accountsCount = $accountsCount;
}
}

Wyświetl plik

@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Entity;
use Doctrine\ORM\Mapping as ORM;
use OCP\IURLGenerator;
/**
* @ORM\Entity
* @ORM\Table(name="social_media_attachment")
*/
class MediaAttachment {
const TYPE_IMAGE = 1;
public const IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/x-xbitmap',
'image/x-ms-bmp',
'image/bmp',
'image/svg+xml',
'image/webp',
];
public const IMAGE_MIME_TYPES_CONVERSATION = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/jpg' => 'jpg',
'image/gif' => 'gif',
'image/x-xbitmap' => 'bmp',
'image/x-ms-bmp' => 'bmp',
'image/bmp' => 'bmp',
'image/svg+xml' => 'svg',
'image/webp' => 'webp',
];
/**
* @ORM\Id
* @ORM\Column(type="bigint")
* @ORM\GeneratedValue
*/
private ?string $id = '-1';
/**
* @ORM\ManyToOne
*/
private ?Status $status = null;
/**
* @ORM\Column(name="remote_url", nullable=false)
*/
private string $remoteUrl = "";
/**
* @ORM\Column(name="created_at", type="datetime", nullable=false)
*/
private \DateTime $createdAt;
/**
* @ORM\Column(name="updated_at", type="datetime", nullable=false)
*/
private \DateTime $updatedAt;
/**
* @ORM\Column
*/
private ?string $shortcode = null;
/**
* @ORM\Column
*/
private string $mimetype = 'image/png';
/**
* @ORM\Column(type="text")
*/
private string $description = '';
/**
* @ORM\Column
*/
private int $type = self::TYPE_IMAGE;
/**
* @ORM\Column(type="json")
*/
private ?array $meta;
/**
* @ORM\ManyToOne
*/
private ?Account $account = null;
/**
* @ORM\Column
*/
private string $blurhash = '';
public function __construct() {
$this->updatedAt = new \DateTime();
$this->createdAt = new \DateTime();
$this->meta = [];
}
static public function create(): self {
$attachement = new MediaAttachment();
$length = 14;
$length = ($length < 4) ? 4 : $length;
$attachement->setShortcode(bin2hex(random_bytes(($length - ($length % 2)) / 2)));
return $attachement;
}
public function getId(): string {
return $this->id;
}
public function setId(?string $id): void {
$this->id = $id;
}
public function getStatus(): ?Status {
return $this->status;
}
public function setStatus(?Status $status): void {
$this->status = $status;
}
public function getRemoteUrl(): string {
return $this->remoteUrl;
}
public function setRemoteUrl(string $remoteUrl): void {
$this->remoteUrl = $remoteUrl;
}
public function getCreatedAt(): \DateTime {
return $this->createdAt;
}
public function setCreatedAt(\DateTime $createdAt): void {
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): \DateTime {
return $this->updatedAt;
}
public function setUpdatedAt(\DateTime $updatedAt): void {
$this->updatedAt = $updatedAt;
}
public function getShortcode(): ?string {
return $this->shortcode;
}
public function setShortcode(?string $shortcode): void {
$this->shortcode = $shortcode;
}
public function getType(): int {
return $this->type;
}
public function setType(int $type): void {
$this->type = $type;
}
public function getMeta(): ?array {
return $this->meta;
}
public function setMeta(?array $meta): void {
$this->meta = $meta;
}
public function getAccount(): ?Account {
return $this->account;
}
public function setAccount(?Account $account): void {
$this->account = $account;
}
public function getBlurhash(): ?string {
return $this->blurhash;
}
public function setBlurhash(?string $blurhash): void {
$this->blurhash = $blurhash;
}
public function getDescription(): ?string {
return $this->description;
}
public function setDescription(?string $description): void {
$this->description = $description;
}
public function getMimetype(): string {
return $this->mimetype;
}
public function setMimetype(string $mimetype): void {
$this->mimetype = $mimetype;
}
function toMastodonApi(IURLGenerator $generator) {
return [
'id' => $this->getId(),
'url' => $generator->getAbsoluteURL('/apps/social/media/' . $this->getShortcode() . '.'),
'preview_url' => $generator->getAbsoluteURL('/apps/social/media/' . $this->getShortcode() . '.' . self::IMAGE_MIME_TYPES_CONVERSATION[$this->getMimetype()]),
'remote_url' => null,
'text_url' => $generator->getAbsoluteURL('/apps/social/media/' . $this->getShortcode()),
'description' => $this->getDescription(),
'meta' => $this->getMeta(),
];
}
}

Wyświetl plik

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="social_mention")
*/
class Mention {
/**
* @ORM\Id
* @ORM\Column(type="bigint")
* @ORM\GeneratedValue
*/
private string $id = "";
/**
* @ORM\ManyToOne
* @ORM\JoinColumn()
*/
private ?Status $status = null;
/**
* @ORM\ManyToOne
* @ORM\JoinColumn()
*/
private ?Account $account = null;
/**
* @ORM\Column(name="created_at", type="datetime", nullable=false)
*/
private \DateTimeInterface $createdAt;
/**
* @ORM\Column(name="updated_at", type="datetime", nullable=false)
*/
private \DateTimeInterface $updatedAt;
/**
* @ORM\Column
*/
private bool $silent = false;
public function __construct() {
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
}
public function getId(): string {
return $this->id;
}
public function getStatus(): ?Status {
return $this->status;
}
public function setStatus(?Status $status): self {
$this->status = $status;
return $this;
}
public function getAccount(): ?Account {
return $this->account;
}
public function setAccount(?Account $account): self {
$this->account = $account;
return $this;
}
public function getCreatedAt() {
return $this->createdAt;
}
public function setCreatedAt($createdAt): self {
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt() {
return $this->updatedAt;
}
public function setUpdatedAt($updatedAt): self {
$this->updatedAt = $updatedAt;
return $this;
}
public function isSilent(): bool {
return $this->silent;
}
public function setSilent(bool $silent): self {
$this->silent = $silent;
return $this;
}
}

Wyświetl plik

@ -0,0 +1,401 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Mapping as ORM;
use OCA\Social\Service\ActivityPub;
/**
* @ORM\Entity
* @ORM\Table(name="social_status")
* @ORM\HasLifecycleCallbacks
*/
class Status {
const STATUS_PUBLIC = "public";
const STATUS_UNLISTED = "unlisted";
const STATUS_PRIVATE = "private";
const STATUS_DIRECT = "direct";
/**
* @ORM\Id
* @ORM\Column(type="bigint")
* @ORM\GeneratedValue
*/
private string $id = "";
/**
* @ORM\Column(type="string", nullable=true)
*/
private ?string $uri = null;
/**
* @ORM\Column(type="text", nullable=false)
*/
private string $text = "";
/**
* @ORM\Column(name="created_at", type="datetime", nullable=false)
*/
private \DateTime $createdAt;
/**
* @ORM\Column(name="updated_at", type="datetime", nullable=false)
*/
private \DateTime $updatedAt;
/**
* @ORM\ManyToOne
* @ORM\JoinColumn(name="in_reply_to_id", referencedColumnName="id", nullable=true)
*/
private ?Status $inReplyTo = null;
/**
* @ORM\Column(type="string", nullable=true)
*/
private ?string $url = null;
/**
* @ORM\Column(name="`sensitive`")
*/
private bool $sensitive = false;
/**
* @ORM\Column
*/
private int $visibility = 0;
/**
* @ORM\Column(name="spoiler_text", type="text", nullable=false)
*/
private string $spoilerText = "";
/**
* @ORM\Column(type="boolean", nullable=false)
*/
private bool $reply = false;
/**
* @ORM\Column(type="string", nullable=false)
*/
private string $language = "en";
/**
* @ORM\Column(name="conversation_id", type="bigint", nullable=true)
*/
private ?string $conversationId = null;
/**
* @ORM\Column(name="local", type="boolean", nullable=false)
*/
private bool $local = false;
/**
* @ORM\ManyToOne
* @ORM\JoinColumn(nullable=false)
*/
private ?Account $account = null;
/**
* @ORM\ManyToOne
* @ORM\JoinColumn(name="application_id", referencedColumnName="id", nullable=true)
*/
private ?Application $application = null;
/**
* @ORM\Column(name="in_reply_to_account_id", type="bigint", nullable=true)
*/
private ?string $inReplyToAccountId = null;
/**
* @ORM\Column(name="poll_id", type="bigint", nullable=true)
*/
private ?string $pollId = null;
/**
* @ORM\Column(name="deleted_at", type="datetime", nullable=true)
*/
private ?\DateTime $deletedAt = null;
/**
* @ORM\Column(name="edited_at", type="datetime", nullable=true)
*/
private ?\DateTime $editedAt = null;
/**
* @ORM\Column(nullable=false)
*/
private bool $trendable = false;
/**
* @var list<int>
* @ORM\Column(name="ordered_media_attachment_ids", type="array", nullable=false)
*/
private array $orderedMediaAttachmentIds = [];
/**
* @ORM\OneToMany(targetEntity="Mention", mappedBy="status")
*/
private Collection $mentions;
public function __construct() {
$this->mentions = new ArrayCollection();
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
}
/**
* @ORM\PostPersist
*/
public function generateUri(): void {
if ($this->uri !== null) {
return;
}
$this->uri = ActivityPub\TagManager::getInstance()->uriFor($this);
}
public function getId(): string {
return $this->id;
}
public function setId(string $id): void {
$this->id = $id;
}
public function getUri(): ?string {
return $this->uri;
}
public function setUri(?string $uri): void {
$this->uri = $uri;
}
public function getText(): string {
return $this->text;
}
public function setText(string $text): void {
$this->text = $text;
}
public function getCreatedAt(): \DateTime {
return $this->createdAt;
}
public function setCreatedAt(\DateTime $createdAt): void {
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): \DateTime {
return $this->updatedAt;
}
public function setUpdatedAt(\DateTime $updatedAt): void {
$this->updatedAt = $updatedAt;
}
public function getInReplyTo(): ?Status {
return $this->inReplyTo;
}
public function setInReplyTo(?Status $inReplyTo): void {
$this->inReplyTo = $inReplyTo;
}
public function getReblogOf(): ?Status {
return $this->reblogOf;
}
public function setReblogOf(?Status $reblogOf): void {
$this->reblogOf = $reblogOf;
}
public function getUrl(): ?string {
return $this->url;
}
public function setUrl(?string $url): void {
$this->url = $url;
}
public function isSensitive(): bool {
return $this->sensitive;
}
public function setSensitive(bool $sensitive): void {
$this->sensitive = $sensitive;
}
public function getVisibility(): int {
return $this->visibility;
}
public function setVisibility(int $visibility): void {
$this->visibility = $visibility;
}
public function getSpoilerText(): string {
return $this->spoilerText;
}
public function setSpoilerText(string $spoilerText): void {
$this->spoilerText = $spoilerText;
}
public function isReply(): bool {
return $this->reply;
}
public function setReply(bool $reply): void {
$this->reply = $reply;
}
public function getLanguage(): string {
return $this->language;
}
public function setLanguage(string $language): void {
$this->language = $language;
}
public function getConversationId(): ?string {
return $this->conversationId;
}
public function setConversationId(?string $conversationId): void {
$this->conversationId = $conversationId;
}
public function isLocal(): bool {
return $this->local;
}
public function setLocal(bool $local): void {
$this->local = $local;
}
public function getAccount(): Account {
return $this->account;
}
public function setAccount(Account $account): void {
$this->account = $account;
}
public function getApplication(): ?Application {
return $this->application;
}
public function setApplication(?Application $application): void {
$this->application = $application;
}
public function getInReplyToAccountId(): ?string {
return $this->inReplyToAccountId;
}
public function setInReplyToAccountId(?string $inReplyToAccountId): void {
$this->inReplyToAccountId = $inReplyToAccountId;
}
public function getPollId(): ?string {
return $this->pollId;
}
public function setPollId(?string $pollId): void {
$this->pollId = $pollId;
}
public function getDeletedAt(): ?\DateTime {
return $this->deletedAt;
}
public function setDeletedAt(?\DateTime $deletedAt): void {
$this->deletedAt = $deletedAt;
}
public function getEditedAt(): ?\DateTime {
return $this->editedAt;
}
public function setEditedAt(?\DateTime $editedAt): void {
$this->editedAt = $editedAt;
}
public function isTrendable(): bool {
return $this->trendable;
}
public function setTrendable(bool $trendable): void {
$this->trendable = $trendable;
}
/**
* @return int[]
*/
public function getOrderedMediaAttachmentIds(): array {
return $this->orderedMediaAttachmentIds;
}
/**
* @param int[] $orderedMediaAttachmentIds
*/
public function setOrderedMediaAttachmentIds(array $orderedMediaAttachmentIds): void {
$this->orderedMediaAttachmentIds = $orderedMediaAttachmentIds;
}
public function getMentions(): Collection {
return $this->mentions;
}
/**
* @return Collection<Mention>
*/
public function getActiveMentions(): Collection {
$criteria = Criteria::create();
$criteria->where(Criteria::expr()->eq('silent', false));
return $this->mentions->matching($criteria);
}
public function setMentions(Collection $mentions): void {
$this->mentions = $mentions;
}
public function isReblog(): bool {
return $this->reblogOf !== null;
}
public function toMastodonApi(): array {
return [
'id' => $this->id,
'created_at' => $this->createdAt->format(\DateTimeInterface::ISO8601),
'in_reply_to_id' => $this->inReplyTo ? $this->inReplyTo->getId() : null,
'in_reply_to_account_id' => $this->inReplyTo ? $this->inReplyTo->getAccount()->getId() : null,
'sensitive' => $this->sensitive,
'spoiler_text' => $this->spoilerText,
'visibility' => $this->visibility,
'language' => $this->language,
'uri' => $this->uri,
'url' => $this->url,
'replies_count' => 0,
'reblogs_count' => 0,
'favourites_count' => 0,
'favourited' => false,
'reblogged' => false,
'muted' => false,
'bookmarked' => false,
'content' => $this->text,
'reblog' => $this->reblogOf,
'application' => $this->application ? $this->application->toMastodonApi() : null,
'account' => $this->account->toMastodonApi(),
];
}
}

Wyświetl plik

@ -0,0 +1,29 @@
<?php
namespace OCA\Social;
use OCP\IURLGenerator;
class InstanceUtils {
private IURLGenerator $generator;
public function __construct(IURLGenerator $generator) {
$this->generator = $generator;
}
/**
* Return the url of the instance: e.g. https://hello.social
*/
public function getLocalInstanceUrl(): string {
$url = $this->generator->getAbsoluteURL('/');
return rtrim($url, '/');
}
/**
* Return the name of the instance: e.g. hello.social
*/
public function getLocalInstanceName(): string {
$url = $this->generator->getAbsoluteURL('/');
$url = rtrim($url, '/');
return substr($url, 8);
}
}

Wyświetl plik

@ -283,7 +283,7 @@ class ACore extends Item implements JsonSerializable {
*
* @throws UrlCloudException
*/
public function generateUniqueId(string $base = '', bool $root = true) {
public function generateUniqueId(string $base = '', bool $root = true): void {
$url = '';
if ($root) {
$url = $this->getUrlCloud();

Wyświetl plik

@ -31,34 +31,27 @@ declare(strict_types=1);
namespace OCA\Social\Model\ActivityPub\Object;
use OCA\Social\Tools\IQueryRow;
use OCA\Social\Entity\Follow as FollowEntitiy;
use JsonSerializable;
use OCA\Social\Model\ActivityPub\ACore;
/**
* Virtual rep
* Class Follow
*
* @package OCA\Social\Model\ActivityPub\Object
*/
class Follow extends ACore implements JsonSerializable, IQueryRow {
class Follow extends ACore implements JsonSerializable {
public const TYPE = 'Follow';
private string $followId = '';
private string $followIdPrim = '';
private bool $accepted = false;
/**
* Follow constructor.
*
* @param ACore $parent
*/
static public function create(FollowEntitiy $follow): self {
$followActivity = new Follow();
$followActivity->setId($follow->getUri() ?: $follow->getAccount()->getUri() . '#follows/' . $follow->getId());
$followActivity->setActor($follow->getAccount());
$followActivity->setVirtualObject();
return $followActivity
}
public function __construct($parent = null) {
parent::__construct($parent);
$this->setType(self::TYPE);
}
@ -119,27 +112,6 @@ class Follow extends ACore implements JsonSerializable, IQueryRow {
return $this;
}
/**
* @param array $data
*/
public function import(array $data) {
parent::import($data);
}
/**
* @param array $data
*/
public function importFromDatabase(array $data) {
parent::importFromDatabase($data);
$this->setAccepted(($this->getInt('accepted', $data, 0) === 1) ? true : false);
$this->setFollowId($this->get('follow_id', $data, ''));
$this->setFollowIdPrim($this->get('follow_id_prim', $data, ''));
}
/**
* @return array
*/
@ -150,9 +122,6 @@ class Follow extends ACore implements JsonSerializable, IQueryRow {
$result = array_merge(
$result,
[
'follow_id' => $this->getFollowId(),
'follow_id_prim' => $this->getFollowIdPrim(),
'accepted' => $this->isAccepted()
]
);
}

Wyświetl plik

@ -0,0 +1,12 @@
<?php
namespace OCA\Social\Repository;
use OC\DB\ORM\EntityRepositoryAdapter;
use OCA\Social\Entity\Instance;
class InstanceRepository extends EntityRepositoryAdapter {
public function getLocalInstance(): Instance {
return $this->findOneBy(['local' => true]);
}
}

Wyświetl plik

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Serializer;
use OCA\Social\Entity\Account;
use OCA\Social\InstanceUtils;
use OCP\IRequest;
use OCP\IUserManager;
class AccountSerializer extends ActivityPubSerializer {
private IUserManager $userManager;
private InstanceUtils $instanceUtils;
public function __construct(IUserManager $userManager, InstanceUtils $instanceUtils) {
$this->userManager = $userManager;
$this->instanceUtils = $instanceUtils;
}
public function toJsonLd(object $account): array {
assert($account instanceof Account && $account->isLocal());
$user = $this->userManager->get($account->getUserId());
$baseUrl = $this->instanceUtils->getLocalInstanceUrl() . '/';
$baseUserUrl = $baseUrl . "/users/" . $account->getUserName() . '/';
return array_merge($this->getContext(), [
"id" => $baseUrl . $account->getUserName(),
"type" => $account->getActorType(),
"following" => $baseUserUrl . "following",
"followers" => $baseUserUrl . "followers",
"inbox" => $baseUserUrl . "inbox",
"outbox" => $baseUserUrl . "outbox",
"preferredUsername" => $account->getUserName(),
"name" => $user->getDisplayName(),
"publicKey" => [
"id" => $baseUrl . $account->getUserName() . "#main-key",
"owner" => $baseUrl . $account->getUserName(),
"publicKeyPem" => $account->getPublicKey(),
]
]);
}
}

Wyświetl plik

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Serializer;
/**
* @template T
*/
abstract class ActivityPubSerializer {
/**
* @param T $account
* @return array
*/
abstract public function toJsonLd(object $account): array;
protected function getContext(): array {
// Provide namespace information
return [
"@context" => [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
[
"manuallyApprovesFollowers" => "as:manuallyApprovesFollowers",
"toot" => "http://joinmastodon.org/ns#",
"featured" => [
"@id" => "toot:featured",
"@type" => "@id",
],
"featuredTags" => [
"@id" => "toot:featuredTags",
"@type" => "@id",
],
"alsoKnownAs" => [
"@id" => "as:alsoKnownAs",
"@type" => "@id",
],
"movedTo" => [
"@id" => "as=>movedTo",
"@type" => "@id"
],
"schema" => "http=>//schema.org#",
"PropertyValue" => "schema:PropertyValue",
"value" => "schema:value",
"discoverable" => "toot:discoverable",
"Device" => "toot:Device",
"Ed25519Signature" => "toot:Ed25519Signature",
"Ed25519Key" => "toot:Ed25519Key",
"Curve25519Key" => "toot:Curve25519Key",
"EncryptedMessage" => "toot:EncryptedMessage",
"publicKeyBase64" => "toot:publicKeyBase64",
"deviceId" => "toot:deviceId",
"claim" => [
"@type" => "@id",
"@id" => "toot:claim"
],
"fingerprintKey" => [
"@type" => "@id",
"@id" => "toot:fingerprintKey"
],
"identityKey" => [
"@type" => "@id",
"@id" => "toot:identityKey"
],
"devices" => [
"@type" => "@id",
"@id" => "toot:devices"
],
"messageFranking" => "toot:messageFranking",
"messageType" => "toot:messageType",
"cipherText" => "toot:cipherText",
"suspended" => "toot:suspended",
"focalPoint" => [
"@container" => "@list",
"@id" => "toot:focalPoint"
]
]
]
];
}
}

Wyświetl plik

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Serializer;
use Psr\Container\ContainerInterface;
class SerializerFactory {
/**
* @template T
* @var array<class-string<T>, class-string<ActivityPubSerializer<T>>>
*/
private array $serializers = [];
private ContainerInterface $container;
public function __construct(ContainerInterface $container) {
$this->container = $container;
}
/**
* @template T
* @param class-string<T> $className
* @param class-string<ActivityPubSerializer<T>> $serializerName
*/
public function registerSerializer(string $className, string $serializerName): void {
$this->serializers[$className] = $serializerName;
}
/**
* @template T
* @param T $object
* @return ActivityPubSerializer<T>
*/
public function getSerializerFor(object $object): ActivityPubSerializer {
return $this->container->get($this->serializers[get_class($object)]);
}
}

Wyświetl plik

@ -0,0 +1,107 @@
<?php
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Service;
use Doctrine\Common\Collections\Collection;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Follow;
use OCA\Social\Entity\Instance;
use OCP\DB\ORM\IEntityManager;
use OCP\DB\ORM\IEntityRepository;
use OCP\DB\ORM\NoResultException;
use OCP\IRequest;
use OCP\IUser;
class AccountFinder {
private IEntityManager $entityManager;
private IEntityRepository $repository;
private IRequest $request;
private ?Account $representative = null;
public function __construct(IEntityManager $entityManager, IRequest $request) {
$this->entityManager = $entityManager;
$this->repository = $this->entityManager->getRepository(Account::class);
$this->request = $request;
}
public function findRemote(string $userName, ?string $domain): ?Account {
if ($domain !== null) {
$instance = new Instance();
$instance->setDomain($domain);
return $this->entityManager->createQuery('SELECT a FROM \OCA\Social\Entity\Account a WHERE a.instance = :instance AND a.userName = :userName')
->setParameters([
'instance' => $instance,
'userName' => $userName,
])->getOneOrNullResult();
} else {
return $this->entityManager->createQuery('SELECT a FROM \OCA\Social\Entity\Account a WHERE a.instance is NULL AND a.userName = :userName')
->setParameters([
'userName' => $userName,
])->getOneOrNullResult();
}
}
public function findLocal(string $userName): ?Account {
return $this->findRemote($userName, null);
}
public function getAccountByNextcloudId(string $userId): ?Account {
return $this->repository->findOneBy([
'userId' => $userId,
]);
}
public function getCurrentAccount(IUser $user): Account {
$account = $this->getAccountByNextcloudId($user->getUID());
if ($account) {
return $account;
}
$account = Account::newLocal();
$account->setUserName($user->getUID());
$account->setUserId($user->getUID());
$account->setName($user->getDisplayName());
$account->generateKeys();
$this->entityManager->persist($account);
$this->entityManager->flush();
return $account;
}
public function getRepresentative(): Account {
if ($this->representative !== null) {
return $this->representative;
}
$account = $this->repository->findOneBy([
'userId' => '__self',
]);
if ($account) {
$this->representative = $account;
return $account;
}
$account = Account::newLocal();
$account->setRepresentative()
->setActorType(Account::TYPE_APPLICATION)
->setUserName($this->request->getServerHost())
->setUserId('__self')
->setLocked(true)
->generateKeys();
$this->entityManager->persist($account);
$this->entityManager->flush();
$this->representative = $account;
return $account;
}
/**
* @param Account $account
* @return array<Follow>
*/
public function getLocalFollowersOf(Account $account): array {
return $this->entityManager
->createQuery('SELECT f,a FROM \OCA\Social\Entity\Follow f LEFT JOIN f.account a WHERE f.targetAccount = :target')
->setParameters(['target' => $account])
->getResult();
}
}

Wyświetl plik

@ -0,0 +1,49 @@
<?php
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Service\ActivityPub;
use OCA\Social\Entity\Account;
use OCP\IRequest;
class RemoteAccountFetchOption {
public bool $id = true;
public ?string $prefetchedBody = null;
public bool $breakOnRedirect = false;
public bool $onlyKey = false;
static public function default(): self {
return new self();
}
}
class RemoteAccountFetcher {
private IRequest $request;
private TagManager $tagManager;
public function __construct(IRequest $request) {
$this->request = $request;
$this->tagManager = TagManager::getInstance();
}
public function fetch(?string $uri, RemoteAccountFetchOption $fetchOption): ?Account {
if ($this->tagManager->isLocalUri($uri)) {
return $this->tagManager->uriToResource($uri, Account::class);
}
if ($fetchOption->prefetchedBody !== null) {
$json = json_decode($fetchOption->prefetchedBody);
} else {
$json = $this->fetchResource($uri, $fetchOption->id);
}
return null;
}
public function fetchResource() {
}
}

Wyświetl plik

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Service\ActivityPub;
use OCA\Social\Entity\Account;
use OCA\Social\Service\AccountFinder;
use OCA\Social\Tools\Model\NCRequest;
use OCA\Social\Tools\Model\Request;
use OCA\Social\Tools\Model\Uri;
class SignedRequest extends Request {
private ?Account $account = null;
const FORMAT_URI = 'URI';
const FORMAT_ACCOUNT = 'ACCOUNT';
public const DATE_HEADER = 'D, d M Y H:i:s T';
public const DATE_OBJECT = 'Y-m-d\TH:i:s\Z';
private bool $alreadySigned = false;
public function __construct(Uri $url, int $type = 0, bool $binary = false) {
parent::__construct($url, $type, $binary);
}
/**
* @param self::FORMAT_* $keyIdFormat
*/
public function setOnBehalfOf(Account $onBehalfOf): self {
$this->account = $onBehalfOf;
return $this;
}
public function setKeyIdFormat(string $keyIdFormat = self::FORMAT_URI): self {
$this->format = $keyIdFormat;
return $this;
}
public function sign() {
if ($this->alreadySigned) {
throw new \RuntimeException('Trying to sign a request two times');
}
$date = gmdate(self::DATE_HEADER);
$headersElements = ['(request-target)', 'content-length', 'date', 'host', 'digest'];
$allElements = [
'(request-target)' => Request::method($this->getType()) . ' ' . $this->getPath(),
'date' => $date,
'host' => $this->getHost(),
'digest' => $this->generateDigest($this->getDataBody()),
'content-length' => strlen($this->getDataBody())
];
$signing = $this->generateHeaders($headersElements, $allElements);
openssl_sign($signing, $signed, $this->account->getPrivateKey(), OPENSSL_ALGO_SHA256);
$signed = base64_encode($signed);
$signature = $this->generateSignature($headersElements, $this->account->getUserName(), $signed);
$this->addHeader('Signature', $signature);
}
private function generateHeaders(array $elements, array $data): string {
$signingElements = [];
foreach ($elements as $element) {
$signingElements[] = $element . ': ' . $data[$element];
$this->addHeader($element, $data[$element]);
}
return implode("\n", $signingElements);
}
private function generateSignature(array $elements, string $actorId, string $signed): string {
$signatureElements[] = 'keyId="' . $actorId . '#main-key"';
$signatureElements[] = 'algorithm="rsa-sha256"';
$signatureElements[] = 'headers="' . implode(' ', $elements) . '"';
$signatureElements[] = 'signature="' . $signed . '"';
return implode(',', $signatureElements);
}
private function generateDigest(string $data): string {
$encoded = hash("sha256", utf8_encode($data), true);
return 'SHA-256=' . base64_encode($encoded);
}
}

Wyświetl plik

@ -0,0 +1,49 @@
<?php
namespace OCA\Social\Service\ActivityPub;
use OCA\Social\Entity\Account;
use OCA\Social\Tools\Model\NCRequest;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\ICache;
use OCP\ICacheFactory;
/**
* Service responsible for fetching and caching JSON-Ld activity stream
*/
class JsonLdService {
private IClient $client;
private ICache $jsonLdCache;
public function __construct(ICacheFactory $cacheFactory, IClientService $clientService) {
$this->client = $clientService->newClient();
$this->jsonLdCache = $cacheFactory->createLocal('social.jsonld');
}
public function fetchResource(string $uri, bool $id, ?Account $onBehalfOf = null) {
if (!$id) {
$json = $this->fetchResourceWithoutIdValidation($uri, $onBehalfOf);
}
}
private function fetchResourceWithoutIdValidation(string $uri, ?Account $onBehalfOf): array {
$this->client->get($uri, [
'header' => [
'Accept' => 'application/activity+json, application/ld+json',
],
]);
}
public function onBehalfOf(Account $account, $keyIdFormat, string $signWith): array {
return [];
}
private function buildRequest(?Account $onBehalfOf): SignedRequest {
$request = new SignedRequest();
$request->setOnBehalfOf($onBehalfOf);
$request->addHeader('Accept', 'application/activity+json, application/ld+json');
return $request;
}
}

Wyświetl plik

@ -0,0 +1,100 @@
<?php
namespace OCA\Social\Service\ActivityPub;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Status;
use OCA\Social\InstanceUtils;
use OCP\IRequest;
final class TagManager {
private IRequest $request;
static private ?TagManager $instance = null;
public static function getInstance(): self {
if (self::$instance === null) {
self::$instance = new TagManager(\OCP\Server::get(IRequest::class));
}
return self::$instance;
}
private function __construct(IRequest $request) {
$this->request = $request;
}
private function __clone() {
}
/**
* @template T
* @param class-string<T> $className
* @return ?T
*/
public function uriToResource(string $uri, string $className): object {
if ($this->isLocalUri($uri)) {
// Find resource but from the DB
switch ($className) {
case Account::class:
return null; // TODO
case Status::class:
return null; // TODO
return null;
}
} else {
// Find remote resource
}
}
public function isLocalUri(?string $uri) {
if ($uri === null) {
return false;
}
$parsedUrl = parse_url($uri);
if (!isset($parsedUrl['host'])) {
return false;
}
$host = $parsedUrl['host'];
if (isset($parsedUrl['port'])) {
$host = $host . ':' . $parsedUrl['port'];
}
return $host === $this->request->getServerHost();
}
public function uriFor(object $target): string {
if ($target->getUri()) {
return $target->getUri();
}
$instanceUtils = \OCP\Server::get(InstanceUtils::class);
if ($target instanceof Status) {
if ($target->isReblog()) {
// todo
}
return $instanceUtils->getLocalInstanceUrl() . '/users/' . $target->getAccount()->getUserName() . '/statues/' . $target->getId();
}
}
public function urlFor(object $target): string {
if ($target->getUrl()) {
return $target->getUrl();
}
$instanceUtils = \OCP\Server::get(InstanceUtils::class);
if ($target instanceof Status) {
if ($target->isReblog()) {
// todo
}
return $instanceUtils->getLocalInstanceUrl() . '/@' . $target->account->getUserName() . '/' . $target->getId();
}
}
public function __wakeup() {
throw new \Exception("Cannot unserialize singleton");
}
}

Wyświetl plik

@ -0,0 +1,6 @@
<?php
namespace OCA\Social\Service;
class ApiException extends \InvalidArgumentException {
}

Wyświetl plik

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
// Nextcloud Social
// SPDX-FileCopyrightText: 2018 <maxence@artificial-owl.com>
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Service;
use OCA\Social\Entity\Application;
use Exception;
use OCA\Social\Exceptions\ClientException;
use OCA\Social\Exceptions\ClientNotFoundException;
use OCA\Social\Tools\Traits\TStringTools;
use OCP\DB\ORM\IEntityManager;
use OCP\DB\ORM\IEntityRepository;
class ApplicationService {
public const TIME_TOKEN_REFRESH = 300; // 5m
// const TIME_TOKEN_TTL = 21600; // 6h
// const TIME_AUTH_TTL = 30672000; // 1y
// looks like there is no token refresh. token must have been updated in the last year.
public const TIME_TOKEN_TTL = 30672000; // 1y
use TStringTools;
private IEntityManager $em;
/** @var IEntityRepository<Application> */
private IEntityRepository $applicationRepository;
public function __construct(IEntityManager $em) {
$this->em = $em;
$this->applicationRepository = $em->getRepository(Application::class);
}
/**
* @throws ClientException
*/
public function createApp(Application $application): void {
if ($application->getAppName() === '') {
throw new ClientException('missing client_name');
}
if (empty($application->getAppRedirectUris())) {
throw new ClientException('missing redirect_uris');
}
$application->setAppClientId($this->token(40));
$application->setAppClientSecret($this->token(40));
$this->em->persist($application);
$this->em->flush();
}
public function authClient(Application $client) {
$client->setAuthCode($this->token(60));
$this->em->flush();
}
public function generateToken(Application $client): void {
$client->setToken($this->token(80));
$this->em->flush();
}
public function getFromClientId(string $clientId): Application {
return $this->applicationRepository->findOneBy([
'appClientId' => $clientId,
]);
}
/**
* @throws ClientNotFoundException
*/
public function getFromToken(string $token): Application {
/** @var Application $application */
$application = $this->applicationRepository->findOneBy(['token' => $token]);
if ($application->getLastUpdate() + self::TIME_TOKEN_TTL < time()) {
try {
$this->em->remove($application);
$this->em->flush();
} catch (Exception $e) {
}
throw new ClientNotFoundException();
}
if ($application->getLastUpdate() + self::TIME_TOKEN_REFRESH > time()) {
$application->setLastUpdate((new \DateTime('now'))->getTimestamp());
$this->em->flush();
}
return $application;
}
/**
* @throws ClientException
*/
public function confirmData(Application $client, array $data): void {
if (array_key_exists('redirect_uri', $data)
&& !in_array($data['redirect_uri'], $client->getAppRedirectUris())) {
throw new ClientException('unknown redirect_uri');
}
if (array_key_exists('client_secret', $data)
&& $data['client_secret'] !== $client->getAppClientSecret()) {
throw new ClientException('wrong client_secret');
}
if (array_key_exists('app_scopes', $data)) {
$scopes = $data['app_scopes'];
if (!is_array($scopes)) {
$scopes = $client->getScopesFromString($scopes);
}
foreach ($scopes as $scope) {
if (!in_array($scope, $client->getAppScopes())) {
throw new ClientException('invalid scope');
}
}
}
if (array_key_exists('auth_scopes', $data)) {
$scopes = $data['auth_scopes'];
if (!is_array($scopes)) {
$scopes = $client->getScopesFromString($scopes);
}
foreach ($scopes as $scope) {
if (!in_array($scope, $client->getAuthScopes())) {
throw new ClientException('invalid scope');
}
}
}
if (array_key_exists('code', $data) && $data['code'] !== $client->getAuthCode()) {
throw new ClientException('unknown code');
}
}
}

Wyświetl plik

@ -57,7 +57,6 @@ class CheckService {
use TArrayTools;
use TStringTools;
public const CACHE_PREFIX = 'social_check_';
private IUserManager $userManager;

Wyświetl plik

@ -1,200 +0,0 @@
<?php
declare(strict_types=1);
/**
* Nextcloud - Social Support
*
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
* @author Maxence Lange <maxence@artificial-owl.com>
* @copyright 2018, Maxence Lange <maxence@artificial-owl.com>
* @license GNU AGPL version 3 or any later version
*
* This program 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.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Social\Service;
use OCA\Social\Tools\Traits\TStringTools;
use Exception;
use OCA\Social\Db\ClientRequest;
use OCA\Social\Exceptions\ClientException;
use OCA\Social\Exceptions\ClientNotFoundException;
use OCA\Social\Model\Client\SocialClient;
/**
* Class ClientService
*
* @package OCA\Social\Service
*/
class ClientService {
public const TIME_TOKEN_REFRESH = 300; // 5m
// const TIME_TOKEN_TTL = 21600; // 6h
// const TIME_AUTH_TTL = 30672000; // 1y
// looks like there is no token refresh. token must have been updated in the last year.
public const TIME_TOKEN_TTL = 30672000; // 1y
use TStringTools;
private ClientRequest $clientRequest;
private MiscService $miscService;
/**
* ClientService constructor.
*
* @param ClientRequest $clientRequest
* @param MiscService $miscService
*/
public function __construct(ClientRequest $clientRequest, MiscService $miscService) {
$this->clientRequest = $clientRequest;
$this->miscService = $miscService;
}
/**
* @param SocialClient $client
*
* @throws ClientException
*/
public function createApp(SocialClient $client): void {
if ($client->getAppName() === '') {
throw new ClientException('missing client_name');
}
if (empty($client->getAppRedirectUris())) {
throw new ClientException('missing redirect_uris');
}
$client->setAppClientId($this->token(40));
$client->setAppClientSecret($this->token(40));
$this->clientRequest->saveApp($client);
}
/**
* @param SocialClient $client
*/
public function authClient(SocialClient $client) {
$client->setAuthCode($this->token(60));
// $clientAuth->setClientId($client->getId());
$this->clientRequest->authClient($client);
}
/**
* @param SocialClient $client
*/
public function generateToken(SocialClient $client): void {
$client->setToken($this->token(80));
$this->clientRequest->updateToken($client);
}
/**
* @param string $clientId
*
* @return SocialClient
* @throws ClientNotFoundException
*/
public function getFromClientId(string $clientId): SocialClient {
return $this->clientRequest->getFromClientId($clientId);
}
/**
* @param string $token
*
* @return SocialClient
* @throws ClientNotFoundException
*/
public function getFromToken(string $token): SocialClient {
$client = $this->clientRequest->getFromToken($token);
if ($client->getLastUpdate() + self::TIME_TOKEN_TTL < time()) {
try {
$this->clientRequest->deprecateToken();
} catch (Exception $e) {
}
throw new ClientNotFoundException();
}
if ($client->getLastUpdate() + self::TIME_TOKEN_REFRESH > time()) {
$this->clientRequest->updateTime($client);
}
return $client;
}
/**
* @param SocialClient $client
* @param array $data
*
* @throws ClientException
*/
public function confirmData(SocialClient $client, array $data) {
if (array_key_exists('redirect_uri', $data)
&& !in_array($data['redirect_uri'], $client->getAppRedirectUris())) {
throw new ClientException('unknown redirect_uri');
}
if (array_key_exists('client_secret', $data)
&& $data['client_secret'] !== $client->getAppClientSecret()) {
throw new ClientException('wrong client_secret');
}
if (array_key_exists('app_scopes', $data)) {
$scopes = $data['app_scopes'];
if (!is_array($scopes)) {
$scopes = $client->getScopesFromString($scopes);
}
foreach ($scopes as $scope) {
if (!in_array($scope, $client->getAppScopes())) {
throw new ClientException('invalid scope');
}
}
}
if (array_key_exists('auth_scopes', $data)) {
$scopes = $data['auth_scopes'];
if (!is_array($scopes)) {
$scopes = $client->getScopesFromString($scopes);
}
foreach ($scopes as $scope) {
if (!in_array($scope, $client->getAuthScopes())) {
throw new ClientException('invalid scope');
}
}
}
if (array_key_exists('code', $data) && $data['code'] !== $client->getAuthCode()) {
throw new ClientException('unknown code');
}
}
}

Wyświetl plik

@ -307,19 +307,8 @@ class ConfigService {
* @throws SocialAppConfigException
*/
public function getCloudUrl(bool $noPhp = false) {
$address = $this->getAppValue(self::CLOUD_URL);
if ($address === '') {
throw new SocialAppConfigException();
}
if ($noPhp) {
$pos = strpos($address, '/index.php');
if ($pos) {
$address = substr($address, 0, $pos);
}
}
return $this->withoutEndSlash($address, false, false);
$url = $this->urlGenerator->getAbsoluteURL('/');
return rtrim($url, '/');
}
/**

Wyświetl plik

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Service\Feed;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Status;
/**
* This service handle storing and retrieving the feeds from Redis
*/
class FeedManager {
/**
* Number of items in the feed since last reblog of status
* before the new reblog will be inserted. Must be <= MAX_ITEMS
* or the tracking sets will grow forever
*/
const REBLOG_FALLOFF = 40;
const HOME_FEED = "home";
const MAX_ITEM = 400;
private IFeedProvider $feedProvider;
public function __construct(IFeedProvider $feedProvider) {
$this->feedProvider = $feedProvider;
}
public function addToHome(string $accountId, Status $status): bool {
return $this->addToFeed(self::HOME_FEED, $accountId, $status);
}
public function removeFromHome(string $accountId, Status $status): bool {
return $this->removeFromFeed(self::HOME_FEED, $accountId, $status);
}
public function addToFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool {
return $this->feedProvider->addToFeed($timelineType, $accountId, $status, $aggregateReblog);
}
public function removeFromFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool {
return $this->feedProvider->removeFromFeed($timelineType, $accountId, $status, $aggregateReblog);
}
public function mergeIntoHome(Account $fromAccount, Account $toAccount): void {
$this->feedProvider->mergeIntoHome($fromAccount, $toAccount);
}
}

Wyświetl plik

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Service\Feed;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Status;
/**
* Interface abstracting the feed. Currently, there is only one implementation
* relying on Redis.
*/
interface IFeedProvider {
/**
* Add a status from a feed
*/
public function addToFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool;
/**
* Remove a status from a feed
*/
public function removeFromFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool;
/**
* Fill a home feed with an account's status
*/
public function mergeIntoHome(Account $fromAccount, Account $toAccount): void;
}

Wyświetl plik

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Service\Feed;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Mention;
use OCA\Social\Entity\Status;
use OCA\Social\Service\AccountFinder;
use OCA\Social\Service\Feed\FeedManager;
class PostDeliveryService {
private FeedManager $feedManager;
private AccountFinder $accountFinder;
public function __construct(FeedManager $feedManager, AccountFinder $accountFinder) {
$this->feedManager = $feedManager;
$this->accountFinder = $accountFinder;
}
public function run(Status $status): void {
$author = $status->getAccount();
// deliver to self
if ($status->isLocal()) {
$this->feedManager->addToHome($author->getId(), $status);
}
// deliver to mentioned accounts
$status->getActiveMentions()->forAll(function ($mention) use ($status): void{
if ($mention && $mention->getAccount()->isLocal()) {
$this->deliverLocalAccount($status, $mention->getAccount());
}
});
// deliver to local followers
$localFollowers = $this->accountFinder->getLocalFollowersOf($author);
foreach ($localFollowers as $follower) {
$this->deliverLocalAccount($status, $follower->getAccount());
};
}
public function deliverLocalAccount(Status $status, Account $account) {
assert($account->isLocal());
// TODO create notification
$this->feedManager->addToHome($account->getId(), $status);
}
}

Wyświetl plik

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Service\Feed;
use OC\RedisFactory;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Status;
class RedisFeedProvider implements IFeedProvider {
private \Redis $redis;
public function __construct(RedisFactory $redisFactory) {
$this->redis = $redisFactory->getInstance();
}
private function key(string $feedName, string $accountId, ?string $subType = null) {
if ($subType === null) {
return 'feed:' . $feedName . ':' . $accountId;
}
return 'feed:' . $feedName . ':' . $accountId . ':' . $subType;
}
public function addToFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool {
$timelineKey = $this->key($timelineType, $accountId);
$reblogKey = $this->key($timelineType, $accountId, 'reblogs');
if ($status->isReblog() && $aggregateReblog) {
$rank = $this->redis->zRevRank($timelineKey, $status->getReblogOf()->getId());
if ($rank !== null && $rank < FeedManager::REBLOG_FALLOFF) {
return false;
}
if ($this->redis->zAdd($reblogKey, ['NX'], $status->getId(), $status->getReblogOf()->getId())) {
$this->redis->zAdd($timelineKey, $status->getId(), $status->getReblogOf()->getId());
} else {
$reblogSetKey = $this->key($timelineType, $accountId, 'reblogs:' . $status->getReblogOf()->getId());
$this->redis->sAdd($reblogSetKey, $status->getId());
return false;
}
} else {
if ($this->redis->zScore($reblogKey, $status->getId()) === false) {
return false;
}
$this->redis->zAdd($timelineKey, $status->getId(), $status->getId());
}
return true;
}
public function removeFromFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool {
return false;
}
public function mergeIntoHome(Account $fromAccount, Account $intoAccount): void {
$timelineKey = $this->key(FeedManager::HOME_FEED, $intoAccount->getId());
$aggregate = true; // TODO make configurable
if ($this->redis->zCard($timelineKey) > (FeedManager::MAX_ITEM / 4)) {
$oldestHomeScore = $this->redis->zRange($timelineKey, 0, 0, true);
}
}
}

Wyświetl plik

@ -30,79 +30,126 @@ declare(strict_types=1);
namespace OCA\Social\Service;
use OCA\Social\Tools\Exceptions\MalformedArrayException;
use OCA\Social\Tools\Traits\TArrayTools;
use ActivityPhp\Type;
use OCA\Social\AP;
use OCA\Social\Db\FollowsRequest;
use OCA\Social\Exceptions\CacheActorDoesNotExistException;
use OCA\Social\Exceptions\FollowNotFoundException;
use OCA\Social\Exceptions\FollowSameAccountException;
use OCA\Social\Exceptions\InvalidOriginException;
use OCA\Social\Exceptions\InvalidResourceException;
use OCA\Social\Exceptions\ItemUnknownException;
use OCA\Social\Exceptions\RedundancyLimitException;
use OCA\Social\Tools\Exceptions\RequestContentException;
use OCA\Social\Tools\Exceptions\RequestNetworkException;
use OCA\Social\Tools\Exceptions\RequestResultNotJsonException;
use OCA\Social\Tools\Exceptions\RequestResultSizeException;
use OCA\Social\Tools\Exceptions\RequestServerException;
use OCA\Social\Exceptions\RetrieveAccountFormatException;
use OCA\Social\Exceptions\SocialAppConfigException;
use OCA\Social\Exceptions\UnauthorizedFediverseException;
use OCA\Social\Exceptions\UrlCloudException;
use OCA\Social\Model\ActivityPub\Activity\Undo;
use OCA\Social\Model\ActivityPub\Actor\Person;
use OCA\Social\Model\ActivityPub\Object\Follow;
use OCA\Social\Model\ActivityPub\OrderedCollection;
use OCA\Social\Model\InstancePath;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Follow;
use OCA\Social\Entity\FollowRequest;
use OCA\Social\Service\Feed\FeedManager;
use OCP\DB\ORM\IEntityManager;
use OCP\DB\ORM\IEntityRepository;
class FollowOption {
/**
* Show reblog of the account
*/
public bool $showReblogs = true;
/**
* Notify about new posts
*/
public bool $notify = false;
static public function default(): self {
return new FollowOption();
}
}
class FollowService {
use TArrayTools;
private IEntityManager $entityManager;
/** @var IEntityRepository<Follow> $followRepository */
private IEntityRepository $followRepository;
/** @var IEntityRepository<FollowRequest> $followRepository */
private IEntityRepository $followRequestRepository;
private FeedManager $feedManager;
private FollowsRequest $followsRequest;
private ActivityService $activityService;
private CacheActorService $cacheActorService;
private ConfigService $configService;
private MiscService $miscService;
private ?Person $viewer = null;
/**
* FollowService constructor.
*
* @param FollowsRequest $followsRequest
* @param ActivityService $activityService
* @param CacheActorService $cacheActorService
* @param ConfigService $configService
* @param MiscService $miscService
*/
public function __construct(
FollowsRequest $followsRequest, ActivityService $activityService,
CacheActorService $cacheActorService, ConfigService $configService, MiscService $miscService
) {
$this->followsRequest = $followsRequest;
$this->activityService = $activityService;
$this->cacheActorService = $cacheActorService;
$this->configService = $configService;
$this->miscService = $miscService;
public function __construct(IEntityManager $entityManager, FeedManager $feedManager) {
$this->entityManager = $entityManager;
$this->followRepository = $entityManager->getRepository(Follow::class);
$this->followRepository = $entityManager->getRepository(FollowRequest::class);
$this->feedManager = $feedManager;
}
public function follow(Account $sourceAccount, Account $targetAccount, FollowOption $option): void {
if ($sourceAccount->following($targetAccount)) {
$this->updateFollow($sourceAccount, $targetAccount, $option->notify, $option->showReblogs);
return;
} elseif ($sourceAccount->followRequested($targetAccount)) {
$this->updateFollowRequest($sourceAccount, $targetAccount, $option->notify, $option->showReblogs);
return;
}
/**
* @param Person $viewer
*/
public function setViewer(Person $viewer) {
$this->viewer = $viewer;
$this->followsRequest->setViewer($viewer);
if ($targetAccount->isLocked() || !$targetAccount->isLocal()) {
$this->requestFollow($sourceAccount, $targetAccount);
} else {
$this->directFollow($sourceAccount, $targetAccount);
}
}
private function updateFollow(Account $sourceAccount, Account $targetAccount, bool $notify, bool $showReblogs): void {
/** @var Follow $follow */
$follow = $this->followRepository->findOneBy([
'account' => $sourceAccount,
'targetAccount' => $targetAccount,
]);
assert($follow);
$follow->setNotify($notify);
$follow->setShowReblogs($showReblogs);
$this->entityManager->persist($follow);
$this->entityManager->flush();
}
private function updateFollowRequest(Account $sourceAccount, Account $targetAccount, bool $notify, bool $showReblogs): void {
/** @var Follow $follow */
$followRequest = $this->followRequestRepository->findOneBy([
'account' => $sourceAccount,
'targetAccount' => $targetAccount,
]);
assert($followRequest);
$followRequest->setNotify($notify);
$followRequest->setShowReblogs($showReblogs);
$this->entityManager->persist($followRequest);
$this->entityManager->flush();
}
private function directFollow(Account $sourceAccount, Account $targetAccount): Follow {
$follow = $sourceAccount->follow($targetAccount);
$this->entityManager->persist($follow);
$this->entityManager->flush();
// TODO Notify target account they got a new follower
// Add statues of target user into source user timeline
$this->feedManager->mergeIntoHome($targetAccount, $sourceAccount);
return $follow;
}
private function requestFollow(Account $sourceAccount, Account $targetAccount) {
if ($targetAccount->isLocal()) {
// Just create an internal follow request
$followRequest = $sourceAccount->requestFollow($targetAccount);
$this->entityManager->persist($followRequest);
$this->entityManager->flush();
// TODO Notify target account they got a new follow request
} else {
$this->createRemoteFollowRequest($sourceAccount, $targetAccount);
}
}
private function createRemoteFollowRequest(Account $sourceAccount, Account $targetAccount): void {
/** @var Type\Extended\Activity\Follow $follow */
$follow = Type::create('Follow', [
'@context' => 'https://www.w3.org/ns/activitystreams',
'actor' => $sourceAccount->getUri(),
'object' => $targetAccount->getUri(),
]);
// TODO send follow request
}
/**
* @param Person $actor
@ -131,12 +178,6 @@ class FollowService {
throw new FollowSameAccountException("Don't follow yourself, be your own lead");
}
/** @var Follow $follow */
$follow = AP::$activityPub->getItemFromType(Follow::TYPE);
$follow->generateUniqueId();
$follow->setActorId($actor->getId());
$follow->setObjectId($remoteActor->getId());
$follow->setFollowId($remoteActor->getFollowers());
try {
$this->followsRequest->getByPersons($actor->getId(), $remoteActor->getId());

Wyświetl plik

@ -0,0 +1,95 @@
<?php
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Service;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Status;
use OCA\Social\Service\Feed\PostDeliveryService;
use OCP\DB\ORM\IEntityManager;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
class PostServiceStatus {
private ICache $idempotenceCache;
private IConfig $config;
private IEntityManager $entityManager;
private ProcessMentionsService $mentionsService;
private PostDeliveryService $deliveryService;
public function __construct(
ICacheFactory $cacheFactory,
IConfig $config,
IEntityManager $entityManager,
ProcessMentionsService $mentionsService,
PostDeliveryService $deliveryService
) {
$this->idempotenceCache = $cacheFactory->createDistributed('social.idempotence');
$this->config = $config;
$this->entityManager = $entityManager;
$this->mentionsService = $mentionsService;
$this->deliveryService = $deliveryService;
}
/**
* @psalm-param array{?text: string, ?spoilerText: string, ?sensitive: bool, ?visibility: Status::STATUS_*} $options
*/
public function create(Account $account, array $options): Status {
$this->checkIdempotenceDuplicate($account, $options);
$status = new Status();
$status->setText($options['text'] ?? '');
$status->setSensitive(isset($options['spoilerText'])
|| ($options['sensitive'] ?? $this->config->getUserValue($account->getUserId(), 'social', 'default_sensitivity', 'no') === 'yes'));
$status->setAccount($account);
$status->setLocal(true);
if (isset($options['inReplyToId'])) {
$status->setInReplyToId($options['inReplyToId']);
}
$visibility = $options['visibility'] ?? $this->config->getUserValue($account->getUserId(), 'social', 'default_privacy', Status::STATUS_PUBLIC);
if (!in_array($visibility, [Status::STATUS_DIRECT, Status::STATUS_PRIVATE, Status::STATUS_PUBLIC, Status::STATUS_UNLISTED])) {
throw new ApiException('Invalid visibility');
}
// Add mentioned user to CC
$this->mentionsService->run($status);
// Save status
$this->entityManager->persist($status);
$this->entityManager->persist($account);
$this->entityManager->flush();
$this->deliveryService->run($status);
$this->updateIdempotency($account, $status);
return $status;
}
private function idempotencyKey(Account $account, string $idempotency): string {
return $account->getUserId() . '-' . $idempotency;
}
private function checkIdempotenceDuplicate(Account $account, array $options): void {
if (!isset($options['idempotency'])) {
return;
}
if ($this->idempotenceCache->get($this->idempotencyKey($account, $options['idempotency'])) !== null) {
throw new ApiException('Same message already sent');
}
}
private function updateIdempotency(Account $account, Status $status): void {
if (!isset($options['idempotency'])) {
return;
}
$this->idempotenceCache->set($this->idempotencyKey($account, $options['idempotency']), $status->getId(), 3600);
}
}

Wyświetl plik

@ -0,0 +1,80 @@
<?php
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Service;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use OCA\Social\Entity\Mention;
use OCA\Social\Entity\Status;
use OCP\IRequest;
class ProcessMentionsService {
private Collection $previousMentions;
private Collection $currentMentions;
private IRequest $request;
private AccountFinder $accountFinder;
private ResolveAccountService $resolveAccountService;
public function __construct(IRequest $request, AccountFinder $accountFinder, ResolveAccountService $resolveAccountService) {
$this->previousMentions = new ArrayCollection();
$this->currentMentions = new ArrayCollection();
$this->request = $request;
$this->accountFinder = $accountFinder;
$this->resolveAccountService = $resolveAccountService;
}
public function run(Status $status) {
if (!$status->isLocal()) {
return;
}
$this->previousMentions = $status->getActiveMentions();
$this->currentMentions = new ArrayCollection();
if (preg_match_all('/@(([a-z0-9_]([a-z0-9_\.-]+[a-z0-9_]+)+)(@[[:word:]\.\-]+[[:word:]]+)?)/i', $status->getText(), $matches)) {
$host = $this->request->getServerHost();
for ($i = 0; $i < count($matches[0]); $i++) {
$completeMatch = $matches[0][$i];
$userName = $matches[2][$i];
$domain = $matches[4][$i] === '' ? '' : substr($matches[4][$i], 1);
$isLocal = $domain === '' || $host === $domain;
if ($isLocal) {
$domain = null;
} else {
// normalize domain name
$domain = parse_url('https://' . $domain, PHP_URL_HOST);
}
$mentionnedAccount = $this->accountFinder->findRemote($userName, $domain);
assert($mentionnedAccount !== null);
if (!$mentionnedAccount) {
// try to resolve it
$mentionnedAccount = $this->resolveAccountService->resolveMention($userName, $domain, AccountResolverOption::default());
}
if (!$mentionnedAccount) {
// give up
continue;
}
$mentions = $this->previousMentions->filter(fn (Mention $mention) => $mention->getAccount()->getId() === $mentionnedAccount->getId());
if ($mentions->isEmpty()) {
$mention = new Mention();
$mention->setStatus($status);
$mention->setAccount($mentionnedAccount);
} else {
$mention = $mentions->first();
}
$this->currentMentions->add($mention);
str_replace($completeMatch, $mentionnedAccount->getAccountName(), $status->getText());
}
}
$status->setMentions($this->currentMentions);
}
}

Wyświetl plik

@ -0,0 +1,143 @@
<?php
namespace OCA\Social\Service;
use OCA\Circles\Tools\Model\NCWebfinger;
use OCA\Social\Entity\Account;
use OCA\Social\Service\ActivityPub\RemoteAccountFetcher;
use OCA\Social\Service\ActivityPub\RemoteAccountFetchOption;
use OCP\Http\Client\IClientService;
use OCP\IRequest;
class AccountResolverOption {
/**
* @var bool Whether we should follow webfinger redirection
*/
public bool $followWebfingerRedirection = true;
/**
* @var bool Whether we should attempt to fetch the account from webfinger
*/
public bool $queryWebfinger = true;
static public function default(): self {
return new self();
}
}
class ResolveAccountService {
private IClientService $clientService;
private IRequest $request;
private TrustedDomainChecker $trustedDomainChecker;
private AccountFinder $accountFinder;
private RemoteAccountFetcher $remoteAccountFetcher;
public function __construct(
IClientService $clientService,
IRequest $request,
TrustedDomainChecker $trustedDomainChecker,
AccountFinder $accountFinder,
RemoteAccountFetcher $remoteAccountFetcher
) {
$this->clientService = $clientService;
$this->request = $request;
$this->trustedDomainChecker = $trustedDomainChecker;
$this->accountFinder = $accountFinder;
$this->remoteAccountFetcher = $remoteAccountFetcher;
}
/**
* @param string $userName The username of the user
* @param string $domain The domain of the user
* @return Account|null
*/
public function resolveMention(string $userName, string $domain, AccountResolverOption $option): ?Account {
$webFinger = $this->requestWebfinger($userName, $domain);
if ($webFinger === null) {
return null;
}
[$confirmedUserName, $confirmedDomain] = $webFinger->getSubject();
if ($confirmedDomain !== $domain || $confirmedUserName !== $userName) {
if (!$option->followWebfingerRedirection) {
return null;
}
$webFinger = $this->requestWebfinger($confirmedUserName, $confirmedDomain);
if ($webFinger === null) {
return null;
}
[$newConfirmedUserName, $newConfirmedDomain] = $webFinger->getSubject();
if ($confirmedDomain !== $newConfirmedDomain || $confirmedUserName !== $newConfirmedUserName) {
// Hijack attempt
return null;
}
$confirmedDomain = $newConfirmedDomain;
$confirmedUserName = $newConfirmedUserName;
}
if ($confirmedDomain === $this->request->getServerHost()) {
$confirmedDomain = null;
}
if ($this->trustedDomainChecker->check($confirmedDomain)) {
return null; // blocked
}
$account = $this->accountFinder->findRemote($userName, $domain);
if ($account !== null && ($account->isLocal() || !$account->possiblyStale())) {
return $account;
}
return $this->fetchAccount($webFinger);
}
private function requestWebfinger($userName, $domain): ?NCWebfinger {
$client = $this->clientService->newClient();
$uri = 'acct:' . $userName . '@' . $domain;
if (str_ends_with($domain, '.onion')) {
$url = 'http://' . $domain . '/.well-known/webfinger?resource=' . $uri;
} else {
$url = 'https://' . $domain . '/.well-known/webfinger?resource=' . $uri;
}
$response = $client->get($url, [
'headers' => [
'Accept' => 'application/jrd+json, application/json',
],
]);
if ($response->getStatusCode() !== 200) {
// TODO mark server as unavailable
return null;
}
try {
$webFinger = new NCWebfinger(json_decode($response->getBody()));
} catch (\Exception $e) {
return null;
}
return $webFinger;
}
public function resolveAccount(Account $account, AccountResolverOption $option): ?Account {
if (!$account->isLocal() && $option->queryWebfinger && $account->possiblyStale()) {
return $this->resolveMention($account->getUserName(), $account->getDomain(), $option);
}
return $account;
}
public function fetchAccount(NCWebfinger $webfinger): ?Account {
// TODO lock
$actorUrl = $webfinger->getLink('self');
if (!$actorUrl) {
return null;
}
return $this->remoteAccountFetcher->fetch($actorUrl, RemoteAccountFetchOption::default());
}
}

Wyświetl plik

@ -30,6 +30,7 @@ declare(strict_types=1);
namespace OCA\Social\Service;
use OCA\Social\Entity\Account;
use OCA\Social\Tools\Exceptions\DateTimeException;
use OCA\Social\Tools\Exceptions\MalformedArrayException;
use OCA\Social\Tools\Exceptions\RequestContentException;
@ -43,8 +44,6 @@ use DateTime;
use Exception;
use JsonLdException;
use OCA\Social\AppInfo\Application;
use OCA\Social\Db\ActorsRequest;
use OCA\Social\Exceptions\ActorDoesNotExistException;
use OCA\Social\Exceptions\InvalidOriginException;
use OCA\Social\Exceptions\InvalidResourceException;
use OCA\Social\Exceptions\ItemUnknownException;
@ -77,31 +76,7 @@ class SignatureService {
public const DATE_HEADER = 'D, d M Y H:i:s T';
public const DATE_OBJECT = 'Y-m-d\TH:i:s\Z';
public const DATE_DELAY = 300;
private CacheActorService $cacheActorService;
private ActorsRequest $actorsRequest;
private CurlService $curlService;
private ConfigService $configService;
private MiscService $miscService;
public function __construct(
ActorsRequest $actorsRequest, CacheActorService $cacheActorService,
CurlService $curlService,
ConfigService $configService, MiscService $miscService
) {
$this->actorsRequest = $actorsRequest;
$this->cacheActorService = $cacheActorService;
$this->curlService = $curlService;
$this->configService = $configService;
$this->miscService = $miscService;
}
/**
* @param Person $actor
*/
public function generateKeys(Person &$actor) {
public function generateKeys(Account $account): void {
$res = openssl_pkey_new(
[
"digest_alg" => "rsa",
@ -113,19 +88,11 @@ class SignatureService {
openssl_pkey_export($res, $privateKey);
$publicKey = openssl_pkey_get_details($res)['key'];
$actor->setPublicKey($publicKey);
$actor->setPrivateKey($privateKey);
$account->setPublicKey($publicKey);
$account->setPrivateKey($privateKey);
}
/**
* @param NCRequest $request
* @param RequestQueue $queue
*
* @throws ActorDoesNotExistException
* @throws SocialAppConfigException // TODO: implement in TNCRequest ?
*/
public function signRequest(NCRequest $request, RequestQueue $queue): void {
public function signRequest(SignedRequest $request): void {
$date = gmdate(self::DATE_HEADER);
$path = $queue->getInstance();
@ -133,7 +100,7 @@ class SignatureService {
$headersElements = ['(request-target)', 'content-length', 'date', 'host', 'digest'];
$allElements = [
'(request-target)' => 'post ' . $path->getPath(),
'(request-target)' => 'path' . $request->getPath(),
'date' => $date,
'host' => $path->getAddress(),
'digest' => $this->generateDigest($request->getDataBody()),

Wyświetl plik

@ -0,0 +1,11 @@
<?php
namespace OCA\Social\Service;
class TrustedDomainChecker {
public function check(string $domain): bool {
// TODO extends to optionally support federation trusted domain list
// and social domain block list
return true;
}
}

Wyświetl plik

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Settings;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Settings\ISettings;
class Personal implements ISettings {
public function getForm(): TemplateResponse {
return new TemplateResponse('social', 'settings-personal');
}
public function getSection(): string {
return 'social';
}
public function getPriority(): int {
return 99;
}
}

Wyświetl plik

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Settings;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
class PersonalSection implements IIconSection {
private IURLGenerator $url;
private IL10N $l;
public function __construct(IURLGenerator $generator, IL10N $l) {
$this->url = $generator;
$this->l = $l;
}
public function getID() {
return 'social';
}
public function getName() {
return $this->l->t('Social');
}
public function getPriority() {
return 99;
}
public function getIcon(): string {
return $this->url->imagePath('social', 'social-dark.svg');
}
}

Wyświetl plik

@ -0,0 +1,6 @@
<?php
namespace OCA\Social\Tools\Exceptions;
class MalformedUriException extends \Exception {
}

Wyświetl plik

@ -31,54 +31,15 @@ declare(strict_types=1);
namespace OCA\Social\Tools\Model;
use OCP\Http\Client\IClient;
/**
* Class NCRequest
*
* @package OCA\Social\Tools\Model
*/
class NCRequest extends Request {
private IClient $client;
private array $clientOptions = [];
private bool $localAddressAllowed = false;
private ?Account $onBehalfOf = null;
public function setClient(IClient $client): self {
$this->client = $client;
return $this;
public function getOnBehalfOf(): ?Account {
return $this->onBehalfOf;
}
public function getClient(): IClient {
return $this->client;
public function setOnBehalfOf(?Account $onBehalfOf): void {
$this->onBehalfOf = $onBehalfOf;
}
public function getClientOptions(): array {
return $this->clientOptions;
}
public function setClientOptions(array $clientOptions): self {
$this->clientOptions = $clientOptions;
return $this;
}
public function isLocalAddressAllowed(): bool {
return $this->localAddressAllowed;
}
public function setLocalAddressAllowed(bool $allowed): self {
$this->localAddressAllowed = $allowed;
return $this;
}
public function jsonSerialize(): array {
return array_merge(
parent::jsonSerialize(),
[
'clientOptions' => $this->getClientOptions(),
'localAddressAllowed' => $this->isLocalAddressAllowed(),
]
);
}
}

Wyświetl plik

@ -33,6 +33,7 @@ namespace OCA\Social\Tools\Model;
use OCA\Social\Tools\Traits\TArrayTools;
use JsonSerializable;
use OCP\Http\Client\IClient;
/**
* Class Request
@ -42,211 +43,96 @@ use JsonSerializable;
class Request implements JsonSerializable {
use TArrayTools;
public const TYPE_GET = 0;
public const TYPE_POST = 1;
public const TYPE_PUT = 2;
public const TYPE_DELETE = 3;
public const QS_VAR_DUPLICATE = 1;
public const QS_VAR_ARRAY = 2;
private string $protocol = '';
private array $protocols = ['https'];
private string $host = '';
private int $port = 0;
private ?Uri $url;
private string $baseUrl = '';
private int $type = 0;
private bool $binary = false;
private bool $verifyPeer = true;
private bool $httpErrorsAllowed = false;
private bool $followLocation = true;
private array $headers = [];
private array $cookies = [];
private array $params = [];
private array $data = [];
private int $queryStringType = self::QS_VAR_DUPLICATE;
private int $timeout = 10;
private string $userAgent = '';
private int $resultCode = 0;
private string $contentType = '';
private IClient $client;
/** @var string */
private $protocol = '';
/** @var array */
private $protocols = ['https'];
/** @var string */
private $host = '';
/** @var int */
private $port = 0;
/** @var string */
private $url = '';
/** @var string */
private $baseUrl = '';
/** @var int */
private $type = 0;
/** @var bool */
private $binary = false;
/** @var bool */
private $verifyPeer = true;
/** @var bool */
private $httpErrorsAllowed = false;
/** @var bool */
private $followLocation = true;
/** @var array */
private $headers = [];
/** @var array */
private $cookies = [];
/** @var array */
private $params = [];
/** @var array */
private $data = [];
/** @var int */
private $queryStringType = self::QS_VAR_DUPLICATE;
/** @var int */
private $timeout = 10;
/** @var string */
private $userAgent = '';
/** @var int */
private $resultCode = 0;
/** @var string */
private $contentType = '';
/**
* Request constructor.
*
* @param string $url
* @param int $type
* @param bool $binary
*/
public function __construct(string $url = '', int $type = 0, bool $binary = false) {
public function __construct(?Uri $url = null, int $type = 0, bool $binary = false) {
$this->url = $url;
$this->type = $type;
$this->binary = $binary;
}
/**
* @param string $protocol
*
* @return Request
*/
public function setProtocol(string $protocol): Request {
$this->protocols = [$protocol];
public function setClient(IClient $client): self {
$this->client = $client;
return $this;
}
/**
* @param array $protocols
*
* @return Request
*/
public function setProtocols(array $protocols): Request {
$this->protocols = $protocols;
public function getClient(): IClient {
return $this->client;
}
public function setProtocol(string $protocol): self {
$this->protocol = $protocol;
return $this;
}
/**
* @return string[]
*/
public function getProtocols(): array {
return $this->protocols;
}
/**
* @return string
*/
public function getUsedProtocol(): string {
public function getProtocol(): string {
return $this->protocol;
}
/**
* @param string $protocol
*
* @return Request
*/
public function setUsedProtocol(string $protocol): Request {
$this->protocol = $protocol;
return $this;
}
/**
* @return string
* @deprecated - 19 - use getHost();
*/
public function getAddress(): string {
return $this->getHost();
}
/**
* @param string $address
*
* @return Request
* @deprecated - 19 - use setHost();
*/
public function setAddress(string $address): Request {
$this->setHost($address);
return $this;
}
/**
* @return string
*/
public function getHost(): string {
return $this->host;
return $this->url->getHost();
}
/**
* @param string $host
*
* @return Request
*/
public function setHost(string $host): Request {
$this->host = $host;
public function setHost(string $host): self {
$this->url->setHost($host);
return $this;
}
/**
* @return int
*/
public function getPort(): int {
return $this->port;
public function getPort(): ?int {
return $this->url->getPort();
}
/**
* @param int $port
*
* @return Request
*/
public function setPort(int $port): Request {
$this->port = $port;
public function setPort(?int $port): self {
$this->url->setPort($port);
return $this;
}
/**
* @param string $instance
* Set the instance
*
* @return Request
* @param string $instance The instance for example floss.social, cloud.com:442, 4u3849u3.onion
* @return $this
*/
public function setInstance(string $instance): Request {
public function setInstance(string $instance): self {
$this->setPort(null);
if (strpos($instance, ':') === false) {
$this->setHost($instance);
return $this;
}
list($host, $port) = explode(':', $instance, 2);
[$host, $port] = explode(':', $instance, 2);
$this->setHost($host);
if ($port !== '') {
$this->setPort((int)$port);
@ -255,63 +141,20 @@ class Request implements JsonSerializable {
return $this;
}
/**
* @return string
*/
public function getInstance(): string {
$instance = $this->getHost();
if ($this->getPort() > 0) {
if ($this->getPort() !== null) {
$instance .= ':' . $this->getPort();
}
return $instance;
}
/**
* @param string $url
*
* @deprecated - 19 - use basedOnUrl();
*/
public function setAddressFromUrl(string $url) {
$this->basedOnUrl($url);
public function parse(string $url): void {
$this->url = new Uri($url);
}
/**
* @param string $url
*/
public function basedOnUrl(string $url) {
$protocol = parse_url($url, PHP_URL_SCHEME);
if ($protocol === null) {
if (strpos($url, '/') > -1) {
list($address, $baseUrl) = explode('/', $url, 2);
$this->setBaseUrl('/' . $baseUrl);
} else {
$address = $url;
}
if (strpos($address, ':') > -1) {
list($address, $port) = explode(':', $address, 2);
$this->setPort((int)$port);
}
$this->setHost($address);
} else {
$this->setProtocols([$protocol]);
$this->setUsedProtocol($protocol);
$this->setHost(parse_url($url, PHP_URL_HOST));
$this->setBaseUrl(parse_url($url, PHP_URL_PATH));
if (is_numeric($port = parse_url($url, PHP_URL_PORT))) {
$this->setPort($port);
}
}
}
/**
* @param string|null $baseUrl
*
* @return Request
*/
public function setBaseUrl(?string $baseUrl): Request {
public function setBaseUrl(?string $baseUrl): self {
if ($baseUrl !== null) {
$this->baseUrl = $baseUrl;
}
@ -319,92 +162,40 @@ class Request implements JsonSerializable {
return $this;
}
/**
* @return bool
*/
public function isBinary(): bool {
return $this->binary;
}
/**
* @param bool $verifyPeer
*
* @return $this
*/
public function setVerifyPeer(bool $verifyPeer): Request {
public function setVerifyPeer(bool $verifyPeer): self {
$this->verifyPeer = $verifyPeer;
return $this;
}
/**
* @return bool
*/
public function isVerifyPeer(): bool {
return $this->verifyPeer;
}
/**
* @param bool $httpErrorsAllowed
*
* @return Request
*/
public function setHttpErrorsAllowed(bool $httpErrorsAllowed): Request {
public function setHttpErrorsAllowed(bool $httpErrorsAllowed): self {
$this->httpErrorsAllowed = $httpErrorsAllowed;
return $this;
}
/**
* @return bool
*/
public function isHttpErrorsAllowed(): bool {
return $this->httpErrorsAllowed;
}
/**
* @param bool $followLocation
*
* @return $this
*/
public function setFollowLocation(bool $followLocation): Request {
public function setFollowLocation(bool $followLocation): self {
$this->followLocation = $followLocation;
return $this;
}
/**
* @return bool
*/
public function isFollowLocation(): bool {
return $this->followLocation;
}
/**
* @return string
* @deprecated - 19 - use getParametersUrl() + addParam()
*/
public function getParsedUrl(): string {
$url = $this->getPath();
$ak = array_keys($this->getData());
foreach ($ak as $k) {
if (!is_string($this->data[$k])) {
continue;
}
$url = str_replace(':' . $k, $this->data[$k], $url);
}
return $url;
}
/**
* @return string
*/
public function getParametersUrl(): string {
$url = $this->getPath();
$ak = array_keys($this->getParams());
@ -419,43 +210,19 @@ class Request implements JsonSerializable {
return $url;
}
/**
* @return string
*/
public function getPath(): string {
return $this->baseUrl . $this->url;
}
/**
* @return string
* @deprecated - 19 - use getPath()
*/
public function getUrl(): string {
return $this->getPath();
}
/**
* @return string
*/
public function getCompleteUrl(): string {
$port = ($this->getPort() > 0) ? ':' . $this->getPort() : '';
return $this->getUsedProtocol() . '://' . $this->getHost() . $port . $this->getParametersUrl();
return (string)$this->url;
}
/**
* @return int
*/
public function getType(): int {
return $this->type;
}
public function addHeader($key, $value): Request {
public function addHeader($key, $value): self {
$header = $this->get($key, $this->headers);
if ($header !== '') {
$header .= ', ' . $value;
@ -474,213 +241,92 @@ class Request implements JsonSerializable {
public function getHeaders(): array {
return array_merge(['User-Agent' => $this->getUserAgent()], $this->headers);
}
/**
* @param array $headers
*
* @return Request
*/
public function setHeaders(array $headers): Request {
public function setHeaders(array $headers): self {
$this->headers = $headers;
return $this;
}
/**
* @return array
*/
public function getCookies(): array {
return $this->cookies;
}
/**
* @param array $cookies
*
* @return Request
*/
public function setCookies(array $cookies): Request {
public function setCookies(array $cookies): self {
$this->cookies = $cookies;
return $this;
}
/**
* @param int $queryStringType
*
* @return Request
*/
public function setQueryStringType(int $queryStringType): self {
$this->queryStringType = $queryStringType;
return $this;
}
/**
* @return int
*/
public function getQueryStringType(): int {
return $this->queryStringType;
}
/**
* @return array
*/
public function getData(): array {
return $this->data;
}
/**
* @param array $data
*
* @return Request
*/
public function setData(array $data): Request {
public function setData(array $data): self {
$this->data = $data;
return $this;
}
/**
* @param string $data
*
* @return Request
*/
public function setDataJson(string $data): Request {
public function setDataJson(string $data): self {
$this->setData(json_decode($data, true));
return $this;
}
/**
* @param JsonSerializable $data
*
* @return Request
*/
public function setDataSerialize(JsonSerializable $data): Request {
public function setDataSerialize(JsonSerializable $data): self {
$this->setDataJson(json_encode($data));
return $this;
}
/**
* @return array
*/
public function getParams(): array {
return $this->params;
}
/**
* @param array $params
*
* @return Request
*/
public function setParams(array $params): Request {
public function setParams(array $params): self {
$this->params = $params;
return $this;
}
/**
* @param string $k
* @param string $v
*
* @return Request
*/
public function addParam(string $k, string $v): Request {
public function addParam(string $k, string $v): self {
$this->params[$k] = $v;
return $this;
}
/**
* @param string $k
* @param int $v
*
* @return Request
*/
public function addParamInt(string $k, int $v): Request {
public function addParamInt(string $k, int $v): self {
$this->params[$k] = $v;
return $this;
}
/**
* @param string $k
* @param string $v
*
* @return Request
*/
public function addData(string $k, string $v): Request {
public function addData(string $k, string $v): self {
$this->data[$k] = $v;
return $this;
}
/**
* @param string $k
* @param int $v
*
* @return Request
*/
public function addDataInt(string $k, int $v): Request {
public function addDataInt(string $k, int $v): self {
$this->data[$k] = $v;
return $this;
}
/**
* @return string
*/
public function getDataBody(): string {
return json_encode($this->getData());
}
/**
* @return string
* @deprecated - 19 - use getUrlParams();
*/
public function getUrlData(): string {
if ($this->getData() === []) {
return '';
}
return preg_replace(
'/([(%5B)]{1})[0-9]+([(%5D)]{1})/', '$1$2', http_build_query($this->getData())
);
}
/**
* @return string
* @deprecated - 21 - use getQueryString();
*/
public function getUrlParams(): string {
if ($this->getParams() === []) {
return '';
}
return preg_replace(
'/([(%5B)]{1})[0-9]+([(%5D)]{1})/', '$1$2', http_build_query($this->getParams())
);
}
/**
* @param int $type
*
* @return string
*/
public function getQueryString(): string {
if (empty($this->getParams())) {
return '';
@ -698,90 +344,48 @@ class Request implements JsonSerializable {
}
}
/**
* @return int
*/
public function getTimeout(): int {
return $this->timeout;
}
/**
* @param int $timeout
*
* @return Request
*/
public function setTimeout(int $timeout): Request {
public function setTimeout(int $timeout): self {
$this->timeout = $timeout;
return $this;
}
/**
* @return string
*/
public function getUserAgent(): string {
return $this->userAgent;
}
/**
* @param string $userAgent
*
* @return Request
*/
public function setUserAgent(string $userAgent): Request {
public function setUserAgent(string $userAgent): self {
$this->userAgent = $userAgent;
return $this;
}
/**
* @return int
*/
public function getResultCode(): int {
return $this->resultCode;
}
/**
* @param int $resultCode
*
* @return Request
*/
public function setResultCode(int $resultCode): Request {
public function setResultCode(int $resultCode): self {
$this->resultCode = $resultCode;
return $this;
}
/**
* @return string
*/
public function getContentType(): string {
return $this->contentType;
}
/**
* @param string $contentType
*
* @return Request
*/
public function setContentType(string $contentType): Request {
public function setContentType(string $contentType): self {
$this->contentType = $contentType;
return $this;
}
/**
* @return array
*/
public function jsonSerialize(): array {
return [
'protocols' => $this->getProtocols(),
'used_protocol' => $this->getUsedProtocol(),
'port' => $this->getPort(),
'host' => $this->getHost(),
'url' => $this->getPath(),
@ -798,12 +402,6 @@ class Request implements JsonSerializable {
];
}
/**
* @param string $type
*
* @return int
*/
public static function type(string $type): int {
switch (strtoupper($type)) {
case 'GET':
@ -819,7 +417,6 @@ class Request implements JsonSerializable {
return 0;
}
public static function method(int $type): string {
switch ($type) {
case self::TYPE_GET:

Wyświetl plik

@ -0,0 +1,910 @@
<?php
namespace OCA\Social\Tools\Model;
use OCA\Social\Tools\Exceptions\MalformedUriException;
use Psr\Http\Message\UriInterface;
class UriResolver {
/**
* Removes dot segments from a path and returns the new path.
*
* @link http://tools.ietf.org/html/rfc3986#section-5.2.4
*/
public static function removeDotSegments(string $path): string
{
if ($path === '' || $path === '/') {
return $path;
}
$results = [];
$segments = explode('/', $path);
foreach ($segments as $segment) {
if ($segment === '..') {
array_pop($results);
} elseif ($segment !== '.') {
$results[] = $segment;
}
}
$newPath = implode('/', $results);
if ($path[0] === '/' && (!isset($newPath[0]) || $newPath[0] !== '/')) {
// Re-add the leading slash if necessary for cases like "/.."
$newPath = '/' . $newPath;
} elseif ($newPath !== '' && ($segment === '.' || $segment === '..')) {
// Add the trailing slash if necessary
// If newPath is not empty, then $segment must be set and is the last segment from the foreach
$newPath .= '/';
}
return $newPath;
}
/**
* Converts the relative URI into a new URI that is resolved against the base URI.
*
* @link http://tools.ietf.org/html/rfc3986#section-5.2
*/
public static function resolve(UriInterface $base, UriInterface $rel): UriInterface {
if ((string) $rel === '') {
// we can simply return the same base URI instance for this same-document reference
return $base;
}
if ($rel->getScheme() != '') {
return $rel->withPath(self::removeDotSegments($rel->getPath()));
}
if ($rel->getAuthority() != '') {
$targetAuthority = $rel->getAuthority();
$targetPath = self::removeDotSegments($rel->getPath());
$targetQuery = $rel->getQuery();
} else {
$targetAuthority = $base->getAuthority();
if ($rel->getPath() === '') {
$targetPath = $base->getPath();
$targetQuery = $rel->getQuery() != '' ? $rel->getQuery() : $base->getQuery();
} else {
if ($rel->getPath()[0] === '/') {
$targetPath = $rel->getPath();
} else {
if ($targetAuthority != '' && $base->getPath() === '') {
$targetPath = '/' . $rel->getPath();
} else {
$lastSlashPos = strrpos($base->getPath(), '/');
if ($lastSlashPos === false) {
$targetPath = $rel->getPath();
} else {
$targetPath = substr($base->getPath(), 0, $lastSlashPos + 1) . $rel->getPath();
}
}
}
$targetPath = self::removeDotSegments($targetPath);
$targetQuery = $rel->getQuery();
}
}
return new Uri(Uri::composeComponents(
$base->getScheme(),
$targetAuthority,
$targetPath,
$targetQuery,
$rel->getFragment()
));
}
/**
* Returns the target URI as a relative reference from the base URI.
*
* This method is the counterpart to resolve():
*
* (string) $target === (string) UriResolver::resolve($base, UriResolver::relativize($base, $target))
*
* One use-case is to use the current request URI as base URI and then generate relative links in your documents
* to reduce the document size or offer self-contained downloadable document archives.
*
* $base = new Uri('http://example.com/a/b/');
* echo UriResolver::relativize($base, new Uri('http://example.com/a/b/c')); // prints 'c'.
* echo UriResolver::relativize($base, new Uri('http://example.com/a/x/y')); // prints '../x/y'.
* echo UriResolver::relativize($base, new Uri('http://example.com/a/b/?q')); // prints '?q'.
* echo UriResolver::relativize($base, new Uri('http://example.org/a/b/')); // prints '//example.org/a/b/'.
*
* This method also accepts a target that is already relative and will try to relativize it further. Only a
* relative-path reference will be returned as-is.
*
* echo UriResolver::relativize($base, new Uri('/a/b/c')); // prints 'c' as well
*/
public static function relativize(UriInterface $base, UriInterface $target): UriInterface
{
if ($target->getScheme() !== '' &&
($base->getScheme() !== $target->getScheme() || $target->getAuthority() === '' && $base->getAuthority() !== '')
) {
return $target;
}
if (Uri::isRelativePathReference($target)) {
// As the target is already highly relative we return it as-is. It would be possible to resolve
// the target with `$target = self::resolve($base, $target);` and then try make it more relative
// by removing a duplicate query. But let's not do that automatically.
return $target;
}
if ($target->getAuthority() !== '' && $base->getAuthority() !== $target->getAuthority()) {
return $target->withScheme('');
}
// We must remove the path before removing the authority because if the path starts with two slashes, the URI
// would turn invalid. And we also cannot set a relative path before removing the authority, as that is also
// invalid.
$emptyPathUri = $target->withScheme('')->withPath('')->withUserInfo('')->withPort(null)->withHost('');
if ($base->getPath() !== $target->getPath()) {
return $emptyPathUri->withPath(self::getRelativePath($base, $target));
}
if ($base->getQuery() === $target->getQuery()) {
// Only the target fragment is left. And it must be returned even if base and target fragment are the same.
return $emptyPathUri->withQuery('');
}
// If the base URI has a query but the target has none, we cannot return an empty path reference as it would
// inherit the base query component when resolving.
if ($target->getQuery() === '') {
$segments = explode('/', $target->getPath());
/** @var string $lastSegment */
$lastSegment = end($segments);
return $emptyPathUri->withPath($lastSegment === '' ? './' : $lastSegment);
}
return $emptyPathUri;
}
private static function getRelativePath(UriInterface $base, UriInterface $target): string
{
$sourceSegments = explode('/', $base->getPath());
$targetSegments = explode('/', $target->getPath());
array_pop($sourceSegments);
$targetLastSegment = array_pop($targetSegments);
foreach ($sourceSegments as $i => $segment) {
if (isset($targetSegments[$i]) && $segment === $targetSegments[$i]) {
unset($sourceSegments[$i], $targetSegments[$i]);
} else {
break;
}
}
$targetSegments[] = $targetLastSegment;
$relativePath = str_repeat('../', count($sourceSegments)) . implode('/', $targetSegments);
// A reference to am empty last segment or an empty first sub-segment must be prefixed with "./".
// This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used
// as the first segment of a relative-path reference, as it would be mistaken for a scheme name.
if ('' === $relativePath || false !== strpos(explode('/', $relativePath, 2)[0], ':')) {
$relativePath = "./$relativePath";
} elseif ('/' === $relativePath[0]) {
if ($base->getAuthority() != '' && $base->getPath() === '') {
// In this case an extra slash is added by resolve() automatically. So we must not add one here.
$relativePath = ".$relativePath";
} else {
$relativePath = "./$relativePath";
}
}
return $relativePath;
}
private function __construct()
{
// cannot be instantiated
}
}
/**
* This class provides an abstraction over an uri
*
* @since 25.0.0
*/
class Uri implements UriInterface, \JsonSerializable {
/**
* Absolute http and https URIs require a host per RFC 7230 Section 2.7
* but in generic URIs the host can be empty. So for http(s) URIs
* we apply this default host when no host is given yet to form a
* valid URI.
*/
private const HTTP_DEFAULT_HOST = 'localhost';
private const DEFAULT_PORTS = [
'http' => 80,
'https' => 443,
'ftp' => 21,
'gopher' => 70,
'nntp' => 119,
'news' => 119,
'telnet' => 23,
'tn3270' => 23,
'imap' => 143,
'pop' => 110,
'ldap' => 389,
];
/**
* Unreserved characters for use in a regex.
*
* @link https://tools.ietf.org/html/rfc3986#section-2.3
*/
private const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
/**
* Sub-delims for use in a regex.
*
* @link https://tools.ietf.org/html/rfc3986#section-2.2
*/
private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26'];
private string $scheme = '';
private string $userInfo = '';
private string $host = '';
private ?int $port = null;
private string $path = '';
private string $query = '';
private string $fragment = '';
private ?string $composedComponents = null;
public function __construct(string $uri = '')
{
if ($uri !== '') {
$parts = self::parse($uri);
if ($parts === false) {
throw new \Exception("Unable to parse URI: $uri");
}
$this->applyParts($parts);
}
}
/**
* UTF-8 aware parse_url replacement
* @param string $url
* @return array|false
*/
private function parse(string $url) {
// If IPv6
$prefix = '';
if (preg_match('%^(.*://\[[0-9:a-f]+\])(.*?)$%', $url, $matches)) {
/** @var array{0:string, 1:string, 2:string} $matches */
$prefix = $matches[1];
$url = $matches[2];
}
/** @var string */
$encodedUrl = preg_replace_callback(
'%[^:/@?&=#]+%usD',
static function ($matches) {
return urlencode($matches[0]);
},
$url
);
$result = parse_url($prefix . $encodedUrl);
if ($result === false) {
return false;
}
return array_map('urldecode', $result);
}
public function __toString(): string
{
if ($this->composedComponents === null) {
$this->composedComponents = self::composeComponents(
$this->scheme,
$this->getAuthority(),
$this->path,
$this->query,
$this->fragment
);
}
return $this->composedComponents;
}
/**
* Composes a URI reference string from its various components.
*
* Usually this method does not need to be called manually but instead is used indirectly via
* `Psr\Http\Message\UriInterface::__toString`.
*
* PSR-7 UriInterface treats an empty component the same as a missing component as
* getQuery(), getFragment() etc. always return a string. This explains the slight
* difference to RFC 3986 Section 5.3.
*
* Another adjustment is that the authority separator is added even when the authority is missing/empty
* for the "file" scheme. This is because PHP stream functions like `file_get_contents` only work with
* `file:///myfile` but not with `file:/myfile` although they are equivalent according to RFC 3986. But
* `file:///` is the more common syntax for the file scheme anyway (Chrome for example redirects to
* that format).
*
* @link https://tools.ietf.org/html/rfc3986#section-5.3
*/
public static function composeComponents(?string $scheme, ?string $authority, string $path, ?string $query, ?string $fragment): string
{
$uri = '';
// weak type checks to also accept null until we can add scalar type hints
if ($scheme != '') {
$uri .= $scheme . ':';
}
if ($authority != ''|| $scheme === 'file') {
$uri .= '//' . $authority;
}
$uri .= $path;
if ($query != '') {
$uri .= '?' . $query;
}
if ($fragment != '') {
$uri .= '#' . $fragment;
}
return $uri;
}
/**
* Whether the URI has the default port of the current scheme.
*
* `Psr\Http\Message\UriInterface::getPort` may return null or the standard port. This method can be used
* independently of the implementation.
*/
public static function isDefaultPort(UriInterface $uri): bool
{
return $uri->getPort() === null
|| (isset(self::DEFAULT_PORTS[$uri->getScheme()]) && $uri->getPort() === self::DEFAULT_PORTS[$uri->getScheme()]);
}
/**
* Whether the URI is absolute, i.e. it has a scheme.
*
* An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true
* if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative
* to another URI, the base URI. Relative references can be divided into several forms:
* - network-path references, e.g. '//example.com/path'
* - absolute-path references, e.g. '/path'
* - relative-path references, e.g. 'subpath'
*
* @see Uri::isNetworkPathReference
* @see Uri::isAbsolutePathReference
* @see Uri::isRelativePathReference
* @link https://tools.ietf.org/html/rfc3986#section-4
*/
public static function isAbsolute(UriInterface $uri): bool
{
return $uri->getScheme() !== '';
}
/**
* Whether the URI is a network-path reference.
*
* A relative reference that begins with two slash characters is termed an network-path reference.
*
* @link https://tools.ietf.org/html/rfc3986#section-4.2
*/
public static function isNetworkPathReference(UriInterface $uri): bool
{
return $uri->getScheme() === '' && $uri->getAuthority() !== '';
}
/**
* Whether the URI is a absolute-path reference.
*
* A relative reference that begins with a single slash character is termed an absolute-path reference.
*
* @link https://tools.ietf.org/html/rfc3986#section-4.2
*/
public static function isAbsolutePathReference(UriInterface $uri): bool
{
return $uri->getScheme() === ''
&& $uri->getAuthority() === ''
&& isset($uri->getPath()[0])
&& $uri->getPath()[0] === '/';
}
/**
* Whether the URI is a relative-path reference.
*
* A relative reference that does not begin with a slash character is termed a relative-path reference.
*
* @link https://tools.ietf.org/html/rfc3986#section-4.2
*/
public static function isRelativePathReference(UriInterface $uri): bool
{
return $uri->getScheme() === ''
&& $uri->getAuthority() === ''
&& (!isset($uri->getPath()[0]) || $uri->getPath()[0] !== '/');
}
/**
* Whether the URI is a same-document reference.
*
* A same-document reference refers to a URI that is, aside from its fragment
* component, identical to the base URI. When no base URI is given, only an empty
* URI reference (apart from its fragment) is considered a same-document reference.
*
* @param UriInterface $uri The URI to check
* @param UriInterface|null $base An optional base URI to compare against
*
* @link https://tools.ietf.org/html/rfc3986#section-4.4
*/
public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool
{
if ($base !== null) {
$uri = UriResolver::resolve($base, $uri);
return ($uri->getScheme() === $base->getScheme())
&& ($uri->getAuthority() === $base->getAuthority())
&& ($uri->getPath() === $base->getPath())
&& ($uri->getQuery() === $base->getQuery());
}
return $uri->getScheme() === '' && $uri->getAuthority() === '' && $uri->getPath() === '' && $uri->getQuery() === '';
}
/**
* Creates a new URI with a specific query string value removed.
*
* Any existing query string values that exactly match the provided key are
* removed.
*
* @param UriInterface $uri URI to use as a base.
* @param string $key Query string key to remove.
*/
public static function withoutQueryValue(UriInterface $uri, string $key): UriInterface
{
$result = self::getFilteredQueryString($uri, [$key]);
return $uri->withQuery(implode('&', $result));
}
/**
* Creates a new URI with a specific query string value.
*
* Any existing query string values that exactly match the provided key are
* removed and replaced with the given key value pair.
*
* A value of null will set the query string key without a value, e.g. "key"
* instead of "key=value".
*
* @param UriInterface $uri URI to use as a base.
* @param string $key Key to set.
* @param string|null $value Value to set
*/
public static function withQueryValue(UriInterface $uri, string $key, ?string $value): UriInterface
{
$result = self::getFilteredQueryString($uri, [$key]);
$result[] = self::generateQueryString($key, $value);
return $uri->withQuery(implode('&', $result));
}
/**
* Creates a new URI with multiple specific query string values.
*
* It has the same behavior as withQueryValue() but for an associative array of key => value.
*
* @param UriInterface $uri URI to use as a base.
* @param array<string, string|null> $keyValueArray Associative array of key and values
*/
public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface
{
$result = self::getFilteredQueryString($uri, array_keys($keyValueArray));
foreach ($keyValueArray as $key => $value) {
$result[] = self::generateQueryString((string) $key, $value !== null ? (string) $value : null);
}
return $uri->withQuery(implode('&', $result));
}
/**
* Creates a URI from a hash of `parse_url` components.
*
* @link http://php.net/manual/en/function.parse-url.php
*
* @throws MalformedUriException If the components do not form a valid URI.
*/
public static function fromParts(array $parts): UriInterface
{
$uri = new self();
$uri->applyParts($parts);
$uri->validateState();
return $uri;
}
public function getScheme(): string
{
return $this->scheme;
}
public function getAuthority(): string
{
$authority = $this->host;
if ($this->userInfo !== '') {
$authority = $this->userInfo . '@' . $authority;
}
if ($this->port !== null) {
$authority .= ':' . $this->port;
}
return $authority;
}
public function getUserInfo(): string {
return $this->userInfo;
}
public function getHost(): string {
return $this->host;
}
public function setHost(string $host): self {
$this->host = $host;
}
public function getPort(): ?int {
return $this->port;
}
public function setPort(?int $port): self {
$this->port = $port !== null ? $this->filterPort($port) : null;
return $this;
}
public function getPath(): string
{
return $this->path;
}
public function getQuery(): string
{
return $this->query;
}
public function getFragment(): string
{
return $this->fragment;
}
public function withScheme($scheme): UriInterface
{
$scheme = $this->filterScheme($scheme);
if ($this->scheme === $scheme) {
return $this;
}
$new = clone $this;
$new->scheme = $scheme;
$new->composedComponents = null;
$new->removeDefaultPort();
$new->validateState();
return $new;
}
public function withUserInfo($user, $password = null): UriInterface
{
$info = $this->filterUserInfoComponent($user);
if ($password !== null) {
$info .= ':' . $this->filterUserInfoComponent($password);
}
if ($this->userInfo === $info) {
return $this;
}
$new = clone $this;
$new->userInfo = $info;
$new->composedComponents = null;
$new->validateState();
return $new;
}
public function withHost($host): UriInterface
{
$host = $this->filterHost($host);
if ($this->host === $host) {
return $this;
}
$new = clone $this;
$new->host = $host;
$new->composedComponents = null;
$new->validateState();
return $new;
}
public function withPort($port): UriInterface
{
$port = $this->filterPort($port);
if ($this->port === $port) {
return $this;
}
$new = clone $this;
$new->port = $port;
$new->composedComponents = null;
$new->removeDefaultPort();
$new->validateState();
return $new;
}
public function withPath($path): UriInterface
{
$path = $this->filterPath($path);
if ($this->path === $path) {
return $this;
}
$new = clone $this;
$new->path = $path;
$new->composedComponents = null;
$new->validateState();
return $new;
}
public function withQuery($query): UriInterface
{
$query = $this->filterQueryAndFragment($query);
if ($this->query === $query) {
return $this;
}
$new = clone $this;
$new->query = $query;
$new->composedComponents = null;
return $new;
}
public function withFragment($fragment): UriInterface
{
$fragment = $this->filterQueryAndFragment($fragment);
if ($this->fragment === $fragment) {
return $this;
}
$new = clone $this;
$new->fragment = $fragment;
$new->composedComponents = null;
return $new;
}
public function jsonSerialize(): string
{
return $this->__toString();
}
/**
* Apply parse_url parts to a URI.
*
* @param array $parts Array of parse_url parts to apply.
*/
private function applyParts(array $parts): void
{
$this->scheme = isset($parts['scheme'])
? $this->filterScheme($parts['scheme'])
: '';
$this->userInfo = isset($parts['user'])
? $this->filterUserInfoComponent($parts['user'])
: '';
$this->host = isset($parts['host'])
? $this->filterHost($parts['host'])
: '';
$this->port = isset($parts['port'])
? $this->filterPort($parts['port'])
: null;
$this->path = isset($parts['path'])
? $this->filterPath($parts['path'])
: '';
$this->query = isset($parts['query'])
? $this->filterQueryAndFragment($parts['query'])
: '';
$this->fragment = isset($parts['fragment'])
? $this->filterQueryAndFragment($parts['fragment'])
: '';
if (isset($parts['pass'])) {
$this->userInfo .= ':' . $this->filterUserInfoComponent($parts['pass']);
}
$this->removeDefaultPort();
}
/**
* @param mixed $scheme
*
* @throws \InvalidArgumentException If the scheme is invalid.
*/
private function filterScheme($scheme): string
{
if (!is_string($scheme)) {
throw new \InvalidArgumentException('Scheme must be a string');
}
return \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
}
/**
* @param mixed $component
*
* @throws \InvalidArgumentException If the user info is invalid.
*/
private function filterUserInfoComponent($component): string
{
if (!is_string($component)) {
throw new \InvalidArgumentException('User info must be a string');
}
return preg_replace_callback(
'/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'],
$component
);
}
/**
* @param mixed $host
*
* @throws \InvalidArgumentException If the host is invalid.
*/
private function filterHost($host): string
{
if (!is_string($host)) {
throw new \InvalidArgumentException('Host must be a string');
}
return \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
}
/**
* @param mixed $port
*
* @throws \InvalidArgumentException If the port is invalid.
*/
private function filterPort($port): ?int
{
if ($port === null) {
return null;
}
$port = (int) $port;
if (0 > $port || 0xffff < $port) {
throw new \InvalidArgumentException(
sprintf('Invalid port: %d. Must be between 0 and 65535', $port)
);
}
return $port;
}
/**
* @param string[] $keys
*
* @return string[]
*/
private static function getFilteredQueryString(UriInterface $uri, array $keys): array
{
$current = $uri->getQuery();
if ($current === '') {
return [];
}
$decodedKeys = array_map('rawurldecode', $keys);
return array_filter(explode('&', $current), function ($part) use ($decodedKeys) {
return !in_array(rawurldecode(explode('=', $part)[0]), $decodedKeys, true);
});
}
private static function generateQueryString(string $key, ?string $value): string
{
// Query string separators ("=", "&") within the key or value need to be encoded
// (while preventing double-encoding) before setting the query string. All other
// chars that need percent-encoding will be encoded by withQuery().
$queryString = strtr($key, self::QUERY_SEPARATORS_REPLACEMENT);
if ($value !== null) {
$queryString .= '=' . strtr($value, self::QUERY_SEPARATORS_REPLACEMENT);
}
return $queryString;
}
private function removeDefaultPort(): void
{
if ($this->port !== null && self::isDefaultPort($this)) {
$this->port = null;
}
}
/**
* Filters the path of a URI
*
* @param mixed $path
*
* @throws \InvalidArgumentException If the path is invalid.
*/
private function filterPath($path): string
{
if (!is_string($path)) {
throw new \InvalidArgumentException('Path must be a string');
}
return preg_replace_callback(
'/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'],
$path
);
}
/**
* Filters the query string or fragment of a URI.
*
* @param mixed $str
*
* @throws \InvalidArgumentException If the query or fragment is invalid.
*/
private function filterQueryAndFragment($str): string
{
if (!is_string($str)) {
throw new \InvalidArgumentException('Query and fragment must be a string');
}
return preg_replace_callback(
'/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/',
[$this, 'rawurlencodeMatchZero'],
$str
);
}
private function rawurlencodeMatchZero(array $match): string
{
return rawurlencode($match[0]);
}
private function validateState(): void
{
if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) {
$this->host = self::HTTP_DEFAULT_HOST;
}
if ($this->getAuthority() === '') {
if (0 === strpos($this->path, '//')) {
throw new MalformedUriException('The path of a URI without an authority must not start with two slashes "//"');
}
if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) {
throw new MalformedUriException('A relative URI must not have a path beginning with a segment containing a colon');
}
} elseif (isset($this->path[0]) && $this->path[0] !== '/') {
throw new MalformedUriException('The path of a URI with an authority must start with a slash "/" or be empty');
}
}
}

Wyświetl plik

@ -118,7 +118,7 @@ export default {
AppContent,
AppNavigation,
AppNavigationItem,
Search,
Search
},
mixins: [currentuserMixin],
data: function() {

Wyświetl plik

@ -25,12 +25,12 @@
<div class="new-post" data-id="">
<input id="file-upload"
ref="fileUploadInput"
@change="handleFileChange($event)"
multiple
type="file"
tabindex="-1"
aria-hidden="true"
class="hidden-visually">
class="hidden-visually"
@change="handleFileChange($event)">
<div class="new-post-author">
<avatar :user="currentUser.uid" :display-name="currentUser.displayName" :disable-tooltip="true"
:size="32" />
@ -62,13 +62,13 @@
@tribute-replaced="updatePostFromTribute" />
</vue-tribute>
<PreviewGrid :uploading="false" :uploadProgress="0.4" :miniatures="previewUrls" />
<PreviewGrid :uploading="false" :upload-progress="0.4" :miniatures="previewUrls" />
<div class="options">
<Button type="tertiary"
@click.prevent="clickImportInput"
<Button v-tooltip="t('social', 'Add attachment')"
type="tertiary"
:aria-label="t('social', 'Add attachment')"
v-tooltip="t('social', 'Add attachment')">
@click.prevent="clickImportInput">
<template #icon>
<FileUpload :size="22" decorative title="" />
</template>
@ -78,10 +78,10 @@
<EmojiPicker ref="emojiPicker" :search="search" :close-on-select="false"
:container="container"
@select="insert">
<Button type="tertiary"
<Button v-tooltip="t('social', 'Add emoji')"
type="tertiary"
:aria-haspopup="true"
:aria-label="t('social', 'Add emoji')"
v-tooltip="t('social', 'Add emoji')">
:aria-label="t('social', 'Add emoji')">
<template #icon>
<EmoticonOutline :size="22" decorative title="" />
</template>
@ -90,10 +90,10 @@
</div>
<div v-click-outside="hidePopoverMenu" class="popovermenu-parent">
<Button type="tertiary"
:class="currentVisibilityIconClass"
@click.prevent="togglePopoverMenu"
v-tooltip="t('social', 'Visibility')" />
<Button v-tooltip="t('social', 'Visibility')"
type="tertiary"
:class="currentVisibilityIconClass"
@click.prevent="togglePopoverMenu" />
<div :class="{open: menuOpened}" class="popovermenu">
<popover-menu :menu="visibilityPopover" />
</div>
@ -142,10 +142,10 @@ export default {
EmoticonOutline,
Button,
Send,
PreviewGrid,
PreviewGrid
},
directives: {
FocusOnCreate,
FocusOnCreate
},
mixins: [CurrentUserMixin],
props: {},
@ -256,13 +256,13 @@ export default {
computed: {
postTo() {
switch (this.type) {
case 'public':
case 'unlisted':
return t('social', 'Post')
case 'followers':
return t('social', 'Post to followers')
case 'direct':
return t('social', 'Post to mentioned users')
case 'public':
case 'unlisted':
return t('social', 'Post')
case 'followers':
return t('social', 'Post to followers')
case 'direct':
return t('social', 'Post to mentioned users')
}
},
currentVisibilityIconClass() {
@ -288,6 +288,14 @@ export default {
currentVisibilityPostLabel() {
return this.visibilityPostLabel(this.type)
},
message: {
get() {
return this.$store.state.obj.message
},
set(value) {
this.$store.commit('updateStatus', value)
}
},
visibilityPostLabel() {
return (type) => {
if (typeof type === 'undefined') {
@ -362,7 +370,7 @@ export default {
},
canPost() {
if (this.previewUrls.length > 0) {
return true;
return true
}
return this.post.length !== 0 && this.post !== '<br>'
}
@ -378,12 +386,9 @@ export default {
this.$refs.fileUploadInput.click()
},
handleFileChange(event) {
const previewUrl = URL.createObjectURL(event.target.files[0])
this.previewUrls.push({
description: '',
url: previewUrl,
result: event.target.files[0],
})
const formData = new FormData()
formData.append('file', event.target.files[0])
this.$store.dispatch('uploadAttachement', formData)
},
removeAttachment(idx) {
this.previewUrls.splice(idx, 1)
@ -410,13 +415,17 @@ export default {
this.menuOpened = false
localStorage.setItem('social.lastPostType', type)
},
getPostData() {
keyup(event) {
if (event.shiftKey || event.ctrlKey) {
this.createPost(event)
}
},
updatePostFromTribute(event) {
// Trick to let vue-contenteditable know that tribute replaced a mention or hashtag
this.$refs.composerInput.oninput(event)
},
createPost: async function(event) {
let element = this.$refs.composerInput.cloneNode(true)
Array.from(element.getElementsByClassName('emoji')).forEach((emoji) => {
var em = document.createTextNode(emoji.getAttribute('alt'))
emoji.replaceWith(em)
})
let contentHtml = element.innerHTML
// Extract mentions from content and create an array out of them
@ -430,67 +439,26 @@ export default {
}
} while (match)
// Add author of original post in case of reply
if (this.replyTo !== null) {
to.push(this.replyTo.actor_info.account)
}
// Extract hashtags from content and create an array ot of them
const hashtagRegex = />#([^<]+)</g
let hashtags = []
match = null
do {
match = hashtagRegex.exec(contentHtml)
if (match) {
hashtags.push(match[1])
}
} while (match)
// Remove all html tags but </div> (wich we turn in newlines) and decode the remaining html entities
let content = contentHtml.replace(/<(?!\/div)[^>]+>/gi, '').replace(/<\/div>/gi, '\n').trim()
content = he.decode(content)
let data = {
content: content,
to: to,
hashtags: hashtags,
type: this.type,
attachments: this.previewUrls.map(preview => preview.result), // TODO send the summary and other props too
}
if (this.replyTo) {
data.replyTo = this.replyTo.id
}
return data
},
keyup(event) {
if (event.shiftKey || event.ctrlKey) {
this.createPost(event)
}
},
updatePostFromTribute(event) {
// Trick to let vue-contenteditable know that tribute replaced a mention or hashtag
this.$refs.composerInput.oninput(event)
},
createPost: async function(event) {
let postData = this.getPostData()
// Trick to validate last mention when the user directly clicks on the "post" button without validating it.
let regex = /@([-\w]+)$/
let lastMention = postData.content.match(regex)
if (lastMention) {
// Ask the server for matching accounts, and wait for the results
let result = await this.remoteSearchAccounts(lastMention[1])
// Validate the last mention only when it matches a single account
if (result.data.result.accounts.length === 1) {
postData.content = postData.content.replace(regex, '@' + result.data.result.accounts[0].account)
postData.to.push(result.data.result.accounts[0].account)
}
}
console.debug(content)
this.$store.dispatch('postStatus', content)
//
// // Trick to validate last mention when the user directly clicks on the "post" button without validating it.
// let regex = /@([-\w]+)$/
// let lastMention = postData.content.match(regex)
// if (lastMention) {
//
// // Ask the server for matching accounts, and wait for the results
// let result = await this.remoteSearchAccounts(lastMention[1])
//
// // Validate the last mention only when it matches a single account
// if (result.data.result.accounts.length === 1) {
// postData.content = postData.content.replace(regex, '@' + result.data.result.accounts[0].account)
// postData.to.push(result.data.result.accounts[0].account)
// }
// }
// Abort if the post is a direct message and no valid mentions were found
// if (this.type === 'direct' && postData.to.length === 0) {
@ -498,17 +466,6 @@ export default {
// return
// }
// Post message
this.loading = true
this.$store.dispatch('post', postData).then((response) => {
this.loading = false
this.replyTo = null
this.post = ''
this.$refs.composerInput.innerText = this.post
this.previewUrls = []
this.$store.dispatch('refreshTimeline')
})
},
closeReply() {
this.replyTo = null

Wyświetl plik

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<template>
<div class="upload-form">
<div class="upload-progress" v-if="false">
<div v-if="false" class="upload-progress">
<div class="upload-progress__icon">
<FileUpload :size="32" />
</div>
@ -19,7 +19,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
</div>
</div>
<div class="preview-grid">
<PreviewGridItem v-for="(item, index) in miniatures" :key="index" :preview="item" :index="index" @delete="deletePreview" />
<PreviewGridItem v-for="(item, index) in draft.attachements" :key="index" :preview="item"
:index="index" />
</div>
</div>
</template>
@ -27,33 +28,33 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<script>
import PreviewGridItem from './PreviewGridItem'
import FileUpload from 'vue-material-design-icons/FileUpload'
import { mapState } from 'vuex'
export default {
name: 'PreviewGrid',
components: {
PreviewGridItem,
FileUpload,
FileUpload
},
computed: {
...mapState({
'draft': state => state.timeline.draft
})
},
props: {
uploadProgress: {
type: Number,
required: true,
required: true
},
uploading: {
type: Boolean,
required: true,
required: true
},
miniatures: {
type: Array,
required: true,
},
},
methods: {
deletePreview(index) {
console.debug("rjeoijreo")
this.miniatures.splice(index, 1)
required: true
}
},
}
}
</script>

Wyświetl plik

@ -2,32 +2,33 @@
<div class="preview-item-wrapper">
<div class="preview-item" :style="backgroundStyle">
<div class="preview-item__actions">
<Button type="tertiary-no-background" @click="$emit('delete', index)">
<Button type="tertiary-no-background" @click="deletePreview">
<template #icon>
<Close :size="16" fillColor="white" />
<Close :size="16" fill-color="white" />
</template>
<span>{{ t('social', 'Delete') }}</span>
</Button>
<Button type="tertiary-no-background" @click="showModal">
<template #icon>
<Edit :size="16" fillColor="white" />
<Edit :size="16" fill-color="white" />
</template>
<span>{{ t('social', 'Edit') }}</span>
</Button>
</div>
<div class="description-warning" v-if="preview.description.length === 0">
<div v-if="preview.description.length === 0" class="description-warning">
{{ t('social', 'No description added') }}
</div>
<Modal v-if="modal" @close="closeModal" size="small">
<Modal v-if="modal" size="small" @close="closeModal">
<div class="modal__content">
<label :for="`image-description-${index}`">
{{ t('social', 'Describe for the visually impaired') }}
</label>
<textarea :id="`image-description-${index}`" v-model="preview.description">
</textarea>
<Button type="primary" @click="closeModal">{{ t('social', 'Close') }}</Button>
<textarea :id="`image-description-${index}`" v-model="internalDescription" />
<Button type="primary" @click="closeModal">
{{ t('social', 'Close') }}
</Button>
</div>
</Modal>
</div>
@ -46,38 +47,51 @@ export default {
Close,
Edit,
Button,
Modal,
Modal
},
props: {
preview: {
type: Object,
required: true
},
index: {
type: Number,
required: true
}
},
data() {
return {
modal: false,
internalDescription: ''
}
},
computed: {
backgroundStyle() {
return {
backgroundImage: `url("${this.preview.preview_url}")`
}
}
},
mounted() {
this.internalDescription = this.preview.description
},
methods: {
deletePreview() {
this.$store.dispatch('deleteAttachement', {
id: this.preview.id
})
},
showModal() {
this.modal = true
},
closeModal() {
this.modal = false
this.$store.dispatch('updateAttachement', {
id: this.preview.id,
description: this.internalDescription
})
}
},
props: {
preview: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
},
computed: {
backgroundStyle() {
return {
backgroundImage: `url("${this.preview.url}")`,
}
},
},
}
}
</script>

Wyświetl plik

@ -19,13 +19,13 @@ import Avatar from '@nextcloud/vue/dist/Components/Avatar'
export default {
name: 'TimelineAvatar',
components: {
Avatar,
Avatar
},
props: {
item: {
type: Object,
default: () => {},
},
default: () => {}
}
},
computed: {
userTest() {
@ -33,8 +33,8 @@ export default {
},
avatarUrl() {
return OC.generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.item.attributedTo)
},
},
}
}
}
</script>

Wyświetl plik

@ -33,31 +33,31 @@
<post-attachment :attachments="item.attachment" />
</div>
<div v-if="this.$route.params.type !== 'notifications' && !serverData.public" class="post-actions">
<Button type="tertiary-no-background"
v-tooltip="t('social', 'Reply')"
<Button v-tooltip="t('social', 'Reply')"
type="tertiary-no-background"
@click="reply">
<template #icon>
<Reply :size="20" />
</template>
</Button>
<Button type="tertiary-no-background"
v-tooltip="t('social', 'Boost')"
<Button v-tooltip="t('social', 'Boost')"
type="tertiary-no-background"
@click="boost">
<template #icon>
<Repeat :size="20" :fill-color="isBoosted ? 'blue' : 'black'" />
</template>
</Button>
<Button v-if="!isLiked"
type="tertiary-no-background"
v-tooltip="t('social', 'Like')"
type="tertiary-no-background"
@click="like">
<template #icon>
<HeartOutline :size="20" />
</template>
</Button>
<Button v-if="isLiked"
type="tertiary-no-background"
v-tooltip="t('social', 'Undo Like')"
type="tertiary-no-background"
@click="like">
<template #icon>
<Heart :size="20" :fill-color="'var(--color-error)'" />
@ -65,8 +65,8 @@
</Button>
<Actions>
<ActionButton v-if="item.actor_info.account === cloudId"
@click="remove()"
icon="icon-delete">
icon="icon-delete"
@click="remove()">
{{ t('social', 'Delete') }}
</ActionButton>
</Actions>
@ -105,7 +105,7 @@ export default {
Repeat,
Reply,
Heart,
HeartOutline,
HeartOutline
},
mixins: [currentUser],
props: {

Wyświetl plik

@ -0,0 +1,26 @@
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
import Vue from 'vue'
import App from './views/SettingsPersonal.vue'
// CSP config for webpack dynamic chunk loading
// eslint-disable-next-line
__webpack_nonce__ = btoa(OC.requestToken)
// Correct the root of the app for chunk loading
// OC.linkTo matches the apps folders
// eslint-disable-next-line
__webpack_public_path__ = OC.linkTo('social', 'js/')
Vue.prototype.t = t
Vue.prototype.n = n
Vue.prototype.OC = OC
Vue.prototype.OCA = OCA
/* eslint-disable-next-line no-new */
new Vue({
render: h => h(App)
}).$mount('#settings-personal')

Wyświetl plik

@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
const state = {
attachements: [],
status: '',
sensitive: false
}
const mutations = {
addAttachement(state, { id, description, url, preview_url }) {
state.attachements.push({ id, description, url, preview_url })
},
updateAttachement(state, { id, description, url, preview_url }) {
const index = state.attachements.findIndex(item => {
return id === item.id
})
state.attachements.splice(index, 1, { id, description, url, preview_url })
},
deleteAttachement(state, { id }) {
const index = state.attachements.findIndex(item => {
return id === item.id
})
state.attachements.splice(index, 1)
},
clearAttachements(state) {
state.attachements.splice(0)
},
updateSensitive(sensitive, status) {
state.sensitive = sensitive
}
}
const actions = {
async uploadAttachement(context, formData) {
const res = await axios.post(generateUrl('apps/social/api/v1/media'), formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
context.commit('addAttachement', {
id: res.data.id,
description: res.data.description,
url: res.data.url,
preview_url: res.data.preview_url
})
},
async updateAttachement(context, { id, description }) {
const res = await axios.put(generateUrl('apps/social/api/v1/media/' + id), {
description
})
context.commit('updateAttachement', {
id: res.data.id,
description: res.data.description,
url: res.data.url,
preview_url: res.data.preview_url
})
},
async deleteAttachement(context, { id }) {
const res = await axios.delete(generateUrl('apps/social/api/v1/media/' + id))
context.commit('deleteAttachement', {
id: res.data.id
})
},
async postStatus({ commit, state }, text) {
const data = {
status: text,
media_ids: state.attachements.map(attachement => attachement.id),
sensitive: state.sensitive
}
try {
const response = await axios.post(generateUrl('apps/social/api/v1/statuses'), data)
} catch (error) {
OC.Notification.showTemporary('Failed to create a post')
Logger.error('Failed to create a post', { 'error': error.response })
}
commit('clearAttachements')
}
}

Wyświetl plik

@ -26,6 +26,7 @@ import Vuex from 'vuex'
import timeline from './timeline'
import account from './account'
import settings from './settings'
import composer from './composer'
Vue.use(Vuex)
@ -35,7 +36,8 @@ export default new Vuex.Store({
modules: {
timeline,
account,
settings
settings,
composer
},
strict: debug
})

Wyświetl plik

@ -52,7 +52,10 @@ const state = {
* It's up to the view to honor this status or not.
* @member {boolean}
*/
composerDisplayStatus: false
composerDisplayStatus: false,
draft: {
attachements: []
}
}
const mutations = {
addToTimeline(state, data) {
@ -111,6 +114,24 @@ const mutations = {
if (typeof parentAnnounce.id !== 'undefined') {
Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object].object.action.values, 'boosted', false)
}
},
addAttachement(state, { id, description, url, preview_url }) {
state.draft.attachements.push({ id, description, url, preview_url })
},
updateAttachement(state, { id, description, url, preview_url }) {
const index = state.draft.attachements.findIndex(item => {
return id === item.id
})
state.draft.attachements.splice(index, 1, { id, description, url, preview_url })
},
deleteAttachement(state, { id }) {
const index = state.draft.attachements.findIndex(item => {
return id === item.id
})
state.draft.attachements.splice(index, 1)
},
clearAttachements(state) {
state.draft.attachements.splice(0)
}
}
const getters = {
@ -144,17 +165,48 @@ const actions = {
context.commit('setTimelineType', 'account')
context.commit('setAccount', account)
},
post(context, post) {
return new Promise((resolve, reject) => {
axios.post(generateUrl('apps/social/api/v1/post'), { data: post }).then((response) => {
Logger.info('Post created with token ' + response.data.result.token)
resolve(response)
}).catch((error) => {
OC.Notification.showTemporary('Failed to create a post')
Logger.error('Failed to create a post', { 'error': error.response })
reject(error)
})
async uploadAttachement(context, formData) {
const res = await axios.post(generateUrl('apps/social/api/v1/media'), formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
context.commit('addAttachement', {
id: res.data.id,
description: res.data.description,
url: res.data.url,
preview_url: res.data.preview_url
})
},
async updateAttachement(context, { id, description }) {
const res = await axios.put(generateUrl('apps/social/api/v1/media/' + id), {
description
})
context.commit('updateAttachement', {
id: res.data.id,
description: res.data.description,
url: res.data.url,
preview_url: res.data.preview_url
})
},
async deleteAttachement(context, { id }) {
const res = await axios.delete(generateUrl('apps/social/api/v1/media/' + id))
context.commit('deleteAttachement', {
id: res.data.id
})
},
async postStatus({ commit, state }, text) {
const data = {
status: text,
media_ids: state.draft.attachements.map(attachement => attachement.id)
}
try {
const response = axios.post(generateUrl('apps/social/api/v1/statuses'), data)
} catch (error) {
OC.Notification.showTemporary('Failed to create a post')
Logger.error('Failed to create a post', { 'error': error.response })
}
commit('clearAttachements')
},
postDelete(context, post) {
return axios.delete(generateUrl(`apps/social/api/v1/post?id=${post.id}`)).then((response) => {

Wyświetl plik

@ -0,0 +1,85 @@
<template>
<SettingsSection :title="t('social', 'Social')" :description="t('social', 'Configure social .......')">
<div class="form-field">
<label>{{ t('social', 'Default post visibility:') }}</label>
<CheckboxRadioSwitch :checked.sync="defaultVisibility"
value="PUBLIC" name="style"
type="radio">
{{ t('social', 'Public') }}
</CheckboxRadioSwitch>
<p class="form-info-checkbox">
{{ t('social', 'Make your post publicly visible') }}
</p>
<CheckboxRadioSwitch :checked.sync="defaultVisibility"
value="INSTANCE" name="style"
type="radio">
{{ t('social', 'Instance Only') }}
</CheckboxRadioSwitch>
<p class="form-info-checkbox">
{{ t('social', 'Make your post visible only to the user of this instance') }}
</p>
<CheckboxRadioSwitch :checked.sync="defaultVisibility"
value="FOLLOWER" name="style"
type="radio">
{{ t('social', 'Followers only') }}
</CheckboxRadioSwitch>
<p class="form-info-checkbox">
{{ t('social', 'Make your post visible only to your follower') }}
</p>
</div>
<div class="form-field">
<CheckboxRadioSwitch :checked.sync="style" value="text" name="style"
type="switch">
{{ t('social', 'Require follow requests') }}
</CheckboxRadioSwitch>
<p class="form-info-checkbox">
{{ t('social', 'Manually control who can follow you by approving follow requests') }}
</p>
</div>
</SettingsSection>
</template>
<script>
import CheckboxRadioSwitch from '@nextcloud/vue/dist/Components/CheckboxRadioSwitch'
import SettingsSection from '@nextcloud/vue/dist/Components/SettingsSection'
export default {
name: 'SetupUser',
components: {
CheckboxRadioSwitch,
SettingsSection
},
data() {
return {
defaultVisibility: 'PUBLIC'
}
}
}
</script>
<style scoped lang="scss">
.form-info-checkbox {
padding-left: 26px;
font-size: 14px;
margin-top: -2px;
}
.form-info-field {
font-size: 14px;
margin-top: -2px;
}
.form-field {
margin-top: 1.5rem;
}
.form-input {
width: 300px;
max-width: 100%;
display: block;
}
</style>

Wyświetl plik

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
\OCP\Util::addScript('social', 'settings-personal');
?>
<div id="settings-personal"></div>

Wyświetl plik

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Tests\Entitiy;
use OCA\Social\Entity\Account;
use OCA\Social\InstanceUtils;
use OCA\Social\Serializer\AccountSerializer;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserManager;
use Test\TestCase;
class AccountSerializerTest extends TestCase {
public function testJsonLd(): void {
$localDomain = "helloworld.social";
$instanceUtil = $this->createMock(InstanceUtils::class);
$instanceUtil->expects($this->any())
->method('getLocalInstanceUrl')
->willReturn('https://' . $localDomain);
$alice = $this->createMock(IUser::class);
$alice->expects($this->atLeastOnce())
->method('getDisplayName')
->willReturn('Alice Alice');
$userManager = $this->createMock(IUserManager::class);
$userManager->expects($this->once())
->method('get')
->with('alice_id')
->willReturn($alice);
$account = Account::newLocal();
$account->setUserName('alice');
$account->setUserId('alice_id');
$accountSerializer = new AccountSerializer($userManager, $instanceUtil);
$jsonLd = $accountSerializer->toJsonLd($account);
$this->assertSame('https://' . $localDomain . '/alice', $jsonLd['id']);
$this->assertSame('Alice Alice', $jsonLd['name']);
}
}

Wyświetl plik

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Tests\Service;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Follow;
use OCA\Social\Service\AccountFinder;
use OCP\DB\ORM\IEntityManager;
use OCP\Server;
use Test\TestCase;
/**
* @group DB
*/
class AccountFinderTest extends TestCase {
private ?Account $account1 = null;
private ?Account $account2 = null;
private ?AccountFinder $accountFinder = null;
public function setUp(): void {
parent::setUp();
$em = Server::get(IEntityManager::class);
$this->account1 = Account::newLocal('user1', 'user1', 'User1');
$this->account2 = Account::newLocal('user2', 'user2', 'User2');
$this->account2->follow($this->account1);
$em->persist($this->account1);
$em->persist($this->account2);
$em->flush();
$this->accountFinder = Server::get(AccountFinder::class);
}
public function tearDown(): void {
$em = Server::get(IEntityManager::class);
$em->remove($this->account1);
$em->remove($this->account2);
$em->flush();
parent::tearDown();
}
public function testGetLocalFollower(): void {
$accounts = $this->accountFinder->getLocalFollowersOf($this->account1);
$this->assertSame(1, count($accounts));
$this->assertSame($accounts[0]->getAccount()->getId(), $this->account2->getId());
}
public function testGetRepresentive(): void {
$account = $this->accountFinder->getRepresentative();
$account1 = $this->accountFinder->getRepresentative();
// Caching works
$this->assertSame($account, $account1);
}
}

Wyświetl plik

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Tests\Service\ActivityPub;
use OCA\Social\Service\ActivityPub\TagManager;
use OCP\IRequest;
use Test\TestCase;
class TagManagerTest extends TestCase {
public function localUriProvider(): array {
return [
[null, 'helloworld.com', false],
['https://helloworld.com', 'helloworld.com', true],
['https://helloworld.com/rehie', 'helloworld.com', true],
['https://helloworld.com:3000/rehie', 'helloworld.com', false],
['https://helloworld1.com', 'helloworld.com', false],
['https://floss.social/@carlschwan', 'helloworld.com', false],
];
}
/**
* @dataProvider localUriProvider
*/
public function testIsLocalUri(?string $url, string $localDomain, bool $result): void {
$request = $this->createMock(IRequest::class);
$request->expects($this->any())
->method('getServerHost')
->willReturn($localDomain);
$tagManager = new TagManager($request);
$this->assertSame($tagManager->isLocalUri($url), $result);
}
}

Wyświetl plik

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Tests\Service\Feed;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Status;
use OCA\Social\Service\AccountFinder;
use OCA\Social\Service\Feed\PostDeliveryService;
use OCA\Social\Service\Feed\FeedManager;
use OCA\Social\Service\ProcessMentionsService;
use OCP\DB\ORM\IEntityManager;
use OCP\Server;
use PHPUnit\Framework\MockObject\MockClass;
use Test\TestCase;
class PostDeliveryServiceTest extends TestCase {
private ?Account $account1 = null;
private ?Account $account2 = null;
private ?Account $account3 = null;
private ?AccountFinder $accountFinder = null;
private ?PostDeliveryService $postDeliveryService = null;
/** @var MockClass&FeedManager */
private $feedManager;
public function setUp(): void {
parent::setUp();
$em = Server::get(IEntityManager::class);
$this->account1 = Account::newLocal('user1', 'user1', 'User1');
$this->account2 = Account::newLocal('user2', 'user2', 'User2');
$this->account3 = Account::newLocal('user3', 'user3', 'User3');
$this->account2->follow($this->account1);
$em->persist($this->account1);
$em->persist($this->account2);
$em->persist($this->account3);
$em->flush();
$this->accountFinder = Server::get(AccountFinder::class);
$this->feedManager = $this->createMock(FeedManager::class);
$this->postDeliveryService = new PostDeliveryService($this->feedManager, $this->accountFinder);
}
public function tearDown(): void {
$em = Server::get(IEntityManager::class);
$em->remove($this->account1);
$em->remove($this->account2);
$em->remove($this->account3);
$em->flush();
parent::tearDown();
}
public function testCreateBasicStatus(): void {
$status = new Status();
$status->setAccount($this->account1);
$status->setText('Hello world!');
$status->setLocal(true);
$this->feedManager->expects($this->exactly(2))
->method('addToHome')
->withConsecutive(
[$this->account1->getId(), $status], // self
[$this->account2->getId(), $status] // follower
);
$this->postDeliveryService->run($status);
}
public function testCreateBasicStatusWithLocalMention(): void {
$status = new Status();
$status->setAccount($this->account1);
$status->setText('Hello world @user3!');
$status->setLocal(true);
$mentionService = \OCP\Server::get(ProcessMentionsService::class);
$mentionService->run($status);
$this->feedManager->expects($this->exactly(2))
->method('addToHome')
->withConsecutive(
[$this->account1->getId(), $status], // self
[$this->account2->getId(), $status], // follower
[$this->account3->getId(), $status] // mentioned user
);
$this->postDeliveryService->run($status);
}
}

Wyświetl plik

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Tests\Service;
use OCA\Social\Entity\Account;
use OCA\Social\Entity\Status;
use OCA\Social\Service\AccountFinder;
use OCA\Social\Service\Feed\FeedManager;
use OCA\Social\Service\Feed\PostDeliveryService;
use OCP\DB\ORM\IEntityManager;
use OCP\IDBConnection;
use OCP\Server;
use phpseclib3\Exception\FileNotFoundException;
use PHPUnit\Framework\MockObject\MockClass;
use Test\TestCase;
class FeedManagerTest extends TestCase {
private ?Account $account1 = null;
private ?Account $account2 = null;
private ?Account $account3 = null;
private ?AccountFinder $accountFinder = null;
private ?PostDeliveryService $postDeliveryService = null;
/** @var MockClass&FeedManager */
private $feedManager;
private IEntityManager $em;
public function setUp(): void {
parent::setUp();
$this->em = Server::get(IEntityManager::class);
$this->account1 = Account::newLocal('user1', 'user1', 'User1');
$this->account2 = Account::newLocal('user2', 'user2', 'User2');
$this->account3 = Account::newLocal('user3', 'user3', 'User3');
$this->em->persist($this->account1);
$this->em->persist($this->account2);
$this->em->persist($this->account3);
$this->em->flush();
$this->accountFinder = Server::get(AccountFinder::class);
$this->feedManager = $this->createMock(FeedManager::class);
$this->postDeliveryService = new PostDeliveryService($this->feedManager, $this->accountFinder);
}
public function tearDown(): void {
Server::get(IDBConnection::class)->executeStatement('DELETE from **PREFIX**social_status');
parent::tearDown();
}
public function testFollowAccont(): void {
$feedManager = Server::get(FeedManager::class);
for ($i = 0; $i < 100; $i++) {
$status = new Status();
$status->setAccount($this->account2);
$status->setText('Hello world!');
$this->em->persist($status);
$this->em->flush();
$feedManager->addToHome($this->account2->getId(), $status);
$status1 = new Status();
$status1->setAccount($this->account3);
$status1->setText('Hello world!');
$this->em->persist($status1);
$this->em->flush();
$feedManager->addToHome($this->account3->getId(), $status1);
}
$status->setLocal(true);
$this->account1->follow($this->account2);
$feedManager->mergeIntoHome($this->account2, $this->account1);
$status->setLocal(true);
$this->account1->follow($this->account3);
$feedManager->mergeIntoHome($this->account3, $this->account1);
}
}

Wyświetl plik

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
// Nextcloud - Social Support
// SPDX-FileCopyrightText: 2022 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Social\Tests\Service;
use OCA\Social\InstanceUtils;
use OCP\IURLGenerator;
use Test\TestCase;
class InstanceUtilsTest extends TestCase {
private InstanceUtils $instanceUtils;
public function setUp(): void {
parent::setUp();
$generator = $this->createMock(IURLGenerator::class);
$generator->expects($this->once())
->method('getAbsoluteUrl')
->willReturn('https://hello.world.social/');
$this->instanceUtils = new InstanceUtils($generator);
}
public function testInstanceName(): void {
$this->assertSame('hello.world.social', $this->instanceUtils->getLocalInstanceName('/'));
}
public function testInstanceUrl(): void {
$this->assertSame('https://hello.world.social', $this->instanceUtils->getLocalInstanceUrl('/'));
}
}

Wyświetl plik

@ -6,6 +6,7 @@ module.exports = {
entry: {
social: path.join(__dirname, 'src', 'main.js'),
ostatus: path.join(__dirname, 'src', 'ostatus.js'),
'settings-personal': path.join(__dirname, 'src', 'settings-personal.js'),
},
output: {
path: path.resolve(__dirname, './js'),