Merge pull request #547 from pixelfed/frontend-ui-refactor

[WIP] The big one.
This commit is contained in:
daniel 2018-11-18 22:31:33 -07:00 committed by GitHub
commit a3d80971bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
150 changed files with 5890 additions and 919 deletions

View file

@ -22,7 +22,7 @@ jobs:
- checkout
- run: sudo apt update && sudo apt install zlib1g-dev libsqlite3-dev
- run: sudo docker-php-ext-install bcmath pcntl zip
- run: sudo docker-php-ext-install pcntl
# Download and cache dependencies

View file

@ -6,6 +6,9 @@ use Illuminate\Database\Eloquent\Model;
class Follower extends Model
{
protected $fillable = ['profile_id', 'following_id', 'local_profile'];
public function actor()
{
return $this->belongsTo(Profile::class, 'profile_id', 'id');
@ -21,9 +24,9 @@ class Follower extends Model
return $this->belongsTo(Profile::class, 'following_id', 'id');
}
public function permalink()
public function permalink($append = null)
{
$path = $this->actor->permalink("/follow/{$this->id}");
$path = $this->actor->permalink("/follow/{$this->id}{$append}");
return url($path);
}

View file

@ -64,10 +64,13 @@ class AccountController extends Controller
]);
$profile = Auth::user()->profile;
$action = $request->input('a');
$allowed = ['like', 'follow'];
$timeago = Carbon::now()->subMonths(3);
$following = $profile->following->pluck('id');
$notifications = Notification::whereIn('actor_id', $following)
->where('profile_id', '!=', $profile->id)
->whereIn('action', $allowed)
->where('actor_id', '<>', $profile->id)
->where('profile_id', '<>', $profile->id)
->whereDate('created_at', '>', $timeago)
->orderBy('notifications.created_at', 'desc')
->simplePaginate(30);

View file

@ -2,22 +2,27 @@
namespace App\Http\Controllers\Api;
use App\Avatar;
use App\Http\Controllers\AvatarController;
use App\Http\Controllers\Controller;
use App\Jobs\AvatarPipeline\AvatarOptimize;
use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\Media;
use App\Profile;
use App\Transformer\Api\AccountTransformer;
use App\Transformer\Api\MediaTransformer;
use App\Transformer\Api\StatusTransformer;
use Auth;
use Cache;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use App\Http\Controllers\{
Controller,
AvatarController
};
use Auth, Cache, URL;
use App\{Avatar,Media,Profile};
use App\Transformer\Api\{
AccountTransformer,
MediaTransformer,
StatusTransformer
};
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Jobs\AvatarPipeline\AvatarOptimize;
use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\Jobs\VideoPipeline\{
VideoOptimize,
VideoPostProcess,
VideoThumbnail
};
class BaseApiController extends Controller
{
@ -187,7 +192,20 @@ class BaseApiController extends Controller
$url = URL::temporarySignedRoute(
'temp-media', now()->addHours(1), ['profileId' => $profile->id, 'mediaId' => $media->id]
);
ImageOptimize::dispatch($media);
switch ($media->mime) {
case 'image/jpeg':
case 'image/png':
ImageOptimize::dispatch($media);
break;
case 'video/mp4':
VideoThumbnail::dispatch($media);
break;
default:
break;
}
$res = [
'id' => $media->id,

View file

@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class CollectionController extends Controller
{
//
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class CollectionItemController extends Controller
{
//
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use Auth;
use Illuminate\Http\Request;
use App\{
DirectMessage,
Profile,
Status
};
class DirectMessageController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function inbox(Request $request)
{
$profile = Auth::user()->profile;
$inbox = DirectMessage::whereToId($profile->id)
->with(['author','status'])
->orderBy('created_at', 'desc')
->groupBy('from_id')
->paginate(10);
return view('account.messages', compact('inbox'));
}
public function show(Request $request, int $pid, $mid)
{
$profile = Auth::user()->profile;
if($pid !== $profile->id) {
abort(403);
}
$msg = DirectMessage::whereToId($profile->id)
->findOrFail($mid);
$thread = DirectMessage::whereToId($profile->id)
->orWhere([['from_id', $profile->id],['to_id', $msg->from_id]])
->orderBy('created_at', 'desc')
->paginate(10);
return view('account.message', compact('msg', 'profile', 'thread'));
}
public function compose(Request $request)
{
$profile = Auth::user()->profile;
}
}

View file

@ -21,53 +21,7 @@ class DiscoverController extends Controller
public function home(Request $request)
{
$this->validate($request, [
'page' => 'nullable|integer|max:50'
]);
$pid = Auth::user()->profile->id;
$following = Cache::remember('feature:discover:following:'.$pid, 720, function() use($pid) {
$following = Follower::select('following_id')
->whereProfileId($pid)
->pluck('following_id');
$filtered = UserFilter::select('filterable_id')
->whereUserId($pid)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id');
$following->push($pid);
if($filtered->count() > 0) {
$following->push($filtered);
}
return $following;
});
$people = Cache::remember('feature:discover:people:'.$pid, 15, function() use($following) {
return Profile::select('id', 'name', 'username')->inRandomOrder()
->whereHas('statuses')
->whereNull('domain')
->whereNotIn('id', $following)
->whereIsPrivate(false)
->take(3)
->get();
});
$posts = Status::select('id', 'caption', 'profile_id')
->whereHas('media')
->whereHas('profile', function($q) {
$q->where('is_private', false);
})
->whereIsNsfw(false)
->whereVisibility('public')
->where('profile_id', '<>', $pid)
->whereNotIn('profile_id', $following)
->withCount(['comments', 'likes'])
->orderBy('created_at', 'desc')
->simplePaginate(21);
return view('discover.home', compact('people', 'posts'));
return view('discover.home');
}
public function showTags(Request $request, $hashtag)
@ -82,13 +36,17 @@ class DiscoverController extends Controller
->firstOrFail();
$posts = $tag->posts()
->whereHas('media')
->withCount(['likes', 'comments'])
->whereIsNsfw(false)
->whereVisibility('public')
->has('media')
->orderBy('id', 'desc')
->simplePaginate(12);
if($posts->count() == 0) {
abort(404);
}
return view('discover.tags.show', compact('tag', 'posts'));
}
}

View file

@ -13,6 +13,7 @@ use Cache;
use Carbon\Carbon;
use Illuminate\Http\Request;
use League\Fractal;
use App\Util\ActivityPub\Helpers;
class FederationController extends Controller
{
@ -133,6 +134,19 @@ class FederationController extends Controller
return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT);
}
public function hostMeta(Request $request)
{
$path = route('well-known.webfinger');
$xml = <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" type="application/xrd+xml" template="{$path}?resource={uri}"/>
</XRD>
XML;
return response($xml)->header('Content-Type', 'application/xrd+xml');
}
public function userOutbox(Request $request, $username)
{
if (config('pixelfed.activitypub_enabled') == false) {
@ -153,6 +167,42 @@ class FederationController extends Controller
public function userInbox(Request $request, $username)
{
return;
// todo
}
public function userFollowing(Request $request, $username)
{
if (config('pixelfed.activitypub_enabled') == false) {
abort(403);
}
$profile = Profile::whereNull('remote_url')->whereUsername($username)->firstOrFail();
$obj = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $request->getUri(),
'type' => 'OrderedCollectionPage',
'totalItems' => $profile->following()->count(),
'orderedItems' => $profile->following->map(function($f) {
return $f->permalink();
})
];
return response()->json($obj);
}
public function userFollowers(Request $request, $username)
{
if (config('pixelfed.activitypub_enabled') == false) {
abort(403);
}
$profile = Profile::whereNull('remote_url')->whereUsername($username)->firstOrFail();
$obj = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $request->getUri(),
'type' => 'OrderedCollectionPage',
'totalItems' => $profile->followers()->count(),
'orderedItems' => $profile->followers->map(function($f) {
return $f->permalink();
})
];
return response()->json($obj);
}
}

View file

