Merge pull request #4237 from pixelfed/staging

Portfolio Federation + Customization
This commit is contained in:
daniel 2023-03-17 02:35:32 -06:00 committed by GitHub
commit 88bcb06448
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1261 additions and 103 deletions

View file

@ -32,6 +32,7 @@ use App\Mail\PasswordChange;
use App\Mail\ConfirmAppEmail; use App\Mail\ConfirmAppEmail;
use App\Http\Resources\StatusStateless; use App\Http\Resources\StatusStateless;
use App\Jobs\StatusPipeline\StatusDelete; use App\Jobs\StatusPipeline\StatusDelete;
use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail;
class ApiV1Dot1Controller extends Controller class ApiV1Dot1Controller extends Controller
{ {
@ -144,6 +145,10 @@ class ApiV1Dot1Controller extends Controller
$report->type = $report_type; $report->type = $report_type;
$report->save(); $report->save();
if(config('instance.reports.email.enabled')) {
ReportNotifyAdminViaEmail::dispatch($report)->onQueue('default');
}
$res = [ $res = [
"msg" => "Successfully sent report", "msg" => "Successfully sent report",
"code" => 200 "code" => 200
@ -399,10 +404,10 @@ class ApiV1Dot1Controller extends Controller
abort_if(!$user, 403); abort_if(!$user, 403);
abort_if($user->status != null, 403); abort_if($user->status != null, 403);
$res = $user->tokens->sortByDesc('created_at')->take(10)->map(function($token, $key) { $res = $user->tokens->sortByDesc('created_at')->take(10)->map(function($token, $key) use($request) {
return [ return [
'id' => $key + 1, 'id' => $token->id,
'did' => encrypt($token->id), 'current_session' => $request->user()->token()->id == $token->id,
'name' => $token->client->name, 'name' => $token->client->name,
'scopes' => $token->scopes, 'scopes' => $token->scopes,
'revoked' => $token->revoked, 'revoked' => $token->revoked,

View file

@ -13,6 +13,10 @@ use App\Services\StatusService;
class PortfolioController extends Controller class PortfolioController extends Controller
{ {
const RSS_FEED_KEY = 'pf:portfolio:rss-feed:';
const CACHED_FEED_KEY = 'pf:portfolio:cached-feed:';
const RECENT_FEED_KEY = 'pf:portfolio:recent-feed:';
public function index(Request $request) public function index(Request $request)
{ {
return view('portfolio.index'); return view('portfolio.index');
@ -60,11 +64,11 @@ class PortfolioController extends Controller
$user = AccountService::get($post['account']['id']); $user = AccountService::get($post['account']['id']);
$portfolio = Portfolio::whereProfileId($user['id'])->first(); $portfolio = Portfolio::whereProfileId($user['id'])->first();
if($user['locked'] || $portfolio->active != true) { if(!$portfolio || $user['locked'] || $portfolio->active != true) {
return view('portfolio.404'); return view('portfolio.404');
} }
if(!$post || $post['visibility'] != 'public' || $post['pf_type'] != 'photo' || $user['id'] != $post['account']['id']) { if(!$post || $post['visibility'] != 'public' || !in_array($post['pf_type'], ['photo', 'photo:album']) || $user['id'] != $post['account']['id']) {
return view('portfolio.404'); return view('portfolio.404');
} }
@ -117,7 +121,7 @@ class PortfolioController extends Controller
$this->validate($request, [ $this->validate($request, [
'profile_source' => 'required|in:recent,custom', 'profile_source' => 'required|in:recent,custom',
'layout' => 'required|in:grid,masonry', 'layout' => 'required|in:grid,masonry',
'layout_container' => 'required|in:fixed,fluid' 'layout_container' => 'required|in:fixed,fluid',
]); ]);
$portfolio = Portfolio::whereUserId($request->user()->id)->first(); $portfolio = Portfolio::whereUserId($request->user()->id)->first();
@ -140,6 +144,7 @@ class PortfolioController extends Controller
$portfolio->show_bio = $request->input('show_bio') === 'on'; $portfolio->show_bio = $request->input('show_bio') === 'on';
$portfolio->profile_layout = $request->input('layout'); $portfolio->profile_layout = $request->input('layout');
$portfolio->profile_container = $request->input('layout_container'); $portfolio->profile_container = $request->input('layout_container');
$portfolio->metadata = $metadata;
$portfolio->save(); $portfolio->save();
return redirect('/' . $request->user()->username); return redirect('/' . $request->user()->username);
@ -167,20 +172,28 @@ class PortfolioController extends Controller
} }
protected function getCustomFeed($portfolio) { protected function getCustomFeed($portfolio) {
if(!$portfolio->metadata['posts']) { if(!isset($portfolio->metadata['posts']) || !$portfolio->metadata['posts']) {
return response()->json([], 400); return response()->json([], 400);
} }
$feed = Cache::remember(self::CACHED_FEED_KEY . $portfolio->profile_id, 86400, function() use($portfolio) {
return collect($portfolio->metadata['posts'])->map(function($p) { return collect($portfolio->metadata['posts'])->map(function($p) {
return StatusService::get($p); return StatusService::get($p);
}) })
->filter(function($p) { ->filter(function($p) {
return $p && isset($p['account']); return $p && isset($p['account']);
})->values(); });
});
if($portfolio->metadata && isset($portfolio->metadata['feed_order']) && $portfolio->metadata['feed_order'] === 'recent') {
return $feed->reverse()->values();
} else {
return $feed->values();
}
} }
protected function getRecentFeed($id) { protected function getRecentFeed($id) {
$media = Cache::remember('portfolio:recent-feed:' . $id, 3600, function() use($id) { $media = Cache::remember(self::RECENT_FEED_KEY . $id, 3600, function() use($id) {
return DB::table('media') return DB::table('media')
->whereProfileId($id) ->whereProfileId($id)
->whereNotNull('status_id') ->whereNotNull('status_id')
@ -215,6 +228,14 @@ class PortfolioController extends Controller
} }
return $res->map(function($p) { return $res->map(function($p) {
$metadata = $p->metadata;
$bgColor = $metadata && isset($metadata['background_color']) ? $metadata['background_color'] : '#000000';
$textColor = $metadata && isset($metadata['text_color']) ? $metadata['text_color'] : '#d4d4d8';
$rssEnabled = $metadata && isset($metadata['rss_enabled']) ? $metadata['rss_enabled'] : false;
$rssButton = $metadata && isset($metadata['show_rss_button']) ? $metadata['show_rss_button'] : false;
$colorScheme = $metadata && isset($metadata['color_scheme']) ? $metadata['color_scheme'] : 'dark';
$feedOrder = $metadata && isset($metadata['feed_order']) ? $metadata['feed_order'] : 'oldest';
return [ return [
'url' => $p->url(), 'url' => $p->url(),
'pid' => (string) $p->profile_id, 'pid' => (string) $p->profile_id,
@ -228,6 +249,13 @@ class PortfolioController extends Controller
'show_bio' => (bool) $p->show_bio, 'show_bio' => (bool) $p->show_bio,
'profile_layout' => $p->profile_layout, 'profile_layout' => $p->profile_layout,
'profile_source' => $p->profile_source, 'profile_source' => $p->profile_source,
'color_scheme' => $colorScheme,
'background_color' => $bgColor,
'text_color' => $textColor,
'show_profile_button' => true,
'rss_enabled' => $rssEnabled,
'show_rss_button' => $rssButton,
'feed_order' => $feedOrder,
'metadata' => $p->metadata 'metadata' => $p->metadata
]; ];
})->first(); })->first();
@ -248,8 +276,13 @@ class PortfolioController extends Controller
if(!$p) { if(!$p) {
return []; return [];
} }
$metadata = $p->metadata;
return [ $rssEnabled = $metadata && isset($metadata['rss_enabled']) ? $metadata['rss_enabled'] : false;
$rssButton = $metadata && isset($metadata['show_rss_button']) ? $metadata['show_rss_button'] : false;
$profileButton = $metadata && isset($metadata['show_profile_button']) ? $metadata['show_profile_button'] : false;
$res = [
'url' => $p->url(), 'url' => $p->url(),
'show_captions' => (bool) $p->show_captions, 'show_captions' => (bool) $p->show_captions,
'show_license' => (bool) $p->show_license, 'show_license' => (bool) $p->show_license,
@ -259,8 +292,27 @@ class PortfolioController extends Controller
'show_avatar' => (bool) $p->show_avatar, 'show_avatar' => (bool) $p->show_avatar,
'show_bio' => (bool) $p->show_bio, 'show_bio' => (bool) $p->show_bio,
'profile_layout' => $p->profile_layout, 'profile_layout' => $p->profile_layout,
'profile_source' => $p->profile_source 'profile_source' => $p->profile_source,
'show_profile_button' => $profileButton,
'rss_enabled' => $rssEnabled,
'show_rss_button' => $rssButton,
]; ];
if($rssEnabled) {
$res['rss_feed_url'] = $p->permalink('.rss');
}
if($p->metadata) {
if(isset($p->metadata['background_color'])) {
$res['background_color'] = $p->metadata['background_color'];
}
if(isset($p->metadata['text_color'])) {
$res['text_color'] = $p->metadata['text_color'];
}
}
return $res;
} }
public function storeSettings(Request $request) public function storeSettings(Request $request)
@ -268,11 +320,99 @@ class PortfolioController extends Controller
abort_if(!$request->user(), 403); abort_if(!$request->user(), 403);
$this->validate($request, [ $this->validate($request, [
'profile_layout' => 'sometimes|in:grid,masonry,album' 'active' => 'sometimes|boolean',
'show_captions' => 'sometimes|boolean',
'show_license' => 'sometimes|boolean',
'show_location' => 'sometimes|boolean',
'show_timestamp' => 'sometimes|boolean',
'show_link' => 'sometimes|boolean',
'show_avatar' => 'sometimes|boolean',
'show_bio' => 'sometimes|boolean',
'profile_layout' => 'sometimes|in:grid,masonry,album',
'profile_source' => 'sometimes|in:recent,custom',
'color_scheme' => 'sometimes|in:light,dark,custom',
'show_profile_button' => 'sometimes|boolean',
'rss_enabled' => 'sometimes|boolean',
'show_rss_button' => 'sometimes|boolean',
'feed_order' => 'sometimes|in:oldest,recent',
'background_color' => [
'sometimes',
'nullable',
'regex:/^#([a-f0-9]{6}|[a-f0-9]{3})$/i'
],
'text_color' => [
'sometimes',
'nullable',
'regex:/^#([a-f0-9]{6}|[a-f0-9]{3})$/i'
],
]); ]);
$res = Portfolio::whereUserId($request->user()->id) $res = Portfolio::whereUserId($request->user()->id)->firstOrFail();
->update($request->only([ $pid = $request->user()->profile_id;
$metadata = $res->metadata;
$clearFeedCache = false;
if($request->has('color_scheme')) {
$metadata['color_scheme'] = $request->input('color_scheme');
}
if($request->has('background_color')) {
$metadata['background_color'] = $request->input('background_color');
$bgc = $request->background_color;
if($bgc && $bgc !== '#000000') {
$metadata['color_scheme'] = 'custom';
}
}
if($request->has('text_color')) {
$metadata['text_color'] = $request->input('text_color');
$txc = $request->text_color;
if($txc && $txc !== '#d4d4d8') {
$metadata['color_scheme'] = 'custom';
}
}
if($request->has('show_profile_button')) {
$metadata['show_profile_button'] = $request->input('show_profile_button');
}
if($request->has('rss_enabled')) {
$metadata['rss_enabled'] = $request->input('rss_enabled');
}
if($request->has('show_rss_button')) {
$metadata['show_rss_button'] = $metadata['rss_enabled'] ? $request->input('show_rss_button') : false;
}
if($request->has('feed_order')) {
$metadata['feed_order'] = $request->input('feed_order');
}
if(isset($metadata['background_color']) || isset($metadata['text_color'])) {
$bgc = isset($metadata['background_color']) ? $metadata['background_color'] : null;
$txc = isset($metadata['text_color']) ? $metadata['text_color'] : null;
if((!$bgc || $bgc == '#000000') && (!$txc || $txc === '#d4d4d8') && $request->color_scheme != 'light') {
$metadata['color_scheme'] = 'dark';
}
}
if($request->has('color_scheme') && $request->color_scheme === 'light') {
$metadata['background_color'] = '#ffffff';
$metadata['text_color'] = '#000000';
$metadata['color_scheme'] = 'light';
}
if($request->metadata !== $metadata) {
$res->metadata = $metadata;
$res->save();
}
if($request->profile_layout != $res->profile_layout) {
$clearFeedCache = true;
}
$res->update($request->only([
'active', 'active',
'show_captions', 'show_captions',
'show_license', 'show_license',
@ -285,7 +425,11 @@ class PortfolioController extends Controller
'profile_source' 'profile_source'
])); ]));
Cache::forget('portfolio:recent-feed:' . $request->user()->profile_id); Cache::forget(self::RECENT_FEED_KEY . $pid);
if($clearFeedCache) {
Cache::forget(self::RSS_FEED_KEY . $pid);
}
return 200; return 200;
} }
@ -295,7 +439,7 @@ class PortfolioController extends Controller
abort_if(!$request->user(), 403); abort_if(!$request->user(), 403);
$this->validate($request, [ $this->validate($request, [
'ids' => 'required|array|max:24' 'ids' => 'required|array|max:100'
]); ]);
$pid = $request->user()->profile_id; $pid = $request->user()->profile_id;
@ -308,11 +452,117 @@ class PortfolioController extends Controller
->findOrFail($ids); ->findOrFail($ids);
$p = Portfolio::whereProfileId($pid)->firstOrFail(); $p = Portfolio::whereProfileId($pid)->firstOrFail();
$p->metadata = ['posts' => $ids]; $metadata = $p->metadata;
$metadata['posts'] = $ids;
$p->metadata = $metadata;
$p->save(); $p->save();
Cache::forget('portfolio:recent-feed:' . $pid); Cache::forget(self::RECENT_FEED_KEY . $pid);
Cache::forget(self::RSS_FEED_KEY . $pid);
Cache::forget(self::CACHED_FEED_KEY . $pid);
return $request->ids; return $request->ids;
} }
public function getRssFeed(Request $request, $username)
{
$user = User::whereUsername($username)->first();
if(!$user) {
return view('portfolio.404');
}
$portfolio = Portfolio::whereUserId($user->id)->where('active', 1)->firstOrFail();
$metadata = $portfolio->metadata;
abort_if(!$metadata || !isset($metadata['rss_enabled']), 404);
abort_unless($metadata['rss_enabled'], 404);
$account = AccountService::get($user->profile_id);
$portfolioUrl = $portfolio->url();
$portfolioLayout = $portfolio->profile_layout;
if(!isset($metadata['posts']) || !count($metadata['posts'])) {
$feed = [];
} else {
$feed = Cache::remember(
self::RSS_FEED_KEY . $user->profile_id,
43200,
function() use($portfolio, $portfolioUrl, $portfolioLayout) {
return collect($portfolio->metadata['posts'])->map(function($post) {
return StatusService::get($post);
})
->filter()
->values()
->map(function($post, $idx) use($portfolioLayout, $portfolioUrl) {
$ts = now()->parse($post['created_at']);
$url = $portfolioLayout == 'album' ? $portfolioUrl . '?slide=' . ($idx + 1) : $portfolioUrl . '/' . $post['id'];
return [
'title' => 'Post by ' . $post['account']['username'] . ' on ' . $ts->format('D, d M Y'),
'description' => $post['content_text'],
'pubDate' => date('D, d M Y H:i:s ', strtotime($post['created_at'])) . 'GMT',
'url' => $url
];
})
->reverse()
->take(10)
->toArray();
}
);
}
$now = date('D, d M Y H:i:s ') . 'GMT';
return response()
->view('portfolio.rss_feed', compact('account', 'now', 'feed', 'portfolioUrl'), 200)
->header('Content-Type', 'text/xml');
return response($feed)->withHeaders(['Content-Type' => 'text/xml']);
}
public function getApFeed(Request $request, $username)
{
$user = User::whereUsername($username)->first();
if(!$user) {
return view('portfolio.404');
}
$portfolio = Portfolio::whereUserId($user->id)->where('active', 1)->firstOrFail();
$metadata = $portfolio->metadata;
$baseUrl = config('app.url');
$page = $request->input('page');
$res = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $portfolio->permalink('.json'),
'type' => 'OrderedCollection',
'totalItems' => isset($metadata['posts']) ? count($metadata['posts']) : 0,
];
if($request->has('page')) {
$start = $page == 1 ? 0 : ($page * 10 - 10);
$res['id'] = $portfolio->permalink('.json?page=' . $page);
$res['type'] = 'OrderedCollectionPage';
$res['next'] = $portfolio->permalink('.json?page=' . $page + 1);
$res['partOf'] = $portfolio->permalink('.json');
$res['orderedItems'] = collect($metadata['posts'])->slice($start)->take(10)->map(function($p) {
return StatusService::get($p);
})
->filter()
->map(function($p) {
return $p['url'];
})
->values();
if(!$res['orderedItems'] || $res['orderedItems']->count() != 10) {
unset($res['next']);
}
} else {
$res['first'] = $portfolio->permalink('.json?page=1');
}
return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT)
->header('Content-Type', 'application/activity+json');
}
} }

View file

@ -8,6 +8,7 @@ use App\Status;
use App\User; use App\User;
use Auth; use Auth;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail;
class ReportController extends Controller class ReportController extends Controller
{ {
@ -165,6 +166,10 @@ class ReportController extends Controller
$report->message = e($request->input('msg')); $report->message = e($request->input('msg'));
$report->save(); $report->save();
if(config('instance.reports.email.enabled')) {
ReportNotifyAdminViaEmail::dispatch($report)->onQueue('default');
}
if($request->wantsJson()) { if($request->wantsJson()) {
return response()->json(200); return response()->json(200);
} else { } else {

View file

@ -0,0 +1,52 @@
<?php
namespace App\Jobs\ReportPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Mail\AdminNewAutospam;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class AutospamNotifyAdminViaEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $report;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($report)
{
$this->report = $report;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$addresses = config('instance.reports.email.to');
if(config('instance.reports.email.enabled') == false || empty($addresses) || !config('instance.reports.email.autospam')) {
return;
}
if(strpos($addresses, ',')) {
$to = explode(',', $addresses);
} else {
$to = $addresses;
}
Mail::to($to)->send(new AdminNewAutospam($this->report));
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Jobs\ReportPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Mail\AdminNewReport;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class ReportNotifyAdminViaEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $report;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($report)
{
$this->report = $report;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$addresses = config('instance.reports.email.to');
if(config('instance.reports.email.enabled') == false || empty($addresses)) {
return;
}
if(strpos($addresses, ',')) {
$to = explode(',', $addresses);
} else {
$to = $addresses;
}
Mail::to($to)->send(new AdminNewReport($this->report));
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use App\Services\AccountService;
use App\Services\StatusService;
class AdminNewAutospam extends Mailable
{
use Queueable, SerializesModels;
public $report;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct($report)
{
$this->report = $report;
}
/**
* Get the message envelope.
*
* @return \Illuminate\Mail\Mailables\Envelope
*/
public function envelope()
{
return new Envelope(
subject: '[' . config('pixelfed.domain.app') . '] Spam Post Detected',
);
}
/**
* Get the message content definition.
*
* @return \Illuminate\Mail\Mailables\Content
*/
public function content()
{
$data = $this->report->toArray();
$reported_status = null;
$reported_account = null;
$url = url('/i/admin/reports/autospam/' . $this->report->id);
if($data['item_type'] === 'App\Status') {
$reported_status = StatusService::get($this->report->item_id, false);
$reported_account = AccountService::get($reported_status['account']['id'], true);
}
return new Content(
markdown: 'emails.admin.new_autospam',
with: [
'report' => $data,
'url' => $url,
'reported_status' => $reported_status,
'reported_account' => $reported_account
]
);
}
/**
* Get the attachments for the message.
*
* @return array
*/
public function attachments()
{
return [];
}
}

100
app/Mail/AdminNewReport.php Normal file
View file

@ -0,0 +1,100 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use App\Services\AccountService;
use App\Services\StatusService;
class AdminNewReport extends Mailable
{
use Queueable, SerializesModels;
public $report;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct($report)
{
$this->report = $report;
}
/**
* Get the message envelope.
*
* @return \Illuminate\Mail\Mailables\Envelope
*/
public function envelope()
{
$type = $this->report->type;
$id = $this->report->id;
$object_type = last(explode("\\", $this->report->object_type));
return new Envelope(
subject: '[' . config('pixelfed.domain.app') . '] ' . $object_type . ' Report (#' . $id . '-' . $type . ')',
);
}
/**
* Get the message content definition.
*
* @return \Illuminate\Mail\Mailables\Content
*/
public function content()
{
$report = $this->report;
$object_type = last(explode("\\", $this->report->object_type));
$reporter = AccountService::get($report->profile_id, true);
$reported = AccountService::get($report->reported_profile_id, true);
$title = 'New ' . $object_type . ' Report (#' . $report->id . ')';
$reportUrl = url('/i/admin/reports/show/' . $report->id . '?ref=email');
$data = [
'report' => $report,
'object_type' => $object_type,
'title' => $title,
'reporter' => $reporter,
'reported' => $reported,
'url' => $reportUrl,
'message' => 'You have a new moderation report.'
];
if($object_type === 'Status') {
$data['reported_status'] = StatusService::get($report['object_id'], false);
if($reporter && $reported) {
$data['message'] = '<a href="' . url('/i/web/profile/' . $reporter['id']) . '">@' .
$reporter['acct'] . '</a> reported a post by <a href="' . url('/i/web/profile/' . $reported['id']) .
'">@' . $reported['acct'] . '</a> as ' . $report->type . '.';
}
}
if($object_type === 'Profile') {
if($reporter && $reported) {
$data['message'] = '<a href="' . url('/i/web/profile/' . $reporter['id']) . '">@' .
$reporter['acct'] . '</a> reported <a href="' . url('/i/web/profile/' . $reported['id']) .
'">@' . $reported['acct'] . '</a>\'s profile as ' . $report->type . '.';
}
}
return new Content(
markdown: 'emails.admin.new_report',
with: $data
);
}
/**
* Get the attachments for the message.
*
* @return array
*/
public function attachments()
{
return [];
}
}

View file

@ -28,13 +28,20 @@ class Portfolio extends Model
'metadata' => 'json' 'metadata' => 'json'
]; ];
public function url() public function url($suffix = '')
{ {
$account = AccountService::get($this->profile_id); $account = AccountService::get($this->profile_id);
if(!$account) { if(!$account) {
return null; return null;
} }
return 'https://' . config('portfolio.domain') . config('portfolio.path') . '/' . $account['username']; return 'https://' . config('portfolio.domain') . config('portfolio.path') . '/' . $account['username'] . $suffix;
}
public function permalink($suffix = '')
{
$account = AccountService::get($this->profile_id);
return config('app.url') . '/account/portfolio/' . $account['username'] . $suffix;
} }
} }

View file

@ -139,6 +139,9 @@ class RestrictedNames
'css', 'css',
'd', 'd',
'dashboard', 'dashboard',
'delete',
'deleted',
'deleting',
'dmca', 'dmca',
'db', 'db',
'deck', 'deck',

View file

@ -7,6 +7,7 @@ use App\Status;
use Cache; use Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Services\StatusService; use App\Services\StatusService;
use App\Jobs\ReportPipeline\AutospamNotifyAdminViaEmail;
class Bouncer { class Bouncer {
@ -126,6 +127,10 @@ class Bouncer {
]); ]);
$ai->save(); $ai->save();
if(config('instance.reports.email.enabled') && config('instance.reports.email.autospam')) {
AutospamNotifyAdminViaEmail::dispatch($ai);
}
$u = $status->profile->user; $u = $status->profile->user;
$u->has_interstitial = true; $u->has_interstitial = true;
$u->save(); $u->save();

View file

@ -111,5 +111,13 @@ return [
'user_filters' => [ 'user_filters' => [
'max_user_blocks' => env('PF_MAX_USER_BLOCKS', 50), 'max_user_blocks' => env('PF_MAX_USER_BLOCKS', 50),
'max_user_mutes' => env('PF_MAX_USER_MUTES', 50) 'max_user_mutes' => env('PF_MAX_USER_MUTES', 50)
],
'reports' => [
'email' => [
'enabled' => env('INSTANCE_REPORTS_EMAIL_ENABLED', false),
'to' => env('INSTANCE_REPORTS_EMAIL_ADDRESSES'),
'autospam' => env('INSTANCE_REPORTS_EMAIL_AUTOSPAM', false)
]
] ]
]; ];

Binary file not shown.

BIN
public/js/manifest.js vendored

Binary file not shown.

BIN
public/js/portfolio.js vendored

Binary file not shown.

Binary file not shown.

View file

@ -19,11 +19,11 @@
<div class="col-12 mb-4"> <div class="col-12 mb-4">
<p v-if="settings.show_captions && post.content_text">{{ post.content_text }}</p> <p v-if="settings.show_captions && post.content_text">{{ post.content_text }}</p>
<div class="d-md-flex justify-content-between align-items-center"> <div class="d-md-flex justify-content-between align-items-center">
<p class="small text-lighter">by <a :href="profileUrl()" class="text-lighter font-weight-bold">&commat;{{profile.username}}</a></p> <p class="small">by <a :href="profileUrl()" class="font-weight-bold link-color">&commat;{{profile.username}}</a></p>
<p v-if="settings.show_license && post.media_attachments[0].license" class="small text-muted">Licensed under {{ post.media_attachments[0].license.title }}</p> <p v-if="settings.show_license && post.media_attachments[0].license" class="small">Licensed under {{ post.media_attachments[0].license.title }}</p>
<p v-if="settings.show_location && post.place" class="small text-muted">{{ post.place.name }}, {{ post.place.country }}</p> <p v-if="settings.show_location && post.place" class="small text-muted">{{ post.place.name }}, {{ post.place.country }}</p>
<p v-if="settings.show_timestamp" class="small text-muted"> <p v-if="settings.show_timestamp" class="small">
<a v-if="settings.show_link" :href="post.url" class="text-lighter font-weight-bold" style="z-index: 2"> <a v-if="settings.show_link" :href="post.url" class="font-weight-bold link-color" style="z-index: 2">
{{ formatDate(post.created_at) }} {{ formatDate(post.created_at) }}
</a> </a>
<span v-else class="user-select-none"> <span v-else class="user-select-none">
@ -96,6 +96,15 @@
}) })
.then(res => { .then(res => {
this.settings = res.data; this.settings = res.data;
if(res.data.hasOwnProperty('background_color')) {
this.updateCssVariable('--body-bg', res.data.background_color);
}
if(res.data.hasOwnProperty('text_color')) {
this.updateCssVariable('--text-color', res.data.text_color);
this.updateCssVariable('--link-color', res.data.text_color);
}
}) })
.then(() => { .then(() => {
setTimeout(() => { setTimeout(() => {
@ -116,6 +125,11 @@
formatDate(ts) { formatDate(ts) {
const dts = new Date(ts); const dts = new Date(ts);
return dts.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'long', day: 'numeric' }); return dts.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'long', day: 'numeric' });
},
updateCssVariable(k, v) {
let rs = document.querySelector(':root');
rs.style.setProperty(k, v);
} }
} }
} }

View file

@ -10,11 +10,35 @@
<div class="row py-5"> <div class="row py-5">
<div class="col-12"> <div class="col-12">
<div class="d-flex align-items-center flex-column"> <div class="d-flex align-items-center flex-column">
<img :src="profile.avatar" width="60" height="60" class="rounded-circle shadow" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';"> <img
v-if="settings.show_avatar"
:src="profile.avatar"
width="60"
height="60"
class="rounded-circle shadow"
onerror="this.src='/storage/avatars/default.png?v=0';this.onerror=null;">
<div class="py-3 text-center" style="max-width: 60%"> <div class="py-3 text-center" style="max-width: 60%">
<h1 class="font-weight-bold">{{ profile.username }}</h1> <h1 class="font-weight-bold">{{ profile.username }}</h1>
<p class="font-weight-light mb-0">{{ profile.note_text }}</p> <p v-if="settings.show_bio" class="font-weight-light mb-0 text-break">{{ profile.note_text }}</p>
</div>
<div v-if="settings.show_profile_button || (settings.rss_enabled && settings.show_rss_button)" class="pb-3 text-center d-flex flex-column flex-sm-row" style="max-width: 60%;gap: 1rem;">
<a
v-if="settings.show_profile_button"
class="btn btn-outline-primary btn-custom-color"
:href="profile.url"
target="_blank">
View Profile
</a>
<a
v-if="settings.rss_enabled && settings.show_rss_button"
class="btn btn-outline-primary btn-custom-color"
:href="settings.rss_feed_url"
target="_blank">
<i class="far fa-rss"></i> &nbsp; RSS
</a>
</div> </div>
</div> </div>
</div> </div>
@ -26,7 +50,23 @@
<div v-for="(res, index) in feed" class="col-12 col-md-4 mb-1 p-1"> <div v-for="(res, index) in feed" class="col-12 col-md-4 mb-1 p-1">
<div class="square"> <div class="square">
<a :href="postUrl(res)"> <a :href="postUrl(res)">
<img :src="res.media_attachments[0].url" width="100%" height="300" style="overflow: hidden;object-fit: cover;" class="square-content pr-1"> <div class="lazy-img">
<blur-hash-canvas
width="32"
height="32"
:hash="res.media_attachments[0].blurhash"
class="square-content pr-1"
/>
<img
src=""
:data-src="res.media_attachments[0].url"
width="100%"
height="300"
style="overflow: hidden;object-fit: cover;z-index: -1;"
class="square-content pr-1 img-placeholder"
loading="lazy" />
</div>
</a> </a>
</div> </div>
</div> </div>
@ -34,14 +74,14 @@
<div v-else-if="settings.profile_layout ==='album'" class="col-12 mb-1 p-1"> <div v-else-if="settings.profile_layout ==='album'" class="col-12 mb-1 p-1">
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<p class="text-muted font-weight-bold">{{ albumIndex + 1 }} <span class="font-weight-light">/</span> {{ feed.length }}</p> <p class="text-color font-weight-bold">{{ albumIndex + 1 }} <span class="font-weight-light">/</span> {{ feed.length }}</p>
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<span v-if="albumIndex === 0"> <span v-if="albumIndex === 0">
<i class="fa fa-arrow-circle-left fa-3x text-dark" /> <i class="fa fa-arrow-circle-left fa-3x text-color-lighter" />
</span> </span>
<a v-else @click.prevent="albumPrev()" href="#"> <a v-else @click.prevent="albumPrev()" href="#">
<i class="fa fa-arrow-circle-left fa-3x text-muted"/> <i class="fa fa-arrow-circle-left fa-3x text-color"/>
</a> </a>
<transition name="slide-fade"> <transition name="slide-fade">
<a :href="postUrl(feed[albumIndex])" class="mx-4" :key="albumIndex"> <a :href="postUrl(feed[albumIndex])" class="mx-4" :key="albumIndex">
@ -55,10 +95,10 @@
</a> </a>
</transition> </transition>
<span v-if="albumIndex === feed.length - 1"> <span v-if="albumIndex === feed.length - 1">
<i class="fa fa-arrow-circle-right fa-3x text-dark" /> <i class="fa fa-arrow-circle-right fa-3x text-color-lighter" />
</span> </span>
<a v-else @click.prevent="albumNext()" href="#"> <a v-else @click.prevent="albumNext()" href="#">
<i class="fa fa-arrow-circle-right fa-3x text-muted"/> <i class="fa fa-arrow-circle-right fa-3x text-color"/>
</a> </a>
</div> </div>
</div> </div>
@ -87,7 +127,7 @@
<span class="text-gradient-primary">portfolio</span> <span class="text-gradient-primary">portfolio</span>
</span> </span>
<p v-if="user && user.id == profile.id" class="text-center mb-0"> <p v-if="user && user.id == profile.id" class="text-center mb-0">
<a :href="settingsUrl" class="text-muted"><i class="far fa-cog fa-lg"></i></a> <a :href="settingsUrl" class="link-color"><i class="far fa-cog fa-lg"></i></a>
</p> </p>
</div> </div>
</div> </div>
@ -109,7 +149,7 @@
settings: undefined, settings: undefined,
feed: [], feed: [],
albumIndex: 0, albumIndex: 0,
settingsUrl: window._portfolio.path + '/settings' settingsUrl: window._portfolio.path + '/settings',
} }
}, },
@ -135,6 +175,15 @@
}) })
.then(res => { .then(res => {
this.settings = res.data; this.settings = res.data;
if(res.data.hasOwnProperty('background_color')) {
this.updateCssVariable('--body-bg', res.data.background_color);
}
if(res.data.hasOwnProperty('text_color')) {
this.updateCssVariable('--text-color', res.data.text_color);
this.updateCssVariable('--link-color', res.data.text_color);
}
}) })
.then(() => { .then(() => {
this.fetchFeed(); this.fetchFeed();
@ -145,7 +194,7 @@
async fetchFeed() { async fetchFeed() {
axios.get('/api/portfolio/' + this.profile.id + '/feed') axios.get('/api/portfolio/' + this.profile.id + '/feed')
.then(res => { .then(res => {
this.feed = res.data.filter(p => p.pf_type === "photo"); this.feed = res.data.filter(p => ['photo', 'photo:album'].includes(p.pf_type));
}) })
.then(() => { .then(() => {
this.setAlbumSlide(); this.setAlbumSlide();
@ -162,6 +211,14 @@
}, 500); }, 500);
} }
}) })
.then(() => {
setTimeout(() => {
this.bootIntersectors()
}, 500);
})
.catch(err => {
this.loading = false;
})
}, },
postUrl(res) { postUrl(res) {
@ -217,6 +274,38 @@
gutter: 20, gutter: 20,
modal: false, modal: false,
}); });
},
updateCssVariable(k, v) {
let rs = document.querySelector(':root');
rs.style.setProperty(k, v);
},
bootIntersectors() {
var lazyImages = [].slice.call(document.querySelectorAll("img.img-placeholder"));
if ("IntersectionObserver" in window) {
let lazyImageObserver = new IntersectionObserver(function(entries, observer) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
let lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.style.zIndex = 2;
lazyImage.classList.remove("img-placeholder");
lazyImageObserver.unobserve(lazyImage);
}
});
});
lazyImages.forEach(function(lazyImage) {
lazyImageObserver.observe(lazyImage);
});
} else {
lazyImages.forEach(function(img) {
img.src = img.dataset.src;
img.style.zIndex = 2;
})
}
} }
} }
} }

