mirror of
https://github.com/pixelfed/pixelfed.git
synced 2024-11-10 16:44:50 +00:00
commit
01931396c4
21 changed files with 1241 additions and 799 deletions
|
@ -8,66 +8,13 @@ use Carbon\Carbon;
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use App\Services\InstanceService;
|
||||
use App\Http\Resources\AdminInstance;
|
||||
|
||||
trait AdminInstanceController
|
||||
{
|
||||
|
||||
public function instances(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
|
||||
'filter' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:20',
|
||||
Rule::in([
|
||||
'cw',
|
||||
'unlisted',
|
||||
'banned',
|
||||
// 'popular',
|
||||
'new',
|
||||
'all'
|
||||
])
|
||||
],
|
||||
]);
|
||||
if($request->has('q') && $request->filled('q')) {
|
||||
$instances = Instance::where('domain', 'like', '%' . $request->input('q') . '%')->simplePaginate(10);
|
||||
} else if($request->has('filter') && $request->filled('filter')) {
|
||||
switch ($request->filter) {
|
||||
case 'cw':
|
||||
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereAutoCw(true)->orderByDesc('id')->simplePaginate(10);
|
||||
break;
|
||||
case 'unlisted':
|
||||
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereUnlisted(true)->orderByDesc('id')->simplePaginate(10);
|
||||
break;
|
||||
case 'banned':
|
||||
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->whereBanned(true)->orderByDesc('id')->simplePaginate(10);
|
||||
break;
|
||||
case 'new':
|
||||
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->latest()->simplePaginate(10);
|
||||
break;
|
||||
// case 'popular':
|
||||
// $popular = Profile::selectRaw('*, count(domain) as count')
|
||||
// ->whereNotNull('domain')
|
||||
// ->groupBy('domain')
|
||||
// ->orderByDesc('count')
|
||||
// ->take(10)
|
||||
// ->get()
|
||||
// ->pluck('domain')
|
||||
// ->toArray();
|
||||
// $instances = Instance::whereIn('domain', $popular)->simplePaginate(10);
|
||||
// break;
|
||||
|
||||
default:
|
||||
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->orderByDesc('id')->simplePaginate(10);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$instances = Instance::select('id', 'domain', 'unlisted', 'auto_cw', 'banned')->orderByDesc('id')->simplePaginate(10);
|
||||
}
|
||||
|
||||
return view('admin.instances.home', compact('instances'));
|
||||
return view('admin.instances.home');
|
||||
}
|
||||
|
||||
public function instanceScan(Request $request)
|
||||
|
@ -133,4 +80,146 @@ trait AdminInstanceController
|
|||
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
public function getInstancesStatsApi(Request $request)
|
||||
{
|
||||
return InstanceService::stats();
|
||||
}
|
||||
|
||||
public function getInstancesQueryApi(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'q' => 'required'
|
||||
]);
|
||||
|
||||
$q = $request->input('q');
|
||||
|
||||
return AdminInstance::collection(
|
||||
Instance::where('domain', 'like', '%' . $q . '%')
|
||||
->orderByDesc('user_count')
|
||||
->cursorPaginate(20)
|
||||
->withQueryString()
|
||||
);
|
||||
}
|
||||
|
||||
public function getInstancesApi(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'filter' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'min:1',
|
||||
'max:20',
|
||||
Rule::in([
|
||||
'cw',
|
||||
'unlisted',
|
||||
'banned',
|
||||
'popular_users',
|
||||
'popular_statuses',
|
||||
'new',
|
||||
'all'
|
||||
])
|
||||
],
|
||||
]);
|
||||
$filter = $request->input('filter');
|
||||
$query = $request->input('q');
|
||||
|
||||
return AdminInstance::collection(Instance::when($query, function($q, $qq) use($query) {
|
||||
return $q->where('domain', 'like', '%' . $query . '%');
|
||||
})
|
||||
->when($filter, function($q, $f) use($filter) {
|
||||
if($filter == 'cw') { return $q->whereAutoCw(true)->orderByDesc('id'); }
|
||||
if($filter == 'unlisted') { return $q->whereUnlisted(true)->orderByDesc('id'); }
|
||||
if($filter == 'banned') { return $q->whereBanned(true)->orderByDesc('id'); }
|
||||
if($filter == 'new') { return $q->orderByDesc('id'); }
|
||||
if($filter == 'popular_users') { return $q->orderByDesc('user_count'); }
|
||||
if($filter == 'popular_statuses') { return $q->orderByDesc('status_count'); }
|
||||
return $q->orderByDesc('id');
|
||||
}, function($q) {
|
||||
return $q->orderByDesc('id');
|
||||
})
|
||||
->cursorPaginate(10)
|
||||
->withQueryString());
|
||||
}
|
||||
|
||||
public function postInstanceUpdateApi(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'id' => 'required',
|
||||
'banned' => 'boolean',
|
||||
'auto_cw' => 'boolean',
|
||||
'unlisted' => 'boolean',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$id = $request->input('id');
|
||||
$instance = Instance::findOrFail($id);
|
||||
$instance->update($request->only([
|
||||
'banned',
|
||||
'auto_cw',
|
||||
'unlisted',
|
||||
'notes'
|
||||
]));
|
||||
|
||||
InstanceService::refresh();
|
||||
|
||||
return new AdminInstance($instance);
|
||||
}
|
||||
|
||||
public function postInstanceCreateNewApi(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'domain' => 'required|string',
|
||||
'banned' => 'boolean',
|
||||
'auto_cw' => 'boolean',
|
||||
'unlisted' => 'boolean',
|
||||
'notes' => 'nullable|string|max:500'
|
||||
]);
|
||||
|
||||
$domain = $request->input('domain');
|
||||
|
||||
abort_if(!strpos($domain, '.'), 400, 'Invalid domain');
|
||||
abort_if(!filter_var($domain, FILTER_VALIDATE_DOMAIN), 400, 'Invalid domain');
|
||||
|
||||
$instance = new Instance;
|
||||
$instance->domain = $request->input('domain');
|
||||
$instance->banned = $request->input('banned');
|
||||
$instance->auto_cw = $request->input('auto_cw');
|
||||
$instance->unlisted = $request->input('unlisted');
|
||||
$instance->manually_added = true;
|
||||
$instance->notes = $request->input('notes');
|
||||
$instance->save();
|
||||
|
||||
InstanceService::refresh();
|
||||
|
||||
return new AdminInstance($instance);
|
||||
}
|
||||
|
||||
public function postInstanceRefreshStatsApi(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'id' => 'required'
|
||||
]);
|
||||
|
||||
$instance = Instance::findOrFail($request->input('id'));
|
||||
$instance->user_count = Profile::whereDomain($instance->domain)->count();
|
||||
$instance->status_count = Profile::whereDomain($instance->domain)->leftJoin('statuses', 'profiles.id', '=', 'statuses.profile_id')->count();
|
||||
$instance->save();
|
||||
|
||||
return new AdminInstance($instance);
|
||||
}
|
||||
|
||||
public function postInstanceDeleteApi(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'id' => 'required'
|
||||
]);
|
||||
|
||||
$instance = Instance::findOrFail($request->input('id'));
|
||||
$instance->delete();
|
||||
|
||||
InstanceService::refresh();
|
||||
|
||||
return 200;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -96,89 +96,6 @@ class BaseApiController extends Controller
|
|||
return response()->json($res);
|
||||
}
|
||||
|
||||
public function accounts(Request $request, $id)
|
||||
{
|
||||
abort_if(!$request->user(), 403);
|
||||
$profile = Profile::findOrFail($id);
|
||||
$resource = new Fractal\Resource\Item($profile, new AccountTransformer());
|
||||
$res = $this->fractal->createData($resource)->toArray();
|
||||
|
||||
return response()->json($res);
|
||||
}
|
||||
|
||||
public function accountFollowers(Request $request, $id)
|
||||
{
|
||||
abort_if(!$request->user(), 403);
|
||||
$profile = Profile::findOrFail($id);
|
||||
$followers = $profile->followers;
|
||||
$resource = new Fractal\Resource\Collection($followers, new AccountTransformer());
|
||||
$res = $this->fractal->createData($resource)->toArray();
|
||||
|
||||
return response()->json($res);
|
||||
}
|
||||
|
||||
public function accountFollowing(Request $request, $id)
|
||||
{
|
||||
abort_if(!$request->user(), 403);
|
||||
$profile = Profile::findOrFail($id);
|
||||
$following = $profile->following;
|
||||
$resource = new Fractal\Resource\Collection($following, new AccountTransformer());
|
||||
$res = $this->fractal->createData($resource)->toArray();
|
||||
|
||||
return response()->json($res);
|
||||
}
|
||||
|
||||
public function accountStatuses(Request $request, $id)
|
||||
{
|
||||
abort_if(!$request->user(), 403);
|
||||
$this->validate($request, [
|
||||
'only_media' => 'nullable',
|
||||
'pinned' => 'nullable',
|
||||
'exclude_replies' => 'nullable',
|
||||
'max_id' => 'nullable|integer|min:1',
|
||||
'since_id' => 'nullable|integer|min:1',
|
||||
'min_id' => 'nullable|integer|min:1',
|
||||
'limit' => 'nullable|integer|min:1|max:24'
|
||||
]);
|
||||
$limit = $request->limit ?? 20;
|
||||
$max_id = $request->max_id ?? false;
|
||||
$min_id = $request->min_id ?? false;
|
||||
$since_id = $request->since_id ?? false;
|
||||
$only_media = $request->only_media ?? false;
|
||||
$user = Auth::user();
|
||||
$account = Profile::whereNull('status')->findOrFail($id);
|
||||
$statuses = $account->statuses()->getQuery();
|
||||
if($only_media == true) {
|
||||
$statuses = $statuses
|
||||
->whereIn('scope', ['public','unlisted'])
|
||||
->whereHas('media')
|
||||
->whereNull('in_reply_to_id')
|
||||
->whereNull('reblog_of_id');
|
||||
}
|
||||
if($id == $account->id && !$max_id && !$min_id && !$since_id) {
|
||||
$statuses = $statuses->orderBy('id', 'desc')
|
||||
->paginate($limit);
|
||||
} else if($since_id) {
|
||||
$statuses = $statuses->where('id', '>', $since_id)
|
||||
->orderBy('id', 'DESC')
|
||||
->paginate($limit);
|
||||
} else if($min_id) {
|
||||
$statuses = $statuses->where('id', '>', $min_id)
|
||||
->orderBy('id', 'ASC')
|
||||
->paginate($limit);
|
||||
} else if($max_id) {
|
||||
$statuses = $statuses->where('id', '<', $max_id)
|
||||
->orderBy('id', 'DESC')
|
||||
->paginate($limit);
|
||||
} else {
|
||||
$statuses = $statuses->whereScope('public')->orderBy('id', 'desc')->paginate($limit);
|
||||
}
|
||||
$resource = new Fractal\Resource\Collection($statuses, new StatusTransformer());
|
||||
$res = $this->fractal->createData($resource)->toArray();
|
||||
|
||||
return response()->json($res);
|
||||
}
|
||||
|
||||
public function avatarUpdate(Request $request)
|
||||
{
|
||||
abort_if(!$request->user(), 403);
|
||||
|
@ -215,21 +132,6 @@ class BaseApiController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
public function showTempMedia(Request $request, $profileId, $mediaId, $timestamp)
|
||||
{
|
||||
abort(400, 'Endpoint deprecated');
|
||||
}
|
||||
|
||||
public function uploadMedia(Request $request)
|
||||
{
|
||||
abort(400, 'Endpoint deprecated');
|
||||
}
|
||||
|
||||
public function deleteMedia(Request $request)
|
||||
{
|
||||
abort(400, 'Endpoint deprecated');
|
||||
}
|
||||
|
||||
public function verifyCredentials(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
@ -242,21 +144,6 @@ class BaseApiController extends Controller
|
|||
return response()->json($res);
|
||||
}
|
||||
|
||||
public function drafts(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_if(!$request->user(), 403);
|
||||
|
||||
$medias = Media::whereUserId($user->id)
|
||||
->whereNull('status_id')
|
||||
->latest()
|
||||
->take(13)
|
||||
->get();
|
||||
$resource = new Fractal\Resource\Collection($medias, new MediaDraftTransformer());
|
||||
$res = $this->fractal->createData($resource)->toArray();
|
||||
return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
public function accountLikes(Request $request)
|
||||
{
|
||||
abort_if(!$request->user(), 403);
|
||||
|
|
|
@ -14,16 +14,9 @@ use Auth, Cache;
|
|||
use Illuminate\Support\Facades\Redis;
|
||||
use App\Util\Site\Config;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\SuggestionService;
|
||||
|
||||
class ApiController extends BaseApiController
|
||||
{
|
||||
// todo: deprecate and remove
|
||||
public function hydrateLikes(Request $request)
|
||||
{
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
public function siteConfiguration(Request $request)
|
||||
{
|
||||
return response()->json(Config::get());
|
||||
|
@ -31,79 +24,6 @@ class ApiController extends BaseApiController
|
|||
|
||||
public function userRecommendations(Request $request)
|
||||
{
|
||||
abort_if(!Auth::check(), 403);
|
||||
abort_if(!config('exp.rec'), 400);
|
||||
|
||||
$id = Auth::user()->profile->id;
|
||||
|
||||
$following = Cache::remember('profile:following:'.$id, now()->addHours(12), function() use ($id) {
|
||||
return Follower::whereProfileId($id)->pluck('following_id')->toArray();
|
||||
});
|
||||
array_push($following, $id);
|
||||
$ids = SuggestionService::get();
|
||||
$filters = UserFilter::whereUserId($id)
|
||||
->whereFilterableType('App\Profile')
|
||||
->whereIn('filter_type', ['mute', 'block'])
|
||||
->pluck('filterable_id')->toArray();
|
||||
$following = array_merge($following, $filters);
|
||||
|
||||
$key = config('cache.prefix').':api:local:exp:rec:'.$id;
|
||||
$ttl = (int) Redis::ttl($key);
|
||||
|
||||
if($request->filled('refresh') == true && (290 > $ttl) == true) {
|
||||
Cache::forget('api:local:exp:rec:'.$id);
|
||||
}
|
||||
|
||||
$res = Cache::remember('api:local:exp:rec:'.$id, now()->addMinutes(5), function() use($id, $following, $ids) {
|
||||
return Profile::select(
|
||||
'id',
|
||||
'username'
|
||||
)
|
||||
->whereNotIn('id', $following)
|
||||
->whereIn('id', $ids)
|
||||
->whereIsPrivate(0)
|
||||
->whereNull('status')
|
||||
->whereNull('domain')
|
||||
->inRandomOrder()
|
||||
->take(3)
|
||||
->get()
|
||||
->map(function($item, $key) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'avatar' => $item->avatarUrl(),
|
||||
'username' => $item->username,
|
||||
'message' => 'Recommended for You'
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
return response()->json($res->all());
|
||||
return response()->json([]);
|
||||
}
|
||||
|
||||
public function composeLocationSearch(Request $request)
|
||||
{
|
||||
abort_if(!Auth::check(), 403);
|
||||
$this->validate($request, [
|
||||
'q' => 'required|string|max:100'
|
||||
]);
|
||||
$q = filter_var($request->input('q'), FILTER_SANITIZE_STRING);
|
||||
$hash = hash('sha256', $q);
|
||||
$key = 'search:location:id:' . $hash;
|
||||
$places = Cache::remember($key, now()->addMinutes(15), function() use($q) {
|
||||
$q = '%' . $q . '%';
|
||||
return Place::where('name', 'like', $q)
|
||||
->take(80)
|
||||
->get()
|
||||
->map(function($r) {
|
||||
return [
|
||||
'id' => $r->id,
|
||||
'name' => $r->name,
|
||||
'country' => $r->country,
|
||||
'url' => $r->url()
|
||||
];
|
||||
});
|
||||
});
|
||||
return $places;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -207,7 +207,7 @@ class ProfileController extends Controller
|
|||
|
||||
abort_if(!$profile || $profile['locked'] || !$profile['local'], 404);
|
||||
|
||||
$data = Cache::remember('pf:atom:user-feed:by-id:' . $profile['id'], 86400, function() use($pid, $profile) {
|
||||
$data = Cache::remember('pf:atom:user-feed:by-id:' . $profile['id'], 43200, function() use($pid, $profile) {
|
||||
$items = DB::table('statuses')
|
||||
->whereProfileId($pid)
|
||||
->whereVisibility('public')
|
||||
|
|
|
@ -24,6 +24,9 @@ class AdminInstance extends JsonResource
|
|||
'user_count' => $this->user_count,
|
||||
'status_count' => $this->status_count,
|
||||
'last_crawled_at' => $this->last_crawled_at,
|
||||
'notes' => $this->notes,
|
||||
'base_domain' => $this->base_domain,
|
||||
'ban_subdomains' => $this->ban_subdomains,
|
||||
'actors_last_synced_at' => $this->actors_last_synced_at,
|
||||
'created_at' => $this->created_at,
|
||||
];
|
||||
|
|
|
@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||
|
||||
class Instance extends Model
|
||||
{
|
||||
protected $fillable = ['domain'];
|
||||
protected $fillable = ['domain', 'banned', 'auto_cw', 'unlisted', 'notes'];
|
||||
|
||||
public function profiles()
|
||||
{
|
||||
|
|
|
@ -126,6 +126,11 @@ class DeleteWorker implements ShouldQueue
|
|||
return false;
|
||||
}
|
||||
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
||||
|
||||
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
||||
$id = Helpers::validateUrl($bodyDecoded['id']);
|
||||
$keyDomain = parse_url($keyId, PHP_URL_HOST);
|
||||
|
@ -186,6 +191,11 @@ class DeleteWorker implements ShouldQueue
|
|||
return;
|
||||
}
|
||||
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
||||
|
||||
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
||||
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
|
||||
if(!$actor) {
|
||||
|
|
|
@ -5,9 +5,9 @@ namespace App\Jobs\InboxPipeline;
|
|||
use Cache;
|
||||
use App\Profile;
|
||||
use App\Util\ActivityPub\{
|
||||
Helpers,
|
||||
HttpSignature,
|
||||
Inbox
|
||||
Helpers,
|
||||
HttpSignature,
|
||||
Inbox
|
||||
};
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
@ -21,189 +21,199 @@ use Illuminate\Support\Lottery;
|
|||
|
||||
class InboxValidator implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $username;
|
||||
protected $headers;
|
||||
protected $payload;
|
||||
protected $username;
|
||||
protected $headers;
|
||||
protected $payload;
|
||||
|
||||
public $timeout = 300;
|
||||
public $tries = 1;
|
||||
public $maxExceptions = 1;
|
||||
public $timeout = 300;
|
||||
public $tries = 1;
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($username, $headers, $payload)
|
||||
{
|
||||
$this->username = $username;
|
||||
$this->headers = $headers;
|
||||
$this->payload = $payload;
|
||||
}
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($username, $headers, $payload)
|
||||
{
|
||||
$this->username = $username;
|
||||
$this->headers = $headers;
|
||||
$this->payload = $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$username = $this->username;
|
||||
$headers = $this->headers;
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$username = $this->username;
|
||||
$headers = $this->headers;
|
||||
|
||||
if(empty($headers) || empty($this->payload) || !isset($headers['signature']) || !isset($headers['date'])) {
|
||||
return;
|
||||
}
|
||||
if(empty($headers) || empty($this->payload) || !isset($headers['signature']) || !isset($headers['date'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_decode($this->payload, true, 8);
|
||||
$payload = json_decode($this->payload, true, 8);
|
||||
|
||||
if(isset($payload['id'])) {
|
||||
$lockKey = 'pf:ap:user-inbox:activity:' . hash('sha256', $payload['id']);
|
||||
if(Cache::get($lockKey) !== null) {
|
||||
// Job processed already
|
||||
return 1;
|
||||
}
|
||||
Cache::put($lockKey, 1, 3600);
|
||||
}
|
||||
if(isset($payload['id'])) {
|
||||
$lockKey = 'pf:ap:user-inbox:activity:' . hash('sha256', $payload['id']);
|
||||
if(Cache::get($lockKey) !== null) {
|
||||
// Job processed already
|
||||
return 1;
|
||||
}
|
||||
Cache::put($lockKey, 1, 3600);
|
||||
}
|
||||
|
||||
$profile = Profile::whereNull('domain')->whereUsername($username)->first();
|
||||
$profile = Profile::whereNull('domain')->whereUsername($username)->first();
|
||||
|
||||
if(empty($profile) || empty($headers) || empty($payload)) {
|
||||
return;
|
||||
}
|
||||
if(empty($profile) || empty($headers) || empty($payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if($profile->status != null) {
|
||||
return;
|
||||
}
|
||||
if($profile->status != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if($this->verifySignature($headers, $profile, $payload) == true) {
|
||||
if(isset($payload['type']) && in_array($payload['type'], ['Follow', 'Accept']) ) {
|
||||
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue('follow');
|
||||
} else {
|
||||
$onQueue = Lottery::odds(1, 12)->winner(fn () => 'high')->loser(fn () => 'inbox')->choose();
|
||||
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue($onQueue);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if($this->verifySignature($headers, $profile, $payload) == true) {
|
||||
if(isset($payload['type']) && in_array($payload['type'], ['Follow', 'Accept']) ) {
|
||||
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue('follow');
|
||||
} else {
|
||||
$onQueue = Lottery::odds(1, 12)->winner(fn () => 'high')->loser(fn () => 'inbox')->choose();
|
||||
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue($onQueue);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
protected function verifySignature($headers, $profile, $payload)
|
||||
{
|
||||
$body = $this->payload;
|
||||
$bodyDecoded = $payload;
|
||||
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
|
||||
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
|
||||
if(!$signature) {
|
||||
return false;
|
||||
}
|
||||
if(!$date) {
|
||||
return false;
|
||||
}
|
||||
if(!now()->parse($date)->gt(now()->subDays(1)) ||
|
||||
!now()->parse($date)->lt(now()->addDays(1))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if(!isset($bodyDecoded['id'])) {
|
||||
return false;
|
||||
}
|
||||
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
||||
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
||||
$id = Helpers::validateUrl($bodyDecoded['id']);
|
||||
$keyDomain = parse_url($keyId, PHP_URL_HOST);
|
||||
$idDomain = parse_url($id, PHP_URL_HOST);
|
||||
if(isset($bodyDecoded['object'])
|
||||
&& is_array($bodyDecoded['object'])
|
||||
&& isset($bodyDecoded['object']['attributedTo'])
|
||||
) {
|
||||
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
|
||||
if(is_array($attr)) {
|
||||
if(isset($attr['id'])) {
|
||||
$attr = $attr['id'];
|
||||
} else {
|
||||
$attr = "";
|
||||
}
|
||||
}
|
||||
if(parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
|
||||
return false;
|
||||
}
|
||||
$actor = Profile::whereKeyId($keyId)->first();
|
||||
if(!$actor) {
|
||||
$actorUrl = Helpers::pluckval($bodyDecoded['actor']);
|
||||
$actor = Helpers::profileFirstOrNew($actorUrl);
|
||||
}
|
||||
if(!$actor) {
|
||||
return false;
|
||||
}
|
||||
$pkey = openssl_pkey_get_public($actor->public_key);
|
||||
if(!$pkey) {
|
||||
return false;
|
||||
}
|
||||
$inboxPath = "/users/{$profile->username}/inbox";
|
||||
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
|
||||
if($verified == 1) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
protected function verifySignature($headers, $profile, $payload)
|
||||
{
|
||||
$body = $this->payload;
|
||||
$bodyDecoded = $payload;
|
||||
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
|
||||
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
|
||||
if(!$signature) {
|
||||
return false;
|
||||
}
|
||||
if(!$date) {
|
||||
return false;
|
||||
}
|
||||
if(!now()->parse($date)->gt(now()->subDays(1)) ||
|
||||
!now()->parse($date)->lt(now()->addDays(1))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if(!isset($bodyDecoded['id'])) {
|
||||
return false;
|
||||
}
|
||||
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
||||
|
||||
protected function blindKeyRotation($headers, $profile, $payload)
|
||||
{
|
||||
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
|
||||
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
|
||||
if(!$signature) {
|
||||
return;
|
||||
}
|
||||
if(!$date) {
|
||||
return;
|
||||
}
|
||||
if(!now()->parse($date)->gt(now()->subDays(1)) ||
|
||||
!now()->parse($date)->lt(now()->addDays(1))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
||||
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
||||
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
|
||||
if(!$actor) {
|
||||
return;
|
||||
}
|
||||
if(Helpers::validateUrl($actor->remote_url) == false) {
|
||||
return;
|
||||
}
|
||||
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::timeout(20)->withHeaders([
|
||||
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
|
||||
])->get($actor->remote_url);
|
||||
} catch (ConnectionException $e) {
|
||||
return false;
|
||||
}
|
||||
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
||||
$id = Helpers::validateUrl($bodyDecoded['id']);
|
||||
$keyDomain = parse_url($keyId, PHP_URL_HOST);
|
||||
$idDomain = parse_url($id, PHP_URL_HOST);
|
||||
if(isset($bodyDecoded['object'])
|
||||
&& is_array($bodyDecoded['object'])
|
||||
&& isset($bodyDecoded['object']['attributedTo'])
|
||||
) {
|
||||
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
|
||||
if(is_array($attr)) {
|
||||
if(isset($attr['id'])) {
|
||||
$attr = $attr['id'];
|
||||
} else {
|
||||
$attr = "";
|
||||
}
|
||||
}
|
||||
if(parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
|
||||
return false;
|
||||
}
|
||||
$actor = Profile::whereKeyId($keyId)->first();
|
||||
if(!$actor) {
|
||||
$actorUrl = Helpers::pluckval($bodyDecoded['actor']);
|
||||
$actor = Helpers::profileFirstOrNew($actorUrl);
|
||||
}
|
||||
if(!$actor) {
|
||||
return false;
|
||||
}
|
||||
$pkey = openssl_pkey_get_public($actor->public_key);
|
||||
if(!$pkey) {
|
||||
return false;
|
||||
}
|
||||
$inboxPath = "/users/{$profile->username}/inbox";
|
||||
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
|
||||
if($verified == 1) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if(!$res->ok()) {
|
||||
return false;
|
||||
}
|
||||
protected function blindKeyRotation($headers, $profile, $payload)
|
||||
{
|
||||
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
|
||||
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
|
||||
if(!$signature) {
|
||||
return;
|
||||
}
|
||||
if(!$date) {
|
||||
return;
|
||||
}
|
||||
if(!now()->parse($date)->gt(now()->subDays(1)) ||
|
||||
!now()->parse($date)->lt(now()->addDays(1))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
||||
|
||||
$res = json_decode($res->body(), true, 8);
|
||||
if(!$res || empty($res) || !isset($res['publicKey']) || !isset($res['publicKey']['id'])) {
|
||||
return;
|
||||
}
|
||||
if($res['publicKey']['id'] !== $actor->key_id) {
|
||||
return;
|
||||
}
|
||||
$actor->public_key = $res['publicKey']['publicKeyPem'];
|
||||
$actor->save();
|
||||
return $this->verifySignature($headers, $profile, $payload);
|
||||
}
|
||||
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
||||
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
|
||||
if(!$actor) {
|
||||
return;
|
||||
}
|
||||
if(Helpers::validateUrl($actor->remote_url) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::timeout(20)->withHeaders([
|
||||
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
|
||||
])->get($actor->remote_url);
|
||||
} catch (ConnectionException $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!$res->ok()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$res = json_decode($res->body(), true, 8);
|
||||
if(!$res || empty($res) || !isset($res['publicKey']) || !isset($res['publicKey']['id'])) {
|
||||
return;
|
||||
}
|
||||
if($res['publicKey']['id'] !== $actor->key_id) {
|
||||
return;
|
||||
}
|
||||
$actor->public_key = $res['publicKey']['publicKeyPem'];
|
||||
$actor->save();
|
||||
return $this->verifySignature($headers, $profile, $payload);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,9 +5,9 @@ namespace App\Jobs\InboxPipeline;
|
|||
use Cache;
|
||||
use App\Profile;
|
||||
use App\Util\ActivityPub\{
|
||||
Helpers,
|
||||
HttpSignature,
|
||||
Inbox
|
||||
Helpers,
|
||||
HttpSignature,
|
||||
Inbox
|
||||
};
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
@ -20,171 +20,180 @@ use Illuminate\Http\Client\ConnectionException;
|
|||
|
||||
class InboxWorker implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $headers;
|
||||
protected $payload;
|
||||
protected $headers;
|
||||
protected $payload;
|
||||
|
||||
public $timeout = 300;
|
||||
public $tries = 1;
|
||||
public $maxExceptions = 1;
|
||||
public $timeout = 300;
|
||||
public $tries = 1;
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($headers, $payload)
|
||||
{
|
||||
$this->headers = $headers;
|
||||
$this->payload = $payload;
|
||||
}
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($headers, $payload)
|
||||
{
|
||||
$this->headers = $headers;
|
||||
$this->payload = $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$profile = null;
|
||||
$headers = $this->headers;
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$profile = null;
|
||||
$headers = $this->headers;
|
||||
|
||||
if(empty($headers) || empty($this->payload) || !isset($headers['signature']) || !isset($headers['date'])) {
|
||||
return;
|
||||
}
|
||||
if(empty($headers) || empty($this->payload) || !isset($headers['signature']) || !isset($headers['date'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_decode($this->payload, true, 8);
|
||||
$payload = json_decode($this->payload, true, 8);
|
||||
|
||||
if(isset($payload['id'])) {
|
||||
$lockKey = 'pf:ap:user-inbox:activity:' . hash('sha256', $payload['id']);
|
||||
if(Cache::get($lockKey) !== null) {
|
||||
// Job processed already
|
||||
return 1;
|
||||
}
|
||||
Cache::put($lockKey, 1, 3600);
|
||||
}
|
||||
if(isset($payload['id'])) {
|
||||
$lockKey = 'pf:ap:user-inbox:activity:' . hash('sha256', $payload['id']);
|
||||
if(Cache::get($lockKey) !== null) {
|
||||
// Job processed already
|
||||
return 1;
|
||||
}
|
||||
Cache::put($lockKey, 1, 3600);
|
||||
}
|
||||
|
||||
if($this->verifySignature($headers, $payload) == true) {
|
||||
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue('shared');
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if($this->verifySignature($headers, $payload) == true) {
|
||||
ActivityHandler::dispatch($headers, $profile, $payload)->onQueue('shared');
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
protected function verifySignature($headers, $payload)
|
||||
{
|
||||
$body = $this->payload;
|
||||
$bodyDecoded = $payload;
|
||||
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
|
||||
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
|
||||
if(!$signature) {
|
||||
return false;
|
||||
}
|
||||
if(!$date) {
|
||||
return false;
|
||||
}
|
||||
if(!now()->parse($date)->gt(now()->subDays(1)) ||
|
||||
!now()->parse($date)->lt(now()->addDays(1))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if(!isset($bodyDecoded['id'])) {
|
||||
return false;
|
||||
}
|
||||
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
||||
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
||||
$id = Helpers::validateUrl($bodyDecoded['id']);
|
||||
$keyDomain = parse_url($keyId, PHP_URL_HOST);
|
||||
$idDomain = parse_url($id, PHP_URL_HOST);
|
||||
if(isset($bodyDecoded['object'])
|
||||
&& is_array($bodyDecoded['object'])
|
||||
&& isset($bodyDecoded['object']['attributedTo'])
|
||||
) {
|
||||
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
|
||||
if(is_array($attr)) {
|
||||
if(isset($attr['id'])) {
|
||||
$attr = $attr['id'];
|
||||
} else {
|
||||
$attr = "";
|
||||
}
|
||||
}
|
||||
if(parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
|
||||
return false;
|
||||
}
|
||||
$actor = Profile::whereKeyId($keyId)->first();
|
||||
if(!$actor) {
|
||||
$actorUrl = Helpers::pluckval($bodyDecoded['actor']);
|
||||
$actor = Helpers::profileFirstOrNew($actorUrl);
|
||||
}
|
||||
if(!$actor) {
|
||||
return false;
|
||||
}
|
||||
$pkey = openssl_pkey_get_public($actor->public_key);
|
||||
if(!$pkey) {
|
||||
return false;
|
||||
}
|
||||
$inboxPath = "/f/inbox";
|
||||
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
|
||||
if($verified == 1) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
protected function verifySignature($headers, $payload)
|
||||
{
|
||||
$body = $this->payload;
|
||||
$bodyDecoded = $payload;
|
||||
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
|
||||
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
|
||||
if(!$signature) {
|
||||
return false;
|
||||
}
|
||||
if(!$date) {
|
||||
return false;
|
||||
}
|
||||
if(!now()->parse($date)->gt(now()->subDays(1)) ||
|
||||
!now()->parse($date)->lt(now()->addDays(1))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if(!isset($bodyDecoded['id'])) {
|
||||
return false;
|
||||
}
|
||||
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
||||
|
||||
protected function blindKeyRotation($headers, $payload)
|
||||
{
|
||||
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
|
||||
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
|
||||
if(!$signature) {
|
||||
return;
|
||||
}
|
||||
if(!$date) {
|
||||
return;
|
||||
}
|
||||
if(!now()->parse($date)->gt(now()->subDays(1)) ||
|
||||
!now()->parse($date)->lt(now()->addDays(1))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
||||
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
||||
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
|
||||
if(!$actor) {
|
||||
return;
|
||||
}
|
||||
if(Helpers::validateUrl($actor->remote_url) == false) {
|
||||
return;
|
||||
}
|
||||
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::timeout(20)->withHeaders([
|
||||
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
|
||||
])->get($actor->remote_url);
|
||||
} catch (ConnectionException $e) {
|
||||
return false;
|
||||
}
|
||||
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
||||
$id = Helpers::validateUrl($bodyDecoded['id']);
|
||||
$keyDomain = parse_url($keyId, PHP_URL_HOST);
|
||||
$idDomain = parse_url($id, PHP_URL_HOST);
|
||||
if(isset($bodyDecoded['object'])
|
||||
&& is_array($bodyDecoded['object'])
|
||||
&& isset($bodyDecoded['object']['attributedTo'])
|
||||
) {
|
||||
$attr = Helpers::pluckval($bodyDecoded['object']['attributedTo']);
|
||||
if(is_array($attr)) {
|
||||
if(isset($attr['id'])) {
|
||||
$attr = $attr['id'];
|
||||
} else {
|
||||
$attr = "";
|
||||
}
|
||||
}
|
||||
if(parse_url($attr, PHP_URL_HOST) !== $keyDomain) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
|
||||
return false;
|
||||
}
|
||||
$actor = Profile::whereKeyId($keyId)->first();
|
||||
if(!$actor) {
|
||||
$actorUrl = Helpers::pluckval($bodyDecoded['actor']);
|
||||
$actor = Helpers::profileFirstOrNew($actorUrl);
|
||||
}
|
||||
if(!$actor) {
|
||||
return false;
|
||||
}
|
||||
$pkey = openssl_pkey_get_public($actor->public_key);
|
||||
if(!$pkey) {
|
||||
return false;
|
||||
}
|
||||
$inboxPath = "/f/inbox";
|
||||
list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
|
||||
if($verified == 1) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if(!$res->ok()) {
|
||||
return false;
|
||||
}
|
||||
protected function blindKeyRotation($headers, $payload)
|
||||
{
|
||||
$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
|
||||
$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
|
||||
if(!$signature) {
|
||||
return;
|
||||
}
|
||||
if(!$date) {
|
||||
return;
|
||||
}
|
||||
if(!now()->parse($date)->gt(now()->subDays(1)) ||
|
||||
!now()->parse($date)->lt(now()->addDays(1))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
$signatureData = HttpSignature::parseSignatureHeader($signature);
|
||||
if(!isset($signatureData['keyId'], $signatureData['signature'], $signatureData['headers']) || isset($signatureData['error'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$res = json_decode($res->body(), true, 8);
|
||||
if(!$res || empty($res) || !isset($res['publicKey']) || !isset($res['publicKey']['id'])) {
|
||||
return;
|
||||
}
|
||||
if($res['publicKey']['id'] !== $actor->key_id) {
|
||||
return;
|
||||
}
|
||||
$actor->public_key = $res['publicKey']['publicKeyPem'];
|
||||
$actor->save();
|
||||
return $this->verifySignature($headers, $payload);
|
||||
}
|
||||
$keyId = Helpers::validateUrl($signatureData['keyId']);
|
||||
$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
|
||||
if(!$actor) {
|
||||
return;
|
||||
}
|
||||
if(Helpers::validateUrl($actor->remote_url) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::timeout(20)->withHeaders([
|
||||
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
|
||||
])->get($actor->remote_url);
|
||||
} catch (ConnectionException $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!$res->ok()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$res = json_decode($res->body(), true, 8);
|
||||
if(!$res || empty($res) || !isset($res['publicKey']) || !isset($res['publicKey']['id'])) {
|
||||
return;
|
||||
}
|
||||
if($res['publicKey']['id'] !== $actor->key_id) {
|
||||
return;
|
||||
}
|
||||
$actor->public_key = $res['publicKey']['publicKeyPem'];
|
||||
$actor->save();
|
||||
return $this->verifySignature($headers, $payload);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\Jobs\StatusPipeline;
|
||||
|
||||
use DB, Storage;
|
||||
use DB, Cache, Storage;
|
||||
use App\{
|
||||
AccountInterstitial,
|
||||
Bookmark,
|
||||
|
@ -81,6 +81,8 @@ class StatusDelete implements ShouldQueue
|
|||
}
|
||||
}
|
||||
|
||||
Cache::forget('pf:atom:user-feed:by-id:' . $status->profile_id);
|
||||
|
||||
if(config_cache('federation.activitypub.enabled') == true) {
|
||||
return $this->fanoutDelete($status);
|
||||
} else {
|
||||
|
|
|
@ -12,6 +12,7 @@ use App\Services\PublicTimelineService;
|
|||
use App\Util\Lexer\Autolink;
|
||||
use App\Util\Lexer\Extractor;
|
||||
use App\Util\Sentiment\Bouncer;
|
||||
use Cache;
|
||||
use DB;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
@ -166,6 +167,8 @@ class StatusEntityLexer implements ShouldQueue
|
|||
if(config_cache('pixelfed.bouncer.enabled')) {
|
||||
Bouncer::get($status);
|
||||
}
|
||||
|
||||
Cache::forget('pf:atom:user-feed:by-id:' . $status->profile_id);
|
||||
$hideNsfw = config('instance.hide_nsfw_on_public_feeds');
|
||||
if( $status->uri == null &&
|
||||
$status->scope == 'public' &&
|
||||
|
|
|
@ -11,6 +11,7 @@ class InstanceService
|
|||
const CACHE_KEY_BANNED_DOMAINS = 'instances:banned:domains';
|
||||
const CACHE_KEY_UNLISTED_DOMAINS = 'instances:unlisted:domains';
|
||||
const CACHE_KEY_NSFW_DOMAINS = 'instances:auto_cw:domains';
|
||||
const CACHE_KEY_STATS = 'pf:services:instances:stats';
|
||||
|
||||
public static function getByDomain($domain)
|
||||
{
|
||||
|
@ -52,11 +53,24 @@ class InstanceService
|
|||
});
|
||||
}
|
||||
|
||||
public static function stats()
|
||||
{
|
||||
return Cache::remember(self::CACHE_KEY_STATS, 86400, function() {
|
||||
return [
|
||||
'total_count' => Instance::count(),
|
||||
'new_count' => Instance::where('created_at', '>', now()->subDays(14))->count(),
|
||||
'banned_count' => Instance::whereBanned(true)->count(),
|
||||
'nsfw_count' => Instance::whereAutoCw(true)->count()
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public static function refresh()
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY_BANNED_DOMAINS);
|
||||
Cache::forget(self::CACHE_KEY_UNLISTED_DOMAINS);
|
||||
Cache::forget(self::CACHE_KEY_NSFW_DOMAINS);
|
||||
Cache::forget(self::CACHE_KEY_STATS);
|
||||
|
||||
self::getBannedDomains();
|
||||
self::getUnlistedDomains();
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('instances', function (Blueprint $table) {
|
||||
$table->text('notes')->nullable();
|
||||
$table->boolean('manually_added')->default(false);
|
||||
$table->string('base_domain')->nullable();
|
||||
$table->boolean('ban_subdomains')->nullable()->index();
|
||||
$table->string('ip_address')->nullable();
|
||||
$table->boolean('list_limitation')->default(false)->index();
|
||||
$table->index('banned');
|
||||
$table->index('auto_cw');
|
||||
$table->index('unlisted');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('instances', function (Blueprint $table) {
|
||||
$table->dropColumn('notes');
|
||||
$table->dropColumn('manually_added');
|
||||
$table->dropColumn('base_domain');
|
||||
$table->dropColumn('ban_subdomains');
|
||||
$table->dropColumn('ip_address');
|
||||
$table->dropColumn('list_limitation');
|
||||
$table->dropIndex('instances_banned_index');
|
||||
$table->dropIndex('instances_auto_cw_index');
|
||||
$table->dropIndex('instances_unlisted_index');
|
||||
});
|
||||
}
|
||||
};
|
BIN
public/js/admin.js
vendored
BIN
public/js/admin.js
vendored
Binary file not shown.
Binary file not shown.
628
resources/assets/components/admin/AdminInstances.vue
Normal file
628
resources/assets/components/admin/AdminInstances.vue
Normal file
|
@ -0,0 +1,628 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="header bg-primary pb-3 mt-n4">
|
||||
<div class="container-fluid">
|
||||
<div class="header-body">
|
||||
<div class="row align-items-center py-4">
|
||||
<div class="col-lg-6 col-7">
|
||||
<p class="display-1 text-white d-inline-block mb-0">Instances</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<h5 class="text-light text-uppercase mb-0">Total Instances</h5>
|
||||
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.total_count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<h5 class="text-light text-uppercase mb-0">New (past 14 days)</h5>
|
||||
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.new_count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<h5 class="text-light text-uppercase mb-0">Banned Instances</h5>
|
||||
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.banned_count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<h5 class="text-light text-uppercase mb-0">NSFW Instances</h5>
|
||||
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.nsfw_count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-outline-white btn-block btn-sm mt-1" @click.prevent="showAddModal = true">Create New Instance</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loaded" class="my-5 text-center">
|
||||
<b-spinner />
|
||||
</div>
|
||||
|
||||
<div v-else class="m-n2 m-lg-4">
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row mb-3 justify-content-between">
|
||||
<div class="col-12 col-md-8">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 0}]" @click="toggleTab(0)">All</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 1}]" @click="toggleTab(1)">New</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 2}]" @click="toggleTab(2)">Banned</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 3}]" @click="toggleTab(3)">NSFW</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 4}]" @click="toggleTab(4)">Unlisted</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 5}]" @click="toggleTab(5)">Most Users</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 6}]" @click="toggleTab(6)">Most Statuses</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<autocomplete
|
||||
:search="composeSearch"
|
||||
:disabled="searchLoading"
|
||||
:defaultValue="searchQuery"
|
||||
placeholder="Search instances by domain"
|
||||
aria-label="Search instances by domain"
|
||||
:get-result-value="getTagResultValue"
|
||||
@submit="onSearchResultClick"
|
||||
ref="autocomplete"
|
||||
>
|
||||
<template #result="{ result, props }">
|
||||
<li
|
||||
v-bind="props"
|
||||
class="autocomplete-result d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<div class="font-weight-bold" :class="{ 'text-danger': result.banned }">
|
||||
{{ result.domain }}
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
{{ prettyCount(result.user_count) }} users
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</autocomplete>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('ID', 'id')" @click="toggleCol('id')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('Domain', 'name')" @click="toggleCol('name')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('Software', 'software')" @click="toggleCol('software')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('User Count', 'user_count')" @click="toggleCol('user_count')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('Status Count', 'status_count')" @click="toggleCol('status_count')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('Banned', 'banned')" @click="toggleCol('banned')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('NSFW', 'auto_cw')" @click="toggleCol('auto_cw')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('Unlisted', 'unlisted')" @click="toggleCol('unlisted')"></th>
|
||||
<th scope="col">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(instance, idx) in instances">
|
||||
<td class="font-weight-bold text-monospace text-muted">
|
||||
<a href="#" @click.prevent="openInstanceModal(instance.id)">
|
||||
{{ instance.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="font-weight-bold">{{ instance.domain }}</td>
|
||||
<td class="font-weight-bold">{{ instance.software }}</td>
|
||||
<td class="font-weight-bold">{{ prettyCount(instance.user_count) }}</td>
|
||||
<td class="font-weight-bold">{{ prettyCount(instance.status_count) }}</td>
|
||||
<td class="font-weight-bold" v-html="boolIcon(instance.banned, 'text-danger')"></td>
|
||||
<td class="font-weight-bold" v-html="boolIcon(instance.auto_cw, 'text-danger')"></td>
|
||||
<td class="font-weight-bold" v-html="boolIcon(instance.unlisted, 'text-danger')"></td>
|
||||
<td class="font-weight-bold">{{ timeAgo(instance.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary rounded-pill"
|
||||
:disabled="!pagination.prev"
|
||||
@click="paginate('prev')">
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary rounded-pill"
|
||||
:disabled="!pagination.next"
|
||||
@click="paginate('next')">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-modal
|
||||
v-model="showInstanceModal"
|
||||
title="View Instance"
|
||||
header-class="d-flex align-items-center justify-content-center mb-0 pb-0"
|
||||
ok-title="Save"
|
||||
:ok-disabled="!editingInstanceChanges"
|
||||
@ok="saveInstanceModalChanges">
|
||||
<div v-if="editingInstance && canEditInstance" class="list-group">
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Domain</div>
|
||||
<div class="font-weight-bold">{{ editingInstance.domain }}</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div v-if="editingInstance.software">
|
||||
<div class="text-muted small">Software</div>
|
||||
<div class="font-weight-bold">{{ editingInstance.software ?? 'Unknown' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small">Total Users</div>
|
||||
<div class="font-weight-bold">{{ formatCount(editingInstance.user_count ?? 0) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted small">Total Statuses</div>
|
||||
<div class="font-weight-bold">{{ formatCount(editingInstance.status_count ?? 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Banned</div>
|
||||
<div class="mr-n2 mb-1">
|
||||
<b-form-checkbox v-model="editingInstance.banned" switch size="lg"></b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Apply CW to Media</div>
|
||||
<div class="mr-n2 mb-1">
|
||||
<b-form-checkbox v-model="editingInstance.auto_cw" switch size="lg"></b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Unlisted</div>
|
||||
<div class="mr-n2 mb-1">
|
||||
<b-form-checkbox v-model="editingInstance.unlisted" switch size="lg"></b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex justify-content-between" :class="[ instanceModalNotes ? 'flex-column gap-2' : 'align-items-center']">
|
||||
<div class="text-muted small">Notes</div>
|
||||
<transition name="fade">
|
||||
<div v-if="instanceModalNotes" class="w-100">
|
||||
<b-form-textarea v-model="editingInstance.notes" rows="3" max-rows="5" maxlength="500"></b-form-textarea>
|
||||
<p class="small text-muted">{{editingInstance.notes ? editingInstance.notes.length : 0}}/500</p>
|
||||
</div>
|
||||
<div v-else class="mb-1">
|
||||
<a href="#" class="font-weight-bold small" @click.prevent="showModalNotes()">{{editingInstance.notes ? 'View' : 'Add'}}</a>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<template #modal-footer>
|
||||
<div class="w-100 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<b-button
|
||||
variant="outline-danger"
|
||||
size="sm"
|
||||
@click="deleteInstanceModal"
|
||||
>
|
||||
Delete
|
||||
</b-button>
|
||||
<b-button
|
||||
v-if="!refreshedModalStats"
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
@click="refreshModalStats"
|
||||
>
|
||||
Refresh Stats
|
||||
</b-button>
|
||||
</div>
|
||||
<div>
|
||||
<b-button
|
||||
variant="secondary"
|
||||
@click="showInstanceModal = false"
|
||||
>
|
||||
Close
|
||||
</b-button>
|
||||
<b-button
|
||||
variant="primary"
|
||||
@click="saveInstanceModalChanges"
|
||||
>
|
||||
Save
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
|
||||
<b-modal
|
||||
v-model="showAddModal"
|
||||
title="Add Instance"
|
||||
ok-title="Save"
|
||||
:ok-disabled="addNewInstance.domain.length < 2"
|
||||
@ok="saveNewInstance">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Domain</div>
|
||||
<div>
|
||||
<b-form-input v-model="addNewInstance.domain" placeholder="Add domain here" />
|
||||
<p class="small text-light mb-0">Enter a valid domain without https://</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Banned</div>
|
||||
<div class="mr-n2 mb-1">
|
||||
<b-form-checkbox v-model="addNewInstance.banned" switch size="lg"></b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Apply CW to Media</div>
|
||||
<div class="mr-n2 mb-1">
|
||||
<b-form-checkbox v-model="addNewInstance.auto_cw" switch size="lg"></b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Unlisted</div>
|
||||
<div class="mr-n2 mb-1">
|
||||
<b-form-checkbox v-model="addNewInstance.unlisted" switch size="lg"></b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex flex-column gap-2 justify-content-between">
|
||||
<div class="text-muted small">Notes</div>
|
||||
<div class="w-100">
|
||||
<b-form-textarea v-model="addNewInstance.notes" rows="3" max-rows="5" maxlength="500" placeholder="Add optional notes here"></b-form-textarea>
|
||||
<p class="small text-muted">{{addNewInstance.notes ? addNewInstance.notes.length : 0}}/500</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import Autocomplete from '@trevoreyre/autocomplete-vue'
|
||||
import '@trevoreyre/autocomplete-vue/dist/style.css'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Autocomplete,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loaded: false,
|
||||
tabIndex: 0,
|
||||
stats: {
|
||||
total_count: 0,
|
||||
new_count: 0,
|
||||
banned_count: 0,
|
||||
nsfw_count: 0
|
||||
},
|
||||
instances: [],
|
||||
pagination: [],
|
||||
sortCol: undefined,
|
||||
sortDir: undefined,
|
||||
searchQuery: undefined,
|
||||
filterMap: [
|
||||
'all',
|
||||
'new',
|
||||
'banned',
|
||||
'cw',
|
||||
'unlisted',
|
||||
'popular_users',
|
||||
'popular_statuses'
|
||||
],
|
||||
searchLoading: false,
|
||||
showInstanceModal: false,
|
||||
instanceModal: {},
|
||||
editingInstanceChanges: false,
|
||||
canEditInstance: false,
|
||||
editingInstance: {},
|
||||
editingInstanceIndex: 0,
|
||||
instanceModalNotes: false,
|
||||
showAddModal: false,
|
||||
refreshedModalStats: false,
|
||||
addNewInstance: {
|
||||
domain: "",
|
||||
banned: false,
|
||||
auto_cw: false,
|
||||
unlisted: false,
|
||||
notes: undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fetchStats();
|
||||
|
||||
let u = new URLSearchParams(window.location.search);
|
||||
if(u.has('filter') || u.has('cursor') && !u.has('q')) {
|
||||
let url = '/i/admin/api/instances/get?';
|
||||
|
||||
let filter = u.get('filter');
|
||||
if(filter) {
|
||||
this.tabIndex = this.filterMap.indexOf(filter);
|
||||
url = url + 'filter=' + filter + '&';
|
||||
}
|
||||
|
||||
let cursor = u.get('cursor');
|
||||
if(cursor) {
|
||||
url = url + 'cursor=' + cursor;
|
||||
}
|
||||
|
||||
this.fetchInstances(url);
|
||||
} else if(u.has('q')) {
|
||||
this.tabIndex = -1;
|
||||
this.searchQuery = u.get('q');
|
||||
let cursor = u.has('cursor');
|
||||
let q = u.get('q');
|
||||
let url = '/i/admin/api/instances/query?q=' + q;
|
||||
if(cursor) {
|
||||
url = url + '&cursor=' + u.get('cursor');
|
||||
}
|
||||
this.fetchInstances(url);
|
||||
} else {
|
||||
this.fetchInstances();
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
editingInstance: {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
handler: function(updated, old) {
|
||||
if(!this.canEditInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(
|
||||
JSON.stringify(old) === JSON.stringify(this.instances.filter(i => i.id === updated.id)[0]) &&
|
||||
JSON.stringify(updated) === JSON.stringify(this.instanceModal)
|
||||
) {
|
||||
this.editingInstanceChanges = true;
|
||||
} else {
|
||||
this.editingInstanceChanges = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchStats() {
|
||||
axios.get('/i/admin/api/instances/stats')
|
||||
.then(res => {
|
||||
this.stats = res.data;
|
||||
})
|
||||
},
|
||||
|
||||
fetchInstances(url = '/i/admin/api/instances/get') {
|
||||
axios.get(url)
|
||||
.then(res => {
|
||||
this.instances = res.data.data;
|
||||
this.pagination = {...res.data.links, ...res.data.meta};
|
||||
})
|
||||
.then(() => {
|
||||
this.$nextTick(() => {
|
||||
this.loaded = true;
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
toggleTab(idx) {
|
||||
this.loaded = false;
|
||||
this.tabIndex = idx;
|
||||
this.searchQuery = undefined;
|
||||
let url = '/i/admin/api/instances/get?filter=' + this.filterMap[idx];
|
||||
history.pushState(null, '', '/i/admin/instances?filter=' + this.filterMap[idx]);
|
||||
this.fetchInstances(url);
|
||||
},
|
||||
|
||||
prettyCount(str) {
|
||||
if(str) {
|
||||
return str.toLocaleString('en-CA', { compactDisplay: "short", notation: "compact"});
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
return str;
|
||||
},
|
||||
|
||||
formatCount(str) {
|
||||
if(str) {
|
||||
return str.toLocaleString('en-CA');
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
return str;
|
||||
},
|
||||
|
||||
timeAgo(str) {
|
||||
if(!str) {
|
||||
return str;
|
||||
}
|
||||
return App.util.format.timeAgo(str);
|
||||
},
|
||||
|
||||
boolIcon(val, success = 'text-success', danger = 'text-muted') {
|
||||
if(val) {
|
||||
return `<i class="far fa-check-circle fa-lg ${success}"></i>`;
|
||||
}
|
||||
|
||||
return `<i class="far fa-times-circle fa-lg ${danger}"></i>`;
|
||||
},
|
||||
|
||||
toggleCol(col) {
|
||||
// this.sortCol = col;
|
||||
|
||||
// if(!this.sortDir) {
|
||||
// this.sortDir = 'desc';
|
||||
// } else {
|
||||
// this.sortDir = this.sortDir == 'asc' ? 'desc' : 'asc';
|
||||
// }
|
||||
|
||||
// let url = '/i/admin/api/hashtags/query?sort=' + col + '&dir=' + this.sortDir;
|
||||
// this.fetchHashtags(url);
|
||||
},
|
||||
|
||||
buildColumn(name, col) {
|
||||
let icon = `<i class="far fa-sort"></i>`;
|
||||
if(col == this.sortCol) {
|
||||
icon = this.sortDir == 'desc' ?
|
||||
`<i class="far fa-sort-up"></i>` :
|
||||
`<i class="far fa-sort-down"></i>`
|
||||
}
|
||||
return `${name} ${icon}`;
|
||||
},
|
||||
|
||||
paginate(dir) {
|
||||
event.currentTarget.blur();
|
||||
let apiUrl = dir == 'next' ? this.pagination.next : this.pagination.prev;
|
||||
let cursor = dir == 'next' ? this.pagination.next_cursor : this.pagination.prev_cursor;
|
||||
let url = '/i/admin/instances?';
|
||||
if(this.tabIndex && !this.searchQuery) {
|
||||
url = url + 'filter=' + this.filterMap[this.tabIndex] + '&';
|
||||
}
|
||||
if(cursor) {
|
||||
url = url + 'cursor=' + cursor;
|
||||
}
|
||||
if(this.searchQuery) {
|
||||
url = url + '&q=' + this.searchQuery;
|
||||
}
|
||||
history.pushState(null, '', url);
|
||||
this.fetchInstances(apiUrl);
|
||||
},
|
||||
|
||||
composeSearch(input) {
|
||||
if (input.length < 1) { return []; };
|
||||
this.searchQuery = input;
|
||||
history.pushState(null, '', '/i/admin/instances?q=' + input);
|
||||
return axios.get('/i/admin/api/instances/query', {
|
||||
params: {
|
||||
q: input,
|
||||
}
|
||||
}).then(res => {
|
||||
if(!res || !res.data) {
|
||||
this.fetchInstances();
|
||||
} else {
|
||||
this.tabIndex = -1;
|
||||
this.instances = res.data.data;
|
||||
this.pagination = {...res.data.links, ...res.data.meta};
|
||||
}
|
||||
return res.data.data;
|
||||
});
|
||||
},
|
||||
|
||||
getTagResultValue(result) {
|
||||
return result.name;
|
||||
},
|
||||
|
||||
onSearchResultClick(result) {
|
||||
this.openInstanceModal(result.id);
|
||||
return;
|
||||
},
|
||||
|
||||
openInstanceModal(id) {
|
||||
const cached = this.instances.filter(i => i.id === id)[0];
|
||||
this.refreshedModalStats = false;
|
||||
this.editingInstanceChanges = false;
|
||||
this.instanceModalNotes = false;
|
||||
this.canEditInstance = false;
|
||||
this.instanceModal = cached;
|
||||
this.$nextTick(() => {
|
||||
this.editingInstance = cached;
|
||||
this.showInstanceModal = true;
|
||||
this.canEditInstance = true;
|
||||
})
|
||||
},
|
||||
|
||||
showModalNotes() {
|
||||
this.instanceModalNotes = true;
|
||||
},
|
||||
|
||||
saveInstanceModalChanges() {
|
||||
axios.post('/i/admin/api/instances/update', this.editingInstance)
|
||||
.then(res => {
|
||||
this.showInstanceModal = false;
|
||||
this.$bvToast.toast(`Successfully updated ${res.data.data.domain}`, {
|
||||
title: 'Instance Updated',
|
||||
autoHideDelay: 5000,
|
||||
appendToast: true,
|
||||
variant: 'success'
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
saveNewInstance() {
|
||||
axios.post('/i/admin/api/instances/create', this.addNewInstance)
|
||||
.then(res => {
|
||||
this.showInstanceModal = false;
|
||||
this.instances.unshift(res.data.data);
|
||||
})
|
||||
.catch(err => {
|
||||
swal('Oops!', 'An error occured, please try again later.', 'error');
|
||||
this.addNewInstance = {
|
||||
domain: "",
|
||||
banned: false,
|
||||
auto_cw: false,
|
||||
unlisted: false,
|
||||
notes: undefined
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
refreshModalStats() {
|
||||
axios.post('/i/admin/api/instances/refresh-stats', {
|
||||
id: this.instanceModal.id
|
||||
})
|
||||
.then(res => {
|
||||
this.refreshedModalStats = true;
|
||||
this.instanceModal = res.data.data;
|
||||
this.editingInstance = res.data.data;
|
||||
this.instances = this.instances.map(i => {
|
||||
if(i.id === res.data.data.id) {
|
||||
return res.data.data;
|
||||
}
|
||||
return i;
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
deleteInstanceModal() {
|
||||
if(!window.confirm('Are you sure you want to delete this instance? This will not delete posts or profiles from this instance.')) {
|
||||
return;
|
||||
}
|
||||
axios.post('/i/admin/api/instances/delete', {
|
||||
id: this.instanceModal.id
|
||||
})
|
||||
.then(res => {
|
||||
this.showInstanceModal = false;
|
||||
this.instances = this.instances.filter(i => i.id != this.instanceModal.id);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gap-2 {
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
5
resources/assets/js/admin.js
vendored
5
resources/assets/js/admin.js
vendored
|
@ -26,6 +26,11 @@ Vue.component(
|
|||
require('./../components/admin/AdminDirectory.vue').default
|
||||
);
|
||||
|
||||
Vue.component(
|
||||
'instances-component',
|
||||
require('./../components/admin/AdminInstances.vue').default
|
||||
);
|
||||
|
||||
Vue.component(
|
||||
'hashtag-component',
|
||||
require('./../components/admin/AdminHashtags.vue').default
|
||||
|
|
|
@ -1,219 +1,12 @@
|
|||
@extends('admin.partial.template-full')
|
||||
|
||||
@section('section')
|
||||
<div class="title d-flex justify-content-between align-items-center">
|
||||
<h3 class="font-weight-bold mr-5">Instances</h3>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a class="btn btn-{{!request()->filled('filter')||request()->query('filter')=='all'?'primary':'outline-primary'}} font-weight-bold" href="?filter=all">All</a>
|
||||
{{-- <a class="btn btn-{{request()->query('filter')=='popular'?'primary':'outline-primary'}} font-weight-bold" href="?filter=popular">Popular</a> --}}
|
||||
<a class="btn btn-{{request()->query('filter')=='new'?'primary':'outline-primary'}} font-weight-bold" href="?filter=new">New</a>
|
||||
<a class="btn btn-{{request()->query('filter')=='cw'?'primary':'outline-primary'}} font-weight-bold" href="?filter=cw">CW</a>
|
||||
<a class="btn btn-{{request()->query('filter')=='banned'?'primary':'outline-primary'}} font-weight-bold" href="?filter=banned">Banned</a>
|
||||
<a class="btn btn-{{request()->query('filter')=='unlisted'?'primary':'outline-primary'}} font-weight-bold" href="?filter=unlisted">Unlisted</a>
|
||||
</div>
|
||||
<div class="">
|
||||
</div>
|
||||
<form class="" method="get">
|
||||
<input class="form-control rounded-pill" name="q" value="{{request()->query('q')}}" placeholder="Search domain">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8 offset-md-2">
|
||||
@if($instances->count() == 0 && !request()->has('filter') && !request()->has('q'))
|
||||
<div class="alert alert-warning mb-3">
|
||||
<p class="lead font-weight-bold mb-0">Warning</p>
|
||||
<p class="font-weight-lighter mb-0">No instances were found.</p>
|
||||
</div>
|
||||
<p class="font-weight-lighter">Do you want to scan and populate instances from Profiles and Statuses?</p>
|
||||
<p>
|
||||
<form method="post">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-primary py-1 font-weight-bold">Run Scan</button>
|
||||
</form>
|
||||
</p>
|
||||
@else
|
||||
<ul class="list-group">
|
||||
@foreach($instances as $instance)
|
||||
<li class="list-group-item">
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<p class="h4 font-weight-light mb-0 text-break mr-2">
|
||||
{{$instance->domain}}
|
||||
</p>
|
||||
<p class="mb-0 text-right" style="min-width: 210px;">
|
||||
@if($instance->unlisted)
|
||||
<i class="fas fa-minus-circle text-danger" data-toggle="tooltip" title="Unlisted from timelines"></i>
|
||||
@endif
|
||||
@if($instance->auto_cw)
|
||||
<i class="fas fa-eye-slash text-danger" data-toggle="tooltip" title="CW applied to all media"></i>
|
||||
@endif
|
||||
@if($instance->banned)
|
||||
<i class="fas fa-shield-alt text-danger" data-toggle="tooltip" title="Instance is banned"></i>
|
||||
@endif
|
||||
<a class="btn btn-outline-primary btn-sm py-0 font-weight-normal ml-2" href="{{$instance->getUrl()}}">Overview</a>
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 font-weight-normal btn-action"
|
||||
data-instance-id="{{$instance->id}}"
|
||||
data-instance-domain="{{$instance->domain}}"
|
||||
data-instance-unlisted="{{$instance->unlisted}}"
|
||||
data-instance-autocw="{{$instance->auto_cw}}"
|
||||
data-instance-banned="{{$instance->banned}}"
|
||||
>Actions</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<div class="d-flex justify-content-center mt-5 small">
|
||||
{{$instances->links()}}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(request()->filled('q') && $instances->count() == 0)
|
||||
<p class="text-center lead mb-0">No results found</p>
|
||||
<p class="text-center font-weight-bold mb-0"><a href="/i/admin/instances">Go back</a></p>
|
||||
@endif
|
||||
@if(request()->filled('filter') && $instances->count() == 0)
|
||||
<p class="text-center lead mb-0">No results found</p>
|
||||
<p class="text-center font-weight-bold mb-0"><a href="/i/admin/instances">Go back</a></p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<instances-component />
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script type="text/javascript" src="{{mix('js/components.js')}}"></script>
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$('.filesize').each(function(k,v) {
|
||||
$(this).text(filesize(v.getAttribute('data-size')))
|
||||
});
|
||||
|
||||
$('.btn-action').on('click', function(e) {
|
||||
let id = this.getAttribute('data-instance-id');
|
||||
let instanceDomain = this.getAttribute('data-instance-domain');
|
||||
let text = 'Domain: ' + instanceDomain;
|
||||
let unlisted = this.getAttribute('data-instance-unlisted');
|
||||
let autocw = this.getAttribute('data-instance-autocw');
|
||||
let banned = this.getAttribute('data-instance-banned');
|
||||
swal({
|
||||
title: 'Instance Actions',
|
||||
text: text,
|
||||
icon: 'warning',
|
||||
buttons: {
|
||||
unlist: {
|
||||
text: unlisted == 0 ? "Unlist" : "Re-list",
|
||||
className: "bg-warning",
|
||||
value: "unlisted",
|
||||
},
|
||||
cw: {
|
||||
text: autocw == 0 ? "CW Media" : "Remove AutoCW",
|
||||
className: "bg-warning",
|
||||
value: "autocw",
|
||||
},
|
||||
ban: {
|
||||
text: banned == 0 ? "Ban" : "Unban",
|
||||
className: "bg-danger",
|
||||
value: "ban",
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((value) => {
|
||||
switch (value) {
|
||||
case "unlisted":
|
||||
swal({
|
||||
title: "Are you sure?",
|
||||
text: unlisted == 0 ?
|
||||
"Are you sure you want to unlist " + instanceDomain + " ?" :
|
||||
"Are you sure you want to remove the unlisted rule of " + instanceDomain + " ?",
|
||||
icon: "warning",
|
||||
buttons: true,
|
||||
dangerMode: true,
|
||||
})
|
||||
.then((unlist) => {
|
||||
if (unlist) {
|
||||
axios.post('/i/admin/instances/edit/' + id, {
|
||||
action: 'unlist'
|
||||
}).then((res) => {
|
||||
swal("Domain action was successful! The page will now refresh.", {
|
||||
icon: "success",
|
||||
});
|
||||
setTimeout(function() {
|
||||
window.location.href = window.location.href;
|
||||
}, 5000);
|
||||
}).catch((err) => {
|
||||
swal("Something went wrong!", "Please try again later.", "error");
|
||||
})
|
||||
} else {
|
||||
swal("Action Cancelled", "You successfully cancelled this action.", "error");
|
||||
}
|
||||
});
|
||||
break;
|
||||
case "autocw":
|
||||
swal({
|
||||
title: "Are you sure?",
|
||||
text: autocw == 0 ?
|
||||
"Are you sure you want to auto CW all media from " + instanceDomain + " ?" :
|
||||
"Are you sure you want to remove the auto cw rule for " + instanceDomain + " ?",
|
||||
icon: "warning",
|
||||
buttons: true,
|
||||
dangerMode: true,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
axios.post('/i/admin/instances/edit/' + id, {
|
||||
action: 'autocw'
|
||||
}).then((res) => {
|
||||
swal("Domain action was successful! The page will now refresh.", {
|
||||
icon: "success",
|
||||
});
|
||||
setTimeout(function() {
|
||||
window.location.href = window.location.href;
|
||||
}, 5000);
|
||||
}).catch((err) => {
|
||||
swal("Something went wrong!", "Please try again later.", "error");
|
||||
})
|
||||
} else {
|
||||
swal("Action Cancelled", "You successfully cancelled this action.", "error");
|
||||
}
|
||||
});
|
||||
break;
|
||||
case "ban":
|
||||
swal({
|
||||
title: "Are you sure?",
|
||||
text: autocw == 0 ?
|
||||
"Are you sure you want to ban " + instanceDomain + " ?" :
|
||||
"Are you sure you want unban " + instanceDomain + " ?",
|
||||
icon: "warning",
|
||||
buttons: true,
|
||||
dangerMode: true,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res) {
|
||||
axios.post('/i/admin/instances/edit/' + id, {
|
||||
action: 'ban'
|
||||
}).then((res) => {
|
||||
swal("Domain action was successful! The page will now refresh.", {
|
||||
icon: "success",
|
||||
});
|
||||
setTimeout(function() {
|
||||
window.location.href = window.location.href;
|
||||
}, 5000);
|
||||
}).catch((err) => {
|
||||
swal("Something went wrong!", "Please try again later.", "error");
|
||||
})
|
||||
} else {
|
||||
swal("Action Cancelled", "You successfully cancelled this action.", "error");
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
new Vue({ el: '#panel'});
|
||||
</script>
|
||||
@endpush
|
||||
|
|
|
@ -24,12 +24,14 @@
|
|||
</author>
|
||||
<content type="html">
|
||||
<![CDATA[
|
||||
<img id="rss_item_{{$loop->iteration}}" src="{{ $item['media_attachments'][0]['url'] }}" alt="{{ $item['media_attachments'][0]['description'] }}">
|
||||
<p style="padding:10px;">{{ $item['content'] }}</p>
|
||||
<img id="rss_item_{{$item['id']}}" src="{{ $item['media_attachments'][0]['url'] }}" alt="{{ $item['media_attachments'][0]['description'] }}">
|
||||
<p style="padding:10px;">{!! $item['content'] !!}</p>
|
||||
]]>
|
||||
</content>
|
||||
<link rel="alternate" href="{{ $item['url'] }}" />
|
||||
@if($item['content'] && strlen($item['content']))
|
||||
<summary type="html">{{ $item['content'] }}</summary>
|
||||
@endif
|
||||
<media:content url="{{ $item['media_attachments'][0]['url'] }}" type="{{ $item['media_attachments'][0]['mime'] }}" medium="image" />
|
||||
</entry>
|
||||
@endforeach
|
||||
|
|
17
resources/views/site/help/licenses.blade.php
Normal file
17
resources/views/site/help/licenses.blade.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
@extends('site.help.partial.template', ['breadcrumb'=>'Licenses'])
|
||||
|
||||
@section('section')
|
||||
<div class="title">
|
||||
<h3 class="font-weight-bold">Licenses</h3>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<p class="lead">
|
||||
A Creative Commons license is one of several public copyright licenses that enable the free distribution of an otherwise copyrighted "work".
|
||||
</p>
|
||||
<p class="lead">
|
||||
A CC license is used when an author wants to give other people the right to share, use, and build upon a work that the author has created.
|
||||
</p>
|
||||
|
||||
<p>For more information, please visit <a href="https://creativecommons.org/licenses/" rel="nofollow">creativecommons.org</a></p>
|
||||
@endsection
|
|
@ -113,6 +113,13 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
|
|||
Route::get('hashtags/get', 'AdminController@hashtagsGet');
|
||||
Route::post('hashtags/update', 'AdminController@hashtagsUpdate');
|
||||
Route::post('hashtags/clear-trending-cache', 'AdminController@hashtagsClearTrendingCache');
|
||||
Route::get('instances/get', 'AdminController@getInstancesApi');
|
||||
Route::get('instances/stats', 'AdminController@getInstancesStatsApi');
|
||||
Route::get('instances/query', 'AdminController@getInstancesQueryApi');
|
||||
Route::post('instances/update', 'AdminController@postInstanceUpdateApi');
|
||||
Route::post('instances/create', 'AdminController@postInstanceCreateNewApi');
|
||||
Route::post('instances/delete', 'AdminController@postInstanceDeleteApi');
|
||||
Route::post('instances/refresh-stats', 'AdminController@postInstanceRefreshStatsApi');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -211,9 +218,6 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
|
|||
Route::get('accounts/{id}', 'PublicApiController@account');
|
||||
Route::post('avatar/update', 'ApiController@avatarUpdate');
|
||||
Route::get('custom_emojis', 'Api\ApiV1Controller@customEmojis');
|
||||
Route::get('likes', 'ApiController@hydrateLikes');
|
||||
Route::post('media', 'ApiController@uploadMedia');
|
||||
Route::delete('media', 'ApiController@deleteMedia');
|
||||
Route::get('notifications', 'ApiController@notifications');
|
||||
Route::get('timelines/public', 'PublicApiController@publicTimelineApi');
|
||||
Route::get('timelines/home', 'PublicApiController@homeTimelineApi');
|
||||
|
@ -277,7 +281,6 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
|
|||
Route::post('collection/{id}/publish', 'CollectionController@publish');
|
||||
Route::get('profile/collections/{id}', 'CollectionController@getUserCollections');
|
||||
|
||||
Route::get('compose/location/search', 'ApiController@composeLocationSearch');
|
||||
Route::post('compose/tag/untagme', 'MediaTagController@untagProfile');
|
||||
});
|
||||
|
||||
|
@ -340,8 +343,6 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
|
|||
Route::get('auth/checkpoint', 'AccountController@twoFactorCheckpoint');
|
||||
Route::post('auth/checkpoint', 'AccountController@twoFactorVerify');
|
||||
|
||||
Route::get('media/preview/{profileId}/{mediaId}/{timestamp}', 'ApiController@showTempMedia')->name('temp-media');
|
||||
|
||||
Route::get('results', 'SearchController@results');
|
||||
Route::post('visibility', 'StatusController@toggleVisibility');
|
||||
|
||||
|
@ -421,8 +422,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
|
|||
Route::delete('avatar', 'AvatarController@deleteAvatar');
|
||||
Route::get('password', 'SettingsController@password')->name('settings.password')->middleware('dangerzone');
|
||||
Route::post('password', 'SettingsController@passwordUpdate')->middleware('dangerzone');
|
||||
Route::get('email', 'SettingsController@email')->name('settings.email');
|
||||
Route::post('email', 'SettingsController@emailUpdate');
|
||||
Route::get('email', 'SettingsController@email')->name('settings.email')->middleware('dangerzone');
|
||||
Route::post('email', 'SettingsController@emailUpdate')->middleware('dangerzone');
|
||||
Route::get('notifications', 'SettingsController@notifications')->name('settings.notifications');
|
||||
Route::get('privacy', 'SettingsController@privacy')->name('settings.privacy');
|
||||
Route::post('privacy', 'SettingsController@privacyStore');
|
||||
|
@ -549,6 +550,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
|
|||
Route::view('data-policy', 'site.help.data-policy')->name('help.data-policy');
|
||||
Route::view('labs-deprecation', 'site.help.labs-deprecation')->name('help.labs-deprecation');
|
||||
Route::view('tagging-people', 'site.help.tagging-people')->name('help.tagging-people');
|
||||
Route::view('licenses', 'site.help.licenses')->name('help.licenses');
|
||||
});
|
||||
Route::get('newsroom/{year}/{month}/{slug}', 'NewsroomController@show');
|
||||
Route::get('newsroom/archive', 'NewsroomController@archive');
|
||||
|
|
Loading…
Reference in a new issue