@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class HomeController extends Controller
{
/**
@ -19,7 +21,7 @@ class HomeController extends Controller
*
* @return \Illuminate\Http\Response
*/
public function index()
public function index(Request $request)
{
return view('home');
}

View file

@ -0,0 +1,151 @@
<?php
namespace App\Http\Controllers\Import;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Auth, DB;
use App\{
ImportData,
ImportJob,
Profile,
User
};
trait Instagram
{
public function instagram()
{
return view('settings.import.instagram.home');
}
public function instagramStart(Request $request)
{
$job = $this->instagramRedirectOrNew();
return redirect($job->url());
}
protected function instagramRedirectOrNew()
{
$profile = Auth::user()->profile;
$exists = ImportJob::whereProfileId($profile->id)
->whereService('instagram')
->whereNull('completed_at')
->exists();
if($exists) {
$job = ImportJob::whereProfileId($profile->id)
->whereService('instagram')
->whereNull('completed_at')
->first();
} else {
$job = new ImportJob;
$job->profile_id = $profile->id;
$job->service = 'instagram';
$job->uuid = (string) Str::uuid();
$job->stage = 1;
$job->save();
}
return $job;
}
public function instagramStepOne(Request $request, $uuid)
{
$profile = Auth::user()->profile;
$job = ImportJob::whereProfileId($profile->id)
->whereNull('completed_at')
->whereUuid($uuid)
->whereStage(1)
->firstOrFail();
return view('settings.import.instagram.step-one', compact('profile', 'job'));
}
public function instagramStepOneStore(Request $request, $uuid)
{
$this->validate($request, [
'media.*' => 'required|mimes:bin,jpeg,png,gif|max:500',
//'mediajson' => 'required|file|mimes:json'
]);
$media = $request->file('media');
$profile = Auth::user()->profile;
$job = ImportJob::whereProfileId($profile->id)
->whereNull('completed_at')
->whereUuid($uuid)
->whereStage(1)
->firstOrFail();
foreach ($media as $k => $v) {
$original = $v->getClientOriginalName();
if(strlen($original) < 32 || $k > 100) {
continue;
}
$storagePath = "import/{$job->uuid}";
$path = $v->store($storagePath);
DB::transaction(function() use ($profile, $job, $path, $original) {
$data = new ImportData;
$data->profile_id = $profile->id;
$data->job_id = $job->id;
$data->service = 'instagram';
$data->path = $path;
$data->stage = $job->stage;
$data->original_name = $original;
$data->save();
});
}
DB::transaction(function() use ($profile, $job) {
$job->stage = 2;
$job->save();
});
return redirect($job->url());
return view('settings.import.instagram.step-one', compact('profile', 'job'));
}
public function instagramStepTwo(Request $request, $uuid)
{
$profile = Auth::user()->profile;
$job = ImportJob::whereProfileId($profile->id)
->whereNull('completed_at')
->whereUuid($uuid)
->whereStage(2)
->firstOrFail();
return view('settings.import.instagram.step-two', compact('profile', 'job'));
}
public function instagramStepTwoStore(Request $request, $uuid)
{
$this->validate($request, [
'media' => 'required|file|max:1000'
]);
$profile = Auth::user()->profile;
$job = ImportJob::whereProfileId($profile->id)
->whereNull('completed_at')
->whereUuid($uuid)
->whereStage(2)
->firstOrFail();
$media = $request->file('media');
$file = file_get_contents($media);
$json = json_decode($file, true);
if(!$json || !isset($json['photos'])) {
return abort(500);
}
$storagePath = "import/{$job->uuid}";
$path = $media->store($storagePath);
$job->media_json = $path;
$job->stage = 3;
$job->save();
return redirect($job->url());
return $json;
}
public function instagramStepThree(Request $request, $uuid)
{
$profile = Auth::user()->profile;
$job = ImportJob::whereProfileId($profile->id)
->whereNull('completed_at')
->whereUuid($uuid)
->whereStage(3)
->firstOrFail();
return view('settings.import.instagram.step-three', compact('profile', 'job'));
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers\Import;
use Illuminate\Http\Request;
trait Mastodon
{
public function mastodon()
{
return view('settings.import.mastodon.home');
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ImportController extends Controller
{
use Import\Instagram, Import\Mastodon;
public function __construct()
{
$this->middleware('auth');
}
}

View file

@ -4,12 +4,17 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\{
DirectMessage,
Hashtag,
Like,
Media,
Notification,
Profile,
StatusHashtag,
Status,
};
use Auth,Cache;
use Carbon\Carbon;
use League\Fractal;
use App\Transformer\Api\{
AccountTransformer,
@ -30,60 +35,6 @@ class InternalApiController extends Controller
$this->fractal->setSerializer(new ArraySerializer());
}
public function status(Request $request, $username, int $postid)
{
$auth = Auth::user()->profile;
$profile = Profile::whereUsername($username)->first();
$status = Status::whereProfileId($profile->id)->find($postid);
$status = new Fractal\Resource\Item($status, new StatusTransformer());
$user = new Fractal\Resource\Item($auth, new AccountTransformer());
$res = [];
$res['status'] = $this->fractal->createData($status)->toArray();
$res['user'] = $this->fractal->createData($user)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
public function statusComments(Request $request, $username, int $postId)
{
$this->validate($request, [
'min_id' => 'nullable|integer|min:1',
'max_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
'limit' => 'nullable|integer|min:5|max:50'
]);
$limit = $request->limit ?? 10;
$auth = Auth::user()->profile;
$profile = Profile::whereUsername($username)->first();
$status = Status::whereProfileId($profile->id)->find($postId);
if($request->filled('min_id') || $request->filled('max_id')) {
$q = false;
$limit = 50;
if($request->filled('min_id')) {
$replies = $status->comments()
->select('id', 'caption', 'rendered', 'profile_id', 'created_at')
->where('id', '>=', $request->min_id)
->orderBy('id', 'desc')
->paginate($limit);
}
if($request->filled('max_id')) {
$replies = $status->comments()
->select('id', 'caption', 'rendered', 'profile_id', 'created_at')
->where('id', '<=', $request->max_id)
->orderBy('id', 'desc')
->paginate($limit);
}
} else {
$replies = $status->comments()
->select('id', 'caption', 'rendered', 'profile_id', 'created_at')
->orderBy('id', 'desc')
->paginate($limit);
}
$resource = new Fractal\Resource\Collection($replies, new StatusTransformer(), 'data');
$resource->setPaginator(new IlluminatePaginatorAdapter($replies));
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
public function compose(Request $request)
{
$this->validate($request, [
@ -101,13 +52,15 @@ class InternalApiController extends Controller
$attachments = [];
$status = new Status;
foreach($medias as $media) {
foreach($medias as $k => $media) {
$m = Media::findOrFail($media['id']);
if($m->profile_id !== $profile->id || $m->status_id) {
abort(403, 'Invalid media id');
}
$m->filter_class = $media['filter'];
$m->license = $media['license'];
$m->caption = strip_tags($media['alt']);
$m->order = isset($media['cursor']) && is_int($media['cursor']) ? (int) $media['cursor'] : $k;
if($media['cw'] == true) {
$m->is_nsfw = true;
$status->is_nsfw = true;
@ -135,4 +88,116 @@ class InternalApiController extends Controller
return $status->url();
}
public function notifications(Request $request)
{
$this->validate($request, [
'page' => 'nullable|min:1|max:3',
]);
$profile = Auth::user()->profile;
$timeago = Carbon::now()->subMonths(6);
$notifications = Notification::with('actor')
->whereProfileId($profile->id)
->whereDate('created_at', '>', $timeago)
->orderBy('id', 'desc')
->simplePaginate(30);
$notifications = $notifications->map(function($k, $v) {
return [
'id' => $k->id,
'action' => $k->action,
'message' => $k->message,
'rendered' => $k->rendered,
'actor' => [
'avatar' => $k->actor->avatarUrl(),
'username' => $k->actor->username,
'url' => $k->actor->url(),
],
'url' => $k->item->url(),
'read_at' => $k->read_at,
];
});
return response()->json($notifications, 200, [], JSON_PRETTY_PRINT);
}
public function discover(Request $request)
{
$profile = Auth::user()->profile;
$following = Cache::get('feature:discover:following:'.$profile->id, []);
$people = Profile::select('id', 'name', 'username')
->with('avatar')
->inRandomOrder()
->whereHas('statuses')
->whereNull('domain')
->whereNotIn('id', $following)
->whereIsPrivate(false)
->take(3)
->get();
$posts = Status::select('id', 'caption', 'profile_id')
->whereHas('media')
->whereHas('profile', function($q) {
$q->where('is_private', false);
})
->whereIsNsfw(false)
->whereVisibility('public')
->where('profile_id', '<>', $profile->id)
->whereNotIn('profile_id', $following)
->withCount(['comments', 'likes'])
->orderBy('created_at', 'desc')
->take(21)
->get();
$res = [
'people' => $people->map(function($profile) {
return [
'avatar' => $profile->avatarUrl(),
'name' => $profile->name,
'username' => $profile->username,
'url' => $profile->url(),
];
}),
'posts' => $posts->map(function($post) {
return [
'url' => $post->url(),
'thumb' => $post->thumb(),
'comments_count' => $post->comments_count,
'likes_count' => $post->likes_count,
];
})
];
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
public function directMessage(Request $request, $profileId, $threadId)
{
$profile = Auth::user()->profile;
if($profileId != $profile->id) {
abort(403);
}
$msg = DirectMessage::whereToId($profile->id)
->orWhere('from_id',$profile->id)
->findOrFail($threadId);
$thread = DirectMessage::with('status')->whereIn('to_id', [$profile->id, $msg->from_id])
->whereIn('from_id', [$profile->id,$msg->from_id])
->orderBy('created_at', 'asc')
->paginate(30);
return response()->json(compact('msg', 'profile', 'thread'), 200, [], JSON_PRETTY_PRINT);
}
public function notificationMarkAllRead(Request $request)
{
$profile = Auth::user()->profile;
$notifications = Notification::whereProfileId($profile->id)->get();
foreach($notifications as $n) {
$n->read_at = Carbon::now();
$n->save();
}
return;
}
}

View file

@ -34,6 +34,8 @@ class ProfileController extends Controller
if ($user->remote_url) {
$settings = new \StdClass;
$settings->crawlable = false;
$settings->show_profile_follower_count = true;
$settings->show_profile_following_count = true;
} else {
$settings = User::whereUsername($username)->firstOrFail()->settings;
}

View file

@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\{
Hashtag,
Like,
Media,
Notification,
Profile,
StatusHashtag,
Status,
};
use Auth,Cache;
use Carbon\Carbon;
use League\Fractal;
use App\Transformer\Api\{
AccountTransformer,
StatusTransformer,
};
use App\Jobs\StatusPipeline\NewStatusPipeline;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
class PublicApiController extends Controller
{
protected $fractal;
public function __construct()
{
$this->middleware('throttle:200, 15');
$this->fractal = new Fractal\Manager();
$this->fractal->setSerializer(new ArraySerializer());
}
protected function getUserData()
{
if(false == Auth::check()) {
return [];
} else {
$profile = Auth::user()->profile;
$user = new Fractal\Resource\Item($profile, new AccountTransformer());
return $this->fractal->createData($user)->toArray();
}
}
public function status(Request $request, $username, int $postid)
{
$profile = Profile::whereUsername($username)->first();
$status = Status::whereProfileId($profile->id)->find($postid);
$item = new Fractal\Resource\Item($status, new StatusTransformer());
$res = [
'status' => $this->fractal->createData($item)->toArray(),
'user' => $this->getUserData(),
'reactions' => [
'liked' => $status->liked(),
'shared' => $status->shared(),
'bookmarked' => $status->bookmarked(),
],
];
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
public function statusComments(Request $request, $username, int $postId)
{
$this->validate($request, [
'min_id' => 'nullable|integer|min:1',
'max_id' => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
'limit' => 'nullable|integer|min:5|max:50'
]);
$limit = $request->limit ?? 10;
$profile = Profile::whereUsername($username)->first();
$status = Status::whereProfileId($profile->id)->find($postId);
if($request->filled('min_id') || $request->filled('max_id')) {
if($request->filled('min_id')) {
$replies = $status->comments()
->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'created_at')
->where('id', '>=', $request->min_id)
->orderBy('id', 'desc')
->paginate($limit);
}
if($request->filled('max_id')) {
$replies = $status->comments()
->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'created_at')
->where('id', '<=', $request->max_id)
->orderBy('id', 'desc')
->paginate($limit);
}
} else {
$replies = $status->comments()
->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'created_at')
->orderBy('id', 'desc')
->paginate($limit);
}
$resource = new Fractal\Resource\Collection($replies, new StatusTransformer(), 'data');
$resource->setPaginator(new IlluminatePaginatorAdapter($replies));
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
}

View file

@ -38,7 +38,10 @@ class SearchController extends Controller
});
$tokens->push($tags);
}
$users = Profile::select('username', 'name', 'id')->where('username', 'like', '%'.$tag.'%')->limit(20)->get();
$users = Profile::select('username', 'name', 'id')
->where('username', 'like', '%'.$tag.'%')
->limit(20)
->get();
if($users->count() > 0) {
$profiles = $users->map(function ($item, $key) {
@ -71,7 +74,7 @@ class SearchController extends Controller
'count' => 0,
'url' => $item->url(),
'type' => 'status',
'value' => 'Posted '.$item->created_at->diffForHumans(),
'value' => "by {$item->profile->username} <span class='float-right'>{$item->created_at->diffForHumans(null, true, true)}</span>",
'tokens' => [$item->caption],
'name' => $item->caption,
'thumb' => $item->thumb(),

View file

@ -11,6 +11,7 @@ use App\UserFilter;
use App\Util\Lexer\PrettyNumber;
use Auth;
use DB;
use Purify;
use Illuminate\Http\Request;
trait HomeSettings
@ -40,8 +41,8 @@ trait HomeSettings
]);
$changes = false;
$name = $request->input('name');
$bio = $request->input('bio');
$name = strip_tags($request->input('name'));
$bio = $request->filled('bio') ? Purify::clean($request->input('bio')) : null;
$website = $request->input('website');
$email = $request->input('email');
$user = Auth::user();
@ -79,12 +80,12 @@ trait HomeSettings
$profile->name = $name;
}
if (!$profile->website || $profile->website != $website) {
if ($profile->website != $website) {
$changes = true;
$profile->website = $website;
}
if (!$profile->bio || !$profile->bio != $bio) {
if ($profile->bio != $bio) {
$changes = true;
$profile->bio = $bio;
}

View file

@ -8,6 +8,7 @@ use App\UserFilter;
use Auth;
use DB;
use Cache;
use Purify;
use Illuminate\Http\Request;
use App\Http\Controllers\Settings\{
HomeSettings,

View file

@ -33,12 +33,19 @@ class SiteController extends Controller
{
$pid = Auth::user()->profile->id;
// TODO: Use redis for timelines
$following = Follower::whereProfileId(Auth::user()->profile->id)->pluck('following_id');
$following->push(Auth::user()->profile->id);
$filtered = UserFilter::whereUserId($pid)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id');
$following = Cache::rememberForever("user:following:list:$pid", function() use($pid) {
$following = Follower::whereProfileId($pid)->pluck('following_id');
$following->push($pid);
return $following->toArray();
});
$filtered = Cache::rememberForever("user:filter:list:$pid", function() use($pid) {
return UserFilter::whereUserId($pid)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id')->toArray();
});
$timeline = Status::whereIn('profile_id', $following)
->whereNotIn('profile_id', $filtered)
->whereHas('media')
@ -53,29 +60,22 @@ class SiteController extends Controller
public function changeLocale(Request $request, $locale)
{
if (!App::isLocale($locale)) {
return redirect()->back();
// todo: add other locales after pushing new l10n strings
$locales = ['en'];
if(in_array($locale, $locales)) {
session()->put('locale', $locale);
}
App::setLocale($locale);
return redirect()->back();
}
public function about()
{
$res = Cache::remember('site:page:about', 15, function () {
$statuses = Status::whereHas('media')
->whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->count();
$statusCount = PrettyNumber::convert($statuses);
$userCount = PrettyNumber::convert(User::count());
$remoteCount = PrettyNumber::convert(Profile::whereNotNull('remote_url')->count());
$adminContact = User::whereIsAdmin(true)->first();
return view('site.about');
}
return view('site.about')->with(compact('statusCount', 'userCount', 'remoteCount', 'adminContact'))->render();
});
return $res;
public function language()
{
return view('site.language');
}
}

View file

@ -22,8 +22,7 @@ class StatusController extends Controller
$user = Profile::whereUsername($username)->firstOrFail();
$status = Status::whereProfileId($user->id)
->where('visibility', '!=', 'draft')
->withCount(['likes', 'comments', 'media'])
->whereNotIn('visibility',['draft','direct'])
->findOrFail($id);
if($status->visibility == 'private' || $user->is_private) {
@ -40,34 +39,8 @@ class StatusController extends Controller
return $this->showActivityPub($request, $status);
}
$template = $this->detectTemplate($status);
$replies = Status::whereInReplyToId($status->id)->orderBy('created_at', 'desc')->simplePaginate(30);
return view($template, compact('user', 'status', 'replies'));
}
protected function detectTemplate($status)
{
$template = Cache::rememberForever('template:status:type:'.$status->id, function () use ($status) {
$template = 'status.show.photo';
if (!$status->media_path && $status->in_reply_to_id) {
$template = 'status.reply';
}
if ($status->media->count() > 1) {
$template = 'status.show.album';
}
if ($status->viewType() == 'video') {
$template = 'status.show.video';
}
if ($status->viewType() == 'video-album') {
$template = 'status.show.video-album';
}
return $template;
});
return $template;
$template = $status->in_reply_to_id ? 'status.reply' : 'status.show';
return view($template, compact('user', 'status'));
}
public function compose()
@ -148,9 +121,7 @@ class StatusController extends Controller
public function delete(Request $request)
{
if (!Auth::check()) {
abort(403);
}
$this->authCheck();
$this->validate($request, [
'type' => 'required|string',
@ -168,6 +139,8 @@ class StatusController extends Controller
public function storeShare(Request $request)
{
$this->authCheck();
$this->validate($request, [
'item' => 'required|integer',
]);

View file

@ -2,12 +2,13 @@
namespace App\Http\Controllers;
use Auth, Cache;
use App\Follower;
use App\Profile;
use App\Status;
use App\User;
use App\UserFilter;
use Auth;
use Illuminate\Http\Request;
class TimelineController extends Controller
{
@ -17,39 +18,23 @@ class TimelineController extends Controller
$this->middleware('twofactor');
}
public function personal()
{
$pid = Auth::user()->profile->id;
// TODO: Use redis for timelines
$following = Follower::whereProfileId($pid)->pluck('following_id');
$following->push($pid);
$filtered = UserFilter::whereUserId($pid)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id');
$timeline = Status::whereIn('profile_id', $following)
->whereNotIn('profile_id', $filtered)
->whereVisibility('public')
->orderBy('created_at', 'desc')
->withCount(['comments', 'likes'])
->simplePaginate(20);
$type = 'personal';
return view('timeline.template', compact('timeline', 'type'));
}
public function local()
public function local(Request $request)
{
$this->validate($request,[
'page' => 'nullable|integer|max:20'
]);
// TODO: Use redis for timelines
// $timeline = Timeline::build()->local();
$pid = Auth::user()->profile->id;
$filtered = UserFilter::whereUserId($pid)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id');
$filtered = Cache::rememberForever("user:filter:list:$pid", function() use($pid) {
return UserFilter::whereUserId($pid)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id')->toArray();
});
$private = Profile::whereIsPrivate(true)->pluck('id');
$filtered = $filtered->merge($private);
$filtered = array_merge($private->toArray(), $filtered);
$timeline = Status::whereHas('media')
->whereNotIn('profile_id', $filtered)
->whereNull('in_reply_to_id')
@ -57,7 +42,7 @@ class TimelineController extends Controller
->whereVisibility('public')
->withCount(['comments', 'likes'])
->orderBy('created_at', 'desc')
->simplePaginate(20);
->simplePaginate(10);
$type = 'local';
return view('timeline.template', compact('timeline', 'type'));

View file

@ -58,6 +58,7 @@ class Kernel extends HttpKernel
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'dangerzone' => \App\Http\Middleware\DangerZone::class,
'localization' => \App\Http\Middleware\Localization::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure, Session;
class Localization
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if(Session::has('locale')) {
app()->setLocale(Session::get('locale'));
}
return $next($request);
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace App\Jobs\ImportPipeline;
use DB;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\Jobs\StatusPipeline\NewStatusPipeline;
use App\{
ImportJob,
ImportData,
Media,
Profile,
Status,
};
class ImportInstagram implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $job;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(ImportJob $job)
{
$this->job = $job;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$job = $this->job;
$profile = $this->job->profile;
$json = $job->mediaJson();
$collection = $json['photos'];
$files = $job->files;
$monthHash = hash('sha1', date('Y').date('m'));
$userHash = hash('sha1', $profile->id . (string) $profile->created_at);
$fs = new Filesystem;
foreach($collection as $import)
{
$caption = $import['caption'];
try {
$min = Carbon::create(2010, 10, 6, 0, 0, 0);
$taken_at = Carbon::parse($import['taken_at']);
if(!$min->lt($taken_at)) {
$taken_at = Carbon::now();
}
} catch (Exception $e) {
}
$filename = last( explode('/', $import['path']) );
$importData = ImportData::whereJobId($job->id)
->whereOriginalName($filename)
->firstOrFail();
if(is_file(storage_path("app/$importData->path")) == false) {
continue;
}
DB::transaction(function() use(
$fs, $job, $profile, $caption, $taken_at, $filename,
$monthHash, $userHash, $importData
) {
$status = new Status();
$status->profile_id = $profile->id;
$status->caption = strip_tags($caption);
$status->is_nsfw = false;
$status->visibility = 'public';
$status->created_at = $taken_at;
$status->save();
$path = storage_path("app/$importData->path");
$storagePath = "public/m/{$monthHash}/{$userHash}";
$newPath = "app/$storagePath/$filename";
$fs->move($path,storage_path($newPath));
$path = $newPath;
$hash = \hash_file('sha256', storage_path($path));
$media = new Media();
$media->status_id = $status->id;
$media->profile_id = $profile->id;
$media->user_id = $profile->user->id;
$media->media_path = "$storagePath/$filename";
$media->original_sha256 = $hash;
$media->size = $fs->size(storage_path($path));
$media->mime = $fs->mimeType(storage_path($path));
$media->filter_class = null;
$media->filter_name = null;
$media->order = 1;
$media->save();
ImageOptimize::dispatch($media);
NewStatusPipeline::dispatch($status);
});
}
$job->completed_at = Carbon::now();
$job->save();
}
}

View file

@ -14,7 +14,7 @@ class InboxWorker implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $request;
protected $headers;
protected $profile;
protected $payload;
@ -23,9 +23,9 @@ class InboxWorker implements ShouldQueue
*
* @return void
*/
public function __construct($request, Profile $profile, $payload)
public function __construct($headers, $profile, $payload)
{
$this->request = $request;
$this->headers = $headers;
$this->profile = $profile;
$this->payload = $payload;
}
@ -37,6 +37,6 @@ class InboxWorker implements ShouldQueue
*/
public function handle()
{
(new Inbox($this->request, $this->profile, $this->payload))->handle();
(new Inbox($this->headers, $this->profile, $this->payload))->handle();
}
}

View file

@ -41,8 +41,8 @@ class LikePipeline implements ShouldQueue
$status = $this->like->status;
$actor = $this->like->actor;
if ($status->url !== null) {
// Ignore notifications to remote statuses
if (!$status || $status->url !== null) {
// Ignore notifications to remote statuses, or deleted statuses
return;
}

View file

@ -207,6 +207,7 @@ class RemoteFollowImportRecent implements ShouldQueue
try {
$info = pathinfo($url);
$url = str_replace(' ', '%20', $url);
$img = file_get_contents($url);
$file = '/tmp/'.str_random(12).$info['basename'];
file_put_contents($file, $img);

View file

@ -83,7 +83,7 @@ class RemoteFollowPipeline implements ShouldQueue
$profile->domain = $domain;
$profile->username = $remoteUsername;
$profile->name = $res['name'];
$profile->bio = str_limit($res['summary'], 125);
$profile->bio = Purify::clean($res['summary']);
$profile->sharedInbox = $res['endpoints']['sharedInbox'];
$profile->remote_url = $res['url'];
$profile->save();

View file

@ -0,0 +1,79 @@
<?php
namespace App\Jobs\SharePipeline;
use App\Status;
use App\Notification;
use Cache;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Log;
use Redis;
class SharePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $like;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Status $status)
{
$this->status = $status;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$status = $this->status;
$actor = $this->status->profile;
$target = $this->status->parent()->profile;
if ($status->url !== null) {
// Ignore notifications to remote statuses
return;
}
$exists = Notification::whereProfileId($status->profile_id)
->whereActorId($actor->id)
->whereAction('like')
->whereItemId($status->id)
->whereItemType('App\Status')
->count();
if ($actor->id === $status->profile_id || $exists !== 0) {
return true;
}
try {
$notification = new Notification();
$notification->profile_id = $status->profile_id;
$notification->actor_id = $actor->id;
$notification->action = 'like';
$notification->message = $like->toText();
$notification->rendered = $like->toHtml();
$notification->item_id = $status->id;
$notification->item_type = "App\Status";
$notification->save();
Cache::forever('notification.'.$notification->id, $notification);
$redis = Redis::connection();
$key = config('cache.prefix').':user.'.$status->profile_id.'.notifications';
$redis->lpush($key, $notification->id);
} catch (Exception $e) {
Log::error($e);
}
}
}

View file

@ -37,7 +37,7 @@ class NewStatusPipeline implements ShouldQueue
$status = $this->status;
StatusEntityLexer::dispatch($status);
//StatusActivityPubDeliver::dispatch($status);
StatusActivityPubDeliver::dispatch($status);
Cache::forever('post.'.$status->id, $status);

View file

@ -8,6 +8,10 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use App\Transformer\ActivityPub\Verb\CreateNote;
use App\Util\ActivityPub\Helpers;
class StatusActivityPubDeliver implements ShouldQueue
{
@ -34,6 +38,18 @@ class StatusActivityPubDeliver implements ShouldQueue
{
$status = $this->status;
$audience = $status->profile->getAudienceInbox();
$profile = $status->profile;
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($status, new CreateNote());
$activity = $fractal->createData($resource)->toArray();
foreach($audience as $url) {
Helpers::sendSignedObject($profile, $url, $activity);
}
// todo: fanout on write
}
}

View file

@ -2,9 +2,12 @@
namespace App\Jobs\StatusPipeline;
use App\Notification;
use App\Status;
use App\StatusHashtag;
use App\{
Notification,
Report,
Status,
StatusHashtag,
};
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -73,6 +76,9 @@ class StatusDelete implements ShouldQueue
->whereItemId($status->id)
->delete();
StatusHashtag::whereStatusId($status->id)->delete();
Report::whereObjectType('App\Status')
->whereObjectId($status->id)
->delete();
$status->delete();
return true;

View file

@ -69,7 +69,7 @@ class StatusEntityLexer implements ShouldQueue
$this->storeMentions();
DB::transaction(function () {
$status = $this->status;
$status->rendered = $this->autolink;
$status->rendered = nl2br($this->autolink);
$status->entities = json_encode($this->entities);
$status->save();
});

View file

@ -0,0 +1,34 @@
<?php
namespace App\Jobs\VideoPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class VideoOptimize implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
//
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Jobs\VideoPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class VideoPostProcess implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
//
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Jobs\VideoPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use FFMpeg;
use App\Media;
class VideoThumbnail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Media $media)
{
$this->media = $media;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$media = $this->media;
$base = $media->media_path;
$path = explode('/', $base);
$name = last($path);
try {
$t = explode('.', $name);
$t = $t[0].'_thumb.png';
$i = count($path) - 1;
$path[$i] = $t;
$save = implode('/', $path);
$video = FFMpeg::open($base);
if($video->getDurationInSeconds() < 1) {
$video->getFrameFromSeconds(0);
} elseif($video->getDurationInSeconds() < 5) {
$video->getFrameFromSeconds(4);
}
$video->export()
->save($save);
$media->thumbnail_path = $save;
$media->save();
} catch (Exception $e) {
}
}
}

View file

@ -15,6 +15,7 @@ class Like extends Model
* @var array
*/
protected $dates = ['deleted_at'];
protected $fillable = ['profile_id', 'status_id'];
public function actor()
{

View file

@ -19,8 +19,12 @@ class Media extends Model
public function url()
{
$path = $this->media_path;
$url = Storage::url($path);
if(!empty($this->remote_media) && $this->remote_url) {
$url = $this->remote_url;
} else {
$path = $this->media_path;
$url = Storage::url($path);
}
return url($url);
}
@ -60,4 +64,15 @@ class Media extends Model
{
return json_decode($this->metadata, true, 3);
}
public function getModel()
{
if(empty($this->metadata)) {
return false;
}
$meta = $this->getMetadata();
if($meta && isset($meta['Model'])) {
return $meta['Model'];
}
}
}

View file

@ -46,6 +46,9 @@ class Profile extends Model
public function permalink($suffix = '')
{
if($this->remote_url) {
return $this->remote_url;
}
return url('users/'.$this->username.$suffix);
}
@ -248,4 +251,44 @@ class Profile extends Model
{
return $this->sharedInbox ?? $this->inboxUrl();
}
public function getDefaultScope()
{
return $this->is_private == true ? 'private' : 'public';
}
public function getAudience($scope = false)
{
if($this->remote_url) {
return [];
}
$scope = $scope ?? $this->getDefaultScope();
$audience = [];
switch ($scope) {
case 'public':
$audience = [
'to' => [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc' => [
$this->permalink('/followers')
]
];
break;
}
return $audience;
}
public function getAudienceInbox($scope = 'public')
{
return $this
->followers()
->whereLocalProfile(false)
->get()
->map(function($follow) {
return $follow->sharedInbox ?? $follow->inbox_url;
})
->unique()
->toArray();
}
}

View file

@ -65,8 +65,7 @@ class RouteServiceProvider extends ServiceProvider
*/
protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('api')
Route::middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
}

View file

@ -22,14 +22,15 @@ class Report extends Model
{
$class = $this->object_type;
switch ($class) {
case 'App\Status':
$column = 'id';
break;
case 'App\Status':
$column = 'id';
break;
default:
$column = 'id';
break;
}
default:
$class = 'App\Status';
$column = 'id';
break;
}
return (new $class())->where($column, $this->object_id)->firstOrFail();
}

View file

@ -18,7 +18,7 @@ class Status extends Model
*/
protected $dates = ['deleted_at'];
protected $fillable = ['profile_id', 'visibility'];
protected $fillable = ['profile_id', 'visibility', 'in_reply_to_id'];
public function profile()
{
@ -52,7 +52,7 @@ class Status extends Model
{
$type = $this->viewType();
$is_nsfw = !$showNsfw ? $this->is_nsfw : false;
if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['image', 'album'])) {
if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['image', 'album', 'video'])) {
return 'data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
}
@ -64,11 +64,6 @@ class Status extends Model
$id = $this->id;
$username = $this->profile->username;
$path = config('app.url')."/p/{$username}/{$id}";
if (!is_null($this->in_reply_to_id)) {
$pid = $this->in_reply_to_id;
$path = config('app.url')."/p/{$username}/{$pid}/c/{$id}";
}
return url($path);
}
@ -103,8 +98,10 @@ class Status extends Model
public function liked() : bool
{
if(Auth::check() == false) {
return false;
}
$profile = Auth::user()->profile;
return Like::whereProfileId($profile->id)->whereStatusId($this->id)->count();
}
@ -116,7 +113,7 @@ class Status extends Model
public function bookmarked()
{
if (!Auth::check()) {
return 0;
return false;
}
$profile = Auth::user()->profile;
@ -130,6 +127,9 @@ class Status extends Model
public function shared() : bool
{
if(Auth::check() == false) {
return false;
}
$profile = Auth::user()->profile;
return self::whereProfileId($profile->id)->whereReblogOfId($this->id)->count();
@ -139,7 +139,7 @@ class Status extends Model
{
$parent = $this->in_reply_to_id ?? $this->reblog_of_id;
if (!empty($parent)) {
return self::findOrFail($parent);
return $this->findOrFail($parent);
}
}
@ -254,7 +254,7 @@ class Status extends Model
'url' => $media->url(),
'name' => null
];
})
})->toArray()
]
];
}
@ -268,18 +268,25 @@ class Status extends Model
$res['to'] = [];
$res['cc'] = [];
$scope = $this->scope;
$mentions = $this->mentions->map(function ($mention) {
return $mention->permalink();
})->toArray();
switch ($scope) {
case 'public':
$res['to'] = [
"https://www.w3.org/ns/activitystreams#Public"
];
$res['cc'] = [
$this->profile->permalink('/followers')
];
$res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions);
break;
default:
# code...
case 'unlisted':
break;
case 'private':
break;
case 'direct':
break;
}
return $res[$audience];