View file

@ -55,10 +55,10 @@
<template v-else> <template v-else>
<div class="mt-n2 mb-4"> <div class="mt-n2 mb-4">
<p class="text-muted small">Select up to 24 photos from your 100 most recent posts. You can only select public photo posts, videos are not supported at this time.</p> <p class="text-muted small">Select up to 100 photos from your 100 most recent posts. You can only select public photo posts, videos are not supported at this time.</p>
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
<p class="font-weight-bold mb-0">Selected {{ selectedRecentPosts.length }}/24</p> <p class="font-weight-bold mb-0">Selected {{ selectedRecentPosts.length }}/100</p>
<div> <div>
<button <button
class="btn btn-link font-weight-bold mr-3 text-decoration-none" class="btn btn-link font-weight-bold mr-3 text-decoration-none"
@ -90,11 +90,13 @@
<transition name="fade"> <transition name="fade">
<img <img
:key="post.id" :key="post.id"
:src="post.media_attachments[0].url" :src="getPreviewUrl(post)"
width="100%" width="100%"
height="300" height="300"
style="overflow: hidden;object-fit: cover;" style="overflow: hidden;object-fit: cover;"
:draggable="false" :draggable="false"
loading="lazy"
onerror="this.src='/storage/no-preview.png';this.onerror=null;"
class="square-content pr-1"> class="square-content pr-1">
</transition> </transition>
@ -112,7 +114,9 @@
</template> </template>
</div> </div>
<div v-else-if="tabIndex === 'Customize'" class="col-12 col-md-8 mt-3 py-2" key="2"> <div v-else-if="tabIndex === 'Customize'" class="col-12 mt-3 py-2" key="2">
<div class="row">
<div class="col-12 col-md-6">
<div v-for="setting in customizeSettings" class="card bg-dark mb-5"> <div v-for="setting in customizeSettings" class="card bg-dark mb-5">
<div class="card-header">{{ setting.title }}</div> <div class="card-header">{{ setting.title }}</div>
<div class="list-group bg-dark"> <div class="list-group bg-dark">
@ -135,6 +139,9 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="col-12 col-md-6">
<div class="card bg-dark mb-5"> <div class="card bg-dark mb-5">
<div class="card-header">Portfolio</div> <div class="card-header">Portfolio</div>
<div class="list-group bg-dark"> <div class="list-group bg-dark">
@ -149,6 +156,84 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="settings.profile_source === 'custom'" class="list-group-item">
<div class="d-flex justify-content-between align-items-center py-2">
<div class="setting-label">
<p class="mb-0">Order</p>
</div>
<div>
<b-form-select
v-model="settings.feed_order"
:options="profileLayoutFeedOrder" />
</div>
</div>
</div>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center py-2">
<div class="setting-label">
<p class="mb-0">Color Scheme</p>
</div>
<div>
<b-form-select
v-model="settings.color_scheme"
:options="profileLayoutColorSchemeOptions"
:disabled="settings.color_scheme === 'custom'"
@change="updateColorScheme" />
</div>
</div>
</div>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center py-2">
<div class="setting-label">
<p class="mb-0">Background Color</p>
</div>
<b-col sm="2">
<b-form-input
v-model="settings.background_color"
debounce="1000"
type="color"
@change="updateBackgroundColor" />
<b-button
v-if="!['#000000', null].includes(settings.background_color)"
variant="link"
@click="resetBackgroundColor">
Reset
</b-button>
</b-col>
</div>
</div>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center py-2">
<div class="setting-label">
<p class="mb-0">Text Color</p>
</div>
<b-col sm="2">
<b-form-input
v-model="settings.text_color"
debounce="1000"
type="color"
@change="updateTextColor" />
<b-button
v-if="!['#d4d4d8', null].includes(settings.text_color)"
variant="link"
@click="resetTextColor">
Reset
</b-button>
</b-col>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -185,6 +270,7 @@
isSavingCurated: false, isSavingCurated: false,
canSaveCurated: false, canSaveCurated: false,
customizeSettings: [], customizeSettings: [],
skipWatch: false,
profileSourceOptions: [ profileSourceOptions: [
{ value: null, text: 'Please select an option', disabled: true }, { value: null, text: 'Please select an option', disabled: true },
{ value: 'recent', text: 'Most recent posts' }, { value: 'recent', text: 'Most recent posts' },
@ -194,6 +280,16 @@
{ value: 'grid', text: 'Grid' }, { value: 'grid', text: 'Grid' },
{ value: 'masonry', text: 'Masonry' }, { value: 'masonry', text: 'Masonry' },
{ value: 'album', text: 'Album' }, { value: 'album', text: 'Album' },
],
profileLayoutColorSchemeOptions: [
{ value: null, text: 'Please select an option', disabled: true },
{ value: 'light', text: 'Light mode' },
{ value: 'dark', text: 'Dark mode' },
{ value: 'custom', text: 'Custom color scheme', disabled: true },
],
profileLayoutFeedOrder: [
{ value: 'oldest', text: 'Oldest first' },
{ value: 'recent', text: 'Recent first' }
] ]
} }
}, },
@ -217,7 +313,7 @@
deep: true, deep: true,
immediate: true, immediate: true,
handler: function(o, n) { handler: function(o, n) {
if(this.loading) { if(this.loading || this.skipWatch) {
return; return;
} }
if(!n.show_timestamp) { if(!n.show_timestamp) {
@ -260,6 +356,20 @@
if(res.data.metadata && res.data.metadata.posts) { if(res.data.metadata && res.data.metadata.posts) {
this.selectedRecentPosts = res.data.metadata.posts; this.selectedRecentPosts = res.data.metadata.posts;
} }
if(res.data.color_scheme != 'dark') {
if(res.data.color_scheme === 'light') {
this.updateBackgroundColor('#ffffff');
} else {
if(res.data.hasOwnProperty('background_color')) {
this.updateBackgroundColor(res.data.background_color);
}
if(res.data.hasOwnProperty('text_color')) {
this.updateTextColor(res.data.text_color);
}
}
}
}) })
.then(() => { .then(() => {
this.initCustomizeSettings(); this.initCustomizeSettings();
@ -325,24 +435,30 @@
} }
}, },
updateSettings() { updateSettings(silent = false) {
if(this.skipWatch) {
return;
}
axios.post(this.apiPath('/api/portfolio/self/update-settings.json'), this.settings) axios.post(this.apiPath('/api/portfolio/self/update-settings.json'), this.settings)
.then(res => { .then(res => {
this.updateTabs(); this.updateTabs();
if(!silent) {
this.$bvToast.toast(`Your settings have been successfully updated!`, { this.$bvToast.toast(`Your settings have been successfully updated!`, {
variant: 'dark', variant: 'dark',
title: 'Settings Updated', title: 'Settings Updated',
autoHideDelay: 2000, autoHideDelay: 2000,
appendToast: false appendToast: false
}) })
}
}) })
}, },
loadRecentPosts() { loadRecentPosts() {
axios.get('/api/v1/accounts/' + this.user.id + '/statuses?only_media=1&media_types=photo&limit=100') axios.get('/api/v1/accounts/' + this.user.id + '/statuses?only_media=1&media_types=photo&limit=100&_pe=1')
.then(res => { .then(res => {
if(res.data.length) { if(res.data.length) {
this.recentPosts = res.data.filter(p => p.visibility === "public"); this.recentPosts = res.data.filter(p => ['photo', 'photo:album'].includes(p.pf_type) && p.visibility === "public");
} }
}) })
.then(() => { .then(() => {
@ -354,7 +470,7 @@
toggleRecentPost(id) { toggleRecentPost(id) {
if(this.selectedRecentPosts.indexOf(id) == -1) { if(this.selectedRecentPosts.indexOf(id) == -1) {
if(this.selectedRecentPosts.length === 24) { if(this.selectedRecentPosts.length === 100) {
return; return;
} }
this.selectedRecentPosts.push(id); this.selectedRecentPosts.push(id);
@ -449,10 +565,105 @@
{ {
label: "Show Bio", label: "Show Bio",
model: "show_bio" model: "show_bio"
} },
{
label: "Show View Profile Button",
model: "show_profile_button"
},
{
label: "Enable RSS Feed",
description: "Enable your RSS feed with the 10 most recent portfolio items",
model: "rss_enabled"
},
{
label: "Show RSS Feed Button",
model: "show_rss_button",
requiredWithTrue: "rss_enabled"
},
] ]
}, },
] ]
},
updateBackgroundColor(e) {
this.skipWatch = true;
let rs = document.querySelector(':root');
rs.style.setProperty('--body-bg', e);
if(e !== '#000000' && e !== '#ffffff') {
this.settings.color_scheme = 'custom';
}
this.$nextTick(() => {
this.skipWatch = false;
});
},
updateTextColor(e) {
this.skipWatch = true;
let rs = document.querySelector(':root');
rs.style.setProperty('--text-color', e);
if(e !== '#d4d4d8') {
this.settings.color_scheme = 'custom';
}
this.$nextTick(() => {
this.skipWatch = false;
});
},
resetBackgroundColor() {
this.skipWatch = true;
this.$nextTick(() => {
this.updateBackgroundColor('#000000');
this.settings.color_scheme = 'dark';
this.settings.background_color = '#000000';
this.updateSettings(true);
setTimeout(() => {
this.skipWatch = false;
}, 1000);
});
},
resetTextColor() {
this.skipWatch = true;
this.$nextTick(() => {
this.updateTextColor('#d4d4d8');
this.settings.color_scheme = 'dark';
this.settings.text_color = '#d4d4d8';
this.updateSettings(true);
setTimeout(() => {
this.skipWatch = false;
}, 1000);
});
},
updateColorScheme(e) {
if(e === 'light') {
this.updateBackgroundColor('#ffffff');
}
if(e === 'dark') {
this.updateBackgroundColor('#000000');
}
},
getPreviewUrl(post) {
let media = post.media_attachments[0];
if(!media) { return '/storage/no-preview.png'; }
if(media.preview_url && !media.preview_url.endsWith('/no-preview.png')) {
return media.preview_url;
}
return media.url;
} }
} }
} }

