sforkowany z mirror/social
Porównaj commity
11 Commity
master
...
db-rewrite
Autor | SHA1 | Data |
---|---|---|
Carl Schwan | ee1b2945bb | |
Carl Schwan | a2fca565a1 | |
Carl Schwan | 9f49b14657 | |
Carl Schwan | e821566eca | |
Carl Schwan | d6aa923b8d | |
Carl Schwan | f21a5a70d8 | |
Carl Schwan | b023f93deb | |
Carl Schwan | b00831783d | |
Carl Schwan | d53fc73448 | |
Carl Schwan | 95f3fc3a5a | |
Carl Schwan | ff816897dd |
|
@ -8,3 +8,5 @@ cypress/screenshots
|
|||
cypress/snapshots
|
||||
|
||||
.php-cs-fixer.cache
|
||||
|
||||
tests/.phpunit.result.cache
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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",
|
||||
|
|
Plik diff jest za duży
Load Diff
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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 {
|
||||
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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)]);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace OCA\Social\Service;
|
||||
|
||||
class ApiException extends \InvalidArgumentException {
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -57,7 +57,6 @@ class CheckService {
|
|||
use TArrayTools;
|
||||
use TStringTools;
|
||||
|
||||
|
||||
public const CACHE_PREFIX = 'social_check_';
|
||||
|
||||
private IUserManager $userManager;
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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()),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace OCA\Social\Tools\Exceptions;
|
||||
|
||||
class MalformedUriException extends \Exception {
|
||||
}
|
|
@ -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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -118,7 +118,7 @@ export default {
|
|||
AppContent,
|
||||
AppNavigation,
|
||||
AppNavigationItem,
|
||||
Search,
|
||||
Search
|
||||
},
|
||||
mixins: [currentuserMixin],
|
||||
data: function() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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')
|
|
@ -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')
|
||||
}
|
||||
}
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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']);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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('/'));
|
||||
}
|
||||
}
|
|
@ -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'),
|
||||
|
|
Ładowanie…
Reference in New Issue