10
app/Story.php Normal file
View file

@ -0,0 +1,10 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Story extends Model
{
//
}

10
app/StoryReaction.php Normal file
View file

@ -0,0 +1,10 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class StoryReaction extends Model
{
//
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use App\Status;
use League\Fractal;
class Announce extends Fractal\TransformerAbstract
{
public function transform(Status $status)
{
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Announce',
'actor' => $status->profile->permalink(),
'object' => $status->parent()->url()
];
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use App\Status;
use League\Fractal;
class CreateNote extends Fractal\TransformerAbstract
{
public function transform(Status $status)
{
$mentions = $status->mentions->map(function ($mention) {
return [
'type' => 'Mention',
'href' => $mention->permalink(),
'name' => $mention->emailUrl()
];
})->toArray();
$hashtags = $status->hashtags->map(function ($hashtag) {
return [
'type' => 'Hashtag',
'href' => $hashtag->url(),
'name' => "#{$hashtag->name}",
];
})->toArray();
$tags = array_merge($mentions, $hashtags);
return [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
'featured' => [
'https://pixelfed.org/ns#featured' => ['@type' => '@id'],
],
],
],
'id' => $status->permalink(),
'type' => 'Create',
'actor' => $status->profile->permalink(),
'published' => $status->created_at->toAtomString(),
'to' => $status->scopeToAudience('to'),
'cc' => $status->scopeToAudience('cc'),
'object' => [
'id' => $status->url(),
'type' => 'Note',
'summary' => null,
'content' => $status->rendered ?? $status->caption,
'inReplyTo' => $status->in_reply_to_id ? $status->parent()->url() : null,
'published' => $status->created_at->toAtomString(),
'url' => $status->url(),
'attributedTo' => $status->profile->permalink(),
'to' => $status->scopeToAudience('to'),
'cc' => $status->scopeToAudience('cc'),
'sensitive' => (bool) $status->is_nsfw,
'attachment' => $status->media->map(function ($media) {
return [
'type' => 'Document',
'mediaType' => $media->mime,
'url' => $media->url(),
'name' => null,
];
})->toArray(),
'tag' => $tags,
]
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use App\Follower;
use League\Fractal;
class Follow extends Fractal\TransformerAbstract
{
public function transform(Follower $follower)
{
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Follow',
'actor' => $follower->actor->permalink(),
'object' => $follower->target->permalink()
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Transformer\ActivityPub\Verb;
use App\Like as LikeModel;
use League\Fractal;
class Like extends Fractal\TransformerAbstract
{
public function transform(LikeModel $like)
{
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Like',
'actor' => $like->actor->permalink(),
'object' => $like->status->url()
];
}
}

View file

@ -17,7 +17,12 @@ class MediaTransformer extends Fractal\TransformerAbstract
'preview_url' => $media->thumbnailUrl(),
'text_url' => null,
'meta' => $media->metadata,
'description' => null,
'description' => $media->caption,
'license' => $media->license,
'is_nsfw' => $media->is_nsfw,
'orientation' => $media->orientation,
'filter_name' => $media->filter_name,
'filter_class' => $media->filter_class,
];
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Transformer\Api;
use App\Profile;
use League\Fractal;
class RelationshipTransformer extends Fractal\TransformerAbstract
{
public function transform(Profile $profile)
{
return [
'id' => $profile->id,
'following' => null,
'followed_by' => null,
'blocking' => null,
'muting' => null,
'muting_notifications' => null,
'requested' => null,
'domain_blocking' => null,
'showing_reblogs' => null,
'endorsed' => null
];
}
}

View file

@ -59,7 +59,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
public function includeMediaAttachments(Status $status)
{
$media = $status->media;
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
}

View file

@ -2,18 +2,30 @@
namespace App\Util\ActivityPub;
use App\Like;
use App\Profile;
use Cache, DB, Log, Redis, Validator;
use App\{
Activity,
Follower,
FollowRequest,
Like,
Notification,
Profile,
Status
};
use Carbon\Carbon;
use App\Util\ActivityPub\Helpers;
use App\Jobs\LikePipeline\LikePipeline;
class Inbox
{
protected $request;
protected $headers;
protected $profile;
protected $payload;
protected $logger;
public function __construct($request, Profile $profile, $payload)
public function __construct($headers, $profile, $payload)
{
$this->request = $request;
$this->headers = $headers;
$this->profile = $profile;
$this->payload = $payload;
}
@ -25,15 +37,31 @@ class Inbox
public function authenticatePayload()
{
// todo
try {
$signature = Helpers::validateSignature($this->headers, $this->payload);
$payload = Helpers::validateObject($this->payload);
if($signature == false) {
return;
}
} catch (Exception $e) {
return;
}
$this->payloadLogger();
}
public function payloadLogger()
{
$logger = new Activity;
$logger->data = json_encode($this->payload);
$logger->save();
$this->logger = $logger;
Log::info('AP:inbox:activity:new:'.$this->logger->id);
$this->handleVerb();
}
public function handleVerb()
{
$verb = $this->payload['type'];
switch ($verb) {
case 'Create':
$this->handleCreateActivity();
@ -43,43 +71,254 @@ class Inbox
$this->handleFollowActivity();
break;
case 'Announce':
$this->handleAnnounceActivity();
break;
case 'Accept':
$this->handleAcceptActivity();
break;
case 'Delete':
$this->handleDeleteActivity();
break;
case 'Like':
$this->handleLikeActivity();
break;
case 'Reject':
$this->handleRejectActivity();
break;
case 'Undo':
$this->handleUndoActivity();
break;
default:
// TODO: decide how to handle invalid verbs.
break;
}
}
public function handleCreateActivity()
public function verifyNoteAttachment()
{
// todo
}
$activity = $this->payload['object'];
public function handleFollowActivity()
{
$actor = $this->payload['object'];
$target = $this->profile;
if(isset($activity['inReplyTo']) &&
!empty($activity['inReplyTo']) &&
Helpers::validateUrl($activity['inReplyTo'])
) {
// reply detected, skip attachment check
return true;
}
$valid = Helpers::verifyAttachments($activity);
return $valid;
}
public function actorFirstOrCreate($actorUrl)
{
if (Profile::whereRemoteUrl($actorUrl)->count() !== 0) {
return Profile::whereRemoteUrl($actorUrl)->firstOrFail();
return Helpers::profileFirstOrNew($actorUrl);
}
public function handleCreateActivity()
{
$activity = $this->payload['object'];
if(!$this->verifyNoteAttachment()) {
return;
}
if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) {
$this->handleNoteReply();
} elseif($activity['type'] == 'Note' && !empty($activity['attachment'])) {
$this->handleNoteCreate();
}
}
public function handleNoteReply()
{
$activity = $this->payload['object'];
$actor = $this->actorFirstOrCreate($this->payload['actor']);
$inReplyTo = $activity['inReplyTo'];
if(!Helpers::statusFirstOrFetch($activity['url'], true)) {
$this->logger->delete();
return;
}
$res = (new DiscoverActor($url))->discover();
$this->logger->to_id = $this->profile->id;
$this->logger->from_id = $actor->id;
$this->logger->processed_at = Carbon::now();
$this->logger->save();
}
$domain = parse_url($res['url'], PHP_URL_HOST);
$username = $res['preferredUsername'];
$remoteUsername = "@{$username}@{$domain}";
public function handleNoteCreate()
{
$activity = $this->payload['object'];
$actor = $this->actorFirstOrCreate($this->payload['actor']);
if(!$actor || $actor->domain == null) {
return;
}
$profile = new Profile();
$profile->user_id = null;
$profile->domain = $domain;
$profile->username = $remoteUsername;
$profile->name = $res['name'];
$profile->bio = str_limit($res['summary'], 125);
$profile->sharedInbox = $res['endpoints']['sharedInbox'];
$profile->remote_url = $res['url'];
$profile->save();
if(Helpers::userInAudience($this->profile, $this->payload) == false) {
//Log::error('AP:inbox:userInAudience:false - Activity#'.$this->logger->id);
$logger = Activity::find($this->logger->id);
$logger->delete();
return;
}
if(Status::whereUrl($activity['url'])->exists()) {
return;
}
$status = DB::transaction(function() use($activity, $actor) {
$status = new Status;
$status->profile_id = $actor->id;
$status->caption = strip_tags($activity['content']);
$status->visibility = $status->scope = 'public';
$status->url = $activity['url'];
$status->save();
return $status;
});
Helpers::importNoteAttachment($activity, $status);
$logger = Activity::find($this->logger->id);
$logger->to_id = $this->profile->id;
$logger->from_id = $actor->id;
$logger->processed_at = Carbon::now();
$logger->save();
}
public function handleFollowActivity()
{
$actor = $this->actorFirstOrCreate($this->payload['actor']);
if(!$actor || $actor->domain == null) {
return;
}
$target = $this->profile;
if($target->is_private == true) {
// make follow request
FollowRequest::firstOrCreate([
'follower_id' => $actor->id,
'following_id' => $target->id
]);
// todo: send notification
} else {
// store new follower
$follower = Follower::firstOrCreate([
'profile_id' => $actor->id,
'following_id' => $target->id,
'local_profile' => empty($actor->domain)
]);
if($follower->wasRecentlyCreated == false) {
$this->logger->delete();
return;
}
// send notification
$notification = new Notification();
$notification->profile_id = $target->id;
$notification->actor_id = $actor->id;
$notification->action = 'follow';
$notification->message = $follower->toText();
$notification->rendered = $follower->toHtml();
$notification->item_id = $target->id;
$notification->item_type = "App\Profile";
$notification->save();
\Cache::forever('notification.'.$notification->id, $notification);
$redis = Redis::connection();
$nkey = config('cache.prefix').':user.'.$target->id.'.notifications';
$redis->lpush($nkey, $notification->id);
// send Accept to remote profile
$accept = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $follower->permalink('/accept'),
'type' => 'Accept',
'actor' => $target->permalink(),
'object' => [
'id' => $this->payload['id'],
'type' => 'Follow',
'actor' => $target->permalink(),
'object' => $actor->permalink()
]
];
Helpers::sendSignedObject($target, $actor->inbox_url, $accept);
}
$this->logger->to_id = $target->id;
$this->logger->from_id = $actor->id;
$this->logger->processed_at = Carbon::now();
$this->logger->save();
}
public function handleAnnounceActivity()
{
}
public function handleAcceptActivity()
{
}
public function handleDeleteActivity()
{
}
public function handleLikeActivity()
{
$actor = $this->payload['actor'];
$profile = self::actorFirstOrCreate($actor);
$obj = $this->payload['object'];
if(Helpers::validateLocalUrl($obj) == false) {
return;
}
$status = Helpers::statusFirstOrFetch($obj);
$like = Like::firstOrCreate([
'profile_id' => $profile->id,
'status_id' => $status->id
]);
if($like->wasRecentlyCreated == false) {
return;
}
LikePipeline::dispatch($like);
$this->logger->to_id = $status->profile_id;
$this->logger->from_id = $profile->id;
$this->logger->processed_at = Carbon::now();
$this->logger->save();
}
public function handleRejectActivity()
{
}
public function handleUndoActivity()
{
$actor = $this->payload['actor'];
$profile = self::actorFirstOrCreate($actor);
$obj = $this->payload['object'];
$status = Helpers::statusFirstOrFetch($obj['object']);
switch ($obj['type']) {
case 'Like':
Like::whereProfileId($profile->id)
->whereStatusId($status->id)
->delete();
break;
}
$this->logger->to_id = $status->profile_id;
$this->logger->from_id = $profile->id;
$this->logger->processed_at = Carbon::now();
$this->logger->save();
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Util\HttpSignatures;
abstract class Algorithm
{
/**
* @param string $name
*
* @return HmacAlgorithm
*
* @throws Exception
*/
public static function create($name)
{
switch ($name) {
case 'hmac-sha1':
return new HmacAlgorithm('sha1');
break;
case 'hmac-sha256':
return new HmacAlgorithm('sha256');
break;
case 'rsa-sha1':
return new RsaAlgorithm('sha1');
break;
case 'rsa-sha256':
return new RsaAlgorithm('sha256');
break;
default:
throw new AlgorithmException("No algorithm named '$name'");
break;
}
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Util\HttpSignatures;
class AlgorithmException extends Exception
{
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Util\HttpSignatures;
interface AlgorithmInterface
{
/**
* @return string
*/
public function name();
/**
* @param string $key
* @param string $data
*
* @return string
*/
public function sign($key, $data);
}

View file

@ -0,0 +1,119 @@
<?php
namespace App\Util\HttpSignatures;
class Context
{
/** @var array */
private $headers;
/** @var KeyStoreInterface */
private $keyStore;
/** @var array */
private $keys;
/** @var string */
private $signingKeyId;
/** @var AlgorithmInterface */
private $algorithm;
/**
* @param array $args
*
* @throws Exception
*/
public function __construct($args)
{
if (isset($args['keys']) && isset($args['keyStore'])) {
throw new Exception(__CLASS__.' accepts keys or keyStore but not both');
} elseif (isset($args['keys'])) {
// array of keyId => keySecret
$this->keys = $args['keys'];
} elseif (isset($args['keyStore'])) {
$this->setKeyStore($args['keyStore']);
}
// algorithm for signing; not necessary for verifying.
if (isset($args['algorithm'])) {
$this->algorithm = Algorithm::create($args['algorithm']);
}
// headers list for signing; not necessary for verifying.
if (isset($args['headers'])) {
$this->headers = $args['headers'];
}
// signingKeyId specifies the key used for signing messages.
if (isset($args['signingKeyId'])) {
$this->signingKeyId = $args['signingKeyId'];
} elseif (isset($args['keys']) && 1 === count($args['keys'])) {
list($this->signingKeyId) = array_keys($args['keys']); // first key
}
}
/**
* @return Signer
*
* @throws Exception
*/
public function signer()
{
return new Signer(
$this->signingKey(),
$this->algorithm,
$this->headerList()
);
}
/**
* @return Verifier
*/
public function verifier()
{
return new Verifier($this->keyStore());
}
/**
* @return Key
*
* @throws Exception
* @throws KeyStoreException
*/
private function signingKey()
{
if (isset($this->signingKeyId)) {
return $this->keyStore()->fetch($this->signingKeyId);
} else {
throw new Exception('no implicit or specified signing key');
}
}
/**
* @return HeaderList
*/
private function headerList()
{
return new HeaderList($this->headers);
}
/**
* @return KeyStore
*/
private function keyStore()
{
if (empty($this->keyStore)) {
$this->keyStore = new KeyStore($this->keys);
}
return $this->keyStore;
}
/**
* @param KeyStoreInterface $keyStore
*/
private function setKeyStore(KeyStoreInterface $keyStore)
{
$this->keyStore = $keyStore;
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Util\HttpSignatures;
class Exception extends \Exception
{
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Util\HttpSignatures;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use App\Util\HttpSignatures\Context;
class GuzzleHttpSignatures
{
/**
* @param Context $context
* @return HandlerStack
*/
public static function defaultHandlerFromContext(Context $context)
{
$stack = HandlerStack::create();
$stack->push(self::middlewareFromContext($context));
return $stack;
}
/**
* @param Context $context
* @return \Closure
*/
public static function middlewareFromContext(Context $context)
{
return function (callable $handler) use ($context)
{
return function (
Request $request,
array $options
) use ($handler, $context)
{
$request = $context->signer()->sign($request);
return $handler($request, $options);
};
};
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Util\HttpSignatures;
class HeaderList
{
/** @var array */
public $names;
/**
* @param array $names
*/
public function __construct(array $names)
{
$this->names = array_map(
[$this, 'normalize'],
$names
);
}
/**
* @param $string
*
* @return HeaderList
*/
public static function fromString($string)
{
return new static(explode(' ', $string));
}
/**
* @return string
*/
public function string()
{
return implode(' ', $this->names);
}
/**
* @param $name
*
* @return string
*/
private function normalize($name)
{
return strtolower($name);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Util\HttpSignatures;
class HmacAlgorithm implements AlgorithmInterface
{
/** @var string */
private $digestName;
/**
* @param string $digestName
*/
public function __construct($digestName)
{
$this->digestName = $digestName;
}
/**
* @return string
*/
public function name()
{
return sprintf('hmac-%s', $this->digestName);
}
/**
* @param string $key
* @param string $data
*
* @return string
*/
public function sign($secret, $data)
{
return hash_hmac($this->digestName, $data, $secret, true);
}
}

260
app/Util/HTTPSignatures/Key.php Executable file
View file

@ -0,0 +1,260 @@
<?php
namespace App\Util\HttpSignatures;
class Key
{
/** @var string */
private $id;
/** @var string */
private $secret;
/** @var resource */
private $certificate;
/** @var resource */
private $publicKey;
/** @var resource */
private $privateKey;
/** @var string */
private $type;
/**
* @param string $id
* @param string|array $secret
*/
public function __construct($id, $item)
{
$this->id = $id;
if (Key::hasX509Certificate($item) || Key::hasPublicKey($item)) {
$publicKey = Key::getPublicKey($item);
} else {
$publicKey = null;
}
if (Key::hasPrivateKey($item)) {
$privateKey = Key::getPrivateKey($item);
} else {
$privateKey = null;
}
if (($publicKey || $privateKey)) {
$this->type = 'asymmetric';
if ($publicKey && $privateKey) {
$publicKeyPEM = openssl_pkey_get_details($publicKey)['key'];
$privateKeyPublicPEM = openssl_pkey_get_details($privateKey)['key'];
if ($privateKeyPublicPEM != $publicKeyPEM) {
throw new KeyException('Supplied Certificate and Key are not related');
}
}
$this->privateKey = $privateKey;
$this->publicKey = $publicKey;
$this->secret = null;
} else {
$this->type = 'secret';
$this->secret = $item;
$this->publicKey = null;
$this->privateKey = null;
}
}
/**
* Retrieves private key resource from a input string or
* array of strings.
*
* @param string|array $object PEM-format Private Key or file path to same
*
* @return resource|false
*/
public static function getPrivateKey($object)
{
if (is_array($object)) {
foreach ($object as $candidateKey) {
$privateKey = Key::getPrivateKey($candidateKey);
if ($privateKey) {
return $privateKey;
}
}
} else {
// OpenSSL libraries don't have detection methods, so try..catch
try {
$privateKey = openssl_get_privatekey($object);
return $privateKey;
} catch (\Exception $e) {
return null;
}
}
}
/**
* Retrieves public key resource from a input string or
* array of strings.
*
* @param string|array $object PEM-format Public Key or file path to same
*
* @return resource|false
*/
public static function getPublicKey($object)
{
if (is_array($object)) {
// If we implement key rotation in future, this should add to a collection
foreach ($object as $candidateKey) {
$publicKey = Key::getPublicKey($candidateKey);
if ($publicKey) {
return $publicKey;
}
}
} else {
// OpenSSL libraries don't have detection methods, so try..catch
try {
$publicKey = openssl_get_publickey($object);
return $publicKey;
} catch (\Exception $e) {
return null;
}
}
}
/**
* Signing HTTP Messages 'keyId' field.
*
* @return string
*
* @throws KeyException
*/
public function getId()
{
return $this->id;
}
/**
* Retrieve Verifying Key - Public Key for Asymmetric/PKI, or shared secret for HMAC.
*
* @return string Shared Secret or PEM-format Public Key
*
* @throws KeyException
*/
public function getVerifyingKey()
{
switch ($this->type) {
case 'asymmetric':
if ($this->publicKey) {
return openssl_pkey_get_details($this->publicKey)['key'];
} else {
return null;
}
break;
case 'secret':
return $this->secret;
default:
throw new KeyException("Unknown key type $this->type");
}
}
/**
* Retrieve Signing Key - Private Key for Asymmetric/PKI, or shared secret for HMAC.
*
* @return string Shared Secret or PEM-format Private Key
*
* @throws KeyException
*/
public function getSigningKey()
{
switch ($this->type) {
case 'asymmetric':
if ($this->privateKey) {
openssl_pkey_export($this->privateKey, $pem);
return $pem;
} else {
return null;
}
break;
case 'secret':
return $this->secret;
default:
throw new KeyException("Unknown key type $this->type");
}
}
/**
* @return string 'secret' for HMAC or 'asymmetric'
*/
public function getType()
{
return $this->type;
}
/**
* Test if $object is, points to or contains, X.509 PEM-format certificate.
*
* @param string|array $object PEM Format X.509 Certificate or file path to one
*
* @return bool
*/
public static function hasX509Certificate($object)
{
if (is_array($object)) {
foreach ($object as $candidateCertificate) {
$result = Key::hasX509Certificate($candidateCertificate);
if ($result) {
return $result;
}
}
} else {
// OpenSSL libraries don't have detection methods, so try..catch
try {
openssl_x509_export($object, $null);
return true;
} catch (\Exception $e) {
return false;
}
}
}
/**
* Test if $object is, points to or contains, PEM-format Public Key.
*
* @param string|array $object PEM-format Public Key or file path to one
*
* @return bool
*/
public static function hasPublicKey($object)
{
if (is_array($object)) {
foreach ($object as $candidatePublicKey) {
$result = Key::hasPublicKey($candidatePublicKey);
if ($result) {
return $result;
}
}
} else {
return false == !openssl_pkey_get_public($object);
}
}
/**
* Test if $object is, points to or contains, PEM-format Private Key.
*
* @param string|array $object PEM-format Private Key or file path to one
*
* @return bool
*/
public static function hasPrivateKey($object)
{
if (is_array($object)) {
foreach ($object as $candidatePrivateKey) {
$result = Key::hasPrivateKey($candidatePrivateKey);
if ($result) {
return $result;
}
}
} else {
return false != openssl_pkey_get_private($object);
}
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Util\HttpSignatures;
class KeyException extends Exception
{
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Util\HttpSignatures;
class KeyStore implements KeyStoreInterface
{
/** @var Key[] */
private $keys;
/**
* @param array $keys
*/
public function __construct($keys)
{
$this->keys = [];
foreach ($keys as $id => $key) {
$this->keys[$id] = new Key($id, $key);
}
}
/**
* @param string $keyId
*
* @return Key
*
* @throws KeyStoreException
*/
public function fetch($keyId)
{
if (isset($this->keys[$keyId])) {
return $this->keys[$keyId];
} else {
throw new KeyStoreException("Key '$keyId' not found");
}
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Util\HttpSignatures;
class KeyStoreException extends Exception
{
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Util\HttpSignatures;
interface KeyStoreInterface
{
/**
* return the secret for the specified $keyId.
*
* @param string $keyId
*
* @return Key
*/
public function fetch($keyId);
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Util\HttpSignatures;
class RsaAlgorithm implements AlgorithmInterface
{
/** @var string */
private $digestName;
/**
* @param string $digestName
*/
public function __construct($digestName)
{
$this->digestName = $digestName;
}
/**
* @return string
*/
public function name()
{
return sprintf('rsa-%s', $this->digestName);
}
/**
* @param string $key
* @param string $data
*
* @return string
*
* @throws \HttpSignatures\AlgorithmException
*/
public function sign($signingKey, $data)
{
$algo = $this->getRsaHashAlgo($this->digestName);
if (!openssl_get_privatekey($signingKey)) {
throw new AlgorithmException("OpenSSL doesn't understand the supplied key (not valid or not found)");
}
$signature = '';
openssl_sign($data, $signature, $signingKey, $algo);
return $signature;
}
public function verify($message, $signature, $verifyingKey)
{
$algo = $this->getRsaHashAlgo($this->digestName);
return openssl_verify($message, base64_decode($signature), $verifyingKey, $algo);
}
private function getRsaHashAlgo($digestName)
{
switch ($digestName) {
case 'sha256':
return OPENSSL_ALGO_SHA256;
case 'sha1':
return OPENSSL_ALGO_SHA1;
default:
throw new HttpSignatures\AlgorithmException($digestName.' is not a supported hash format');
}
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Util\HttpSignatures;
use Psr\Http\Message\RequestInterface;
class Signature
{
/** @var Key */
private $key;
/** @var AlgorithmInterface */
private $algorithm;
/** @var SigningString */
private $signingString;
/**
* @param RequestInterface $message
* @param Key $key
* @param AlgorithmInterface $algorithm
* @param HeaderList $headerList
*/
public function __construct($message, Key $key, AlgorithmInterface $algorithm, HeaderList $headerList)
{
$this->key = $key;
$this->algorithm = $algorithm;
$this->signingString = new SigningString($headerList, $message);
}
public function string()
{
return $this->algorithm->sign(
$this->key->getSigningKey(),
$this->signingString->string()
);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Util\HttpSignatures;
class SignatureParameters
{
/**
* @param Key $key
* @param AlgorithmInterface $algorithm
* @param HeaderList $headerList
* @param Signature $signature
*/
public function __construct($key, $algorithm, $headerList, $signature)
{
$this->key = $key;
$this->algorithm = $algorithm;
$this->headerList = $headerList;
$this->signature = $signature;
}
/**
* @return string
*/
public function string()
{
return implode(',', $this->parameterComponents());
}
/**
* @return array
*/
private function parameterComponents()
{
return [
sprintf('keyId="%s"', $this->key->getId()),
sprintf('algorithm="%s"', $this->algorithm->name()),
sprintf('headers="%s"', $this->headerList->string()),
sprintf('signature="%s"', $this->signatureBase64()),
];
}
/**
* @return string
*/
private function signatureBase64()
{
return base64_encode($this->signature->string());
}
}

View file

@ -0,0 +1,111 @@
<?php
namespace App\Util\HttpSignatures;
class SignatureParametersParser
{
/** @var string */
private $input;
/**
* @param string $input
*/
public function __construct($input)
{
$this->input = $input;
}
/**
* @return array
*/
public function parse()
{
$result = $this->pairsToAssociative(
$this->arrayOfPairs()
);
$this->validate($result);
return $result;
}
/**
* @param array $pairs
*
* @return array
*/
private function pairsToAssociative($pairs)
{
$result = [];
foreach ($pairs as $pair) {
$result[$pair[0]] = $pair[1];
}
return $result;
}
/**
* @return array
*/
private function arrayOfPairs()
{
return array_map(
[$this, 'pair'],
$this->segments()
);
}
/**
* @return array
*/
private function segments()
{
return explode(',', $this->input);
}
/**
* @param $segment
*
* @return array
*
* @throws SignatureParseException
*/
private function pair($segment)
{
$segmentPattern = '/\A(keyId|algorithm|headers|signature)="(.*)"\z/';
$matches = [];
$result = preg_match($segmentPattern, $segment, $matches);
if (1 !== $result) {
throw new SignatureParseException("Signature parameters segment '$segment' invalid");
}
array_shift($matches);
return $matches;
}
/**
* @param $result
*
* @throws SignatureParseException
*/
private function validate($result)
{
$this->validateAllKeysArePresent($result);
}
/**
* @param $result
*
* @throws SignatureParseException
*/
private function validateAllKeysArePresent($result)
{
// Regexp in pair() ensures no unwanted keys exist.
// Ensure that all wanted keys exist.
$wanted = ['keyId', 'algorithm', 'headers', 'signature'];
$missing = array_diff($wanted, array_keys($result));
if (!empty($missing)) {
$csv = implode(', ', $missing);
throw new SignatureParseException("Missing keys $csv");
}
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Util\HttpSignatures;
class SignatureParseException extends Exception
{
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Util\HttpSignatures;
class SignedHeaderNotPresentException extends Exception
{
}

View file

@ -0,0 +1,104 @@
<?php
namespace App\Util\HttpSignatures;
use Psr\Http\Message\RequestInterface;
class Signer
{
/** @var Key */
private $key;
/** @var HmacAlgorithm */
private $algorithm;
/** @var HeaderList */
private $headerList;
/**
* @param Key $key
* @param HmacAlgorithm $algorithm
* @param HeaderList $headerList
*/
public function __construct($key, $algorithm, $headerList)
{
$this->key = $key;
$this->algorithm = $algorithm;
$this->headerList = $headerList;
}
/**
* @param RequestInterface $message
*
* @return RequestInterface
*/
public function sign($message)
{
$signatureParameters = $this->signatureParameters($message);
$message = $message->withAddedHeader('Signature', $signatureParameters->string());
$message = $message->withAddedHeader('Authorization', 'Signature '.$signatureParameters->string());
return $message;
}
/**
* @param RequestInterface $message
*
* @return RequestInterface
*/
public function signWithDigest($message)
{
$message = $this->addDigest($message);
return $this->sign($message);
}
/**
* @param RequestInterface $message
*
* @return RequestInterface
*/
private function addDigest($message)
{
if (!array_search('digest', $this->headerList->names)) {
$this->headerList->names[] = 'digest';
}
$message = $message->withoutHeader('Digest')
->withHeader(
'Digest',
'SHA-256='.base64_encode(hash('sha256', $message->getBody(), true))
);
return $message;
}
/**
* @param RequestInterface $message
*
* @return SignatureParameters
*/
private function signatureParameters($message)
{
return new SignatureParameters(
$this->key,
$this->algorithm,
$this->headerList,
$this->signature($message)
);
}
/**
* @param RequestInterface $message
*
* @return Signature
*/
private function signature($message)
{
return new Signature(
$message,
$this->key,
$this->algorithm,
$this->headerList
);
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace App\Util\HttpSignatures;
use Psr\Http\Message\RequestInterface;
class SigningString
{
/** @var HeaderList */
private $headerList;
/** @var RequestInterface */
private $message;
/**
* @param HeaderList $headerList
* @param RequestInterface $message
*/
public function __construct(HeaderList $headerList, $message)
{
$this->headerList = $headerList;
$this->message = $message;
}
/**
* @return string
*/
public function string()
{
return implode("\n", $this->lines());
}
/**
* @return array
*/
private function lines()
{
return array_map(
[$this, 'line'],
$this->headerList->names
);
}
/**
* @param string $name
*
* @return string
*
* @throws SignedHeaderNotPresentException
*/
private function line($name)
{
if ('(request-target)' == $name) {
return $this->requestTargetLine();
} else {
return sprintf('%s: %s', $name, $this->headerValue($name));
}
}
/**
* @param string $name
*
* @return string
*
* @throws SignedHeaderNotPresentException
*/
private function headerValue($name)
{
if ($this->message->hasHeader($name)) {
$header = $this->message->getHeader($name);
return end($header);
} else {
throw new SignedHeaderNotPresentException("Header '$name' not in message");
}
}
/**
* @return string
*/
private function requestTargetLine()
{
return sprintf(
'(request-target): %s %s',
strtolower($this->message->getMethod()),
$this->message->getRequestTarget()
);
}
}

View file

@ -0,0 +1,202 @@
<?php
namespace App\Util\HttpSignatures;
use Psr\Http\Message\RequestInterface;
class Verification
{
/** @var RequestInterface */
private $message;
/** @var KeyStoreInterface */
private $keyStore;
/** @var array */
private $_parameters;
/**
* @param RequestInterface $message
* @param KeyStoreInterface $keyStore
*/
public function __construct($message, KeyStoreInterface $keyStore)
{
$this->message = $message;
$this->keyStore = $keyStore;
}
/**
* @return bool
*/
public function isValid()
{
return $this->hasSignatureHeader() && $this->signatureMatches();
}
/**
* @return bool
*/
private function signatureMatches()
{
try {
$key = $this->key();
switch ($key->getType()) {
case 'secret':
$random = random_bytes(32);
$expectedResult = hash_hmac(
'sha256', $this->expectedSignatureBase64(),
$random,
true
);
$providedResult = hash_hmac(
'sha256', $this->providedSignatureBase64(),
$random,
true
);
return $expectedResult === $providedResult;
case 'asymmetric':
$signedString = new SigningString(
$this->headerList(),
$this->message
);
$hashAlgo = explode('-', $this->parameter('algorithm'))[1];
$algorithm = new RsaAlgorithm($hashAlgo);
$result = $algorithm->verify(
$signedString->string(),
$this->parameter('signature'),
$key->getVerifyingKey());
return $result;
default:
throw new Exception("Unknown key type '".$key->getType()."', cannot verify");
}
} catch (SignatureParseException $e) {
return false;
} catch (KeyStoreException $e) {
return false;
} catch (SignedHeaderNotPresentException $e) {
return false;
}
}
/**
* @return string
*/
private function expectedSignatureBase64()
{
return base64_encode($this->expectedSignature()->string());
}
/**
* @return Signature
*/
private function expectedSignature()
{
return new Signature(
$this->message,
$this->key(),
$this->algorithm(),
$this->headerList()
);
}
/**
* @return string
*/
private function providedSignatureBase64()
{
return $this->parameter('signature');
}
/**
* @return Key
*/
private function key()
{
return $this->keyStore->fetch($this->parameter('keyId'));
}
/**
* @return HmacAlgorithm
*/
private function algorithm()
{
return Algorithm::create($this->parameter('algorithm'));
}
/**
* @return HeaderList
*/
private function headerList()
{
return HeaderList::fromString($this->parameter('headers'));
}
/**
* @param string $name
*
* @return string
*
* @throws Exception
*/
private function parameter($name)
{
$parameters = $this->parameters();
if (!isset($parameters[$name])) {
throw new Exception("Signature parameters does not contain '$name'");
}
return $parameters[$name];
}
/**
* @return array
*/
private function parameters()
{
if (!isset($this->_parameters)) {
$parser = new SignatureParametersParser($this->signatureHeader());
$this->_parameters = $parser->parse();
}
return $this->_parameters;
}
/**
* @return bool
*/
private function hasSignatureHeader()
{
return $this->message->hasHeader('Signature') || $this->message->hasHeader('Authorization');
}
/**
* @return string
*
* @throws Exception
*/
private function signatureHeader()
{
if ($signature = $this->fetchHeader('Signature')) {
return $signature;
} elseif ($authorization = $this->fetchHeader('Authorization')) {
return substr($authorization, strlen('Signature '));
} else {
throw new Exception('HTTP message has no Signature or Authorization header');
}
}
/**
* @param $name
*
* @return string|null
*/
private function fetchHeader($name)
{
// grab the most recently set header.
$header = $this->message->getHeader($name);
return end($header);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Util\HttpSignatures;
use Psr\Http\Message\RequestInterface;
class Verifier
{
/** @var KeyStoreInterface */
private $keyStore;
/**
* @param KeyStoreInterface $keyStore
*/
public function __construct(KeyStoreInterface $keyStore)
{
$this->keyStore = $keyStore;
}
/**
* @param RequestInterface $message
*
* @return bool
*/
public function isValid($message)
{
$verification = new Verification($message, $this->keyStore);
return $verification->isValid();
}
}

View file

@ -15,7 +15,7 @@ class Image
public $orientation;
public $acceptedMimes = [
'image/png',
'image/jpeg',
'image/jpeg'
];
public function __construct()
@ -114,9 +114,11 @@ class Image
if($thumbnail) {
$img->crop($aspect['width'], $aspect['height']);
} else {
$metadata = $img->exif();
$img->resize($aspect['width'], $aspect['height'], function ($constraint) {
$constraint->aspectRatio();
});
$media->metadata = json_encode($metadata);
}
$converted = $this->setBaseName($path, $thumbnail, $img->extension);
$newPath = storage_path('app/'.$converted['path']);

View file

@ -1,8 +1,8 @@
{
"name": "laravel/laravel",
"description": "The Laravel Framework.",
"keywords": ["framework", "laravel"],
"license": "MIT",
"name": "pixelfed/pixelfed",
"description": "Open and ethical photo sharing platform, powered by ActivityPub federation.",
"keywords": ["framework", "laravel", "pixelfed", "activitypub", "social", "network", "federation"],
"license": "AGPL-3.0-only",
"type": "project",
"require": {
"php": "^7.1.3",

View file

@ -13,7 +13,7 @@ return [
|
*/
'name' => env('APP_NAME', 'Laravel'),
'name' => env('APP_NAME', 'Pixelfed'),
/*
|--------------------------------------------------------------------------
@ -52,7 +52,7 @@ return [
|
*/
'url' => env('APP_URL', 'http://localhost'),
'url' => env('APP_URL', 'https://localhost'),
/*
|--------------------------------------------------------------------------
@ -214,6 +214,7 @@ return [
'Recaptcha' => Greggilbert\Recaptcha\Facades\Recaptcha::class,
'DotenvEditor' => Jackiedo\DotenvEditor\Facades\DotenvEditor::class,
'PrettyNumber' => App\Util\Lexer\PrettyNumber::class,
'Purify' => Stevebauman\Purify\Facades\Purify::class,
],
];

View file

@ -14,7 +14,7 @@ return [
|
*/
'enabled' => env('DEBUGBAR_ENABLED', false),
'enabled' => false,
'except' => [
//
],
@ -32,7 +32,7 @@ return [
|
*/
'storage' => [
'enabled' => true,
'enabled' => false,
'driver' => 'file', // redis, file, pdo, custom
'path' => storage_path('debugbar'), // For file driver
'connection' => null, // Leave null for default connection (Redis/PDO)

View file

@ -23,7 +23,7 @@ return [
| This value is the version of your PixelFed instance.
|
*/
'version' => '0.1.9',
'version' => '0.2.0',
/*
|--------------------------------------------------------------------------

141
config/purify.php Normal file
View file

@ -0,0 +1,141 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Settings
|--------------------------------------------------------------------------
|
| The configuration settings array is passed directly to HTMLPurifier.
|
| Feel free to add / remove / customize these attributes as you wish.
|
| Documentation: http://htmlpurifier.org/live/configdoc/plain.html
|
*/
'settings' => [
/*
|--------------------------------------------------------------------------
| Core.Encoding
|--------------------------------------------------------------------------
|
| The encoding to convert input to.
|
| http://htmlpurifier.org/live/configdoc/plain.html#Core.Encoding
|
*/
'Core.Encoding' => 'utf-8',
/*
|--------------------------------------------------------------------------
| Core.SerializerPath
|--------------------------------------------------------------------------
|
| The HTML purifier serializer cache path.
|
| http://htmlpurifier.org/live/configdoc/plain.html#Cache.SerializerPath
|
*/
'Cache.SerializerPath' => storage_path('purify'),
/*
|--------------------------------------------------------------------------
| HTML.Doctype
|--------------------------------------------------------------------------
|
| Doctype to use during filtering.
|
| http://htmlpurifier.org/live/configdoc/plain.html#HTML.Doctype
|
*/
'HTML.Doctype' => 'XHTML 1.0 Strict',
/*
|--------------------------------------------------------------------------
| HTML.Allowed
|--------------------------------------------------------------------------
|
| The allowed HTML Elements with their allowed attributes.
|
| http://htmlpurifier.org/live/configdoc/plain.html#HTML.Allowed
|
*/
'HTML.Allowed' => 'a[href|title|rel],p',
/*
|--------------------------------------------------------------------------
| HTML.ForbiddenElements
|--------------------------------------------------------------------------
|
| The forbidden HTML elements. Elements that are listed in
| this string will be removed, however their content will remain.
|
| For example if 'p' is inside the string, the string: '<p>Test</p>',
|
| Will be cleaned to: 'Test'
|
| http://htmlpurifier.org/live/configdoc/plain.html#HTML.ForbiddenElements
|
*/
'HTML.ForbiddenElements' => '',
/*
|--------------------------------------------------------------------------
| CSS.AllowedProperties
|--------------------------------------------------------------------------
|
| The Allowed CSS properties.
|
| http://htmlpurifier.org/live/configdoc/plain.html#CSS.AllowedProperties
|
*/
'CSS.AllowedProperties' => '',
/*
|--------------------------------------------------------------------------
| AutoFormat.AutoParagraph
|--------------------------------------------------------------------------
|
| The Allowed CSS properties.
|
| This directive turns on auto-paragraphing, where double
| newlines are converted in to paragraphs whenever possible.
|
| http://htmlpurifier.org/live/configdoc/plain.html#AutoFormat.AutoParagraph
|
*/
'AutoFormat.AutoParagraph' => false,
/*
|--------------------------------------------------------------------------
| AutoFormat.RemoveEmpty
|--------------------------------------------------------------------------
|
| When enabled, HTML Purifier will attempt to remove empty
| elements that contribute no semantic information to the document.
|
| http://htmlpurifier.org/live/configdoc/plain.html#AutoFormat.RemoveEmpty
|
*/
'AutoFormat.RemoveEmpty' => false,
'Attr.AllowedRel' => [
'noreferrer',
'noopener',
'nofollow'
],
],
];

View file

@ -61,7 +61,7 @@ return [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'default',
'retry_after' => 90,
'retry_after' => 1800,
'block_for' => null,
],

View file

@ -15,9 +15,10 @@ use Faker\Generator as Faker;
$factory->define(App\User::class, function (Faker $faker) {
return [
'name' => $faker->name,
'email' => $faker->unique()->safeEmail,
'password' => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', // secret
'name' => $faker->name,
'username' => str_replace('.', '', $faker->unique()->userName),
'email' => str_random(8).$faker->unique()->safeEmail,
'password' => '$2y$10$TKh8H1.PfQx37YgCzwiKb.KjNyWgaHb9cbcoQgdIVFlYg7B77UdFm', // secret
'remember_token' => str_random(10),
];
});

194
package-lock.json generated
View file

@ -2,6 +2,20 @@
"requires": true,
"lockfileVersion": 1,
"dependencies": {
"@videojs/http-streaming": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-1.2.5.tgz",
"integrity": "sha512-kqjx9oc4NiiUwzqt8EI2PcuebC0WlnxsWydUoMSktLmXc/T6qVS0m8d1eyMA2tjlDILvKkjq2YPS7Jl81phbQQ==",
"requires": {
"aes-decrypter": "3.0.0",
"global": "^4.3.0",
"m3u8-parser": "4.2.0",
"mpd-parser": "0.6.1",
"mux.js": "4.5.1",
"url-toolkit": "^2.1.3",
"video.js": "^6.8.0 || ^7.0.0"
}
},
"abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@ -87,6 +101,16 @@
}
}
},
"aes-decrypter": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-3.0.0.tgz",
"integrity": "sha1-eEihwUW5/b9Xrj4rWxvHzwZEqPs=",
"requires": {
"commander": "^2.9.0",
"global": "^4.3.2",
"pkcs7": "^1.0.2"
}
},
"after": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
@ -1933,8 +1957,7 @@
"commander": {
"version": "2.17.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
"dev": true
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg=="
},
"commondir": {
"version": "1.0.1",
@ -2764,6 +2787,11 @@
"buffer-indexof": "^1.0.0"
}
},
"dom-walk": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz",
"integrity": "sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg="
},
"domain-browser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
@ -3596,6 +3624,14 @@
"debug": "^3.1.0"
}
},
"for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
"integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
"requires": {
"is-callable": "^1.1.3"
}
},
"for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@ -4424,6 +4460,22 @@
}
}
},
"global": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
"integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=",
"requires": {
"min-document": "^2.19.0",
"process": "~0.5.1"
},
"dependencies": {
"process": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
"integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8="
}
}
},
"globals": {
"version": "9.18.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz",
@ -4991,6 +5043,11 @@
"resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
"integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10="
},
"individual": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz",
"integrity": "sha1-gzsJfa0jKU52EXqY+zjg2a1hu5c="
},
"infinite-scroll": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/infinite-scroll/-/infinite-scroll-3.0.5.tgz",
@ -5161,8 +5218,7 @@
"is-callable": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz",
"integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==",
"dev": true
"integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA=="
},
"is-data-descriptor": {
"version": "0.1.4",
@ -5256,6 +5312,11 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
},
"is-function": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.1.tgz",
"integrity": "sha1-Es+5i2W1fdPRk6MSH19uL0N2ArU="
},
"is-glob": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz",
@ -5421,8 +5482,7 @@
"jquery": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz",
"integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg==",
"dev": true
"integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg=="
},
"js-base64": {
"version": "2.4.9",
@ -5836,6 +5896,11 @@
"yallist": "^2.1.2"
}
},
"m3u8-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-4.2.0.tgz",
"integrity": "sha512-LVHw0U6IPJjwk9i9f7Xe26NqaUHTNlIt4SSWoEfYFROeVKHN6MIjOhbRheI3dg8Jbq5WCuMFQ0QU3EgZpmzFPg=="
},
"make-dir": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
@ -6019,6 +6084,14 @@
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
"integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="
},
"min-document": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
"integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=",
"requires": {
"dom-walk": "^0.1.0"
}
},
"minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@ -6126,6 +6199,15 @@
"run-queue": "^1.0.3"
}
},
"mpd-parser": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.6.1.tgz",
"integrity": "sha512-3ucsY5NJMABltTLtYMSDfqZpvKV4yF8YvMx91hZFrHiblseuoKq4XUQ5IkcdtFAIRBAkPhXMU3/eunTFNCNsHw==",
"requires": {
"global": "^4.3.0",
"url-toolkit": "^2.1.1"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -6152,6 +6234,11 @@
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
"integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s="
},
"mux.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-4.5.1.tgz",
"integrity": "sha512-j4rEyZKCRinGaSiBxPx9YD9B782TMPHPOlKyaMY07vIGTNYg4ouCEBvL6zX9Hh1k1fKZ5ZF3S7c+XVk6PB+Igw=="
},
"nan": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.11.0.tgz",
@ -6786,6 +6873,15 @@
}
}
},
"parse-headers": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.1.tgz",
"integrity": "sha1-aug6eqJanZtwCswoaYzR8e1+lTY=",
"requires": {
"for-each": "^0.3.2",
"trim": "0.0.1"
}
},
"parse-json": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz",
@ -6928,6 +7024,11 @@
"pinkie": "^2.0.0"
}
},
"pkcs7": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.2.tgz",
"integrity": "sha1-ttulJ1KMKUK/wSLOLa/NteWQdOc="
},
"pkg-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz",
@ -9329,6 +9430,14 @@
"readable-stream": "^2.0.2"
}
},
"readmore-js": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/readmore-js/-/readmore-js-2.2.1.tgz",
"integrity": "sha512-hbPP0nQpYYkAywCEZ8ozHivvhWyHic37KJ2IXrHES4qzjp0+nmw8R33MeyMAtXBZfXX4Es8cpd5JBVf9qj47+Q==",
"requires": {
"jquery": ">2.1.4"
}
},
"recast": {
"version": "0.11.23",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.11.23.tgz",
@ -9712,6 +9821,14 @@
"aproba": "^1.1.1"
}
},
"rust-result": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz",
"integrity": "sha1-NMdbLm3Dn+WHXlveyFteD5FTb3I=",
"requires": {
"individual": "^2.0.0"
}
},
"rx": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/rx/-/rx-4.1.0.tgz",
@ -9722,6 +9839,14 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"safe-json-parse": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz",
"integrity": "sha1-fA9XjPzNEtM6ccDgVBPi7KFx6qw=",
"requires": {
"rust-result": "^1.0.0"
}
},
"safe-regex": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
@ -10755,6 +10880,11 @@
"punycode": "^1.4.1"
}
},
"trim": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz",
"integrity": "sha1-WFhUf2spB1fulczMZm+1AITEYN0="
},
"trim-newlines": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
@ -10776,6 +10906,11 @@
"glob": "^7.1.2"
}
},
"tsml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tsml/-/tsml-1.0.1.tgz",
"integrity": "sha1-ifghi52eJX9H1/a1bQHFpNLGj8M="
},
"tty-browserify": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
@ -11135,6 +11270,11 @@
"requires-port": "^1.0.0"
}
},
"url-toolkit": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.1.6.tgz",
"integrity": "sha512-UaZ2+50am4HwrV2crR/JAf63Q4VvPYphe63WGeoJxeu8gmOm0qxPt+KsukfakPNrX9aymGNEkkaoICwn+OuvBw=="
},
"use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
@ -11209,6 +11349,34 @@
"extsprintf": "^1.2.0"
}
},
"video.js": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/video.js/-/video.js-7.2.3.tgz",
"integrity": "sha512-oiRGXew1yKk3ILh9+8cvnV0PQp8oqs/2XtkoO46j7BMsFvhgl9L+dy+hS//MUSh1JNgDGUkM/K+E6WTTLlwN7w==",
"requires": {
"@videojs/http-streaming": "1.2.5",
"babel-runtime": "^6.9.2",
"global": "4.3.2",
"safe-json-parse": "4.0.0",
"tsml": "1.0.1",
"videojs-font": "3.0.0",
"videojs-vtt.js": "0.14.1",
"xhr": "2.4.0"
}
},
"videojs-font": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.0.0.tgz",
"integrity": "sha512-XS6agz2T7p2cFuuXulJD70md8XMlAN617SJkMWjoTPqZWv+RU8NcZCKsE3Tk73inzxnQdihOp0cvI7NGz2ngHg=="
},
"videojs-vtt.js": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.14.1.tgz",
"integrity": "sha512-YxOiywx6N9t3J5nqsE5WN2Sw4CSqVe3zV+AZm2T4syOc2buNJaD6ZoexSdeszx2sHLU/RRo2r4BJAXFDQ7Qo2Q==",
"requires": {
"global": "^4.3.1"
}
},
"vm-browserify": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz",
@ -11694,6 +11862,17 @@
"ultron": "~1.1.0"
}
},
"xhr": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/xhr/-/xhr-2.4.0.tgz",
"integrity": "sha1-4W5mpF+GmGHu76tBbV7/ci3ECZM=",
"requires": {
"global": "~4.3.0",
"is-function": "^1.0.1",
"parse-headers": "^2.0.0",
"xtend": "^4.0.0"
}
},
"xmlhttprequest": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz",
@ -11707,8 +11886,7 @@
"xtend": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
"integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=",
"dev": true
"integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68="
},
"y18n": {
"version": "3.2.1",

View file

@ -25,8 +25,10 @@
"infinite-scroll": "^3.0.4",
"laravel-echo": "^1.4.0",
"pusher-js": "^4.2.2",
"readmore-js": "^2.2.1",
"socket.io-client": "^2.1.1",
"sweetalert": "^2.1.0",
"twitter-text": "^2.0.5"
"twitter-text": "^2.0.5",
"video.js": "^7.2.3"
}
}