View file

@ -1,7 +1,10 @@
import Vue from 'vue'; import Vue from 'vue';
window.Vue = Vue; window.Vue = Vue;
import BootstrapVue from 'bootstrap-vue' import BootstrapVue from 'bootstrap-vue';
import VueBlurHash from 'vue-blurhash';
import 'vue-blurhash/dist/vue-blurhash.css'
Vue.use(BootstrapVue); Vue.use(BootstrapVue);
Vue.use(VueBlurHash);
Vue.component( Vue.component(
'portfolio-post', 'portfolio-post',

View file

@ -1,23 +1,67 @@
@import "lib/inter"; @import "lib/inter";
:root {
--body-bg: #000000;
--text-color: #d4d4d8;
--link-color: #3B82F6;
};
body { body {
background: #000000; background: var(--body-bg);
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
font-weight: 400 !important; font-weight: 400 !important;
color: #d4d4d8; color: var(--text-color);
} }
.text-primary { .text-primary {
color: #3B82F6 !important; color: #3B82F6 !important;
} }
.text-color {
color: var(--text-color);
}
.text-color-lighter {
color: var(--text-color);
opacity: 0.3;
}
.btn-custom-color {
border-color: var(--link-color);
color: var(--link-color);
font-weight: bold;
padding: 7px 30px;
border-radius: 20px;
font-size: 13px;
&:active,
&:hover,
&:focus {
border-color: var(--link-color) !important;
color: var(--link-color) !important;
background-color: transparent !important;
opacity: 0.5;
}
}
.link-color {
color: var(--link-color);
&:active,
&:hover,
&:focus {
color: var(--link-color);
opacity: 0.5;
}
}
.lead, .lead,
.font-weight-light { .font-weight-light {
font-weight: 400 !important; font-weight: 400 !important;
} }
a { a {
color: #3B82F6; color: var(--link-color);
text-decoration: none; text-decoration: none;
} }

View file

@ -0,0 +1,87 @@
<x-mail::message>
# Autospam Detection (#{{ $report['id'] }})
We have detected a potential spam post. The post has been unlisted from public feeds.
**Action is required to restore post visibility**. <br />
Please review this report and handle accordingly.
<x-mail::button :url="$url">
Review Autospam Report
</x-mail::button>
@if($reported_status)
<x-mail::panel>
<p style="font-size: 13px; color: #cccccc; text-align: center; font-weight: bold;margin-bottom: 10px;">Reported Status</p>
<div style="display: flex; align-items: center;gap: 10px;">
@if(
isset($reported_status['media_attachments']) &&
isset($reported_status['pf_type']) &&
count($reported_status['media_attachments']) &&
in_array($reported_status['pf_type'], ['photo', 'photo:album'])
)
<img
src="{{$reported_status['media_attachments'][0]['url']}}"
width="100"
height="100"
alt="Media preview"
style="object-fit: cover; border: 1px solid #cccccc; border-radius: 10px; margin-bottom: 10px;"
onerror="this.src='{{url('/storage/no-preview.png')}}';this.onerror=null;" />
@endif
@if(isset($reported_status['content']))
<div style="font-size: 12px !important;">{{ strip_tags(str_replace(["\n", "\r", "\r\n"], ' ', $reported_status['content'])) }}</div>
@endif
</div>
<div style="display: flex; align-items: center; justify-content: space-between;margin-top:10px;">
<a style="font-size: 11px !important;font-weight: bold;text-decoration: none;" href="{{ url('/i/web/post/' . $reported_status['id'])}}">
View status
</a>
<p style="font-size: 11px !important;font-weight: bold;">
Posted {{ now()->parse($reported_status['created_at'])->diffForHumans() }}
</p>
</div>
</x-mail::panel>
@endif
@if($reported_account && isset($reported_account['id']))
<x-mail::panel>
<p style="font-size: 13px; color: #cccccc; text-align: center; font-weight: bold;margin-bottom: 10px;">Reported Account</p>
<div style="display: flex; align-items: flex-start;gap: 10px;">
<img
src="{{$reported_account['avatar']}}"
width="50"
height="50"
alt="Avatar"
style="border-radius: 10px;min-width: 50px;flex-grow: 1;"
onerror="this.src='{{url('/storage/avatars/default.jpg')}}';this.onerror=null;" />
<div>
<p style="margin-bottom: 0;">
<a href="{{ url('/i/web/profile/' . $reported_account['id']) }}" style="text-decoration: none;font-weight: bold">{{ $reported_account['username'] }}</a>
</p>
<p style="margin-bottom: 5px;font-size: 10px;opacity: 0.5;">
{{ strip_tags(str_replace(["\n", "\r", "\r\n"], ' ', $reported_account['note'])) }}
</p>
<div style="display: flex; align-items: center; gap: 5px;">
<p style="font-size: 10px;margin-bottom: 0;">{{ $reported_account['statuses_count'] }} posts</p>
<p style="font-size: 10px;margin-bottom: 0;">·</p>
<p style="font-size: 10px;margin-bottom: 0;">{{ $reported_account['followers_count'] }} followers</p>
<p style="font-size: 10px;margin-bottom: 0;">·</p>
<p style="font-size: 10px;margin-bottom: 0;">{{ $reported_account['following_count'] }} following</p>
<p style="font-size: 10px;margin-bottom: 0;">·</p>
<p style="font-size: 10px;margin-bottom: 0;">Joined {{ now()->parse($reported_account['created_at'])->diffForHumans()}}</p>
</div>
</div>
</div>
</x-mail::panel>
@endif
<p style="font-size: 12px;color: #cccccc;text-align: center;">
This is an automated email that is intended for administrators of {{ config('pixelfed.domain.app')}}.<br />
If you received this email by mistake, kindly disregard and delete this email.
</p>
</x-mail::message>

View file

@ -0,0 +1,122 @@
<x-mail::message>
# {{ $title }}
## {!! $message !!}
<x-mail::button :url="$url">
View Report
</x-mail::button>
@if($object_type === 'Status' && $reported_status)
<x-mail::panel>
<p style="font-size: 13px; color: #cccccc; text-align: center; font-weight: bold;margin-bottom: 10px;">Reported Status</p>
<div style="display: flex; align-items: center;gap: 10px;">
@if(
isset($reported_status['media_attachments']) &&
isset($reported_status['pf_type']) &&
count($reported_status['media_attachments']) &&
in_array($reported_status['pf_type'], ['photo', 'photo:album'])
)
<img
src="{{$reported_status['media_attachments'][0]['url']}}"
width="100"
height="100"
alt="Media preview"
style="object-fit: cover; border: 1px solid #cccccc; border-radius: 10px; margin-bottom: 10px;"
onerror="this.src='{{url('/storage/no-preview.png')}}';this.onerror=null;" />
@endif
@if(isset($reported_status['content']))
<div style="font-size: 12px !important;">{{ strip_tags(str_replace(["\n", "\r", "\r\n"], ' ', $reported_status['content'])) }}</div>
@endif
</div>
<div style="display: flex; align-items: center; justify-content: space-between;margin-top:10px;">
<a style="font-size: 11px !important;font-weight: bold;text-decoration: none;" href="{{ url('/i/web/post/' . $reported_status['id'])}}">
View status
</a>
<p style="font-size: 11px !important;font-weight: bold;">
Posted {{ now()->parse($reported_status['created_at'])->diffForHumans() }}
</p>
</div>
</x-mail::panel>
@endif
@if($reported && isset($reported['id']))
<x-mail::panel>
<p style="font-size: 13px; color: #cccccc; text-align: center; font-weight: bold;margin-bottom: 10px;">Reported Account</p>
<div style="display: flex; align-items: flex-start;gap: 10px;">
<img
src="{{$reported['avatar']}}"
width="50"
height="50"
alt="Avatar"
style="border-radius: 10px;min-width: 50px;flex-grow: 1;"
onerror="this.src='{{url('/storage/avatars/default.jpg')}}';this.onerror=null;" />
<div>
<p style="margin-bottom: 0;">
<a href="{{ url('/i/web/profile/' . $reported['id']) }}" style="text-decoration: none;font-weight: bold">{{ $reported['username'] }}</a>
</p>
<p style="margin-bottom: 5px;font-size: 10px;opacity: 0.5;">
{{ strip_tags(str_replace(["\n", "\r", "\r\n"], ' ', $reported['note'])) }}
</p>
<div style="display: flex; align-items: center; gap: 5px;">
<p style="font-size: 10px;margin-bottom: 0;">{{ $reported['statuses_count'] }} posts</p>
<p style="font-size: 10px;margin-bottom: 0;">·</p>
<p style="font-size: 10px;margin-bottom: 0;">{{ $reported['followers_count'] }} followers</p>
<p style="font-size: 10px;margin-bottom: 0;">·</p>
<p style="font-size: 10px;margin-bottom: 0;">{{ $reported['following_count'] }} following</p>
<p style="font-size: 10px;margin-bottom: 0;">·</p>
<p style="font-size: 10px;margin-bottom: 0;">Joined {{ now()->parse($reported['created_at'])->diffForHumans()}}</p>
</div>
</div>
</div>
</x-mail::panel>
@endif
@if($reporter && isset($reporter['id']))
<x-mail::panel>
<p style="font-size: 13px; color: #cccccc; text-align: center; font-weight: bold;margin-bottom: 10px;">Reported By</p>
<div style="display: flex; align-items: flex-start;gap: 10px;">
<img
src="{{$reporter['avatar']}}"
width="50"
height="50"
alt="Avatar"
style="border-radius: 10px;min-width: 50px;flex-grow: 1;"
onerror="this.src='{{url('/storage/avatars/default.jpg')}}';this.onerror=null;" />
<div>
<p style="margin-bottom: 0;"><a href="{{ url('/i/web/profile/' . $reporter['id']) }}" style="text-decoration: none;font-weight: bold">{{ $reporter['username'] }}</a>
</p>
<p style="margin-bottom: 5px;font-size: 10px;opacity: 0.5;">
{{ strip_tags(str_replace(["\n", "\r", "\r\n"], ' ', $reporter['note'])) }}
</p>
<div style="display: flex; align-items: center; gap: 5px;">
<p style="font-size: 10px;margin-bottom: 0;">{{ $reporter['statuses_count'] }} posts</p>
<p style="font-size: 10px;margin-bottom: 0;">·</p>
<p style="font-size: 10px;margin-bottom: 0;">{{ $reporter['followers_count'] }} followers</p>
<p style="font-size: 10px;margin-bottom: 0;">·</p>
<p style="font-size: 10px;margin-bottom: 0;">{{ $reporter['following_count'] }} following</p>
<p style="font-size: 10px;margin-bottom: 0;">·</p>
<p style="font-size: 10px;margin-bottom: 0;">Joined {{ now()->parse($reporter['created_at'])->diffForHumans()}}</p>
</div>
</div>
</div>
</x-mail::panel>
@endif
<p style="font-size: 12px;color: #cccccc;text-align: center;">
This is an automated email that is intended for administrators of {{ config('pixelfed.domain.app')}}.<br />
If you received this email by mistake, kindly disregard and delete this email.
</p>
</x-mail::message>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- RSS generated by pixelfed v{{config('pixelfed.version')}} on {{$now}} -->
<rss version="2.0">
<channel>
<title>{{ $account['username'] }}'s Portfolio</title>
<link>{{ $portfolioUrl }}</link>
<description>The pixelfed portfolio of {{ $account['username'] }} with the {{ count($feed) }} most recent posts</description>
<pubDate>{{ $now }}</pubDate>
<language>en-us</language>
@foreach($feed as $p)
<item>
<title>{{$p['title']}}</title>
<description>{{$p['description']}}</description>
<guid>{{$p['url']}}</guid>
<link>{{$p['url']}}</link>
<pubDate>{{$p['pubDate']}}</pubDate>
</item>
@endforeach
</channel>
</rss>

View file

@ -407,6 +407,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::get('follow-requests', 'AccountController@followRequests')->name('follow-requests'); Route::get('follow-requests', 'AccountController@followRequests')->name('follow-requests');
Route::post('follow-requests', 'AccountController@followRequestHandle'); Route::post('follow-requests', 'AccountController@followRequestHandle');
Route::get('follow-requests.json', 'AccountController@followRequestsJson'); Route::get('follow-requests.json', 'AccountController@followRequestsJson');
Route::get('portfolio/{username}.json', 'PortfolioController@getApFeed');
Route::get('portfolio/{username}.rss', 'PortfolioController@getRssFeed');
}); });
Route::group(['prefix' => 'settings'], function () { Route::group(['prefix' => 'settings'], function () {