Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • adaures/castopod
  • mkljczk/castopod-host
  • spaetz/castopod-host
  • PatrykMis/castopod
  • jonas/castopod
  • ajeremias/castopod
  • misuzu/castopod
  • KrzysztofDomanczyk/castopod
  • Behel/castopod
  • nebulon/castopod
  • ewen/castopod
  • NeoluxConsulting/castopod
  • nateritter/castopod-og
  • prcutler/castopod
14 results
Show changes
Commits on Source (9)
Showing
with 788 additions and 329 deletions
# [1.0.0-beta.6](https://code.podlibre.org/podlibre/castopod-host/compare/v1.0.0-beta.5...v1.0.0-beta.6) (2022-02-03)
### Bug Fixes
- **activitypub:** add conditions for possibly missing actor properties + add
user-agent to requests
([8fbf948](https://code.podlibre.org/podlibre/castopod-host/commit/8fbf948fbba22ffd33966a1b2ccd42e8f7c1f8a2))
- **activitypub:** add target actor id to like / announce activities to send
directly to note's actor
([962dd30](https://code.podlibre.org/podlibre/castopod-host/commit/962dd305f5d3f6eadc68f400e0e8f953827fe20d))
- **activitypub:** add target_actor_id for create activity to broadcast post
reply
([0128a21](https://code.podlibre.org/podlibre/castopod-host/commit/0128a21ec55dcc0a2fbf4081dadb4c4737735ba1))
- **http-signature:** update SIGNATURE_PATTERN allowing signature keys to be
sent in any order
([b7f285e](https://code.podlibre.org/podlibre/castopod-host/commit/b7f285e4e24247fedb94f030356fa6f291f525cc))
- **install:** set message block on forms to show error messages
([3a0a20d](https://code.podlibre.org/podlibre/castopod-host/commit/3a0a20d59cdae7f166325efb750eaa6e9800ba6e)),
closes [#157](https://code.podlibre.org/podlibre/castopod-host/issues/157)
- **markdown-editor:** remove unnecessary buttons for podcast and episode
editors + add extensions
([9c4f60e](https://code.podlibre.org/podlibre/castopod-host/commit/9c4f60e00bcbd4f784f12d2a6fed357ad402ee2e))
- **podcast-activity:** check if transcript and chapters are set before
including them in audio
([5855a25](https://code.podlibre.org/podlibre/castopod-host/commit/5855a250936f91641efef77650890a18d8e9917f))
- **podcast:** use markdown description value for editor + set prose class to
about description
([f304d97](https://code.podlibre.org/podlibre/castopod-host/commit/f304d97b14e0ef383509cb3bba50beb55bf701ba)),
closes [#156](https://code.podlibre.org/podlibre/castopod-host/issues/156)
# [1.0.0-beta.5](https://code.podlibre.org/podlibre/castopod-host/compare/v1.0.0-beta.4...v1.0.0-beta.5) (2022-01-31)
### Bug Fixes
......
......@@ -11,7 +11,7 @@ declare(strict_types=1);
|
| NOTE: this constant is updated upon release with Continuous Integration.
*/
defined('CP_VERSION') || define('CP_VERSION', '1.0.0-beta.5');
defined('CP_VERSION') || define('CP_VERSION', '1.0.0-beta.6');
/*
| --------------------------------------------------------------------
......
......@@ -26,7 +26,12 @@ use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
use League\CommonMark\MarkdownConverter;
use RuntimeException;
/**
......@@ -473,13 +478,21 @@ class Episode extends Entity
public function setDescriptionMarkdown(string $descriptionMarkdown): static
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
$config = [
'html_input' => 'escape',
'allow_unsafe_links' => false,
]);
];
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new DisallowedRawHtmlExtension());
$converter = new MarkdownConverter($environment);
$this->attributes['description_markdown'] = $descriptionMarkdown;
$this->attributes['description_html'] = $converter->convertToHtml($descriptionMarkdown);
$this->attributes['description_html'] = $converter->convert($descriptionMarkdown);
return $this;
}
......
......@@ -12,7 +12,12 @@ namespace App\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
use League\CommonMark\MarkdownConverter;
/**
* @property int $id
......@@ -49,13 +54,20 @@ class Page extends Entity
public function setContentMarkdown(string $contentMarkdown): static
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
$config = [
'allow_unsafe_links' => false,
]);
];
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new DisallowedRawHtmlExtension());
$converter = new MarkdownConverter($environment);
$this->attributes['content_markdown'] = $contentMarkdown;
$this->attributes['content_html'] = $converter->convertToHtml($contentMarkdown);
$this->attributes['content_html'] = $converter->convert($contentMarkdown);
return $this;
}
......
......@@ -23,7 +23,12 @@ use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
use League\CommonMark\MarkdownConverter;
use Modules\Auth\Entities\User;
use RuntimeException;
......@@ -375,13 +380,21 @@ class Podcast extends Entity
public function setDescriptionMarkdown(string $descriptionMarkdown): static
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
$config = [
'html_input' => 'escape',
'allow_unsafe_links' => false,
]);
];
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new DisallowedRawHtmlExtension());
$converter = new MarkdownConverter($environment);
$this->attributes['description_markdown'] = $descriptionMarkdown;
$this->attributes['description_html'] = $converter->convertToHtml($descriptionMarkdown);
$this->attributes['description_html'] = $converter->convert($descriptionMarkdown);
return $this;
}
......@@ -399,17 +412,25 @@ class Podcast extends Entity
return $this;
}
$converter = new CommonMarkConverter([
'html_input' => 'strip',
$config = [
'html_input' => 'escape',
'allow_unsafe_links' => false,
]);
];
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new DisallowedRawHtmlExtension());
$converter = new MarkdownConverter($environment);
$this->attributes[
'episode_description_footer_markdown'
] = $episodeDescriptionFooterMarkdown;
$this->attributes[
'episode_description_footer_html'
] = $converter->convertToHtml($episodeDescriptionFooterMarkdown);
] = $converter->convert($episodeDescriptionFooterMarkdown);
return $this;
}
......
<?php
declare(strict_types=1);
if (! function_exists('form_markdown_textarea')) {
/**
* Textarea field
*
* @param mixed $data
* @param mixed $extra
*/
function form_markdown_textarea($data = '', string $value = '', $extra = ''): string
{
$defaults = [
'name' => is_array($data) ? '' : $data,
'cols' => '40',
'rows' => '10',
];
if (! is_array($data) || ! isset($data['value'])) {
$val = $value;
} else {
$val = $data['value'];
unset($data['value']); // textareas don't use the value attribute
}
// Unsets default rows and cols if defined in extra field as array or string.
if ((is_array($extra) && array_key_exists('rows', $extra)) || (is_string($extra) && stripos(
preg_replace('~\s+~', '', $extra),
'rows='
) !== false)) {
unset($defaults['rows']);
}
if ((is_array($extra) && array_key_exists('cols', $extra)) || (is_string($extra) && stripos(
preg_replace('~\s+~', '', $extra),
'cols='
) !== false)) {
unset($defaults['cols']);
}
return '<textarea ' . rtrim(parse_form_attributes($data, $defaults)) . stringify_attributes($extra) . '>'
. $val
. "</textarea>\n";
}
}
......@@ -68,10 +68,16 @@ class PodcastEpisode extends ObjectType
'type' => 'Link',
'mediaType' => $episode->audio->file_mimetype,
],
'transcript' => $episode->transcript->file_url,
'chapters' => $episode->chapters->file_url,
];
if ($episode->transcript !== null) {
$this->audio['transcript'] = $episode->transcript->file_url;
}
if ($episode->chapters !== null) {
$this->audio['chapters'] = $episode->chapters->file_url;
}
$this->comments = url_to('episode-comments', $episode->podcast->handle, $episode->slug);
if ($episode->published_at !== null) {
......
......@@ -30,6 +30,8 @@ class Alert extends Component
$title = $this->title === null ? '' : '<div class="font-semibold">' . $this->title . '</div>';
$class = 'inline-flex w-full p-2 text-sm border rounded ' . $variantClasses[$this->variant] . ' ' . $this->class;
unset($this->attributes['slot']);
unset($this->attributes['variant']);
$attributes = stringify_attributes($this->attributes);
return <<<HTML
......
......@@ -6,6 +6,16 @@ namespace App\Views\Components\Forms;
class MarkdownEditor extends FormComponent
{
/**
* @var string[]
*/
protected array $disallowList = [];
public function setDisallowList(string $value): void
{
$this->disallowList = explode(',', $value);
}
public function render(): string
{
$editorClass = 'w-full flex flex-col bg-elevated border-3 border-contrast rounded-lg overflow-hidden focus-within:ring-accent ' . $this->class;
......@@ -13,30 +23,83 @@ class MarkdownEditor extends FormComponent
$this->attributes['class'] = 'bg-elevated border-none focus:border-none focus:outline-none focus:ring-0 w-full h-full';
$this->attributes['rows'] = 6;
// dd(htmlspecialchars_decode($this->value));
$value = htmlspecialchars_decode($this->value);
$textarea = form_textarea($this->attributes, old($this->name, $value, false));
$icons = [
'heading' => icon('heading'),
'bold' => icon('bold'),
'italic' => icon('italic'),
'list-unordered' => icon('list-unordered'),
'list-ordered' => icon('list-ordered'),
'quote' => icon('quote'),
'link' => icon('link'),
'image-add' => icon('image-add'),
'markdown' => icon(
'markdown',
'mr-1 text-lg opacity-40'
),
];
$oldValue = old($this->name);
if ($oldValue === null) {
$oldValue = $value;
}
$textarea = form_textarea($this->attributes, $oldValue);
$markdownIcon = icon(
'markdown',
'mr-1 text-lg opacity-40'
);
$translations = [
'write' => lang('Common.forms.editor.write'),
'preview' => lang('Common.forms.editor.preview'),
'help' => lang('Common.forms.editor.help'),
];
$toolbarGroups = [
[
[
'name' => 'header',
'tag' => 'md-header',
'icon' => icon('heading'),
],
[
'name' => 'bold',
'tag' => 'md-bold',
'icon' => icon('bold'),
],
[
'name' => 'italic',
'tag' => 'md-italic',
'icon' => icon('italic'),
],
],
[
[
'name' => 'unordered-list',
'tag' => 'md-unordered-list',
'icon' => icon('list-unordered'),
],
[
'name' => 'ordered-list',
'tag' => 'md-ordered-list ',
'icon' => icon('list-ordered'),
],
],
[
[
'name' => 'quote',
'tag' => 'md-quote',
'icon' => icon('quote'),
],
[
'name' => 'link',
'tag' => 'md-link',
'icon' => icon('link'),
],
[
'name' => 'image',
'tag' => 'md-image',
'icon' => icon('image-add'),
],
],
];
$toolbarContent = '';
foreach ($toolbarGroups as $buttonsGroup) {
$toolbarContent .= '<div class="inline-flex text-2xl gap-x-1">';
foreach ($buttonsGroup as $button) {
if (! in_array($button['name'], $this->disallowList, true)) {
$toolbarContent .= '<' . $button['tag'] . ' class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100">' . $button['icon'] . '</' . $button['tag'] . '>';
}
}
$toolbarContent .= '</div>';
}
return <<<HTML
<div class="{$editorClass}">
<header class="px-2">
......@@ -45,22 +108,7 @@ class MarkdownEditor extends FormComponent
<button type="button" slot="write" class="px-2 font-semibold focus:ring-inset focus:ring-accent">{$translations['write']}</button>
<button type="button" slot="preview" class="px-2 font-semibold focus:ring-inset focus:ring-accent">{$translations['preview']}</button>
</markdown-write-preview>
<markdown-toolbar for="{$this->id}" class="flex gap-4 px-2 py-1">
<div class="inline-flex text-2xl gap-x-1">
<md-header class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100">{$icons['heading']}</md-header>
<md-bold class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100" data-hotkey-scope="{$this->id}" data-hotkey="Control+b,Meta+b">{$icons['bold']}</md-bold>
<md-italic class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100" data-hotkey-scope="{$this->id}" data-hotkey="Control+i,Meta+i">{$icons['italic']}</md-italic>
</div>
<div class="inline-flex text-2xl gap-x-1">
<md-unordered-list class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100">{$icons['list-unordered']}</md-unordered-list>
<md-ordered-list class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100">{$icons['list-ordered']}</md-ordered-list>
</div>
<div class="inline-flex text-2xl gap-x-1">
<md-quote class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100">{$icons['quote']}</md-quote>
<md-link class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100" data-hotkey-scope="{$this->id}" data-hotkey="Control+k,Meta+k">{$icons['link']}</md-link>
<md-image class="opacity-50 hover:opacity-100 focus:ring-accent focus:opacity-100">{$icons['image-add']}</md-image>
</div>
</markdown-toolbar>
<markdown-toolbar for="{$this->id}" class="flex gap-4 px-2 py-1">{$toolbarContent}</markdown-toolbar>
</div>
</header>
<div class="relative">
......@@ -68,7 +116,7 @@ class MarkdownEditor extends FormComponent
<markdown-preview for="{$this->id}" class="absolute top-0 left-0 hidden w-full h-full max-w-full px-3 py-2 overflow-y-auto prose bg-base" showClass="bg-elevated" />
</div>
<footer class="flex px-2 py-1 border-t bg-base">
<a href="https://commonmark.org/help/" class="inline-flex items-center text-xs font-semibold text-skin-muted hover:text-skin-base" target="_blank" rel="noopener noreferrer">{$icons['markdown']}{$translations['help']}</a>
<a href="https://commonmark.org/help/" class="inline-flex items-center text-xs font-semibold text-skin-muted hover:text-skin-base" target="_blank" rel="noopener noreferrer">{$markdownIcon}{$translations['help']}</a>
</footer>
</div>
HTML;
......
{
"name": "podlibre/castopod-host",
"version": "1.0.0-beta5",
"version": "1.0.0-beta6",
"type": "project",
"description": "Castopod Host is an open-source hosting platform made for podcasters who want engage and interact with their audience.",
"homepage": "https://castopod.org",
......@@ -12,7 +12,7 @@
"geoip2/geoip2": "^v2.11.0",
"myth/auth": "dev-develop",
"codeigniter4/codeigniter4": "dev-develop",
"league/commonmark": "^v1.6.6",
"league/commonmark": "^2.2",
"vlucas/phpdotenv": "^v5.3.0",
"league/html-to-markdown": "^v5.0.1",
"opawg/user-agents-php": "^v1.0",
......
This diff is collapsed.
......@@ -27,15 +27,9 @@ class ActivityRequest
protected ?Activity $activity = null;
/**
* @var array<string, string[]>
* @var array<string, mixed>
*/
protected array $options = [
'headers' => [
'Content-Type' => 'application/activity+json',
'Accept' => 'application/activity+json',
// TODO: outgoing and incoming requests
],
];
protected array $options = [];
public function __construct(string $uri, ?string $activityPayload = null)
{
......@@ -45,12 +39,21 @@ class ActivityRequest
$this->request->setBody($activityPayload);
}
$this->options = [
'headers' => [
'Content-Type' => 'application/activity+json',
'Accept' => 'application/activity+json',
'User-Agent' => 'Castopod/' . CP_VERSION . '; +' . base_url('', 'https'),
// TODO: outgoing and incoming requests
],
];
$this->uri = new URI($uri);
}
public function post(): void
{
// send Message to Fediverse instance
// outgoing message to Fediverse instance
$this->request->post((string) $this->uri, $this->options);
}
......@@ -80,7 +83,7 @@ class ActivityRequest
$digest = 'SHA-256=' . base64_encode($this->getBodyDigest());
$contentType = $this->options['headers']['Content-Type'];
$contentLength = (string) strlen($this->request->getBody());
$userAgent = 'Castopod';
$userAgent = 'Castopod/' . CP_VERSION . '; +' . base_url('', 'https');
$plainText = "(request-target): post {$path}\nhost: {$host}\ndate: {$date}\ndigest: {$digest}\ncontent-type: {$contentType}\ncontent-length: {$contentLength}\nuser-agent: {$userAgent}";
......
......@@ -108,12 +108,13 @@ class ActorController extends Controller
if ($replyToPost !== null) {
// TODO: strip content from html to retrieve message
// remove all html tags and reconstruct message with mentions?
extract_text_from_html($payload->object->content);
$message = get_message_from_object($payload->object);
$reply = new Post([
'uri' => $payload->object->id,
'actor_id' => $payloadActor->id,
'in_reply_to_id' => $replyToPost->id,
'message' => $payload->object->content,
'message' => $message,
'published_at' => Time::parse($payload->object->published),
]);
}
......
......@@ -27,11 +27,22 @@ class SchedulerController extends Controller
// Send activity to all followers
foreach ($scheduledActivities as $scheduledActivity) {
// send activity to all actor followers
send_activity_to_followers(
$scheduledActivity->actor,
json_encode($scheduledActivity->payload, JSON_THROW_ON_ERROR),
);
if ($scheduledActivity->target_actor_id !== null) {
if ($scheduledActivity->actor_id !== $scheduledActivity->target_actor_id) {
// send activity to targeted actor
send_activity_to_actor(
$scheduledActivity->actor,
$scheduledActivity->targetActor,
json_encode($scheduledActivity->payload, JSON_THROW_ON_ERROR)
);
}
} else {
// send activity to all actor followers
send_activity_to_followers(
$scheduledActivity->actor,
json_encode($scheduledActivity->payload, JSON_THROW_ON_ERROR),
);
}
// set activity post to delivered
model('ActivityModel')
......
......@@ -22,7 +22,7 @@ use RuntimeException;
* @property string|null $summary
* @property string|null $private_key
* @property string|null $public_key
* @property string|null $public_key_id
* @property string $public_key_id
* @property string|null $avatar_image_url
* @property string|null $avatar_image_mimetype
* @property string|null $cover_image_url
......
......@@ -97,6 +97,25 @@ if (! function_exists('accept_follow')) {
}
}
if (! function_exists('send_activity_to_actor')) {
/**
* Sends an activity to all actor followers
*/
function send_activity_to_actor(Actor $actor, Actor $targetActor, string $activityPayload): void
{
try {
$acceptRequest = new ActivityRequest($targetActor->inbox_url, $activityPayload);
if ($actor->private_key !== null) {
$acceptRequest->sign($actor->public_key_id, $actor->private_key);
}
$acceptRequest->post();
} catch (Exception $exception) {
// log error
log_message('critical', $exception->getMessage());
}
}
}
if (! function_exists('send_activity_to_followers')) {
/**
* Sends an activity to all actor followers
......@@ -104,14 +123,7 @@ if (! function_exists('send_activity_to_followers')) {
function send_activity_to_followers(Actor $actor, string $activityPayload): void
{
foreach ($actor->followers as $follower) {
try {
$acceptRequest = new ActivityRequest($follower->inbox_url, $activityPayload);
$acceptRequest->sign($actor->public_key_id, $actor->private_key);
$acceptRequest->post();
} catch (Exception $exception) {
// log error
log_message('critical', $exception->getMessage());
}
send_activity_to_actor($actor, $follower, $activityPayload);
}
}
}
......@@ -261,7 +273,7 @@ if (! function_exists('create_actor_from_uri')) {
$newActor->public_key = $actorPayload->publicKey->publicKeyPem;
$newActor->private_key = null;
$newActor->display_name = $actorPayload->name;
$newActor->summary = $actorPayload->summary;
$newActor->summary = property_exists($actorPayload, 'summary') ? $actorPayload->summary : null;
if (property_exists($actorPayload, 'icon')) {
$newActor->avatar_image_url = $actorPayload->icon->url;
$newActor->avatar_image_mimetype = $actorPayload->icon->mediaType;
......@@ -272,8 +284,8 @@ if (! function_exists('create_actor_from_uri')) {
$newActor->cover_image_mimetype = $actorPayload->image->mediaType;
}
$newActor->inbox_url = $actorPayload->inbox;
$newActor->outbox_url = $actorPayload->outbox;
$newActor->followers_url = $actorPayload->followers;
$newActor->outbox_url = property_exists($actorPayload, 'outbox') ? $actorPayload->outbox : null;
$newActor->followers_url = property_exists($actorPayload, 'followers') ? $actorPayload->followers : null;
if (! ($newActorId = model('ActorModel')->insert($newActor, true))) {
return null;
......@@ -307,6 +319,36 @@ if (! function_exists('extract_text_from_html')) {
}
}
if (! function_exists('get_message_from_object')) {
/**
* Gets the message from content, if no content key is present, checks for content in contentMap
*
* TODO: store multiple languages, convert markdown
*
* @return string|false
*/
function get_message_from_object(stdClass $object): string | false
{
if (property_exists($object, 'content')) {
extract_text_from_html($object->content);
return $object->content;
}
$message = '';
if (property_exists($object, 'contentMap')) {
// TODO: update message to be json? (include all languages?)
if (property_exists($object->contentMap, 'en')) {
extract_text_from_html($object->contentMap->en);
$message = $object->contentMap->en;
} else {
$message = current($object->contentMap);
}
}
return $message;
}
}
if (! function_exists('linkify')) {
/**
* Turn all link elements in clickable links. Transforms urls and handles
......
......@@ -28,18 +28,14 @@ class HttpSignature
/**
* @var string
*/
private const SIGNATURE_PATTERN = '/^
keyId="(?P<keyId>
(https?:\/\/[\w\-\.]+[\w]+)
(:[\d]+)?
([\w\-\.#\/@]+)
)",
algorithm="(?P<algorithm>[\w\-]+)",
(headers="\(request-target\) (?P<headers>[\w\\-\s]+)",)?
signature="(?P<signature>[\w+\/]+={0,2})"
private const SIGNATURE_PATTERN = '/
(?=.*(keyId="(?P<keyId>https?:\/\/[\w\-\.]+[\w]+(:[\d]+)?[\w\-\.#\/@]+)"))
(?=.*(signature="(?P<signature>[\w+\/]+={0,2})"))
(?=.*(headers="\(request-target\)(?P<headers>[\w\\-\s]+)"))?
(?=.*(algorithm="(?P<algorithm>[\w\-]+)"))?
/x';
protected ?IncomingRequest $request = null;
protected IncomingRequest $request;
public function __construct(IncomingRequest $request = null)
{
......@@ -66,7 +62,8 @@ class HttpSignature
$requestTime = Time::createFromFormat('D, d M Y H:i:s T', $dateHeader->getValue());
$diff = $requestTime->difference($currentTime);
if ($diff->getSeconds() > 3600) {
$diffSeconds = $diff->getSeconds();
if ($diffSeconds > 3600 || $diffSeconds < 0) {
throw new Exception('Request must be made within the last hour.');
}
......@@ -74,6 +71,7 @@ class HttpSignature
if (! ($digestHeader = $this->request->header('digest'))) {
throw new Exception('Request must include a digest header');
}
// compute body digest and compare with header digest
$bodyDigest = hash('sha256', $this->request->getBody(), true);
$digest = 'SHA-256=' . base64_encode($bodyDigest);
......@@ -94,7 +92,8 @@ class HttpSignature
// set $keyId, $headers and $signature variables
$keyId = $parts['keyId'];
$headers = $parts['headers'];
$algorithm = $parts['algorithm'];
$headers = $parts['headers'] ?? 'date';
$signature = $parts['signature'];
// Fetch the public key linked from keyId
......@@ -102,19 +101,14 @@ class HttpSignature
$actorResponse = $actorRequest->get();
$actor = json_decode($actorResponse->getBody(), false, 512, JSON_THROW_ON_ERROR);
$publicKeyPem = $actor->publicKey->publicKeyPem;
$publicKeyPem = (string) $actor->publicKey->publicKeyPem;
// Create a comparison string from the plaintext headers we got
// in the same order as was given in the signature header,
$data = $this->getPlainText(explode(' ', trim($headers)));
// Verify that string using the public key and the original signature.
$rsa = new RSA();
$rsa->setHash('sha256');
$rsa->setSignatureMode(RSA::SIGNATURE_PKCS1);
$rsa->loadKey($publicKeyPem);
return $rsa->verify($data, base64_decode($signature, true));
// Verify the data string using the public key and the original signature.
return $this->verifySignature($publicKeyPem, $data, $signature, $algorithm);
}
/**
......@@ -124,7 +118,7 @@ class HttpSignature
*/
private function splitSignature(string $signature): array | false
{
if (! preg_match(self::SIGNATURE_PATTERN, $signature, $matches)) {
if (! preg_match(self::SIGNATURE_PATTERN, $signature, $matches, PREG_UNMATCHED_AS_NULL)) {
// Signature pattern failed
return false;
}
......@@ -162,4 +156,27 @@ class HttpSignature
return implode("\n", $strings);
}
/**
* Verifies the signature depending on the algorithm sent
*/
private function verifySignature(
string $publicKeyPem,
string $data,
string $signature,
string $algorithm = 'rsa-sha256'
): bool {
if ($algorithm === 'rsa-sha512' || $algorithm === 'rsa-sha256') {
$hash = substr($algorithm, strpos($algorithm, '-') + 1);
$rsa = new RSA();
$rsa->setHash($hash);
$rsa->setSignatureMode(RSA::SIGNATURE_PKCS1);
$rsa->loadKey($publicKeyPem);
return $rsa->verify($data, (string) base64_decode($signature, true));
}
// not implemented
return false;
}
}
......@@ -68,7 +68,7 @@ class FavouriteModel extends BaseUuidModel
->newActivity(
'Like',
$actor->id,
null,
$post->actor_id,
$post->id,
$likeActivity->toJSON(),
$post->published_at,
......@@ -134,7 +134,7 @@ class FavouriteModel extends BaseUuidModel
->newActivity(
'Undo',
$actor->id,
null,
$post->actor_id,
$post->id,
$undoActivity->toJSON(),
$post->published_at,
......
......@@ -299,7 +299,7 @@ class PostModel extends BaseUuidModel
->newActivity(
'Create',
$post->actor_id,
null,
$post->in_reply_to_id === null ? null : $post->reply_to_post->actor_id,
$newPostId,
$createActivity->toJSON(),
$post->published_at,
......@@ -499,7 +499,7 @@ class PostModel extends BaseUuidModel
->newActivity(
'Announce',
$actor->id,
null,
$post->actor_id,
$post->id,
$announceActivity->toJSON(),
$reblog->published_at,
......@@ -559,7 +559,7 @@ class PostModel extends BaseUuidModel
->newActivity(
'Undo',
$reblogPost->actor_id,
null,
$reblogPost->reblog_of_post->actor_id,
$reblogPost->reblog_of_id,
$undoActivity->toJSON(),
Time::now(),
......
......@@ -39,6 +39,8 @@ class NoteObject extends ObjectType
$this->attributedTo = $post->actor->uri;
if ($post->in_reply_to_id !== null) {
$this->to[] = $post->reply_to_post->actor->uri;
$this->inReplyTo = $post->reply_to_post->uri;
}
......