BIN
public/css/app.css vendored

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/js/app.js vendored

Binary file not shown.

BIN
public/js/components.js vendored Normal file

Binary file not shown.

BIN
public/js/timeline.js vendored

Binary file not shown.

Binary file not shown.

BIN
public/static/beep.mp3 Normal file

Binary file not shown.

View file

@ -1,113 +1,17 @@
window._ = require('lodash');
window.Popper = require('popper.js').default;
import swal from 'sweetalert';
window.pixelfed = {};
window.pixelfed = window.pixelfed || {};
window.$ = window.jQuery = require('jquery');
require('bootstrap');
window.Vue = require('vue');
import BootstrapVue from 'bootstrap-vue'
Vue.use(BootstrapVue);
try {
window.InfiniteScroll = require('infinite-scroll');
window.filesize = require('filesize');
window.typeahead = require('./lib/typeahead');
window.Bloodhound = require('./lib/bloodhound');
require('./components/localstorage');
require('./components/likebutton');
require('./components/commentform');
require('./components/searchform');
require('./components/bookmarkform');
require('./components/statusform');
// require('./components/embed');
// require('./components/shortcuts');
Vue.component(
'follow-suggestions',
require('./components/FollowSuggestions.vue')
);
// Vue.component(
// 'circle-panel',
// require('./components/CirclePanel.vue')
// );
// Vue.component(
// 'post-presenter',
// require('./components/PostPresenter.vue')
// );
// Vue.component(
// 'post-comments',
// require('./components/PostComments.vue')
// );
Vue.component(
'passport-clients',
require('./components/passport/Clients.vue')
);
Vue.component(
'passport-authorized-clients',
require('./components/passport/AuthorizedClients.vue')
);
Vue.component(
'passport-personal-access-tokens',
require('./components/passport/PersonalAccessTokens.vue')
);
} catch (e) {}
$(document).ready(function() {
$(function () {
$('[data-toggle="tooltip"]').tooltip()
});
});
window.typeahead = require('./lib/typeahead');
window.Bloodhound = require('./lib/bloodhound');
window.axios = require('axios');
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
require('readmore-js');
let token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
} else {
console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token');
}
// import Echo from "laravel-echo"
// window.io = require('socket.io-client');
// window.pixelfed.bootEcho = function() {
// window.Echo = new Echo({
// broadcaster: 'socket.io',
// host: window.location.hostname + ':2096',
// auth: {
// headers: {
// Authorization: 'Bearer ' + token.content,
// },
// },
// });
// }
window.pixelfed.copyToClipboard = (str) => {
const el = document.createElement('textarea');
el.value = str;
el.setAttribute('readonly', '');
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
const selected =
document.getSelection().rangeCount > 0
? document.getSelection().getRangeAt(0)
: false;
el.select();
document.execCommand('copy');
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
}
};

