Merge pull request #6 from pixelfed/dev

Sync August 24
This commit is contained in:
okpierre 2019-08-24 18:32:44 -04:00 committed by GitHub
commit 754801e4fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
229 changed files with 782673 additions and 5031 deletions

View file

@ -25,6 +25,7 @@ SESSION_DRIVER=redis
SESSION_LIFETIME=120
QUEUE_DRIVER=redis
REDIS_SCHEME=tcp
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

View file

@ -25,6 +25,7 @@ SESSION_DRIVER=redis
SESSION_LIFETIME=120
QUEUE_DRIVER=redis
REDIS_SCHEME=tcp
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

127
CHANGELOG.md Normal file
View file

@ -0,0 +1,127 @@
# Release Notes
## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.9.4...dev)
## [v0.9.X (TBD)](https://github.com/pixelfed/pixelfed/compare/v0.9.4...dev)
### Added
- Add StatusService [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [425ec91](https://github.com/pixelfed/pixelfed/commit/425ec91)
- Add PublicTimelineService [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [734e892](https://github.com/pixelfed/pixelfed/commit/734e892)
- Add RelationshipSettings trait [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [bf8340f](https://github.com/pixelfed/pixelfed/commit/bf8340f)
- Add Remote Follows [#1388](https://github.com/pixelfed/pixelfed/pull/1388)
- Add Relationship Settings [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [b10e03d](https://github.com/pixelfed/pixelfed/commit/b10e03d)
- Add Configuration Editor to Admin Dashboard [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [323dca1](https://github.com/pixelfed/pixelfed/commit/323dca1)
- Add Migration, adding profile_id to users table [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [bdfe633](https://github.com/pixelfed/pixelfed/commit/bdfe633)
- Add Media configuration [#1414](https://github.com/pixelfed/pixelfed/pull/1414)
- Add Content Warnings to comments [#1430](https://github.com/pixelfed/pixelfed/pull/1430), [42d81fc](https://github.com/pixelfed/pixelfed/commit/42d81fc) [8d4b3bd](https://github.com/pixelfed/pixelfed/commit/8d4b3bd) [73e162e4](https://github.com/pixelfed/pixelfed/commit/3e162e4)
- Add new rate limits [#1436](https://github.com/pixelfed/pixelfed/pull/1436) [1f1df2d](https://github.com/pixelfed/pixelfed/commit/1f1df2d)
- Add RegenerateThumbnails command to force thumbnail regeneration [#1437](https://github.com/pixelfed/pixelfed/pull/1437) [a3be4cd](https://github.com/pixelfed/pixelfed/commit/a3be4cd)
- Add Pages Editor to Admin Dashboard [#1438](https://github.com/pixelfed/pixelfed/pull/1438) [ef3e30d](https://github.com/pixelfed/pixelfed/commit/ef3e30d) [718375a](https://github.com/pixelfed/pixelfed/commit/718375a) [79524a0](https://github.com/pixelfed/pixelfed/commit/79524a0) [13ceef0](https://github.com/pixelfed/pixelfed/commit/13ceef0) [2fbcd6d](https://github.com/pixelfed/pixelfed/commit/2fbcd6d) [bb207a4](https://github.com/pixelfed/pixelfed/commit/bb207a4) [ef07e31](https://github.com/pixelfed/pixelfed/commit/ef07e31) [aca5114](https://github.com/pixelfed/pixelfed/commit/aca5114) [59fcfc2](https://github.com/pixelfed/pixelfed/commit/59fcfc2) [e3cfd81](https://github.com/pixelfed/pixelfed/commit/e3cfd81) [7ade78b](https://github.com/pixelfed/pixelfed/commit/7ade78b) [4539afa](https://github.com/pixelfed/pixelfed/commit/4539afa) [1dbfcae](https://github.com/pixelfed/pixelfed/commit/1dbfcae)
### Changed
- Update SearchController, fix AP verb typo [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [dc8acf9](https://github.com/pixelfed/pixelfed/commit/dc8acf9)
- Update StatusTransformer, increase media cache ttl to 14 days [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [f35718b](https://github.com/pixelfed/pixelfed/commit/f35718b)
- Update webpack config, extract vendor librarys [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [b42db89](https://github.com/pixelfed/pixelfed/commit/b42db89)
- Update admin statuses view, make table header light [#1387](https://github.com/pixelfed/pixelfed/pull/1387), [44afcc7](https://github.com/pixelfed/pixelfed/commit/44afcc7)
- Update settings, move disable/delete to Security Settings [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [ca0d638](https://github.com/pixelfed/pixelfed/commit/ca0d638)
- Update Installer command [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [506dd8b](https://github.com/pixelfed/pixelfed/commit/506dd8b)
- Update UserObserver [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [4ee3d10](https://github.com/pixelfed/pixelfed/commit/4ee3d10)
- Update AuthLogin listener [#1388](https://github.com/pixelfed/pixelfed/pull/1388), [c27c751](https://github.com/pixelfed/pixelfed/commit/c27c751) [1e8b092](https://github.com/pixelfed/pixelfed/commit/1e8b092)
- Update Image Optimization to not store EXIF by default [#1414](https://github.com/pixelfed/pixelfed/pull/1414)
- Update Settings, hide OAuth/Developer pages when not enabled [#1413](https://github.com/pixelfed/pixelfed/pull/1413)
- Update Presenter Components, move alt tag and filters to ```<img>``` element [#1415](https://github.com/pixelfed/pixelfed/pull/1415)
- Update Api Controllers, add missing caption limit to ```composePost()``` and missing ```is_nsfw``` attribute to comment queries [#1429](https://github.com/pixelfed/pixelfed/pull/1429), [1cff278](https://github.com/pixelfed/pixelfed/commit/1cff278)
- Update instances admin view, add scan button to find new instances [#1436](https://github.com/pixelfed/pixelfed/pull/1436) [a94a3ee](https://github.com/pixelfed/pixelfed/commit/a94a3ee)
- Update registration page, add links to terms and privacy pages [#1488](https://github.com/pixelfed/pixelfed/pull/1488)
### Removed
- Remove Classic Compose UI [#1434](https://github.com/pixelfed/pixelfed/pull/1434), [72bffd1](https://github.com/pixelfed/pixelfed/commit/72bffd1) [a2640af](https://github.com/pixelfed/pixelfed/commit/a2640af)
-
## [v0.9.4 (2019-06-03)](https://github.com/pixelfed/pixelfed/compare/v0.9.0...v0.9.4)
PSA: Due to the removal of Google Recaptcha, a one-time manual intervention is required. Please try the following after installing with composer:
```
rm -rf bootstrap/cache/*
composer dump-autoload
php artisan config:cache
```
### Added
- Notification service
- Notification card on timeline
- Double-tap to like posts (no animation yet)
- Moderator Mode for timelines
- Emoji reaction bar
- Like and reply to comments
- Hello Loops! Short videos will now loop and be discoverable from the Discover page.
- Labs: Optional profile recommendations
- Labs: Show full caption instead of "read more" button
- Labs: Simple "distraction-free" timeline -- no buttons, just images and captions
### Changed
- Refactored notification view into a Vue component
- Preparations for Circles, DMs, and other upcoming functionality
- Default limit of 7500 follows
- Default limit of 20 follows per hour
- Default limit of 5 mentions per comment/caption
- Default limit of 30 hashtags per comment/caption
- Default limit of 2 links per comment/caption
- Thumbnail info overlays on profiles should now scale down to small screens (#1234)
- Moment UI containers are now properly sized (#1236)
- Album posts now have contrast for next/prev arrows (#1238)
- Filter previews now fit the image instead of stretching it (#1239)
### Removed
- Google Recaptcha is no longer supported (#1231)
- Lightbox has been deprecated in favor of double-tap-to-like; it will return as a dedicated button in the future (#1277)
## [v0.9.0 (2019-04-17)](https://github.com/pixelfed/pixelfed/compare/v0.8.6...v0.9.0)
### Added
- Allow users to delete existing profile photos.
- Preliminary support for managing developer tokens, as well as authorizing apps
- Unmute and unblock users more easily. Profiles now reflect muting/blocking status.
- Lazy-loading images with `loading="lazy"`, as supported in Blink
- Added Network Timeline which includes non-local posts
- Add broadcast events for real-time updates
- Compose view now shows upload progress bar
- You can now audit logged-in devices
- Added WIP installer
- Moment UI! This alternative profile view is less square and more full-width pictures.
### Changed
- Allow admins to view reported private posts
- Show sensitivity and privacy/audience in status views
- Cleanup of legacy code
- `commentsDisabled` has been replaced with preliminary support for Litepub Capability Enforcement (LiCE)
- `rel="me"` now added to profile websites
- Posts from locked accounts now default to followers-only
### Removed
- Removed identicons due to SVG compatibility issues with federation. New users will instead be assigned a default avatar.
## [v0.8.6 (2019-04-06)](https://github.com/pixelfed/pixelfed/compare/v0.8.5...v0.8.6)
### Added
- Add COSTAR - Confirm Object Sentiment Transform and Reduce
COSTAR is a filtering system that allows admins to define environment variables that will dynamically apply certain policies to posts of a defined scope, similar to Pleroma's MRF system.
Scopes:
- Domain: apply to posts from a specific website
- Actor: apply to posts from a specific profile/user
- Keyword: apply to posts containing a specific string
Policies:
- Block: Default blocks the defined scope
- CW: Automatically rewrites the scope to apply a warning
- Unlist: Removes the scope from public timelines

17
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,17 @@
# Contributing
## Bug Reports
To encourage active collaboration, Pixelfed strongly encourages pull requests, not just bug reports. "Bug reports" may also be sent in the form of a pull request containing a failing test.
However, if you file a bug report, your issue should contain a title and a clear description of the issue. You should also include as much relevant information as possible and a code sample that demonstrates the issue. The goal of a bug report is to make it easy for yourself - and others - to replicate the bug and develop a fix.
Remember, bug reports are created in the hope that others with the same problem will be able to collaborate with you on solving it. Do not expect that the bug report will automatically see any activity or that others will jump to fix it. Creating a bug report serves to help yourself and others start on the path of fixing the problem.
## Core Development Discussion
Informal discussion regarding bugs, new features, and implementation of existing features takes place in the ```#pixelfed-dev``` channel on the Freenode IRC network.
## Compiled Assets
If you are submitting a change that will affect a compiled file, such as most of the files in ```resources/assets/sass``` or ```resources/assets/js``` of the pixelfed/pixelfed repository, do not commit the compiled files. Due to their large size, they cannot realistically be reviewed by a maintainer. This could be exploited as a way to inject malicious code into Pixelfed. In order to defensively prevent this, all compiled files will be generated and committed by Pixelfed maintainers.
## Security Vulnerabilities
If you discover a security vulnerability within Pixelfed, please send an email to Daniel Supernault at hello@pixelfed.org. All security vulnerabilities will be promptly addressed.

View file

@ -17,7 +17,7 @@ A free and ethical photo sharing platform, powered by ActivityPub federation.
## Official Documentation
Documentation for Pixelfed can be found on the [Pixelfed documentation website](https://pixelfed.github.io/docs/master/).
Documentation for Pixelfed can be found on the [Pixelfed documentation website](https://docs.pixelfed.org/).
## License

View file

@ -2,6 +2,7 @@
namespace App;
use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary;
@ -16,8 +17,34 @@ class Collection extends Model
*/
public $incrementing = false;
public $fillable = ['profile_id', 'published_at'];
public $dates = ['published_at'];
public function profile()
{
return $this->belongsTo(Profile::class);
}
public function items()
{
return $this->hasMany(CollectionItem::class);
}
public function posts()
{
return $this->hasManyThrough(
Status::class,
CollectionItem::class,
'collection_id',
'id',
'id',
'object_id'
);
}
public function url()
{
return url("/c/{$this->id}");
}
}

View file

@ -9,6 +9,13 @@ class CollectionItem extends Model
{
use HasSnowflakePrimary;
public $fillable = [
'collection_id',
'object_type',
'object_id',
'order'
];
/**
* Indicates if the IDs are auto-incrementing.
*

View file

@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\User;
use App\Services\EmailService;
class BannedEmailCheck extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'email:bancheck';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Checks user emails for banned domains';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$users = User::whereNull('status')->get()->filter(function($u) {
return EmailService::isBanned($u->email) == true;
});
foreach($users as $user) {
$this->info('Found banned domain: ' . $user->email . PHP_EOL);
}
}
}

View file

@ -2,6 +2,7 @@
namespace App\Console\Commands;
use DB;
use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\Media;
use Illuminate\Console\Command;
@ -39,9 +40,20 @@ class CatchUnoptimizedMedia extends Command
*/
public function handle()
{
$medias = Media::whereNotNull('status_id')->whereNull('processed_at')->take(250)->get();
foreach ($medias as $media) {
ImageOptimize::dispatch($media);
}
DB::transaction(function() {
Media::whereNull('processed_at')
->whereNull('remote_url')
->whereNotNull('status_id')
->whereNotNull('media_path')
->whereIn('mime', [
'image/jpeg',
'image/png',
])
->chunk(50, function($medias) {
foreach ($medias as $media) {
ImageOptimize::dispatch($media);
}
});
});
}
}

View file

@ -0,0 +1,109 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use DB;
use App\{
Hashtag,
Status,
StatusHashtag
};
class FixHashtags extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'fix:hashtags';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fix Hashtags';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->info(' ____ _ ______ __ ');
$this->info(' / __ \(_) _____ / / __/__ ____/ / ');
$this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / ');
$this->info(' / ____/ /> </ __/ / __/ __/ /_/ / ');
$this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ ');
$this->info(' ');
$this->info(' ');
$this->info('Pixelfed version: ' . config('pixelfed.version'));
$this->info(' ');
$this->info('Running Fix Hashtags command');
$this->info(' ');
$missingCount = StatusHashtag::doesntHave('profile')->doesntHave('status')->count();
if($missingCount > 0) {
$this->info("Found {$missingCount} orphaned StatusHashtag records to delete ...");
$this->info(' ');
$bar = $this->output->createProgressBar($missingCount);
$bar->start();
foreach(StatusHashtag::doesntHave('profile')->doesntHave('status')->get() as $tag) {
$tag->delete();
$bar->advance();
}
$bar->finish();
$this->info(' ');
} else {
$this->info(' ');
$this->info('Found no orphaned hashtags to delete!');
}
$this->info(' ');
$count = StatusHashtag::whereNull('status_visibility')->count();
if($count > 0) {
$this->info("Found {$count} hashtags to fix ...");
$this->info(' ');
} else {
$this->info('Found no hashtags to fix!');
$this->info(' ');
return;
}
$bar = $this->output->createProgressBar($count);
$bar->start();
StatusHashtag::with('status')
->whereNull('status_visibility')
->chunk(50, function($tags) use($bar) {
foreach($tags as $tag) {
if(!$tag->status || !$tag->status->scope) {
continue;
}
$tag->status_visibility = $tag->status->scope;
$tag->save();
$bar->advance();
}
});
$bar->finish();
$this->info(' ');
$this->info(' ');
}
}

View file

@ -0,0 +1,158 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Place;
use DB;
use Illuminate\Support\Str;
class ImportCities extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'import:cities {chunk=1000}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Import Cities to database';
/**
* Checksum of city dataset.
*
*/
const CHECKSUM = 'e203c0247538788b2a91166c7cf4b95f58291d998f514e9306d315aa72b09e48bfd3ddf310bf737afc4eefadca9083b8ff796c67796c6bd8e882a3d268bd16af';
/**
* List of shortened countries.
*
* @var array
*/
protected $countries = [
'AE' => 'UAE',
'BA' => 'Bosnia-Herzegovina',
'BO' => 'Bolivia',
'CD' => 'Democratic Republic of Congo',
'CG' => 'Republic of Congo',
'FM' => 'Micronesia',
'GB' => 'United Kingdom',
'IR' => 'Iran',
'KP' => 'DRPK',
'KR' => 'South Korea',
'LA' => 'Laos',
'MD' => 'Moldova',
'PS' => 'Palestine',
'RU' => 'Russia',
'SH' => 'Saint Helena',
'SY' => 'Syria',
'TW' => 'Taiwan',
'TZ' => 'Tanzania',
'US' => 'USA',
'VE' => 'Venezuela',
'XK' => 'Kosovo'
];
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
ini_set('memory_limit', '256M');
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$path = storage_path('app/cities.json');
if(hash_file('sha512', $path) !== self::CHECKSUM) {
$this->error('Invalid or corrupt storage/app/cities.json data.');
$this->line('');
$this->info('Run the following command to fix:');
$this->info('git checkout storage/app/cities.json');
return;
}
if (!is_file($path)) {
$this->error('Missing storage/app/cities.json file!');
return;
}
if (Place::count() > 0) {
DB::table('places')->truncate();
}
$this->info('Importing city data into database ...');
$cities = json_decode(file_get_contents($path));
$cityCount = count($cities);
$this->line('');
$this->info("Found {$cityCount} cities to insert ...");
$this->line('');
$bar = $this->output->createProgressBar($cityCount);
$bar->start();
$buffer = [];
$count = 0;
foreach ($cities as $city) {
$buffer[] = [
"name" => $city->name,
"slug" => Str::slug($city->name),
"country" => $this->codeToCountry($city->country),
"lat" => $city->lat,
"long" => $city->lng
];
$count++;
if ($count % $this->argument('chunk') == 0) {
$this->insertBuffer($buffer);
$bar->advance(count($buffer));
$buffer = [];
}
}
$this->insertBuffer($buffer);
$bar->advance(count($buffer));
$bar->finish();
$this->line('');
$this->line('');
$this->info('Successfully imported ' . $cityCount . ' entries!');
$this->line('');
return;
}
private function insertBuffer($buffer)
{
DB::table('places')->insert($buffer);
}
private function codeToCountry($code)
{
$countries = $this->countries;
if(isset($countries[$code])) {
return $countries[$code];
}
$country = (new \League\ISO3166\ISO3166)->alpha2($code);
$this->countries[$code] = $country['name'];
return $this->countries[$code];
}
}

View file

@ -39,12 +39,16 @@ class MediaGarbageCollector extends Command
*/
public function handle()
{
$gc = Media::whereNull('status_id')
->where('created_at', '<', Carbon::now()->subHours(6)->toDateTimeString())
$limit = 20000;
$gc = Media::doesntHave('status')
->where('created_at', '<', Carbon::now()->subHours(1)->toDateTimeString())
->orderBy('created_at','asc')
->take(500)
->take($limit)
->get();
$bar = $this->output->createProgressBar($gc->count());
$bar->start();
foreach($gc as $media) {
$path = storage_path("app/$media->media_path");
$thumb = storage_path("app/$media->thumbnail_path");
@ -55,6 +59,8 @@ class MediaGarbageCollector extends Command
unlink($thumb);
}
$media->forceDelete();
$bar->advance();
}
$bar->finish();
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Status;
use DB;
use App\Jobs\StatusPipeline\StatusDelete;
class StatusDedupe extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'status:dedup';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Removes duplicate statuses from before unique uri migration';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
DB::table('statuses')
->selectRaw('id, uri, count(uri) as occurences')
->whereNull('deleted_at')
->whereNotNull('uri')
->groupBy('uri')
->orderBy('created_at')
->having('occurences', '>', 1)
->chunk(50, function($statuses) {
foreach($statuses as $status) {
$this->info("Found duplicate: $status->uri");
Status::whereUri($status->uri)
->where('id', '!=', $status->id)
->get()
->map(function($status) {
$this->info("Deleting Duplicate ID: $status->id");
StatusDelete::dispatch($status);
});
}
});
}
}

View file

@ -20,8 +20,8 @@ class Hashtag extends Model
);
}
public function url()
public function url($suffix = '')
{
return config('routes.hashtag.base').$this->slug;
return config('routes.hashtag.base').$this->slug.$suffix;
}
}

19
app/HashtagFollow.php Normal file
View file

@ -0,0 +1,19 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class HashtagFollow extends Model
{
protected $fillable = [
'user_id',
'profile_id',
'hashtag_id'
];
public function hashtag()
{
return $this->belongsTo(Hashtag::class);
}
}

View file

@ -2,476 +2,444 @@
namespace App\Http\Controllers;
use App\EmailVerification;
use App\Follower;
use App\FollowRequest;
use App\Jobs\FollowPipeline\FollowPipeline;
use App\Mail\ConfirmEmail;
use App\Notification;
use App\Profile;
use App\User;
use App\UserFilter;
use Auth;
use Cache;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Mail;
use Redis;
use Carbon\Carbon;
use App\Mail\ConfirmEmail;
use Illuminate\Http\Request;
use PragmaRX\Google2FA\Google2FA;
use App\Jobs\FollowPipeline\FollowPipeline;
use App\{
EmailVerification,
Follower,
FollowRequest,
Notification,
Profile,
User,
UserFilter
};
class AccountController extends Controller
{
protected $filters = [
'user.mute',
'user.block',
];
public function __construct()
{
$this->middleware('auth');
}
public function notifications(Request $request)
{
return view('account.activity');
}
public function followingActivity(Request $request)
{
$this->validate($request, [
'page' => 'nullable|min:1|max:3',
'a' => 'nullable|alpha_dash',
]);
$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)
->whereIn('action', $allowed)
->where('actor_id', '<>', $profile->id)
->where('profile_id', '<>', $profile->id)
->whereDate('created_at', '>', $timeago)
->orderBy('notifications.created_at', 'desc')
->simplePaginate(30);
return view('account.following', compact('profile', 'notifications'));
}
public function verifyEmail(Request $request)
{
return view('account.verify_email');
}
public function sendVerifyEmail(Request $request)
{
$recentAttempt = EmailVerification::whereUserId(Auth::id())
->whereDate('created_at', '>', now()->subHours(12))->count();
if ($recentAttempt > 0) {
return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.');
}
EmailVerification::whereUserId(Auth::id())->delete();
$user = User::whereNull('email_verified_at')->find(Auth::id());
$utoken = str_random(40);
$rtoken = str_random(128);
$verify = new EmailVerification();
$verify->user_id = $user->id;
$verify->email = $user->email;
$verify->user_token = $utoken;
$verify->random_token = $rtoken;
$verify->save();
Mail::to($user->email)->send(new ConfirmEmail($verify));
return redirect()->back()->with('status', 'Verification email sent!');
}
public function confirmVerifyEmail(Request $request, $userToken, $randomToken)
{
$verify = EmailVerification::where('user_token', $userToken)
->where('random_token', $randomToken)
->firstOrFail();
if (Auth::id() === $verify->user_id &&
$verify->user_token === $userToken &&
$verify->random_token === $randomToken) {
$user = User::find(Auth::id());
$user->email_verified_at = Carbon::now();
$user->save();
return redirect('/');
} else {
abort(403);
}
}
public function fetchNotifications(int $id)
{
$key = config('cache.prefix').":user.{$id}.notifications";
$redis = Redis::connection();
$notifications = $redis->lrange($key, 0, 30);
if (empty($notifications)) {
$notifications = Notification::whereProfileId($id)
->orderBy('id', 'desc')->take(30)->get();
} else {
$notifications = $this->hydrateNotifications($notifications);
}
return $notifications;
}
public function hydrateNotifications($keys)
{
$prefix = 'notification.';
$notifications = collect([]);
foreach ($keys as $key) {
$notifications->push(Cache::get("{$prefix}{$key}"));
}
return $notifications;
}
public function messages()
{
return view('account.messages');
}
public function direct()
{
return view('account.direct');
}
public function showMessage(Request $request, $id)
{
return view('account.message');
}
public function mute(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$type = $request->input('type');
$item = $request->input('item');
$action = $type . '.mute';
if (!in_array($action, $this->filters)) {
return abort(406);
}
$filterable = [];
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
return abort(403);
}
$class = get_class($profile);
$filterable['id'] = $profile->id;
$filterable['type'] = $class;
break;
default:
// code...
break;
}
$filter = UserFilter::firstOrCreate([
'user_id' => $user->id,
'filterable_id' => $filterable['id'],
'filterable_type' => $filterable['type'],
'filter_type' => 'mute',
]);
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
return redirect()->back();
}
public function unmute(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$type = $request->input('type');
$item = $request->input('item');
$action = $type . '.mute';
if (!in_array($action, $this->filters)) {
return abort(406);
}
$filterable = [];
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
return abort(403);
}
$class = get_class($profile);
$filterable['id'] = $profile->id;
$filterable['type'] = $class;
break;
default:
abort(400);
break;
}
$filter = UserFilter::whereUserId($user->id)
->whereFilterableId($filterable['id'])
->whereFilterableType($filterable['type'])
->whereFilterType('mute')
->first();
if($filter) {
$filter->delete();
}
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
if($request->wantsJson()) {
return response()->json([200]);
} else {
return redirect()->back();
}
}
public function block(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$type = $request->input('type');
$item = $request->input('item');
$action = $type.'.block';
if (!in_array($action, $this->filters)) {
return abort(406);
}
$filterable = [];
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
return abort(403);
}
$class = get_class($profile);
$filterable['id'] = $profile->id;
$filterable['type'] = $class;
Follower::whereProfileId($profile->id)->whereFollowingId($user->id)->delete();
Notification::whereProfileId($user->id)->whereActorId($profile->id)->delete();
break;
default:
// code...
break;
}
$filter = UserFilter::firstOrCreate([
'user_id' => $user->id,
'filterable_id' => $filterable['id'],
'filterable_type' => $filterable['type'],
'filter_type' => 'block',
]);
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
return redirect()->back();
}
public function unblock(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$type = $request->input('type');
$item = $request->input('item');
$action = $type . '.block';
if (!in_array($action, $this->filters)) {
return abort(406);
}
$filterable = [];
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
return abort(403);
}
$class = get_class($profile);
$filterable['id'] = $profile->id;
$filterable['type'] = $class;
break;
default:
abort(400);
break;
}
$filter = UserFilter::whereUserId($user->id)
->whereFilterableId($filterable['id'])
->whereFilterableType($filterable['type'])
->whereFilterType('block')
->first();
if($filter) {
$filter->delete();
}
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
return redirect()->back();
}
public function followRequests(Request $request)
{
$pid = Auth::user()->profile->id;
$followers = FollowRequest::whereFollowingId($pid)->orderBy('id','desc')->whereIsRejected(0)->simplePaginate(10);
return view('account.follow-requests', compact('followers'));
}
public function followRequestHandle(Request $request)
{
$this->validate($request, [
'action' => 'required|string|max:10',
'id' => 'required|integer|min:1'
]);
$pid = Auth::user()->profile->id;
$action = $request->input('action') === 'accept' ? 'accept' : 'reject';
$id = $request->input('id');
$followRequest = FollowRequest::whereFollowingId($pid)->findOrFail($id);
$follower = $followRequest->follower;
switch ($action) {
case 'accept':
$follow = new Follower();
$follow->profile_id = $follower->id;
$follow->following_id = $pid;
$follow->save();
FollowPipeline::dispatch($follow);
$followRequest->delete();
break;
case 'reject':
$followRequest->is_rejected = true;
$followRequest->save();
break;
}
return response()->json(['msg' => 'success'], 200);
}
public function sudoMode(Request $request)
{
return view('auth.sudo');
}
public function sudoModeVerify(Request $request)
{
$this->validate($request, [
'password' => 'required|string|max:500'
]);
$user = Auth::user();
$password = $request->input('password');
$next = $request->session()->get('redirectNext', '/');
if(password_verify($password, $user->password) === true) {
$request->session()->put('sudoMode', time());
return redirect($next);
} else {
return redirect()
->back()
->withErrors(['password' => __('auth.failed')]);
}
}
public function twoFactorCheckpoint(Request $request)
{
return view('auth.checkpoint');
}
public function twoFactorVerify(Request $request)
{
$this->validate($request, [
'code' => 'required|string|max:32'
]);
$user = Auth::user();
$code = $request->input('code');
$google2fa = new Google2FA();
$verify = $google2fa->verifyKey($user->{'2fa_secret'}, $code);
if($verify) {
$request->session()->push('2fa.session.active', true);
return redirect('/');
} else {
if($this->twoFactorBackupCheck($request, $code, $user)) {
return redirect('/');
}
if($request->session()->has('2fa.attempts')) {
$count = (int) $request->session()->has('2fa.attempts');
$request->session()->push('2fa.attempts', $count + 1);
} else {
$request->session()->push('2fa.attempts', 1);
}
return redirect()->back()->withErrors([
'code' => 'Invalid code'
]);
}
}
protected function twoFactorBackupCheck($request, $code, User $user)
{
$backupCodes = $user->{'2fa_backup_codes'};
if($backupCodes) {
$codes = json_decode($backupCodes, true);
foreach ($codes as $c) {
if(hash_equals($c, $code)) {
// remove code
$codes = array_flatten(array_diff($codes, [$code]));
$user->{'2fa_backup_codes'} = json_encode($codes);
$user->save();
$request->session()->push('2fa.session.active', true);
return true;
} else {
return false;
}
}
} else {
return false;
}
}
public function accountRestored(Request $request)
{
//
}
protected $filters = [
'user.mute',
'user.block',
];
public function __construct()
{
$this->middleware('auth');
}
public function notifications(Request $request)
{
return view('account.activity');
}
public function followingActivity(Request $request)
{
$this->validate($request, [
'page' => 'nullable|min:1|max:3',
'a' => 'nullable|alpha_dash',
]);
$action = $request->input('a');
$allowed = ['like', 'follow'];
$timeago = Carbon::now()->subMonths(3);
$profile = Auth::user()->profile;
$following = $profile->following->pluck('id');
$notifications = Notification::whereIn('actor_id', $following)
->whereIn('action', $allowed)
->where('actor_id', '<>', $profile->id)
->where('profile_id', '<>', $profile->id)
->whereDate('created_at', '>', $timeago)
->orderBy('notifications.created_at', 'desc')
->simplePaginate(30);
return view('account.following', compact('profile', 'notifications'));
}
public function verifyEmail(Request $request)
{
return view('account.verify_email');
}
public function sendVerifyEmail(Request $request)
{
$recentAttempt = EmailVerification::whereUserId(Auth::id())
->whereDate('created_at', '>', now()->subHours(12))->count();
if ($recentAttempt > 0) {
return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.');
}
EmailVerification::whereUserId(Auth::id())->delete();
$user = User::whereNull('email_verified_at')->find(Auth::id());
$utoken = str_random(64);
$rtoken = str_random(128);
$verify = new EmailVerification();
$verify->user_id = $user->id;
$verify->email = $user->email;
$verify->user_token = $utoken;
$verify->random_token = $rtoken;
$verify->save();
Mail::to($user->email)->send(new ConfirmEmail($verify));
return redirect()->back()->with('status', 'Verification email sent!');
}
public function confirmVerifyEmail(Request $request, $userToken, $randomToken)
{
$verify = EmailVerification::where('user_token', $userToken)
->where('created_at', '>', now()->subWeeks(2))
->where('random_token', $randomToken)
->firstOrFail();
if (Auth::id() === $verify->user_id && $verify->user_token === $userToken && $verify->random_token === $randomToken) {
$user = User::find(Auth::id());
$user->email_verified_at = Carbon::now();
$user->save();
return redirect('/');
} else {
abort(403);
}
}
public function messages()
{
return view('account.messages');
}
public function direct()
{
return view('account.direct');
}
public function showMessage(Request $request, $id)
{
return view('account.message');
}
public function mute(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$type = $request->input('type');
$item = $request->input('item');
$action = $type . '.mute';
if (!in_array($action, $this->filters)) {
return abort(406);
}
$filterable = [];
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
return abort(403);
}
$class = get_class($profile);
$filterable['id'] = $profile->id;
$filterable['type'] = $class;
break;
}
$filter = UserFilter::firstOrCreate([
'user_id' => $user->id,
'filterable_id' => $filterable['id'],
'filterable_type' => $filterable['type'],
'filter_type' => 'mute',
]);
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
return redirect()->back();
}
public function unmute(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$type = $request->input('type');
$item = $request->input('item');
$action = $type . '.mute';
if (!in_array($action, $this->filters)) {
return abort(406);
}
$filterable = [];
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
return abort(403);
}
$class = get_class($profile);
$filterable['id'] = $profile->id;
$filterable['type'] = $class;
break;
default:
abort(400);
break;
}
$filter = UserFilter::whereUserId($user->id)
->whereFilterableId($filterable['id'])
->whereFilterableType($filterable['type'])
->whereFilterType('mute')
->first();
if($filter) {
$filter->delete();
}
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
if($request->wantsJson()) {
return response()->json([200]);
} else {
return redirect()->back();
}
}
public function block(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$type = $request->input('type');
$item = $request->input('item');
$action = $type.'.block';
if (!in_array($action, $this->filters)) {
return abort(406);
}
$filterable = [];
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
return abort(403);
}
$class = get_class($profile);
$filterable['id'] = $profile->id;
$filterable['type'] = $class;
Follower::whereProfileId($profile->id)->whereFollowingId($user->id)->delete();
Notification::whereProfileId($user->id)->whereActorId($profile->id)->delete();
break;
}
$filter = UserFilter::firstOrCreate([
'user_id' => $user->id,
'filterable_id' => $filterable['id'],
'filterable_type' => $filterable['type'],
'filter_type' => 'block',
]);
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
return redirect()->back();
}
public function unblock(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'item' => 'required|integer|min:1',
]);
$user = Auth::user()->profile;
$type = $request->input('type');
$item = $request->input('item');
$action = $type . '.block';
if (!in_array($action, $this->filters)) {
return abort(406);
}
$filterable = [];
switch ($type) {
case 'user':
$profile = Profile::findOrFail($item);
if ($profile->id == $user->id) {
return abort(403);
}
$class = get_class($profile);
$filterable['id'] = $profile->id;
$filterable['type'] = $class;
break;
default:
abort(400);
break;
}
$filter = UserFilter::whereUserId($user->id)
->whereFilterableId($filterable['id'])
->whereFilterableType($filterable['type'])
->whereFilterType('block')
->first();
if($filter) {
$filter->delete();
}
$pid = $user->id;
Cache::forget("user:filter:list:$pid");
Cache::forget("feature:discover:posts:$pid");
Cache::forget("api:local:exp:rec:$pid");
return redirect()->back();
}
public function followRequests(Request $request)
{
$pid = Auth::user()->profile->id;
$followers = FollowRequest::whereFollowingId($pid)->orderBy('id','desc')->whereIsRejected(0)->simplePaginate(10);
return view('account.follow-requests', compact('followers'));
}
public function followRequestHandle(Request $request)
{
$this->validate($request, [
'action' => 'required|string|max:10',
'id' => 'required|integer|min:1'
]);
$pid = Auth::user()->profile->id;
$action = $request->input('action') === 'accept' ? 'accept' : 'reject';
$id = $request->input('id');
$followRequest = FollowRequest::whereFollowingId($pid)->findOrFail($id);
$follower = $followRequest->follower;
switch ($action) {
case 'accept':
$follow = new Follower();
$follow->profile_id = $follower->id;
$follow->following_id = $pid;
$follow->save();
FollowPipeline::dispatch($follow);
$followRequest->delete();
break;
case 'reject':
$followRequest->is_rejected = true;
$followRequest->save();
break;
}
return response()->json(['msg' => 'success'], 200);
}
public function sudoMode(Request $request)
{
return view('auth.sudo');
}
public function sudoModeVerify(Request $request)
{
$this->validate($request, [
'password' => 'required|string|max:500'
]);
$user = Auth::user();
$password = $request->input('password');
$next = $request->session()->get('redirectNext', '/');
if(password_verify($password, $user->password) === true) {
$request->session()->put('sudoMode', time());
return redirect($next);
} else {
return redirect()
->back()
->withErrors(['password' => __('auth.failed')]);
}
}
public function twoFactorCheckpoint(Request $request)
{
return view('auth.checkpoint');
}
public function twoFactorVerify(Request $request)
{
$this->validate($request, [
'code' => 'required|string|max:32'
]);
$user = Auth::user();
$code = $request->input('code');
$google2fa = new Google2FA();
$verify = $google2fa->verifyKey($user->{'2fa_secret'}, $code);
if($verify) {
$request->session()->push('2fa.session.active', true);
return redirect('/');
} else {
if($this->twoFactorBackupCheck($request, $code, $user)) {
return redirect('/');
}
if($request->session()->has('2fa.attempts')) {
$count = (int) $request->session()->has('2fa.attempts');
$request->session()->push('2fa.attempts', $count + 1);
} else {
$request->session()->push('2fa.attempts', 1);
}
return redirect()->back()->withErrors([
'code' => 'Invalid code'
]);
}
}
protected function twoFactorBackupCheck($request, $code, User $user)
{
$backupCodes = $user->{'2fa_backup_codes'};
if($backupCodes) {
$codes = json_decode($backupCodes, true);
foreach ($codes as $c) {
if(hash_equals($c, $code)) {
$codes = array_flatten(array_diff($codes, [$code]));
$user->{'2fa_backup_codes'} = json_encode($codes);
$user->save();
$request->session()->push('2fa.session.active', true);
return true;
} else {
return false;
}
}
} else {
return false;
}
}
public function accountRestored(Request $request)
{
}
}

View file

@ -105,10 +105,9 @@ class AdminController extends Controller
{
$col = $request->query('col') ?? 'id';
$dir = $request->query('dir') ?? 'desc';
$stats = $this->collectUserStats($request);
$users = User::withCount('statuses')->orderBy($col, $dir)->simplePaginate(10);
$users = User::select('id', 'username', 'status')->withCount('statuses')->orderBy($col, $dir)->simplePaginate(10);
return view('admin.users.home', compact('users', 'stats'));
return view('admin.users.home', compact('users'));
}
public function editUser(Request $request, $id)
@ -158,34 +157,6 @@ class AdminController extends Controller
return view('admin.reports.show', compact('report'));
}
protected function collectUserStats($request)
{
$total_duration = $request->query('total_duration') ?? '30';
$new_duration = $request->query('new_duration') ?? '7';
$stats = [];
$stats['total'] = [
'count' => User::where('created_at', '>', Carbon::now()->subDays($total_duration))->count(),
'points' => 0//User::selectRaw(''.$day.'created_at) day, count(*) as count')->where('created_at','>', Carbon::now()->subDays($total_duration))->groupBy('day')->pluck('count')
];
$stats['new'] = [
'count' => User::where('created_at', '>', Carbon::now()->subDays($new_duration))->count(),
'points' => 0//User::selectRaw(''.$day.'created_at) day, count(*) as count')->where('created_at','>', Carbon::now()->subDays($new_duration))->groupBy('day')->pluck('count')
];
$stats['active'] = [
'count' => Status::groupBy('profile_id')->count()
];
$stats['profile'] = [
'local' => Profile::whereNull('remote_url')->count(),
'remote' => Profile::whereNotNull('remote_url')->count()
];
$stats['avg'] = [
'likes' => floor(Like::average('profile_id')),
'posts' => floor(Status::avg('profile_id'))
];
return $stats;
}
public function profiles(Request $request)
{
$this->validate($request, [

View file

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Jobs\StatusPipeline\StatusDelete;
use Auth, Cache;
use Carbon\Carbon;
use App\{
Like,
Media,
Profile,
Status
};
use App\Services\NotificationService;
class AdminApiController extends Controller
{
public function __construct()
{
$this->middleware(['auth', 'admin']);
}
public function activity(Request $request)
{
$activity = [];
$limit = request()->input('limit', 20);
$activity['captions'] = Status::select(
'id',
'caption',
'rendered',
'uri',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'scope',
'created_at'
)->whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->orderByDesc('created_at')
->paginate($limit);
$activity['comments'] = Status::select(
'id',
'caption',
'rendered',
'uri',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'scope',
'created_at'
)->whereNotNull('in_reply_to_id')
->whereNull('reblog_of_id')
->orderByDesc('created_at')
->paginate($limit);
return response()->json($activity, 200, [], JSON_PRETTY_PRINT);
}
public function moderateStatus(Request $request)
{
abort(400, 'Unpublished API');
return;
$this->validate($request, [
'type' => 'required|string|in:status,profile',
'id' => 'required|integer|min:1',
'action' => 'required|string|in:cw,unlink,unlist,suspend,delete'
]);
$type = $request->input('type');
$id = $request->input('id');
$action = $request->input('action');
if ($type == 'status') {
$status = Status::findOrFail($id);
switch ($action) {
case 'cw':
$status->is_nsfw = true;
$status->save();
break;
case 'unlink':
$status->rendered = $status->caption;
$status->save();
break;
case 'unlist':
$status->scope = 'unlisted';
$status->visibility = 'unlisted';
$status->save();
break;
default:
break;
}
} else if ($type == 'profile') {
$profile = Profile::findOrFail($id);
switch ($action) {
case 'delete':
StatusDelete::dispatch($status);
break;
default:
break;
}
}
}
}

View file

@ -59,14 +59,11 @@ class BaseApiController extends Controller
$res = $this->fractal->createData($resource)->toArray();
} else {
$this->validate($request, [
'page' => 'nullable|integer|min:1',
'page' => 'nullable|integer|min:1|max:10',
'limit' => 'nullable|integer|min:1|max:10'
]);
$limit = $request->input('limit') ?? 10;
$page = $request->input('page') ?? 1;
if($page > 3) {
return response()->json([]);
}
$end = (int) $page * $limit;
$start = (int) $end - $limit;
$res = NotificationService::get($pid, $start, $end);
@ -121,7 +118,7 @@ class BaseApiController extends Controller
$since_id = $request->since_id ?? false;
$only_media = $request->only_media ?? false;
$user = Auth::user();
$account = Profile::findOrFail($id);
$account = Profile::whereNull('status')->findOrFail($id);
$statuses = $account->statuses()->getQuery();
if($only_media == true) {
$statuses = $statuses
@ -153,15 +150,6 @@ class BaseApiController extends Controller
return response()->json($res);
}
public function followSuggestions(Request $request)
{
$followers = Auth::user()->profile->recommendFollowers();
$resource = new Fractal\Resource\Collection($followers, new AccountTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res);
}
public function avatarUpdate(Request $request)
{
$this->validate($request, [
@ -200,14 +188,9 @@ class BaseApiController extends Controller
public function showTempMedia(Request $request, int $profileId, $mediaId)
{
if (!$request->hasValidSignature()) {
abort(401);
}
$profile = Auth::user()->profile;
if($profile->id !== $profileId) {
abort(403);
}
$media = Media::whereProfileId($profile->id)->findOrFail($mediaId);
abort_if(!$request->hasValidSignature(), 404);
abort_if(Auth::user()->profile_id !== $profileId, 404);
$media = Media::whereProfileId(Auth::user()->profile_id)->findOrFail($mediaId);
$path = storage_path('app/'.$media->media_path);
return response()->file($path);
}
@ -244,7 +227,7 @@ class BaseApiController extends Controller
}
$monthHash = hash('sha1', date('Y').date('m'));
$userHash = hash('sha1', $user->id.(string) $user->created_at);
$userHash = hash('sha1', $user->id . (string) $user->created_at);
$photo = $request->file('file');

View file

@ -6,10 +6,12 @@ use App\Http\Controllers\Api\BaseApiController;
use App\{
Follower,
Like,
Place,
Profile,
UserFilter
};
use Auth, Cache, Redis;
use App\Util\Site\Config;
use Illuminate\Http\Request;
use App\Services\SuggestionService;
@ -23,34 +25,7 @@ class ApiController extends BaseApiController
public function siteConfiguration(Request $request)
{
$res = Cache::remember('api:site:configuration', now()->addMinutes(30), function() {
return [
'uploader' => [
'max_photo_size' => config('pixelfed.max_photo_size'),
'max_caption_length' => config('pixelfed.max_caption_length'),
'album_limit' => config('pixelfed.max_album_length'),
'image_quality' => config('pixelfed.image_quality'),
'optimize_image' => config('pixelfed.optimize_image'),
'optimize_video' => config('pixelfed.optimize_video'),
'media_types' => config('pixelfed.media_types'),
'enforce_account_limit' => config('pixelfed.enforce_account_limit')
],
'activitypub' => [
'enabled' => config('federation.activitypub.enabled'),
'remote_follow' => config('federation.activitypub.remoteFollow')
],
'ab' => [
'lc' => config('exp.lc'),
'rec' => config('exp.rec'),
'loops' => config('exp.loops')
],
];
});
return response()->json($res);
return response()->json(Config::get());
}
public function userRecommendations(Request $request)
@ -104,4 +79,24 @@ class ApiController extends BaseApiController
return response()->json($res->all());
}
public function composeLocationSearch(Request $request)
{
$this->validate($request, [
'q' => 'required|string'
]);
$places = Place::where('name', 'like', '%' . $request->input('q') . '%')
->take(25)
->get()
->map(function($r) {
return [
'id' => $r->id,
'name' => $r->name,
'country' => $r->country,
'url' => $r->url()
];
});
return $places;
}
}

View file

@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use App\Services\EmailService;
class RegisterController extends Controller
{
@ -53,6 +54,8 @@ class RegisterController extends Controller
protected function validator(array $data)
{
$this->validateUsername($data['username']);
$this->validateEmail($data['email']);
$usernameRules = [
'required',
'min:2',
@ -73,7 +76,7 @@ class RegisterController extends Controller
'name' => 'required|string|max:'.config('pixelfed.max_name_length'),
'username' => $usernameRules,
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:6|confirmed',
'password' => 'required|string|min:8|confirmed',
];
return Validator::make($data, $rules);
@ -105,6 +108,14 @@ class RegisterController extends Controller
}
}
public function validateEmail($email)
{
$banned = EmailService::isBanned($email);
if($banned) {
return abort(403, 'Invalid email.');
}
}
/**
* Show the application registration form.
*
@ -112,14 +123,17 @@ class RegisterController extends Controller
*/
public function showRegistrationForm()
{
$count = User::count();
$limit = config('pixelfed.max_users');
if($limit && $limit <= $count) {
$view = 'site.closed-registration';
if(config('pixelfed.open_registration')) {
$limit = config('pixelfed.max_users');
if($limit) {
abort_if($limit <= User::count(), 404);
return view('auth.register');
} else {
return view('auth.register');
}
} else {
$view = config('pixelfed.open_registration') == true ? 'auth.register' : 'site.closed-registration';
abort(404);
}
return view($view);
}
/**

View file

@ -73,7 +73,7 @@ class AvatarController extends Controller
public function buildPath($id)
{
$padded = str_pad($id, 12, 0, STR_PAD_LEFT);
$padded = str_pad($id, 19, 0, STR_PAD_LEFT);
$parts = str_split($padded, 3);
foreach ($parts as $k => $part) {
if ($k == 0) {
@ -93,6 +93,21 @@ class AvatarController extends Controller
$prefix = storage_path('app/'.$avatarpath);
$this->checkDir($prefix);
}
if ($k == 4) {
$avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3].'/'.$parts[4];
$prefix = storage_path('app/'.$avatarpath);
$this->checkDir($prefix);
}
if ($k == 5) {
$avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3].'/'.$parts[4].'/'.$parts[5];
$prefix = storage_path('app/'.$avatarpath);
$this->checkDir($prefix);
}
if ($k == 6) {
$avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3].'/'.$parts[4].'/'.$parts[5].'/'.$parts[6];
$prefix = storage_path('app/'.$avatarpath);
$this->checkDir($prefix);
}
}
return $avatarpath;

View file

@ -3,8 +3,206 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Auth;
use App\{
Collection,
CollectionItem,
Profile,
Status
};
use League\Fractal;
use App\Transformer\Api\{
AccountTransformer,
StatusTransformer,
};
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
class CollectionController extends Controller
{
//
public function create(Request $request)
{
abort_if(!Auth::check(), 403);
$profile = Auth::user()->profile;
$collection = Collection::firstOrCreate([
'profile_id' => $profile->id,
'published_at' => null
]);
return view('collection.create', compact('collection'));
}
public function show(Request $request, int $collection)
{
$collection = Collection::with('profile')->whereNotNull('published_at')->findOrFail($collection);
if($collection->profile->status != null) {
abort(404);
}
if($collection->visibility !== 'public') {
abort_if(!Auth::check() || Auth::user()->profile_id != $collection->profile_id, 404);
}
return view('collection.show', compact('collection'));
}
public function index(Request $request)
{
abort_if(!Auth::check(), 403);
return $request->all();
}
public function store(Request $request, int $id)
{
abort_if(!Auth::check(), 403);
$this->validate($request, [
'title' => 'nullable',
'description' => 'nullable',
'visibility' => 'required|alpha|in:public,private'
]);
$profile = Auth::user()->profile;
$collection = Collection::whereProfileId($profile->id)->findOrFail($id);
$collection->title = e($request->input('title'));
$collection->description = e($request->input('description'));
$collection->visibility = e($request->input('visibility'));
$collection->save();
return 200;
}
public function publish(Request $request, int $id)
{
abort_if(!Auth::check(), 403);
$this->validate($request, [
'title' => 'nullable',
'description' => 'nullable',
'visibility' => 'required|alpha|in:public,private'
]);
$profile = Auth::user()->profile;
$collection = Collection::whereProfileId($profile->id)->findOrFail($id);
if($collection->items()->count() == 0) {
abort(404);
}
$collection->title = e($request->input('title'));
$collection->description = e($request->input('description'));
$collection->visibility = e($request->input('visibility'));
$collection->published_at = now();
$collection->save();
return $collection->url();
}
public function delete(Request $request, int $id)
{
abort_if(!Auth::check(), 403);
$user = Auth::user();
$collection = Collection::whereProfileId($user->profile_id)->findOrFail($id);
$collection->items()->delete();
$collection->delete();
if($request->wantsJson()) {
return 200;
}
return redirect('/');
}
public function storeId(Request $request)
{
$this->validate($request, [
'collection_id' => 'required|int|min:1|exists:collections,id',
'post_id' => 'required|int|min:1|exists:statuses,id'
]);
$profileId = Auth::user()->profile_id;
$collectionId = $request->input('collection_id');
$postId = $request->input('post_id');
$collection = Collection::whereProfileId($profileId)->findOrFail($collectionId);
$count = $collection->items()->count();
if($count >= 18) {
abort(400, 'You can only add 18 posts per collection');
}
$status = Status::whereScope('public')
->whereIn('type', ['photo', 'photo:album', 'video'])
->findOrFail($postId);
$item = CollectionItem::firstOrCreate([
'collection_id' => $collection->id,
'object_type' => 'App\Status',
'object_id' => $status->id
],[
'order' => $count,
]);
return 200;
}
public function get(Request $request, int $id)
{
$profile = Auth::check() ? Auth::user()->profile : [];
$collection = Collection::whereVisibility('public')->findOrFail($id);
if($collection->published_at == null) {
if(!Auth::check() || $profile->id !== $collection->profile_id) {
abort(404);
}
}
return [
'id' => $collection->id,
'title' => $collection->title,
'description' => $collection->description,
'visibility' => $collection->visibility
];
}
public function getItems(Request $request, int $id)
{
$collection = Collection::findOrFail($id);
if($collection->visibility !== 'public') {
abort_if(!Auth::check() || Auth::user()->profile_id != $collection->profile_id, 404);
}
$posts = $collection->posts()->orderBy('order', 'asc')->paginate(18);
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Collection($posts, new StatusTransformer());
$res = $fractal->createData($resource)->toArray();
return response()->json($res);
}
public function getUserCollections(Request $request, int $id)
{
$profile = Profile::whereNull('status')
->whereNull('domain')
->findOrFail($id);
if($profile->is_private) {
abort_if(!Auth::check(), 404);
abort_if(!$profile->followedBy(Auth::user()->profile) && $profile->id != Auth::user()->profile_id, 404);
}
return $profile
->collections()
->has('posts')
->with('posts')
->whereVisibility('public')
->whereNotNull('published_at')
->orderByDesc('published_at')
->paginate(9)
->map(function($collection) {
return [
'id' => $collection->id,
'title' => $collection->title,
'description' => $collection->description,
'thumb' => $collection->posts()->first()->thumb(),
'url' => $collection->url(),
'published_at' => $collection->published_at
];
});
}
}

View file

@ -13,6 +13,7 @@ use App\Jobs\StatusPipeline\NewStatusPipeline;
use App\Util\Lexer\Autolink;
use App\Profile;
use App\Status;
use App\UserFilter;
use League\Fractal;
use App\Transformer\Api\StatusTransformer;
use League\Fractal\Serializer\ArraySerializer;
@ -57,6 +58,16 @@ class CommentController extends Controller
return;
}
$filtered = UserFilter::whereUserId($status->profile_id)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['block'])
->whereFilterableId($profile->id)
->exists();
if($filtered == true) {
return;
}
$reply = DB::transaction(function() use($comment, $status, $profile) {
$autolink = Autolink::create()->autolink($comment);
$reply = new Status();

View file

@ -6,6 +6,7 @@ use App\{
DiscoverCategory,
Follower,
Hashtag,
HashtagFollow,
Profile,
Status,
StatusHashtag,
@ -17,6 +18,7 @@ use App\Transformer\Api\StatusStatelessTransformer;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
use App\Services\StatusHashtagService;
class DiscoverController extends Controller
{
@ -36,57 +38,11 @@ class DiscoverController extends Controller
public function showTags(Request $request, $hashtag)
{
abort_if(!Auth::check(), 403);
abort_if(!config('instance.discover.tags.is_public') && !Auth::check(), 403);
$tag = Hashtag::whereSlug($hashtag)
->firstOrFail();
$page = 1;
$key = 'discover:tag-'.$tag->id.':page-'.$page;
$keyMinutes = 15;
$posts = Cache::remember($key, now()->addMinutes($keyMinutes), function() use ($tag, $request) {
$tags = StatusHashtag::select('status_id')
->whereHashtagId($tag->id)
->orderByDesc('id')
->take(48)
->pluck('status_id');
return Status::select(
'id',
'uri',
'caption',
'rendered',
'profile_id',
'type',
'in_reply_to_id',
'reblog_of_id',
'is_nsfw',
'scope',
'local',
'created_at',
'updated_at'
)->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
->with('media')
->whereLocal(true)
->whereNull('uri')
->whereIn('id', $tags)
->whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->whereNull('url')
->whereNull('uri')
->withCount(['likes', 'comments'])
->whereIsNsfw(false)
->whereVisibility('public')
->orderBy('id', 'desc')
->get();
});
if($posts->count() == 0) {
abort(404);
}
return view('discover.tags.show', compact('tag', 'posts'));
$tag = Hashtag::whereSlug($hashtag)->firstOrFail();
$tagCount = StatusHashtagService::count($tag->id);
return view('discover.tags.show', compact('tag', 'tagCount'));
}
public function showCategory(Request $request, $slug)
@ -156,7 +112,6 @@ class DiscoverController extends Controller
return $res;
}
public function loopWatch(Request $request)
{
abort_if(!Auth::check(), 403);
@ -171,4 +126,26 @@ class DiscoverController extends Controller
return response()->json(200);
}
public function getHashtags(Request $request)
{
$auth = Auth::check();
abort_if(!config('instance.discover.tags.is_public') && !$auth, 403);
$this->validate($request, [
'hashtag' => 'required|alphanum|min:2|max:124',
'page' => 'nullable|integer|min:1|max:' . ($auth ? 19 : 3)
]);
$page = $request->input('page') ?? '1';
$end = $page > 1 ? $page * 9 : 0;
$tag = $request->input('hashtag');
$hashtag = Hashtag::whereName($tag)->firstOrFail();
$res['tags'] = StatusHashtagService::get($hashtag->id, $page, $end);
if($page == 1) {
$res['follows'] = HashtagFollow::whereUserId(Auth::id())->whereHashtagId($hashtag->id)->exists();
}
return $res;
}
}

View file

@ -11,7 +11,8 @@ use App\{
AccountLog,
Like,
Profile,
Status
Status,
User
};
use App\Transformer\ActivityPub\ProfileOutbox;
use App\Util\Lexer\Nickname;
@ -34,47 +35,22 @@ class FederationController extends Controller
abort_if(!Auth::check(), 403);
}
// deprecated, remove in 0.10
public function authorizeFollow(Request $request)
{
$this->authCheck();
$this->validate($request, [
'acct' => 'required|string|min:3|max:255',
]);
$acct = $request->input('acct');
$nickname = Nickname::normalizeProfileUrl($acct);
return view('federation.authorizefollow', compact('acct', 'nickname'));
abort(404);
}
// deprecated, remove in 0.10
public function remoteFollow()
{
$this->authCheck();
return view('federation.remotefollow');
abort(404);
}
// deprecated, remove in 0.10
public function remoteFollowStore(Request $request)
{
return;
$this->authCheck();
$this->validate($request, [
'url' => 'required|string',
]);
abort_if(!config('federation.activitypub.remoteFollow'), 403);
$follower = Auth::user()->profile;
$url = $request->input('url');
$url = Helpers::validateUrl($url);
if(!$url) {
return;
}
RemoteFollowPipeline::dispatch($follower, $url);
return response(['success' => true, 'follower' => $follower]);
abort(404);
}
public function nodeinfoWellKnown()
@ -100,8 +76,8 @@ class FederationController extends Controller
$res = Cache::remember('api:nodeinfo', now()->addMinutes(15), function () {
$activeHalfYear = Cache::remember('api:nodeinfo:ahy', now()->addHours(12), function() {
$count = collect([]);
// $likes = Like::select('profile_id')->where('created_at', '>', now()->subMonths(6)->toDateTimeString())->groupBy('profile_id')->pluck('profile_id')->toArray();
// $count = $count->merge($likes);
$likes = Like::select('profile_id')->with('actor')->where('created_at', '>', now()->subMonths(6)->toDateTimeString())->groupBy('profile_id')->get()->filter(function($like) {return $like->actor && $like->actor->domain == null;})->pluck('profile_id')->toArray();
$count = $count->merge($likes);
$statuses = Status::select('profile_id')->whereLocal(true)->where('created_at', '>', now()->subMonths(6)->toDateTimeString())->groupBy('profile_id')->pluck('profile_id')->toArray();
$count = $count->merge($statuses);
$profiles = Profile::select('id')->whereNull('domain')->where('created_at', '>', now()->subMonths(6)->toDateTimeString())->groupBy('id')->pluck('id')->toArray();
@ -110,8 +86,8 @@ class FederationController extends Controller
});
$activeMonth = Cache::remember('api:nodeinfo:am', now()->addHours(12), function() {
$count = collect([]);
// $likes = Like::select('profile_id')->where('created_at', '>', now()->subMonths(1)->toDateTimeString())->groupBy('profile_id')->pluck('profile_id')->toArray();
// $count = $count->merge($likes);
$likes = Like::select('profile_id')->where('created_at', '>', now()->subMonths(1)->toDateTimeString())->groupBy('profile_id')->get()->filter(function($like) {return $like->actor && $like->actor->domain == null;})->pluck('profile_id')->toArray();
$count = $count->merge($likes);
$statuses = Status::select('profile_id')->whereLocal(true)->where('created_at', '>', now()->subMonths(1)->toDateTimeString())->groupBy('profile_id')->pluck('profile_id')->toArray();
$count = $count->merge($statuses);
$profiles = Profile::select('id')->whereNull('domain')->where('created_at', '>', now()->subMonths(1)->toDateTimeString())->groupBy('id')->pluck('id')->toArray();
@ -120,11 +96,12 @@ class FederationController extends Controller
});
return [
'metadata' => [
'nodeName' => config('app.name'),
'nodeName' => config('pixelfed.domain.app'),
'software' => [
'homepage' => 'https://pixelfed.org',
'repo' => 'https://github.com/pixelfed/pixelfed',
],
'config' => \App\Util\Site\Config::get()
],
'protocols' => [
'activitypub',
@ -138,12 +115,12 @@ class FederationController extends Controller
'version' => config('pixelfed.version'),
],
'usage' => [
'localPosts' => \App\Status::whereLocal(true)->whereHas('media')->count(),
'localComments' => \App\Status::whereLocal(true)->whereNotNull('in_reply_to_id')->count(),
'localPosts' => Status::whereLocal(true)->count(),
'localComments' => 0,
'users' => [
'total' => \App\Profile::whereNull('status')->whereNull('domain')->count(),
'activeHalfyear' => $activeHalfYear,
'activeMonth' => $activeMonth,
'total' => User::count(),
'activeHalfyear' => (int) $activeHalfYear,
'activeMonth' => (int) $activeMonth,
],
],
'version' => '2.0',
@ -162,10 +139,12 @@ class FederationController extends Controller
$this->validate($request, ['resource'=>'required|string|min:3|max:255']);
$resource = $request->input('resource');
$hash = hash('sha256', $resource);
$parsed = Nickname::normalizeProfileUrl($resource);
if($parsed['domain'] !== config('pixelfed.domain.app')) {
abort(404);
}
$username = $parsed['username'];
$profile = Profile::whereUsername($username)->firstOrFail();
$profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
if($profile->status != null) {
return ProfileController::accountCheck($profile);
}
@ -179,7 +158,7 @@ class FederationController extends Controller
abort_if(!config('federation.webfinger.enabled'), 404);
$path = route('well-known.webfinger');
$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 = '<?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>';
return response($xml)->header('Content-Type', 'application/xrd+xml');
}
@ -315,19 +294,16 @@ class FederationController extends Controller
->whereIsPrivate(false)
->firstOrFail();
return [];
if($profile->status != null) {
return [];
abort(404);
}
$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();
})
'totalItems' => 0,
'orderedItems' => []
];
return response()->json($obj);
}
@ -341,20 +317,18 @@ class FederationController extends Controller
->whereIsPrivate(false)
->firstOrFail();
return [];
if($profile->status != null) {
return [];
abort(404);
}
$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();
})
'totalItems' => 0,
'orderedItems' => []
];
return response()->json($obj);
}
}

View file

@ -23,16 +23,11 @@ class FollowerController extends Controller
public function store(Request $request)
{
$this->validate($request, [
'item' => 'required|integer',
'item' => 'required|string',
]);
$item = $request->input('item');
$item = (int) $request->input('item');
$this->handleFollowRequest($item);
if($request->wantsJson()) {
return response()->json([
200
], 200);
}
return redirect()->back();
return response()->json(200);
}
protected function handleFollowRequest($item)
@ -53,7 +48,7 @@ class FollowerController extends Controller
abort(400, 'You cannot follow this user.');
}
$isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->count();
$isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->exists();
if($private == true && $isFollowing == 0 || $remote == true) {
if($user->following()->count() >= Follower::MAX_FOLLOWING) {
@ -83,13 +78,23 @@ class FollowerController extends Controller
$follower->profile_id = $user->id;
$follower->following_id = $target->id;
$follower->save();
if($remote == true && config('federation.activitypub.remoteFollow') == true) {
$this->sendFollow($user, $target);
}
FollowPipeline::dispatch($follower);
} else {
$follower = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->firstOrFail();
if($remote == true) {
$request = FollowRequest::whereFollowerId($user->id)->whereFollowingId($target->id)->exists();
$follower = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->exists();
if($remote == true && $request && !$follower) {
$this->sendFollow($user, $target);
}
if($remote == true && $follower) {
$this->sendUndoFollow($user, $target);
}
$follower->delete();
Follower::whereProfileId($user->id)
->whereFollowingId($target->id)
->delete();
}
Cache::forget('profile:following:'.$target->id);
@ -109,6 +114,7 @@ class FollowerController extends Controller
$payload = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $user->permalink('#follow/'.$target->id.''),
'type' => 'Follow',
'actor' => $user->permalink(),
'object' => $target->permalink()

View file

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Auth;
use App\{
Hashtag,
HashtagFollow,
Status
};
class HashtagFollowController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function store(Request $request)
{
$this->validate($request, [
'name' => 'required|alpha_num|min:1|max:124|exists:hashtags,name'
]);
$user = Auth::user();
$profile = $user->profile;
$tag = $request->input('name');
$hashtag = Hashtag::whereName($tag)->firstOrFail();
$hashtagFollow = HashtagFollow::firstOrCreate([
'user_id' => $user->id,
'profile_id' => $user->profile_id ?? $user->profile->id,
'hashtag_id' => $hashtag->id
]);
if($hashtagFollow->wasRecentlyCreated) {
$state = 'created';
// todo: send to HashtagFollowService
} else {
$state = 'deleted';
$hashtagFollow->delete();
}
return [
'state' => $state
];
}
public function getTags(Request $request)
{
return HashtagFollow::with('hashtag')->whereUserId(Auth::id())
->inRandomOrder()
->take(3)
->get()
->map(function($follow, $k) {
return $follow->hashtag->name;
});
}
}

View file

@ -50,38 +50,7 @@ class InternalApiController extends Controller
// deprecated
public function discover(Request $request)
{
$profile = Auth::user()->profile;
$pid = $profile->id;
$following = Cache::remember('feature:discover:following:'.$pid, now()->addMinutes(60), function() use ($pid) {
return Follower::whereProfileId($pid)->pluck('following_id')->toArray();
});
$filters = Cache::remember("user:filter:list:$pid", now()->addMinutes(60), function() use($pid) {
return UserFilter::whereUserId($pid)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id')->toArray();
});
$following = array_merge($following, $filters);
$posts = Status::select('id', 'caption', 'profile_id')
->whereHas('media')
->whereIsNsfw(false)
->whereVisibility('public')
->whereNotIn('profile_id', $following)
->with('media')
->orderBy('created_at', 'desc')
->take(21)
->get();
$res = [
'posts' => $posts->map(function($post) {
return [
'url' => $post->url(),
'thumb' => $post->thumb(),
];
})
];
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
return;
}
public function discoverPosts(Request $request)
@ -155,22 +124,9 @@ class InternalApiController extends Controller
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;
}
public function statusReplies(Request $request, int $id)
{
$parent = Status::findOrFail($id);
$parent = Status::whereScope('public')->findOrFail($id);
$children = Status::whereInReplyToId($parent->id)
->orderBy('created_at', 'desc')
@ -283,7 +239,8 @@ class InternalApiController extends Controller
'media.*.filter_class' => 'nullable|alpha_dash|max:30',
'media.*.license' => 'nullable|string|max:80',
'cw' => 'nullable|boolean',
'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10'
'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
'place' => 'nullable'
]);
if(config('costar.enabled') == true) {
@ -327,6 +284,9 @@ class InternalApiController extends Controller
array_push($mimes, $m->mime);
}
if($request->filled('place')) {
$status->place_id = $request->input('place')['id'];
}
$status->caption = strip_tags($request->caption);
$status->scope = 'draft';
$status->profile_id = $profile->id;
@ -350,4 +310,18 @@ class InternalApiController extends Controller
Cache::forget('profile:status_count:'.$profile->id);
return $status->url();
}
public function bookmarks(Request $request)
{
$statuses = Auth::user()->profile
->bookmarks()
->withCount(['likes','comments'])
->orderBy('created_at', 'desc')
->simplePaginate(10);
$resource = new Fractal\Resource\Collection($statuses, new StatusTransformer());
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\{
Place,
Status
};
class PlaceController extends Controller
{
public function show(Request $request, $id, $slug)
{
// TODO: Replace with vue component + apis
$place = Place::whereSlug($slug)->findOrFail($id);
$posts = Status::wherePlaceId($place->id)
->whereScope('public')
->orderByDesc('created_at')
->paginate(10);
return view('discover.places.show', compact('place', 'posts'));
}
}

View file

@ -6,6 +6,7 @@ use Illuminate\Http\Request;
use Auth;
use Cache;
use App\Follower;
use App\FollowRequest;
use App\Profile;
use App\User;
use App\UserFilter;
@ -67,8 +68,12 @@ class ProfileController extends Controller
$is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
if ($isPrivate == true || $isBlocked == true) {
return view('profile.private', compact('user', 'is_following'));
$requested = Auth::check() ? FollowRequest::whereFollowerId(Auth::user()->profile_id)
->whereFollowingId($user->id)
->exists() : false;
return view('profile.private', compact('user', 'is_following', 'requested'));
}
$is_admin = is_null($user->domain) ? $user->user->is_admin : false;
$profile = $user;
$settings = [
@ -241,22 +246,9 @@ class ProfileController extends Controller
return view('profile.following', compact('user', 'profile', 'following', 'owner', 'is_following', 'is_admin', 'settings'));
}
public function savedBookmarks(Request $request, $username)
public function meRedirect()
{
if (Auth::check() === false || $username !== Auth::user()->username) {
abort(403);
}
$user = $profile = Auth::user()->profile;
if($profile->status != null) {
return $this->accountCheck($profile);
}
$settings = User::whereUsername($username)->firstOrFail()->settings;
$owner = true;
$following = false;
$timeline = $user->bookmarks()->withCount(['likes','comments'])->orderBy('created_at', 'desc')->simplePaginate(10);
$is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
$is_admin = is_null($user->domain) ? $user->user->is_admin : false;
return view('profile.bookmarks', compact('user', 'profile', 'settings', 'owner', 'following', 'timeline', 'is_following', 'is_admin'));
abort_if(!Auth::check(), 404);
return redirect(Auth::user()->url());
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\ProfileSponsor;
use Auth;
class ProfileSponsorController extends Controller
{
public function get(Request $request, $id)
{
$profile = ProfileSponsor::whereProfileId($id)->first();
$res = $profile ? $profile->sponsors : [];
return response()->json($res);
}
}

View file

@ -113,10 +113,22 @@ class PublicApiController extends Controller
$profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
$status = Status::whereProfileId($profile->id)->whereCommentsDisabled(false)->findOrFail($postId);
$this->scopeCheck($profile, $status);
if(Auth::check()) {
$pid = Auth::user()->profile->id;
$filtered = UserFilter::whereUserId($pid)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->pluck('filterable_id')->toArray();
} else {
$filtered = [];
}
if($request->filled('min_id') || $request->filled('max_id')) {
if($request->filled('min_id')) {
$replies = $status->comments()
->whereNull('reblog_of_id')
->whereNotIn('profile_id', $filtered)
->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
->where('id', '>=', $request->min_id)
->orderBy('id', 'desc')
@ -125,6 +137,7 @@ class PublicApiController extends Controller
if($request->filled('max_id')) {
$replies = $status->comments()
->whereNull('reblog_of_id')
->whereNotIn('profile_id', $filtered)
->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
->where('id', '<=', $request->max_id)
->orderBy('id', 'desc')
@ -133,6 +146,7 @@ class PublicApiController extends Controller
} else {
$replies = $status->comments()
->whereNull('reblog_of_id')
->whereNotIn('profile_id', $filtered)
->select('id', 'caption', 'is_nsfw', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
->orderBy('id', 'desc')
->paginate($limit);
@ -211,6 +225,10 @@ class PublicApiController extends Controller
'limit' => 'nullable|integer|max:20'
]);
if(config('instance.timeline.local.is_public') == false && !Auth::check()) {
abort(403, 'Authentication required.');
}
$page = $request->input('page');
$min = $request->input('min_id');
$max = $request->input('max_id');
@ -251,9 +269,11 @@ class PublicApiController extends Controller
'local',
'reply_count',
'comments_disabled',
'place_id',
'created_at',
'updated_at'
)->where('id', $dir, $id)
->with('profile', 'hashtags', 'mentions')
->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
->whereLocal(true)
->whereNull('uri')
@ -280,8 +300,10 @@ class PublicApiController extends Controller
'reply_count',
'comments_disabled',
'created_at',
'place_id',
'updated_at'
)->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
->with('profile', 'hashtags', 'mentions')
->whereLocal(true)
->whereNull('uri')
->whereNotIn('profile_id', $filtered)
@ -325,13 +347,15 @@ class PublicApiController extends Controller
return $following->push($pid)->toArray();
});
$private = Cache::remember('profiles:private', 1440, function() {
$private = Cache::remember('profiles:private', now()->addMinutes(1440), function() {
return Profile::whereIsPrivate(true)
->orWhere('unlisted', true)
->orWhere('status', '!=', null)
->pluck('id');
});
$private = $private->diff($following)->flatten();
$filters = UserFilter::whereUserId($pid)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
@ -355,9 +379,11 @@ class PublicApiController extends Controller
'local',
'reply_count',
'comments_disabled',
'place_id',
'created_at',
'updated_at'
)->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
->with('profile', 'hashtags', 'mentions')
->where('id', $dir, $id)
->whereIn('profile_id', $following)
->whereNotIn('profile_id', $filtered)
@ -382,9 +408,11 @@ class PublicApiController extends Controller
'local',
'reply_count',
'comments_disabled',
'place_id',
'created_at',
'updated_at'
)->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
->with('profile', 'hashtags', 'mentions')
->whereIn('profile_id', $following)
->whereNotIn('profile_id', $filtered)
->whereNull('in_reply_to_id')
@ -499,7 +527,9 @@ class PublicApiController extends Controller
public function relationships(Request $request)
{
abort_if(!Auth::check(), 403);
if(!Auth::check()) {
return response()->json([]);
}
$this->validate($request, [
'id' => 'required|array|min:1|max:20',
@ -509,7 +539,7 @@ class PublicApiController extends Controller
$filtered = $ids->filter(function($v) {
return $v != Auth::user()->profile->id;
});
$relations = Profile::findOrFail($filtered->all());
$relations = Profile::whereNull('status')->findOrFail($filtered->all());
$fractal = new Fractal\Resource\Collection($relations, new RelationshipTransformer());
$res = $this->fractal->createData($fractal)->toArray();
return response()->json($res);

View file

@ -37,6 +37,7 @@ class SearchController extends Controller
$tokens = Cache::remember('api:search:tag:'.$hash, now()->addMinutes(5), function () use ($tag) {
$tokens = [];
if(Helpers::validateUrl($tag) != false && config('federation.activitypub.enabled') == true && config('federation.activitypub.remoteFollow') == true) {
abort_if(Helpers::validateLocalUrl($tag), 404);
$remote = Helpers::fetchFromUrl($tag);
if(isset($remote['type']) && in_array($remote['type'], ['Note', 'Person']) == true) {
$type = $remote['type'];
@ -50,8 +51,9 @@ class SearchController extends Controller
'tokens' => [$item->username],
'name' => $item->name,
'entity' => [
'id' => $item->id,
'id' => (string) $item->id,
'following' => $item->followedBy(Auth::user()->profile),
'follow_request' => $item->hasFollowRequestById(Auth::user()->profile_id),
'thumb' => $item->avatarUrl()
]
]];
@ -143,6 +145,7 @@ class SearchController extends Controller
'tokens' => [$item->caption],
'name' => $item->caption,
'thumb' => $item->thumb(),
'filter' => $item->firstMedia()->filter_class
];
});
$tokens['posts'] = $posts;

View file

@ -103,7 +103,7 @@ trait ExportSettings
$this->validate($request, [
'type' => 'required|string|in:ap,api'
]);
$limit = 300;
$limit = 500;
$profile = Auth::user()->profile;
$type = 'ap';
@ -116,7 +116,7 @@ trait ExportSettings
$filename = 'outbox.json';
if($type == 'ap') {
$data = Cache::remember('account:export:profile:statuses:ap:'.Auth::user()->profile->id, now()->addDays(7), function() {
$data = Cache::remember('account:export:profile:statuses:ap:'.Auth::user()->profile->id, now()->addHours(1), function() {
$profile = Auth::user()->profile->statuses;
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
@ -125,7 +125,7 @@ trait ExportSettings
});
} else {
$filename = 'api-statuses.json';
$data = Cache::remember('account:export:profile:statuses:api:'.Auth::user()->profile->id, now()->addDays(7), function() {
$data = Cache::remember('account:export:profile:statuses:api:'.Auth::user()->profile->id, now()->addHours(1), function() {
$profile = Auth::user()->profile->statuses;
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());

View file

@ -18,15 +18,29 @@ trait RelationshipSettings
public function relationshipsHome(Request $request)
{
$mode = $request->input('mode') == 'following' ? 'following' : 'followers';
$this->validate($request, [
'mode' => 'nullable|string|in:following,followers,hashtags'
]);
$mode = $request->input('mode');
$profile = Auth::user()->profile;
$following = $followers = [];
switch ($mode) {
case 'following':
$data = $profile->following()->simplePaginate(10);
break;
if($mode == 'following') {
$data = $profile->following()->simplePaginate(10);
} else {
$data = $profile->followers()->simplePaginate(10);
case 'followers':
$data = $profile->followers()->simplePaginate(10);
break;
case 'hashtags':
$data = $profile->hashtagFollowing()->with('hashtag')->simplePaginate(10);
break;
default:
$data = [];
break;
}
return view('settings.relationships.home', compact('profile', 'mode', 'data'));

View file

@ -4,11 +4,13 @@ namespace App\Http\Controllers;
use App\AccountLog;
use App\Following;
use App\ProfileSponsor;
use App\Report;
use App\UserFilter;
use Auth, Cookie, DB, Cache, Purify;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Http\Controllers\Settings\{
ExportSettings,
LabsSettings,
@ -90,12 +92,18 @@ class SettingsController extends Controller
public function removeAccountTemporary(Request $request)
{
$user = Auth::user();
abort_if(!config('pixelfed.account_deletion'), 403);
abort_if($user->is_admin, 403);
return view('settings.remove.temporary');
}
public function removeAccountTemporarySubmit(Request $request)
{
$user = Auth::user();
abort_if(!config('pixelfed.account_deletion'), 403);
abort_if($user->is_admin, 403);
$profile = $user->profile;
$user->status = 'disabled';
$profile->status = 'disabled';
@ -108,9 +116,8 @@ class SettingsController extends Controller
public function removeAccountPermanent(Request $request)
{
if(config('pixelfed.account_deletion') == false) {
abort(404);
}
$user = Auth::user();
abort_if($user->is_admin, 403);
return view('settings.remove.permanent');
}
@ -120,9 +127,8 @@ class SettingsController extends Controller
abort(404);
}
$user = Auth::user();
if($user->is_admin == true) {
return abort(400, 'You cannot delete an admin account.');
}
abort_if(!config('pixelfed.account_deletion'), 403);
abort_if($user->is_admin, 403);
$profile = $user->profile;
$ts = Carbon::now()->addMonth();
$user->status = 'delete';
@ -166,5 +172,61 @@ class SettingsController extends Controller
return response()->json([200])->cookie($cookie);
}
public function sponsor()
{
$default = [
'patreon' => null,
'liberapay' => null,
'opencollective' => null
];
$sponsors = ProfileSponsor::whereProfileId(Auth::user()->profile->id)->first();
$sponsors = $sponsors ? json_decode($sponsors->sponsors, true) : $default;
return view('settings.sponsor', compact('sponsors'));
}
public function sponsorStore(Request $request)
{
$this->validate($request, [
'patreon' => 'nullable|string',
'liberapay' => 'nullable|string',
'opencollective' => 'nullable|string'
]);
$patreon = Str::startsWith($request->input('patreon'), 'https://') ?
substr($request->input('patreon'), 8) :
$request->input('patreon');
$liberapay = Str::startsWith($request->input('liberapay'), 'https://') ?
substr($request->input('liberapay'), 8) :
$request->input('liberapay');
$opencollective = Str::startsWith($request->input('opencollective'), 'https://') ?
substr($request->input('opencollective'), 8) :
$request->input('opencollective');
$patreon = Str::startsWith($patreon, 'patreon.com/') ? e($patreon) : null;
$liberapay = Str::startsWith($liberapay, 'liberapay.com/') ? e($liberapay) : null;
$opencollective = Str::startsWith($opencollective, 'opencollective.com/') ? e($opencollective) : null;
if(empty($patreon) && empty($liberapay) && empty($opencollective)) {
return redirect(route('settings'))->with('error', 'An error occured. Please try again later.');;
}
$res = [
'patreon' => $patreon,
'liberapay' => $liberapay,
'opencollective' => $opencollective
];
$sponsors = ProfileSponsor::firstOrCreate([
'profile_id' => Auth::user()->profile_id ?? Auth::user()->profile->id
]);
$sponsors->sponsors = json_encode($res);
$sponsors->save();
$sponsors = $res;
return redirect(route('settings'))->with('status', 'Sponsor settings successfully updated!');;
}
}

View file

@ -43,6 +43,7 @@ class SiteController extends Controller
public function about()
{
return Cache::remember('site:about', now()->addHours(12), function() {
app()->setLocale('en');
$page = Page::whereSlug('/site/about')->whereActive(true)->first();
$stats = [
'posts' => Status::whereLocal(true)->count(),

View file

@ -1,95 +0,0 @@
<?php
namespace App\Jobs;
use App\Avatar;
use App\Profile;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class ImportAvatar implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $url;
protected $profile;
/**
* Delete the job if its models no longer exist.
*
* @var bool
*/
public $deleteWhenMissingModels = true;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($url, Profile $profile)
{
$this->url = $url;
$this->profile = $profile;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$url = $this->url;
$profile = $this->profile;
$basePath = $this->buildPath();
}
public function buildPath()
{
$baseDir = storage_path('app/public/avatars');
if (!is_dir($baseDir)) {
mkdir($baseDir);
}
$prefix = $this->profile->id;
$padded = str_pad($prefix, 12, 0, STR_PAD_LEFT);
$parts = str_split($padded, 3);
foreach ($parts as $k => $part) {
if ($k == 0) {
$prefix = storage_path('app/public/avatars/'.$parts[0]);
if (!is_dir($prefix)) {
mkdir($prefix);
}
}
if ($k == 1) {
$prefix = storage_path('app/public/avatars/'.$parts[0].'/'.$parts[1]);
if (!is_dir($prefix)) {
mkdir($prefix);
}
}
if ($k == 2) {
$prefix = storage_path('app/public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2]);
if (!is_dir($prefix)) {
mkdir($prefix);
}
}
if ($k == 3) {
$avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3];
$prefix = storage_path('app/'.$avatarpath);
if (!is_dir($prefix)) {
mkdir($prefix);
}
}
}
$dir = storage_path('app/'.$avatarpath);
if (!is_dir($dir)) {
mkdir($dir);
}
$path = $avatarpath.'/avatar.svg';
return storage_path('app/'.$path);
}
}

View file

@ -4,7 +4,8 @@ namespace App\Jobs\CommentPipeline;
use App\{
Notification,
Status
Status,
UserFilter
};
use App\Services\NotificationService;
use DB, Cache, Log, Redis;
@ -56,6 +57,15 @@ class CommentPipeline implements ShouldQueue
if ($actor->id === $target->id || $status->comments_disabled == true) {
return true;
}
$filtered = UserFilter::whereUserId($target->id)
->whereFilterableType('App\Profile')
->whereIn('filter_type', ['mute', 'block'])
->whereFilterableId($actor->id)
->exists();
if($filtered == true) {
return;
}
DB::transaction(function() use($target, $actor, $comment) {
$notification = new Notification();

View file

@ -80,8 +80,10 @@ class DeleteAccountPipeline implements ShouldQueue
Bookmark::whereProfileId($user->profile->id)->forceDelete();
EmailVerification::whereUserId($user->id)->forceDelete();
$id = $user->profile->id;
StatusHashtag::whereProfileId($id)->delete();
FollowRequest::whereFollowingId($id)->orWhere('follower_id', $id)->forceDelete();
Follower::whereProfileId($id)->orWhere('following_id', $id)->forceDelete();

View file

@ -57,7 +57,7 @@ class StatusActivityPubDeliver implements ShouldQueue
$audience = $status->profile->getAudienceInbox();
if(empty($audience) || $status->scope != 'public') {
if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
// Return on profiles with no remote followers
return;
}

View file

@ -104,7 +104,7 @@ class StatusDelete implements ShouldQueue
Report::whereObjectType('App\Status')
->whereObjectId($status->id)
->delete();
$status->delete();
$status->forceDelete();
});
return true;

View file

@ -89,6 +89,9 @@ class StatusEntityLexer implements ShouldQueue
$status = $this->status;
foreach ($tags as $tag) {
if(mb_strlen($tag) > 124) {
continue;
}
DB::transaction(function () use ($status, $tag) {
$slug = str_slug($tag, '-', false);
$hashtag = Hashtag::firstOrCreate(
@ -98,7 +101,8 @@ class StatusEntityLexer implements ShouldQueue
[
'status_id' => $status->id,
'hashtag_id' => $hashtag->id,
'profile_id' => $status->profile_id
'profile_id' => $status->profile_id,
'status_visibility' => $status->visibility,
]
);
});

View file

@ -30,13 +30,14 @@ class Media extends Model
public function url()
{
if(!empty($this->remote_media) && $this->remote_url) {
//$url = \App\Services\MediaProxyService::get($this->remote_url, $this->mime);
$url = $this->remote_url;
} else {
$path = $this->media_path;
$url = $this->cdn_url ?? Storage::url($path);
$url = $this->cdn_url ?? config('app.url') . Storage::url($path);
}
return url($url);
return $url;
}
public function thumbnailUrl()

View file

@ -0,0 +1,64 @@
<?php
namespace App\Observers;
use App\StatusHashtag;
use App\Services\StatusHashtagService;
class StatusHashtagObserver
{
/**
* Handle the notification "created" event.
*
* @param \App\Notification $notification
* @return void
*/
public function created(StatusHashtag $hashtag)
{
StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
}
/**
* Handle the notification "updated" event.
*
* @param \App\Notification $notification
* @return void
*/
public function updated(StatusHashtag $hashtag)
{
StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
}
/**
* Handle the notification "deleted" event.
*
* @param \App\Notification $notification
* @return void
*/
public function deleted(StatusHashtag $hashtag)
{
StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id);
}
/**
* Handle the notification "restored" event.
*
* @param \App\Notification $notification
* @return void
*/
public function restored(StatusHashtag $hashtag)
{
StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
}
/**
* Handle the notification "force deleted" event.
*
* @param \App\Notification $notification
* @return void
*/
public function forceDeleted(StatusHashtag $hashtag)
{
StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id);
}
}

31
app/Place.php Normal file
View file

@ -0,0 +1,31 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Pixelfed\Snowflake\HasSnowflakePrimary;
class Place extends Model
{
protected $visible = ['id', 'name', 'country', 'slug'];
public function url()
{
return url('/discover/places/' . $this->id . '/' . $this->slug);
}
public function posts()
{
return $this->hasMany(Status::class);
}
public function postCount()
{
return $this->posts()->count();
}
public function statuses()
{
return $this->hasMany(Status::class, 'id', 'place_id');
}
}

View file

@ -4,11 +4,19 @@ namespace App;
use Auth, Cache, Storage;
use App\Util\Lexer\PrettyNumber;
use Pixelfed\Snowflake\HasSnowflakePrimary;
use Illuminate\Database\Eloquent\{Model, SoftDeletes};
class Profile extends Model
{
use SoftDeletes;
use HasSnowflakePrimary, SoftDeletes;
/**
* Indicates if the IDs are auto-incrementing.
*
* @var bool
*/
public $incrementing = false;
protected $dates = ['deleted_at'];
protected $hidden = ['private_key'];
@ -132,7 +140,7 @@ class Profile extends Model
$version = hash('sha256', $avatar->change_count);
$path = "{$path}?v={$version}";
return url(Storage::url($path));
return config('app.url') . Storage::url($path);
});
return $url;
@ -278,4 +286,21 @@ class Profile extends Model
'hashtag_id'
);
}
public function hashtagFollowing()
{
return $this->hasMany(HashtagFollow::class);
}
public function collections()
{
return $this->hasMany(Collection::class);
}
public function hasFollowRequestById(int $id)
{
return FollowRequest::whereFollowerId($id)
->whereFollowingId($this->id)
->exists();
}
}

15
app/ProfileSponsor.php Normal file
View file

@ -0,0 +1,15 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class ProfileSponsor extends Model
{
public $fillable = ['profile_id'];
public function profile()
{
return $this->belongsTo(Profile::class);
}
}

View file

@ -5,11 +5,13 @@ namespace App\Providers;
use App\Observers\{
AvatarObserver,
NotificationObserver,
StatusHashtagObserver,
UserObserver
};
use App\{
Avatar,
Notification,
StatusHashtag,
User
};
use Auth, Horizon, URL;
@ -31,6 +33,7 @@ class AppServiceProvider extends ServiceProvider
Avatar::observe(AvatarObserver::class);
Notification::observe(NotificationObserver::class);
StatusHashtag::observe(StatusHashtagObserver::class);
User::observe(UserObserver::class);
Horizon::auth(function ($request) {

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,80 @@
<?php
namespace App\Services;
use Cache, Redis;
use App\{Status, StatusHashtag};
use App\Transformer\Api\StatusHashtagTransformer;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
class StatusHashtagService {
const CACHE_KEY = 'pf:services:status-hashtag:collection:';
public static function get($id, $page = 1, $stop = 9)
{
return StatusHashtag::whereHashtagId($id)
->whereStatusVisibility('public')
->whereHas('media')
->skip($stop)
->latest()
->take(9)
->pluck('status_id')
->map(function ($i, $k) use ($id) {
return self::getStatus($i, $id);
})
->all();
}
public static function coldGet($id, $start = 0, $stop = 2000)
{
$stop = $stop > 2000 ? 2000 : $stop;
$ids = StatusHashtag::whereHashtagId($id)
->whereStatusVisibility('public')
->whereHas('media')
->latest()
->skip($start)
->take($stop)
->pluck('status_id');
foreach($ids as $key) {
self::set($id, $key);
}
return $ids;
}
public static function set($key, $val)
{
return Redis::zadd(self::CACHE_KEY . $key, $val, $val);
}
public static function del($key)
{
return Redis::zrem(self::CACHE_KEY . $key, $key);
}
public static function count($id)
{
$count = Redis::zcount(self::CACHE_KEY . $id, '-inf', '+inf');
if(empty($count)) {
$count = StatusHashtag::whereHashtagId($id)->count();
}
return $count;
}
public static function getStatus($statusId, $hashtagId)
{
return Cache::remember('pf:services:status-hashtag:post:'.$statusId.':hashtag:'.$hashtagId, now()->addMonths(3), function() use($statusId, $hashtagId) {
$statusHashtag = StatusHashtag::with('profile', 'status', 'hashtag')
->whereStatusVisibility('public')
->whereStatusId($statusId)
->whereHashtagId($hashtagId)
->first();
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($statusHashtag, new StatusHashtagTransformer());
return $fractal->createData($resource)->toArray();
});
}
}

View file

@ -379,15 +379,33 @@ class Status extends Model
break;
case 'unlisted':
$res['to'] = [
$this->profile->permalink('/followers')
];
$res['cc'] = [
"https://www.w3.org/ns/activitystreams#Public"
];
break;
case 'private':
$res['to'] = [
$this->profile->permalink('/followers')
];
$res['cc'] = [];
break;
// TODO: Update scope when DMs are supported
case 'direct':
$res['to'] = [];
$res['cc'] = [];
break;
}
return $res[$audience];
}
public function place()
{
return $this->belongsTo(Place::class);
}
}

View file

@ -9,7 +9,8 @@ class StatusHashtag extends Model
public $fillable = [
'status_id',
'hashtag_id',
'profile_id'
'profile_id',
'status_visibility'
];
public function status()
@ -26,4 +27,16 @@ class StatusHashtag extends Model
{
return $this->belongsTo(Profile::class);
}
public function media()
{
return $this->hasManyThrough(
Media::class,
Status::class,
'id',
'status_id',
'status_id',
'id'
);
}
}

View file

@ -15,6 +15,9 @@ class ProfileTransformer extends Fractal\TransformerAbstract
'https://w3id.org/security/v1',
[
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
'PropertyValue' => 'schema:PropertyValue',
'schema' => 'http://schema.org#',
'value' => 'schema:value'
],
],
'id' => $profile->permalink(),

View file

@ -7,20 +7,20 @@ use League\Fractal;
class Announce extends Fractal\TransformerAbstract
{
public function transform(Status $status)
{
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $status->permalink(),
'type' => 'Announce',
'actor' => $status->profile->permalink(),
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [
$status->profile->permalink(),
$status->profile->follower_url ?? $status->profile->permalink('/followers')
],
'published' => $status->created_at->format(DATE_ISO8601),
'object' => $status->parent()->url(),
];
}
public function transform(Status $status)
{
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $status->permalink(),
'type' => 'Announce',
'actor' => $status->profile->permalink(),
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [
$status->profile->permalink(),
$status->profile->follower_url ?? $status->profile->permalink('/followers')
],
'published' => $status->created_at->format(DATE_ISO8601),
'object' => $status->parent()->url(),
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Transformer\Api;
use League\Fractal;
use App\DirectMessage;
class DirectMessageTransformer extends Fractal\TransformerAbstract
{
public function transform(DirectMessage $dm)
{
return [
'id' => $dm->id,
'to_id' => $dm->to_id,
'from_id' => $dm->from_id,
'from_profile_ids' => $dm->from_profile_ids,
'group_message' => $dm->group_message,
'status_id' => $dm->status_id,
'read_at' => $dm->read_at,
'created_at' => $dm->created_at
];
}
}

View file

@ -3,7 +3,10 @@
namespace App\Transformer\Api;
use Auth;
use App\Profile;
use App\{
FollowRequest,
Profile
};
use League\Fractal;
class RelationshipTransformer extends Fractal\TransformerAbstract
@ -12,6 +15,12 @@ class RelationshipTransformer extends Fractal\TransformerAbstract
{
$auth = Auth::check();
$user = $auth ? Auth::user()->profile : false;
$requested = false;
if($user) {
$requested = FollowRequest::whereFollowerId($user->id)
->whereFollowingId($profile->id)
->exists();
}
return [
'id' => (string) $profile->id,
'following' => $auth ? $user->follows($profile) : false,
@ -19,7 +28,7 @@ class RelationshipTransformer extends Fractal\TransformerAbstract
'blocking' => $auth ? $user->blockedIds()->contains($profile->id) : false,
'muting' => $auth ? $user->mutedIds()->contains($profile->id) : false,
'muting_notifications' => null,
'requested' => null,
'requested' => $requested,
'domain_blocking' => null,
'showing_reblogs' => null,
'endorsed' => false

View file

@ -0,0 +1,38 @@
<?php
namespace App\Transformer\Api;
use App\{Hashtag, Status, StatusHashtag};
use League\Fractal;
class StatusHashtagTransformer extends Fractal\TransformerAbstract
{
public function transform(StatusHashtag $statusHashtag)
{
$hashtag = $statusHashtag->hashtag;
$status = $statusHashtag->status;
$profile = $statusHashtag->profile;
return [
'status' => [
'id' => (int) $status->id,
'type' => $status->type,
'url' => $status->url(),
'thumb' => $status->thumb(),
'filter' => $status->firstMedia()->filter_class,
'sensitive' => (bool) $status->is_nsfw,
'like_count' => $status->likes_count,
'share_count' => $status->reblogs_count,
'user' => [
'username' => $profile->username,
'url' => $profile->url(),
],
'visibility' => $status->visibility ?? $status->scope
],
'hashtag' => [
'name' => $hashtag->name,
'url' => $hashtag->url(),
]
];
}
}

View file

@ -10,9 +10,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
{
protected $defaultIncludes = [
'account',
'mentions',
'media_attachments',
'tags',
];
public function transform(Status $status)
@ -41,13 +39,15 @@ class StatusTransformer extends Fractal\TransformerAbstract
],
'language' => null,
'pinned' => null,
'mentions' => [],
'tags' => [],
'pf_type' => $status->type ?? $status->setType(),
'reply_count' => (int) $status->reply_count,
'comments_disabled' => $status->comments_disabled ? true : false,
'thread' => false,
'replies' => [],
'parent' => [],
'place' => $status->place
];
}
@ -58,13 +58,6 @@ class StatusTransformer extends Fractal\TransformerAbstract
return $this->item($account, new AccountTransformer());
}
public function includeMentions(Status $status)
{
$mentions = $status->mentions;
return $this->collection($mentions, new MentionTransformer());
}
public function includeMediaAttachments(Status $status)
{
return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addDays(14), function() use($status) {
@ -74,11 +67,4 @@ class StatusTransformer extends Fractal\TransformerAbstract
}
});
}
public function includeTags(Status $status)
{
$tags = $status->hashtags;
return $this->collection($tags, new HashtagTransformer());
}
}

View file

@ -125,7 +125,7 @@ class Helpers {
{
$audience = self::normalizeAudience($data);
$url = $profile->permalink();
return in_array($url, $audience);
return in_array($url, $audience['to']) || in_array($url, $audience['cc']);
}
public static function validateUrl($url)
@ -181,7 +181,7 @@ class Helpers {
public static function zttpUserAgent()
{
return [
'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'Accept' => 'application/activity+json',
'User-Agent' => 'PixelfedBot - https://pixelfed.org',
];
}
@ -220,7 +220,7 @@ class Helpers {
$id = (int) last(explode('/', $url));
return Status::findOrFail($id);
} else {
$cached = Status::whereUrl($url)->first();
$cached = Status::whereUri($url)->orWhere('url', $url)->first();
if($cached) {
return $cached;
}
@ -241,7 +241,7 @@ class Helpers {
$scope = 'private';
$cw = isset($activity['sensitive']) ? (bool) $activity['sensitive'] : false;
$cw = isset($res['sensitive']) ? (bool) $res['sensitive'] : false;
if(isset($res['to']) == true) {
if(is_array($res['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['to'])) {
@ -280,11 +280,9 @@ class Helpers {
$unlisted = false;
}
$cw = config('costar.domain.cw');
if(in_array(parse_url($url, PHP_URL_HOST), $cw) == true) {
$cwDomains = config('costar.domain.cw');
if(in_array(parse_url($url, PHP_URL_HOST), $cwDomains) == true) {
$cw = true;
} else {
$cw = isset($activity['sensitive']) ? (bool) $activity['sensitive'] : false;
}
}
@ -328,7 +326,9 @@ class Helpers {
$status->scope = $scope;
$status->visibility = $scope;
$status->save();
self::importNoteAttachment($res, $status);
if($reply_to == null) {
self::importNoteAttachment($res, $status);
}
return $status;
});
@ -361,29 +361,16 @@ class Helpers {
if(in_array($type, $allowed) == false || $valid == false) {
continue;
}
$info = pathinfo($url);
// pleroma attachment fix
$url = str_replace(' ', '%20', $url);
$img = file_get_contents($url, false, stream_context_create(['ssl' => ["verify_peer"=>true,"verify_peer_name"=>true]]));
$file = '/tmp/pxmi-'.str_random(32);
file_put_contents($file, $img);
$fdata = new File($file);
$path = Storage::putFile($storagePath, $fdata, 'public');
$media = new Media();
$media->remote_media = true;
$media->status_id = $status->id;
$media->profile_id = $status->profile_id;
$media->user_id = null;
$media->media_path = $path;
$media->size = $fdata->getSize();
$media->mime = $fdata->getMimeType();
$media->media_path = $url;
$media->remote_url = $url;
$media->mime = $type;
$media->save();
ImageThumbnail::dispatch($media);
ImageOptimize::dispatch($media);
unlink($file);
}
$status->viewType();
@ -401,7 +388,10 @@ class Helpers {
if($local == true) {
$id = last(explode('/', $url));
return Profile::whereUsername($id)->firstOrFail();
return Profile::whereNull('status')
->whereNull('domain')
->whereUsername($id)
->firstOrFail();
}
$res = self::fetchProfileFromUrl($url);
if(isset($res['id']) == false) {
@ -423,8 +413,8 @@ class Helpers {
$profile = new Profile();
$profile->domain = $domain;
$profile->username = (string) Purify::clean($remoteUsername);
$profile->name = Purify::clean($res['name']) ?? 'user';
$profile->bio = Purify::clean($res['summary']);
$profile->name = isset($res['name']) ? Purify::clean($res['name']) : 'user';
$profile->bio = isset($res['summary']) ? Purify::clean($res['summary']) : null;
$profile->sharedInbox = isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null;
$profile->inbox_url = $res['inbox'];
$profile->outbox_url = $res['outbox'];

View file

@ -15,8 +15,10 @@ use App\{
use Carbon\Carbon;
use App\Util\ActivityPub\Helpers;
use App\Jobs\LikePipeline\LikePipeline;
use App\Jobs\FollowPipeline\FollowPipeline;
use App\Util\ActivityPub\Validator\{
Accept,
Follow
};
@ -101,7 +103,7 @@ class Inbox
public function actorFirstOrCreate($actorUrl)
{
return Helpers::profileFirstOrNew($actorUrl);
return Helpers::profileFetch($actorUrl);
}
public function handleCreateActivity()
@ -135,15 +137,13 @@ class Inbox
public function handleNoteCreate()
{
return;
$activity = $this->payload['object'];
$actor = $this->actorFirstOrCreate($this->payload['actor']);
if(!$actor || $actor->domain == null) {
return;
}
if(Helpers::userInAudience($this->profile, $this->payload) == false) {
if($actor->followers()->count() == 0) {
return;
}
@ -176,7 +176,7 @@ class Inbox
'following_id' => $target->id,
'local_profile' => empty($actor->domain)
]);
if($follower->wasRecentlyCreated == true) {
if($follower->wasRecentlyCreated == true && $target->domain == null) {
// send notification
Notification::firstOrCreate([
'profile_id' => $target->id,
@ -188,14 +188,19 @@ class Inbox
'item_type' => 'App\Profile'
]);
}
$payload = $this->payload;
// send Accept to remote profile
$accept = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $target->permalink().'#accepts/follows/' . $follower->id,
'type' => 'Accept',
'actor' => $target->permalink(),
'object' => $payload
'object' => [
'id' => $actor->permalink('#follows/' . $follower->id),
'actor' => $actor->permalink(),
'type' => 'Follow',
'object' => $target->permalink()
]
];
Helpers::sendSignedObject($target, $actor->inbox_url, $accept);
}
@ -242,28 +247,40 @@ class Inbox
public function handleAcceptActivity()
{
$actor = $this->payload['actor'];
$obj = $this->payload['object'];
switch ($obj['type']) {
case 'Follow':
$accept = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => $target->permalink().'#accepts/follows/' . $follower->id,
'type' => 'Accept',
'actor' => $target->permalink(),
'object' => [
'id' => $actor->permalink('#follows/'.$target->id),
'type' => 'Follow',
'actor' => $actor->permalink(),
'object' => $target->permalink()
]
];
break;
default:
# code...
break;
$actor = $this->payload['object']['actor'];
$obj = $this->payload['object']['object'];
$type = $this->payload['object']['type'];
if($type !== 'Follow') {
return;
}
$actor = Helpers::validateLocalUrl($actor);
$target = Helpers::validateUrl($obj);
if(!$actor || !$target) {
return;
}
$actor = Helpers::profileFetch($actor);
$target = Helpers::profileFetch($target);
$request = FollowRequest::whereFollowerId($actor->id)
->whereFollowingId($target->id)
->whereIsRejected(false)
->first();
if(!$request) {
return;
}
$follower = Follower::firstOrCreate([
'profile_id' => $actor->id,
'following_id' => $target->id,
]);
FollowPipeline::dispatch($follower);
$request->delete();
}
public function handleDeleteActivity()

View file

@ -16,15 +16,15 @@ class Accept {
'required',
Rule::in(['Accept'])
],
'actor' => 'required|url|active_url',
'actor' => 'required|url',
'object' => 'required',
'object.id' => 'required|url|active_url',
'object.id' => 'required|url',
'object.type' => [
'required',
Rule::in(['Follow'])
],
'object.actor' => 'required|url|active_url',
'object.object' => 'required|url|active_url|same:actor',
'object.actor' => 'required|url',
'object.object' => 'required|url|same:actor',
])->passes();
return $valid;

View file

@ -264,7 +264,9 @@ class Extractor extends Regex
if (preg_match(self::$patterns['end_hashtag_match'], $outer[0])) {
continue;
}
if(mb_strlen($hashtag[0]) > 124) {
continue;
}
$tags[] = [
'hashtag' => $hashtag[0],
'indices' => [$start_position, $end_position],

View file

@ -5,10 +5,6 @@ namespace App\Util\Lexer;
class RestrictedNames
{
public static $blacklist = [
'about',
'abuse',
'administrator',
'app',
'autoconfig',
'blog',
'broadcasthost',
@ -97,7 +93,11 @@ class RestrictedNames
// Reserved routes
'a',
'app',
'about',
'abuse',
'account',
'admins',
'api',
'audio',
'auth',
@ -124,6 +124,7 @@ class RestrictedNames
'css',
'd',
'dashboard',
'dmca',
'db',
'deck',
'dev',
@ -136,15 +137,27 @@ class RestrictedNames
'docs',
'docs',
'drive',
'drives',
'driver',
'e',
'error',
'explore',
'export',
'exports',
'f',
'feed',
'font',
'fonts',
'follow',
'follows',
'followme',
'follow-me',
'follow_me',
'g',
'gdpr',
'graph',
'ghost',
'ghosts',
'group',
'groups',
'h',
@ -164,7 +177,12 @@ class RestrictedNames
'images',
'invite',
'invites',
'import',
'imports',
'j',
'js',
'k',
'key',
'l',
'lab',
'labs',
@ -186,6 +204,7 @@ class RestrictedNames
'news',
'news',
'newsfeed',
'o',
'oauth',
'official',
'p',
@ -197,13 +216,23 @@ class RestrictedNames
'photos',
'password',
'privacy',
'private',
'q',
'quote',
'query',
'r',
'register',
'registers',
'review',
'reset',
'report',
'results',
'reports',
'robot',
'robots',
's',
'search',
'sell',
'send',
'settings',
'status',
@ -217,20 +246,24 @@ class RestrictedNames
'support',
'svg',
'svgs',
't',
'terms',
'telescope',
'timeline',
'timelines',
'tour',
'tv',
'u',
'user',
'users',
'username',
'usernames',
'v',
'valet',
'video',
'videos',
'vendor',
'w',
'waiter',
'wall',
'whats-new',
@ -240,7 +273,9 @@ class RestrictedNames
'ws',
'wss',
'www',
'valet',
'x',
'y',
'z',
'400',
'401',
'403',

View file

@ -110,14 +110,15 @@ class Image
$orientation = $ratio['orientation'];
try {
$img = Intervention::make($file)->orientate();
$img = Intervention::make($file);
$metadata = $img->exif();
$img->orientate();
if($thumbnail) {
$img->resize($aspect['width'], $aspect['height'], function ($constraint) {
$constraint->aspectRatio();
});
} else {
if(config('media.exif.database', false) == true) {
$metadata = $img->exif();
$media->metadata = json_encode($metadata);
}
@ -130,7 +131,7 @@ class Image
$quality = config('pixelfed.image_quality');
$img->save($newPath, $quality);
$img->destroy();
if (!$thumbnail) {
$media->orientation = $orientation;
}

View file

@ -11,7 +11,7 @@ trait User {
public function getMaxPostsPerHourAttribute()
{
return 20;
return 50;
}
public function getMaxPostsPerDayAttribute()
@ -49,8 +49,38 @@ trait User {
return 500;
}
public function getMaxUserBansPerDayAttribute()
{
return 100;
}
public function getMaxInstanceBansPerDayAttribute()
{
return 100;
}
public function getMaxHashtagFollowsPerHourAttribute()
{
return 20;
}
public function getMaxHashtagFollowsPerDayAttribute()
{
return 100;
}
public function getMaxCollectionsPerHourAttribute()
{
return 10;
}
public function getMaxCollectionsPerDayAttribute()
{
return 20;
}
public function getMaxCollectionsPerMonthAttribute()
{
return 100;
}
}

47
app/Util/Site/Config.php Normal file
View file

@ -0,0 +1,47 @@
<?php
namespace App\Util\Site;
use Cache;
class Config {
public static function get() {
return Cache::remember('api:site:configuration', now()->addMinutes(30), function() {
return [
'uploader' => [
'max_photo_size' => config('pixelfed.max_photo_size'),
'max_caption_length' => config('pixelfed.max_caption_length'),
'album_limit' => config('pixelfed.max_album_length'),
'image_quality' => config('pixelfed.image_quality'),
'optimize_image' => config('pixelfed.optimize_image'),
'optimize_video' => config('pixelfed.optimize_video'),
'media_types' => config('pixelfed.media_types'),
'enforce_account_limit' => config('pixelfed.enforce_account_limit')
],
'activitypub' => [
'enabled' => config('federation.activitypub.enabled'),
'remote_follow' => config('federation.activitypub.remoteFollow')
],
'ab' => [
'lc' => config('exp.lc'),
'rec' => config('exp.rec'),
'loops' => config('exp.loops')
],
'site' => [
'domain' => config('pixelfed.domain.app'),
'url' => config('app.url')
]
];
});
}
public static function json() {
return json_encode(self::get(), JSON_FORCE_OBJECT);
}
}

View file

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

View file

@ -23,6 +23,7 @@
"laravel/tinker": "^1.0",
"league/flysystem-aws-s3-v3": "~1.0",
"league/flysystem-cached-adapter": "~1.0",
"league/iso3166": "^2.1",
"moontoast/math": "^1.1",
"pbmedia/laravel-ffmpeg": "4.0.0",
"phpseclib/phpseclib": "~2.0",
@ -38,10 +39,12 @@
"stevebauman/purify": "2.0.*"
},
"require-dev": {
"barryvdh/laravel-debugbar": "dev-master",
"filp/whoops": "^2.0",
"fzaninotto/faker": "^1.4",
"mockery/mockery": "^1.0",
"nunomaduro/collision": "^2.0",
"nunomaduro/phpinsights": "^1.7",
"phpunit/phpunit": "^7.5"
},
"autoload": {

3595
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -150,7 +150,6 @@ return [
/*
* Package Service Providers...
*/
Jackiedo\DotenvEditor\DotenvEditorServiceProvider::class,
/*
* Application Service Providers...
@ -211,7 +210,6 @@ return [
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
'DotenvEditor' => Jackiedo\DotenvEditor\Facades\DotenvEditor::class,
'PrettyNumber' => App\Util\Lexer\PrettyNumber::class,
'Purify' => Stevebauman\Purify\Facades\Purify::class,
'FFMpeg' => Pbmedia\LaravelFFMpeg\FFMpegFacade::class,

View file

@ -73,6 +73,8 @@ return [
'client' => 'predis',
'default' => [
'scheme' => env('REDIS_SCHEME', 'tcp'),
'path' => env('REDIS_PATH'),
'host' => env('REDIS_HOST', 'localhost'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),

View file

@ -109,6 +109,8 @@ return [
'client' => 'predis',
'default' => [
'scheme' => env('REDIS_SCHEME', 'tcp'),
'path' => env('REDIS_PATH'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),

View file

@ -17,7 +17,7 @@ return [
'inbox' => env('AP_INBOX', true),
'sharedInbox' => env('AP_SHAREDINBOX', false),
'remoteFollow' => false,
'remoteFollow' => env('AP_REMOTE_FOLLOW', false),
'delivery' => [
'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 2.0),

View file

@ -1,15 +1,43 @@
<?php
return [
'email' => env('INSTANCE_CONTACT_EMAIL'),
'announcement' => [
'enabled' => env('INSTANCE_ANNOUNCEMENT_ENABLED', false),
'message' => env('INSTANCE_ANNOUNCEMENT_MESSAGE', 'Example announcement message.<br><span class="font-weight-normal">Something else here</span>')
],
'contact' => [
'enabled' => env('INSTANCE_CONTACT_FORM', false),
'max_per_day' => env('INSTANCE_CONTACT_MAX_PER_DAY', 1),
],
'announcement' => [
'enabled' => env('INSTANCE_ANNOUNCEMENT_ENABLED', true),
'message' => env('INSTANCE_ANNOUNCEMENT_MESSAGE', 'Example announcement message.<br><span class="font-weight-normal">Something else here</span>')
]
'discover' => [
'loops' => [
'enabled' => false
],
'tags' => [
'is_public' => env('INSTANCE_PUBLIC_HASHTAGS', false)
],
],
'email' => env('INSTANCE_CONTACT_EMAIL'),
'timeline' => [
'local' => [
'is_public' => env('INSTANCE_PUBLIC_LOCAL_TIMELINE', true)
]
],
'page' => [
'404' => [
'header' => env('PAGE_404_HEADER', 'Sorry, this page isn\'t available.'),
'body' => env('PAGE_404_BODY', 'The link you followed may be broken, or the page may have been removed. <a href="/">Go back to Pixelfed.</a>')
],
'503' => [
'header' => env('PAGE_503_HEADER', 'Service Unavailable'),
'body' => env('PAGE_503_BODY', 'Our service is in maintenance mode, please try again later.')
]
],
];

View file

@ -23,7 +23,7 @@ return [
| This value is the version of your Pixelfed instance.
|
*/
'version' => '0.9.4',
'version' => '0.10.0',
/*
|--------------------------------------------------------------------------

View file

@ -1,4 +1,4 @@
FROM php:7.3-apache
FROM php:7.3-apache-buster
ARG COMPOSER_VERSION="1.8.5"
ARG COMPOSER_CHECKSUM="4e4c1cd74b54a26618699f3190e6f5fc63bb308b13fa660f71f2a2df047c0e17"
@ -7,13 +7,13 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends apt-utils \
&& apt-get install -y --no-install-recommends git gosu \
optipng pngquant jpegoptim gifsicle libpq-dev libsqlite3-dev locales zip unzip libzip-dev libcurl4-openssl-dev \
libfreetype6 libicu-dev libjpeg62-turbo libpng16-16 libxpm4 libwebp6 libmagickwand-6.q16-3 \
libfreetype6 libicu-dev libjpeg62-turbo libpng16-16 libxpm4 libwebp6 libmagickwand-6.q16-6 \
libfreetype6-dev libjpeg62-turbo-dev libpng-dev libxpm-dev libwebp-dev libmagickwand-dev \
&& sed -i '/en_US/s/^#//g' /etc/locale.gen \
&& locale-gen && update-locale \
&& docker-php-source extract \
&& docker-php-ext-configure gd \
--with-freetype-dir=/usr/lib/x86_64-linux-gnu/ \
--enable-freetype \
--with-jpeg-dir=/usr/lib/x86_64-linux-gnu/ \
--with-xpm-dir=/usr/lib/x86_64-linux-gnu/ \
--with-webp-dir=/usr/lib/x86_64-linux-gnu/ \

View file

@ -1,24 +1,25 @@
FROM php:7.2-fpm
FROM php:7.3-fpm-buster
ARG COMPOSER_VERSION="1.8.5"
ARG COMPOSER_CHECKSUM="4e4c1cd74b54a26618699f3190e6f5fc63bb308b13fa660f71f2a2df047c0e17"
RUN apt-get update \
&& apt-get install -y --no-install-recommends apt-utils \
&& apt-get install -y --no-install-recommends git gosu \
optipng pngquant jpegoptim gifsicle libpq-dev libsqlite3-dev locales zip unzip libzip-dev \
libfreetype6 libjpeg62-turbo libpng16-16 libxpm4 libvpx4 libmagickwand-6.q16-3 \
libfreetype6-dev libjpeg62-turbo-dev libpng-dev libxpm-dev libvpx-dev libmagickwand-dev \
optipng pngquant jpegoptim gifsicle libpq-dev libsqlite3-dev locales zip unzip libzip-dev libcurl4-openssl-dev \
libfreetype6 libicu-dev libjpeg62-turbo libpng16-16 libxpm4 libwebp6 libmagickwand-6.q16-6 \
libfreetype6-dev libjpeg62-turbo-dev libpng-dev libxpm-dev libwebp-dev libmagickwand-dev \
&& sed -i '/en_US/s/^#//g' /etc/locale.gen \
&& locale-gen && update-locale \
&& docker-php-source extract \
&& docker-php-ext-configure gd \
--with-freetype-dir=/usr/lib/x86_64-linux-gnu/ \
--enable-freetype \
--with-jpeg-dir=/usr/lib/x86_64-linux-gnu/ \
--with-xpm-dir=/usr/lib/x86_64-linux-gnu/ \
--with-vpx-dir=/usr/lib/x86_64-linux-gnu/ \
&& docker-php-ext-install pdo_mysql pdo_pgsql pdo_sqlite pcntl gd exif bcmath intl zip \
--with-webp-dir=/usr/lib/x86_64-linux-gnu/ \
&& docker-php-ext-install pdo_mysql pdo_pgsql pdo_sqlite pcntl gd exif bcmath intl zip curl \
&& pecl install imagick \
&& docker-php-ext-enable imagick pcntl imagick gd exif zip \
&& docker-php-ext-enable imagick pcntl imagick gd exif zip curl \
&& curl -LsS https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar -o /usr/bin/composer \
&& echo "${COMPOSER_CHECKSUM} /usr/bin/composer" | sha256sum -c - \
&& chmod 755 /usr/bin/composer \
@ -32,7 +33,7 @@ ENV PATH="~/.composer/vendor/bin:./vendor/bin:${PATH}"
COPY . /var/www/
WORKDIR /var/www/
RUN cp -r storage storage.skel \
RUN mkdir public.ext && cp -r storage storage.skel \
&& cp contrib/docker/php.ini /usr/local/etc/php/conf.d/pixelfed.ini \
&& composer install --prefer-dist --no-interaction \
&& rm -rf html && ln -s public html

View file

@ -1,5 +1,5 @@
file_uploads = On
memory_limit = 64M
memory_limit = 128M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 600

View file

@ -1,22 +1,49 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name localhost;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name pixelfed.example; # change this to your fqdn
root /home/pixelfed/public; # path to repo/public
index index.php index.html;
root /var/www/html/public;
ssl_certificate /etc/nginx/ssl/server.crt; # generate your own
ssl_certificate_key /etc/nginx/ssl/server.key; # or use letsencrypt
location / {
try_files $uri $uri/ /$is_args$args;
}
ssl_protocols TLSv1.2;
ssl_ciphers EECDH+AESGCM:EECDH+CHACHA20:EECDH+AES;
ssl_prefer_server_ciphers on;
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
index index.html index.htm index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php-fpm/php-fpm.sock; # make sure this is correct
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; # or $request_filename
}
location ~ /\.(?!well-known).* {
deny all;
}
}
server { # Redirect http to https
server_name pixelfed.example; # change this to your fqdn
listen 80;
listen [::]:80;
return 301 https://$host$request_uri;
}

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateHashtagFollowsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('hashtag_follows', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('user_id')->unsigned()->index();
$table->bigInteger('profile_id')->unsigned()->index();
$table->bigInteger('hashtag_id')->unsigned()->index();
$table->unique(['user_id', 'profile_id', 'hashtag_id']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('hashtag_follows');
}
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddStatusVisibilityToStatusHashtagsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('status_hashtags', function (Blueprint $table) {
$table->string('status_visibility')->nullable()->index()->after('profile_id');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('status_hashtags', function (Blueprint $table) {
$table->dropColumn('status_visibility');
});
}
}

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateProfileSponsorsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('profile_sponsors', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('profile_id')->unsigned()->unique()->index();
$table->json('sponsors')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('profile_sponsors');
}
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class RemoveWebSubsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::dropIfExists('web_subs');
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('web_subs');
}
}

View file

@ -0,0 +1,45 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreatePlacesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('places', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('slug')->index();
$table->string('name')->index();
$table->string('country')->index();
$table->json('aliases')->nullable();
$table->decimal('lat', 9, 6)->nullable();
$table->decimal('long', 9, 6)->nullable();
$table->unique(['slug', 'country', 'lat', 'long']);
$table->timestamps();
});
Schema::table('statuses', function (Blueprint $table) {
$table->bigInteger('place_id')->unsigned()->nullable()->index();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('places');
Schema::table('statuses', function (Blueprint $table) {
$table->dropColumn('place_id');
});
}
}

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddUniqueToStatusesTable extends Migration
{
public function __construct()
{
DB::getDoctrineSchemaManager()->getDatabasePlatform()->registerDoctrineTypeMapping('enum', 'string');
}
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('statuses', function (Blueprint $table) {
$table->string('uri')->nullable()->unique()->index()->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('statuses', function (Blueprint $table) {
$table->string('uri')->nullable()->change();
});
}
}

1019
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
{
"name": "pixelfed",
"private": true,
"scripts": {
"dev": "npm run development",
@ -15,26 +16,28 @@
"bootstrap": ">=4.3.1",
"cross-env": "^5.2.0",
"jquery": "^3.4.1",
"lodash": "^4.17.11",
"lodash": ">=4.17.13",
"popper.js": "^1.15.0",
"resolve-url-loader": "^2.3.2",
"sass": "^1.21.0",
"sass": "^1.22.3",
"sass-loader": "^7.1.0",
"vue": "^2.6.10",
"vue-masonry-css": "^1.0.3",
"vue-template-compiler": "^2.6.10"
},
"dependencies": {
"bootstrap-vue": "^2.0.0-rc.23",
"@trevoreyre/autocomplete-vue": "^2.0.2",
"bootstrap-vue": "^2.0.0-rc.26",
"emoji-mart-vue": "^2.6.6",
"filesize": "^3.6.1",
"howler": "^2.1.2",
"infinite-scroll": "^3.0.6",
"laravel-echo": "^1.5.4",
"laravel-mix": "^4.0.16",
"laravel-mix": "^4.1.2",
"node-sass": "^4.12.0",
"opencollective": "^1.0.3",
"opencollective-postinstall": "^2.0.2",
"plyr": "^3.5.4",
"plyr": "^3.5.6",
"promise-polyfill": "8.1.0",
"pusher-js": "^4.4.0",
"quill": "^1.3.6",
@ -43,6 +46,7 @@
"sweetalert": "^2.1.2",
"twitter-text": "^2.0.5",
"vue-content-loader": "^0.2.2",
"vue-cropperjs": "^4.0.0",
"vue-infinite-loading": "^2.4.4",
"vue-loading-overlay": "^3.2.0",
"vue-timeago": "^5.1.2"

BIN
public/css/app.css vendored

Binary file not shown.

BIN
public/css/appdark.css vendored

Binary file not shown.

BIN
public/css/landing.css vendored

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

BIN
public/js/ace.js vendored

Binary file not shown.

BIN
public/js/activity.js vendored

Binary file not shown.

BIN
public/js/app.js vendored

Binary file not shown.

BIN
public/js/collectioncompose.js vendored Normal file

Binary file not shown.

BIN
public/js/collections.js vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
public/js/compose.js vendored

Binary file not shown.

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