117
resources/assets/js/components.js vendored Normal file
View file

@ -0,0 +1,117 @@
window.Vue = require('vue');
import BootstrapVue from 'bootstrap-vue'
Vue.use(BootstrapVue);
pixelfed.readmore = () => {
$(document).find('.read-more').each(function(k,v) {
let el = $(this);
let attr = el.attr('data-readmore');
if(typeof attr !== typeof undefined && attr !== false) {
return;
}
el.readmore({
collapsedHeight: 44,
heightMargin: 20,
moreLink: '<a href="#" class="font-weight-bold small">Read more</a>',
lessLink: '<a href="#" class="font-weight-bold small">Hide</a>',
});
});
};
window.InfiniteScroll = require('infinite-scroll');
window.filesize = require('filesize');
import swal from 'sweetalert';
require('./components/localstorage');
require('./components/likebutton');
require('./components/commentform');
require('./components/searchform');
require('./components/bookmarkform');
require('./components/statusform');
require('./components/embed');
require('./components/notifications');
// import Echo from "laravel-echo"
// window.io = require('socket.io-client');
// window.pixelfed.bootEcho = function() {
// window.Echo = new Echo({
// broadcaster: 'socket.io',
// host: window.location.hostname + ':2096',
// auth: {
// headers: {
// Authorization: 'Bearer ' + token.content,
// },
// },
// });
// }
// Initalize Notification Helper
window.pixelfed.n = {};
Vue.component(
'follow-suggestions',
require('./components/FollowSuggestions.vue')
);
Vue.component(
'discover-component',
require('./components/DiscoverComponent.vue')
);
// Vue.component(
// 'circle-panel',
// require('./components/CirclePanel.vue')
// );
Vue.component(
'post-component',
require('./components/PostComponent.vue')
);
Vue.component(
'post-comments',
require('./components/PostComments.vue')
);
Vue.component(
'passport-clients',
require('./components/passport/Clients.vue')
);
Vue.component(
'passport-authorized-clients',
require('./components/passport/AuthorizedClients.vue')
);
Vue.component(
'passport-personal-access-tokens',
require('./components/passport/PersonalAccessTokens.vue')
);
window.pixelfed.copyToClipboard = (str) => {
const el = document.createElement('textarea');
el.value = str;
el.setAttribute('readonly', '');
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
const selected =
document.getSelection().rangeCount > 0
? document.getSelection().getRangeAt(0)
: false;
el.select();
document.execCommand('copy');
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
}
};
$(document).ready(function() {
$(function () {
$('[data-toggle="tooltip"]').tooltip()
});
});

View file

@ -0,0 +1,33 @@
<style scoped>
.b-dropdown {
padding:0 !important;
}
</style>
<template>
<div class="card mb-4">
<div class="card-header py-1 bg-white d-flex align-items-center justify-content-between">
<span class="font-weight-bold h5 mb-0">Circles</span>
<b-dropdown variant="link" no-caret right>
<template slot="button-content">
<i class="fas fa-ellipsis-v text-muted"></i><span class="sr-only">Options</span>
</template>
<b-dropdown-item class="font-weight-bold">Create a circle</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item class="font-weight-bold">Settings</b-dropdown-item>
</b-dropdown>
</div>
<div class="card-body">
<div class="text-center p-3">
<p class="mb-0"><a class="btn btn-sm btn-outline-primary" style="border-radius: 20px;" href="/i/circle/create">Create new circle</a></p>
</div>
</div>
</div>
</template>
<script>
export default {
}
</script>

View file

@ -0,0 +1,95 @@
<template>
<div class="container">
<section class="mb-5 section-people">
<p class="lead text-muted font-weight-bold mb-0">Discover People</p>
<div class="loader text-center">
<div class="lds-ring"><div></div><div></div><div></div><div></div></div>
</div>
<div class="row d-none">
<div class="col-4 p-0 p-sm-2 p-md-3" v-for="profile in people">
<div class="card card-md-border-0">
<div class="card-body p-4 text-center">
<div class="avatar pb-3">
<a :href="profile.url">
<img :src="profile.avatar" class="img-thumbnail rounded-circle" width="64px">
</a>
</div>
<p class="lead font-weight-bold mb-0 text-truncate"><a :href="profile.url" class="text-dark">{{profile.username}}</a></p>
<p class="text-muted text-truncate">{{profile.name}}</p>
<form class="follow-form" method="post" action="/i/follow" data-id="#" data-action="follow">
<input type="hidden" name="item" value="#">
<button class="btn btn-primary font-weight-bold px-4 py-0" type="submit">Follow</button>
</form>
</div>
</div>
</div>
</div>
</section>
<section class="mb-5 section-explore">
<p class="lead text-muted font-weight-bold mb-0">Explore</p>
<div class="profile-timeline">
<div class="loader text-center">
<div class="lds-ring"><div></div><div></div><div></div><div></div></div>
</div>
<div class="row d-none">
<div class="col-4 p-0 p-sm-2 p-md-3" v-for="post in posts">
<a class="card info-overlay card-md-border-0" :href="post.url">
<div class="square filter_class">
<div class="square-content" v-bind:style="{ 'background-image': 'url(' + post.thumb + ')' }"></div>
<div class="info-overlay-text">
<h5 class="text-white m-auto font-weight-bold">
<span class="pr-4">
<span class="far fa-heart fa-lg pr-1"></span> {{post.likes_count}}
</span>
<span>
<span class="far fa-comment fa-lg pr-1"></span> {{post.comments_count}}
</span>
</h5>
</div>
</div>
</a>
</div>
</div>
</div>
</section>
<section class="mb-5">
<p class="lead text-center">To view more posts, check the <a href="#" class="font-weight-bold">home</a>, <a href="#" class="font-weight-bold">local</a> or <a href="#" class="font-weight-bold">federated</a> timelines.</p>
</section>
</div>
</template>
<script type="text/javascript">
export default {
data() {
return {
people: {},
posts: {},
trending: {}
}
},
mounted() {
this.fetchData();
},
methods: {
fetchData() {
axios.get('/api/v2/discover')
.then((res) => {
let data = res.data;
this.people = data.people;
this.posts = data.posts;
if(this.people.length > 1) {
$('.section-people .lds-ring').hide();
$('.section-people .row.d-none').removeClass('d-none');
}
if(this.posts.length > 1) {
$('.section-explore .lds-ring').hide();
$('.section-explore .row.d-none').removeClass('d-none');
}
});
}
}
}
</script>

View file

@ -1,82 +1,44 @@
<style type="text/css">
.b-dropdown > button {
padding:0 !important;
}
</style>
<style scoped>
<style>
span {
font-size: 14px;
}
.comment-text {
word-break: break-all;
}
.b-dropdown {
padding:0 !important;
.comment-text p {
display: inline;
}
.b-dropdown < button {
}
.lds-ring {
display: inline-block;
position: relative;
width: 64px;
height: 64px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 51px;
height: 51px;
margin: 6px;
border: 6px solid #6c757d;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #6c757d transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<template>
<div>
<div class="lwrapper text-center">
<div class="postCommentsLoader text-center">
<div class="lds-ring"><div></div><div></div><div></div><div></div></div>
</div>
<div class="cwrapper d-none">
<div class="postCommentsContainer d-none">
<p class="mb-1 text-center load-more-link"><a href="#" class="text-muted" v-on:click="loadMore">Load more comments</a></p>
<div class="comments" data-min-id="0" data-max-id="0">
<p class="mb-0 d-flex justify-content-between align-items-center" v-for="(comment, index) in results" :data-id="comment.id" v-bind:key="comment.id">
<span class="pr-3">
<span class="font-weight-bold pr-1"><bdi><a class="text-dark" :href="comment.account.url">{{comment.account.username}}</a></bdi></span>
<span class="comment-text" v-html="comment.content"></span>
<p class="mb-1" v-for="(comment, index) in results" :data-id="comment.id" v-bind:key="comment.id">
<span class="d-flex justify-content-between align-items-center">
<span class="pr-3" style="overflow: hidden;">
<div class="font-weight-bold pr-1"><bdi><a class="text-dark" :href="comment.account.url" :title="comment.account.username">{{l(comment.account.username)}}</a></bdi>
</div>
<div class="read-more" style="overflow: hidden;">
<span class="comment-text" v-html="comment.content" style="overflow: hidden;"></span>
</div>
</span>
<b-dropdown :id="comment.uri" variant="link" no-caret class="float-right">
<template slot="button-content">
<i class="fas fa-ellipsis-v text-muted"></i><span class="sr-only">Options</span>
</template>
<b-dropdown-item class="font-weight-bold" v-on:click="reply(comment)">Reply</b-dropdown-item>
<b-dropdown-item class="font-weight-bold" :href="comment.url">Permalink</b-dropdown-item>
<b-dropdown-item class="font-weight-bold" v-on:click="embed(comment)">Embed</b-dropdown-item>
<b-dropdown-item class="font-weight-bold" :href="comment.account.url">Profile</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item class="font-weight-bold" :href="'/i/report?type=post&id='+comment.id">Report</b-dropdown-item>
</b-dropdown>
</span>
<b-dropdown :id="comment.uri" variant="link" no-caret class="float-right">
<template slot="button-content">
<i class="fas fa-ellipsis-v text-muted"></i><span class="sr-only">Options</span>
</template>
<b-dropdown-item class="font-weight-bold" v-on:click="reply(comment)">Reply</b-dropdown-item>
<b-dropdown-item class="font-weight-bold" :href="comment.url">Permalink</b-dropdown-item>
<b-dropdown-item class="font-weight-bold" v-on:click="embed(comment)">Embed</b-dropdown-item>
<b-dropdown-item class="font-weight-bold" :href="comment.account.url">Profile</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item class="font-weight-bold" :href="'/i/report?type=post&id='+comment.id">Report</b-dropdown-item>
</b-dropdown>
</p>
</div>
</div>
@ -98,10 +60,18 @@ export default {
mounted() {
this.fetchData();
},
updated() {
pixelfed.readmore();
},
methods: {
embed(e) {
pixelfed.embed.build(e);
},
l(e) {
let len = e.length;
if(len < 10) { return e; }
return e.substr(0, 10)+'...';
},
reply(e) {
this.reply_to_profile_id = e.account.id;
$('.comment-form input[name=comment]').val('@'+e.account.username+' ');
@ -114,11 +84,33 @@ export default {
let self = this;
this.results = response.data.data;
this.pagination = response.data.meta.pagination;
$('.lwrapper').addClass('d-none');
$('.cwrapper').removeClass('d-none');
$('.postCommentsLoader').addClass('d-none');
$('.postCommentsContainer').removeClass('d-none');
}).catch(error => {
$('.lds-ring').attr('style','width:100%').addClass('pt-4 font-weight-bold text-muted').text('An error occured, cannot fetch comments. Please try again later.');
if(!error.response) {
$('.postCommentsLoader .lds-ring')
.attr('style','width:100%')
.addClass('pt-4 font-weight-bold text-muted')
.text('An error occured, cannot fetch comments. Please try again later.');
console.log(error);
} else {
switch(error.response.status) {
case 401:
$('.postCommentsLoader .lds-ring')
.attr('style','width:100%')
.addClass('pt-4 font-weight-bold text-muted')
.text('Please login to view.');
break;
default:
$('.postCommentsLoader .lds-ring')
.attr('style','width:100%')
.addClass('pt-4 font-weight-bold text-muted')
.text('An error occured, cannot fetch comments. Please try again later.');
break;
}
console.log(error.response.status);
}
});
},
loadMore(e) {
@ -127,18 +119,16 @@ export default {
$('.load-more-link').addClass('d-none');
return;
}
$('.cwrapper').addClass('d-none');
$('.lwrapper').removeClass('d-none');
$('.postCommentsLoader').removeClass('d-none');
let next = this.pagination.links.next;
axios.get(next)
.then(response => {
let self = this;
let res = response.data.data;
$('.lwrapper').addClass('d-none');
$('.postCommentsLoader').addClass('d-none');
for(let i=0; i < res.length; i++) {
this.results.unshift(res[i]);
}
$('.cwrapper').removeClass('d-none');
this.pagination = response.data.meta.pagination;
});
}

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