Unverified Commit 700c7805 authored by daniel's avatar daniel Committed by GitHub
Browse files

Merge pull request #1906 from pixelfed/staging

Add S3 + Stories
parents f1e15bac e6419297
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\{
DB,
Storage
};
use App\{
Story,
StoryView
};
class StoryGC extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'story:gc';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear expired Stories';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$stories = Story::where('expires_at', '<', now())->take(50)->get();
if($stories->count() == 0) {
exit;
}
foreach($stories as $story) {
if(Storage::exists($story->path) == true) {
Storage::delete($story->path);
}
DB::transaction(function() use($story) {
StoryView::whereStoryId($story->id)->delete();
$story->delete();
});
}
}
}
......@@ -30,6 +30,7 @@ class Kernel extends ConsoleKernel
$schedule->command('media:gc')
->hourly();
$schedule->command('horizon:snapshot')->everyFiveMinutes();
$schedule->command('story:gc')->everyFiveMinutes();
}
/**
......
......@@ -111,6 +111,10 @@ class FollowerController extends Controller
Cache::forget('api:local:exp:rec:'.$user->id);
Cache::forget('user:account:id:'.$target->user_id);
Cache::forget('user:account:id:'.$user->user_id);
Cache::forget('px:profile:followers-v1.3:'.$user->id);
Cache::forget('px:profile:followers-v1.3:'.$target->id);
Cache::forget('px:profile:following-v1.3:'.$user->id);
Cache::forget('px:profile:following-v1.3:'.$target->id);
return $target->url();
}
......
......@@ -244,7 +244,7 @@ class InternalApiController extends Controller
'cw' => 'nullable|boolean',
'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
'place' => 'nullable',
'comments_disabled' => 'nullable|boolean'
'comments_disabled' => 'nullable'
]);
if(config('costar.enabled') == true) {
......@@ -301,7 +301,7 @@ class InternalApiController extends Controller
}
if($request->filled('comments_disabled')) {
$status->comments_disabled = $request->input('comments_disabled');
$status->comments_disabled = (bool) $request->input('comments_disabled');
}
$status->caption = strip_tags($request->caption);
......@@ -314,10 +314,6 @@ class InternalApiController extends Controller
$media->save();
}
// $resource = new Fractal\Resource\Collection($status->media()->orderBy('order')->get(), new StatusMediaContainerTransformer());
// $mediaContainer = $this->fractal->createData($resource)->toArray();
// $status->media_container = json_encode($mediaContainer);
$visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility;
$cw = $profile->cw == true ? true : $cw;
$status->is_nsfw = $cw;
......
......@@ -9,6 +9,7 @@ use View;
use App\Follower;
use App\FollowRequest;
use App\Profile;
use App\Story;
use App\User;
use App\UserFilter;
use League\Fractal;
......@@ -135,6 +136,21 @@ class ProfileController extends Controller
return false;
}
public static function accountCheck(Profile $profile)
{
switch ($profile->status) {
case 'disabled':
case 'suspended':
case 'delete':
return view('profile.disabled');
break;
default:
break;
}
return abort(404);
}
protected function blockedProfileCheck(Profile $profile)
{
$pid = Auth::user()->profile->id;
......@@ -215,4 +231,18 @@ class ProfileController extends Controller
return response($content)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
}
public function stories(Request $request, $username)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
$pid = $profile->id;
$authed = Auth::user()->profile;
abort_if($pid != $authed->id && $profile->followedBy($authed) == false, 404);
$exists = Story::whereProfileId($pid)
->where('expires_at', '>', now())
->count();
abort_unless($exists > 0, 404);
return view('profile.story', compact('pid'));
}
}
......@@ -3,6 +3,15 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Media;
use App\Profile;
use App\Story;
use App\StoryView;
use App\Services\StoryService;
use Cache, Storage;
use App\Services\FollowerService;
class StoryController extends Controller
{
......@@ -12,8 +21,235 @@ class StoryController extends Controller
$this->middleware('auth');
}
public function home(Request $request)
public function apiV1Add(Request $request)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'file.*' => function() {
return [
'required',
'mimes:image/jpeg,image/png',
'max:' . config('pixelfed.max_photo_size'),
];
},
]);
$user = $request->user();
if(Story::whereProfileId($user->profile_id)->where('expires_at', '>', now())->count() >= Story::MAX_PER_DAY) {
abort(400, 'You have reached your limit for new Stories today.');
}
$story = new Story();
$story->profile_id = $user->profile_id;
$story->save();
$monthHash = substr(hash('sha1', date('Y').date('m')), 0, 12);
$rid = Str::random(6).'.'.Str::random(9);
$photo = $request->file('file');
$mimes = explode(',', config('pixelfed.media_types'));
if(in_array($photo->getMimeType(), [
'image/jpeg',
'image/png'
]) == false) {
abort(400, 'Invalid media type');
return;
}
$storagePath = "public/_esm.t1/{$monthHash}/{$story->id}/{$rid}";
$path = $photo->store($storagePath);
$story->path = $path;
$story->local = true;
$story->expires_at = now()->addHours(24);
$story->save();
return [
'code' => 200,
'msg' => 'Successfully added',
'media_url' => url(Storage::url($story->path))
];
}
public function apiV1Delete(Request $request, $id)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$user = $request->user();
$story = Story::whereProfileId($user->profile_id)
->findOrFail($id);
if(Storage::exists($story->path) == true) {
Storage::delete($story->path);
}
$story->delete();
return [
'code' => 200,
'msg' => 'Successfully deleted'
];
}
public function apiV1Recent(Request $request)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$profile = $request->user()->profile;
$following = FollowerService::build()->profile($profile)->following();
$stories = Story::with('profile')
->whereIn('profile_id', $following)
->groupBy('profile_id')
->where('expires_at', '>', now())
->orderByDesc('expires_at')
->take(9)
->get()
->map(function($s, $k) {
return [
'id' => (string) $s->id,
'photo' => $s->profile->avatarUrl(),
'name' => $s->profile->username,
'link' => $s->profile->url(),
'lastUpdated' => (int) $s->created_at->format('U'),
'seen' => $s->seen(),
'items' => [],
'pid' => (string) $s->profile->id
];
});
return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function apiV1Fetch(Request $request, $id)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$profile = $request->user()->profile;
if($id == $profile->id) {
$publicOnly = true;
} else {
$following = FollowerService::build()->profile($profile)->following();
$publicOnly = in_array($id, $following);
}
$stories = Story::whereProfileId($id)
->orderBy('expires_at', 'desc')
->where('expires_at', '>', now())
->when(!$publicOnly, function($query, $publicOnly) {
return $query->wherePublic(true);
})
->get()
->map(function($s, $k) {
return [
'id' => (string) $s->id,
'type' => 'photo',
'length' => 3,
'src' => url(Storage::url($s->path)),
'preview' => null,
'link' => null,
'linkText' => null,
'time' => $s->created_at->format('U'),
'expires_at' => (int) $s->expires_at->format('U'),
'seen' => $s->seen()
];
})->toArray();
return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function apiV1Profile(Request $request, $id)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$authed = $request->user()->profile;
$profile = Profile::findOrFail($id);
if($id == $authed->id) {
$publicOnly = true;
} else {
$following = FollowerService::build()->profile($authed)->following();
$publicOnly = in_array($id, $following);
}
$stories = Story::whereProfileId($profile->id)
->orderBy('expires_at')
->where('expires_at', '>', now())
->when(!$publicOnly, function($query, $publicOnly) {
return $query->wherePublic(true);
})
->get()
->map(function($s, $k) {
return [
'id' => $s->id,
'type' => 'photo',
'length' => 3,
'src' => url(Storage::url($s->path)),
'preview' => null,
'link' => null,
'linkText' => null,
'time' => $s->created_at->format('U'),
'expires_at' => (int) $s->expires_at->format('U'),
'seen' => $s->seen()
];
})->toArray();
if(count($stories) == 0) {
return [];
}
$cursor = count($stories) - 1;
$stories = [[
'id' => (string) $stories[$cursor]['id'],
'photo' => $profile->avatarUrl(),
'name' => $profile->username,
'link' => $profile->url(),
'lastUpdated' => (int) now()->format('U'),
'seen' => null,
'items' => $stories,
'pid' => (string) $profile->id
]];
return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
}
public function apiV1Viewed(Request $request)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
$this->validate($request, [
'id' => 'required|integer|min:1|exists:stories',
]);
StoryView::firstOrCreate([
'story_id' => $request->input('id'),
'profile_id' => $request->user()->profile_id
]);
return ['code' => 200];
}
public function compose(Request $request)
{
abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
return view('stories.compose');
}
public function apiV1Exists(Request $request, $id)
{
abort_if(!config('instance.stories.enabled'), 404);
$res = (bool) Story::whereProfileId($id)
->where('expires_at', '>', now())
->count();
return response()->json($res);
}
public function iRedirect(Request $request)
{
return view('stories.home');
$user = $request->user();
abort_if(!$user, 404);
$username = $user->username;
return redirect("/stories/{$username}");
}
}
......@@ -303,4 +303,9 @@ class Profile extends Model
->whereFollowingId($this->id)
->exists();
}
public function stories()
{
return $this->hasMany(Story::class);
}
}
......@@ -36,7 +36,6 @@ class AuthServiceProvider extends ServiceProvider
'read',
'write',
'follow',
'push'
]);
Passport::tokensCan([
......
......@@ -131,13 +131,9 @@ class Status extends Model
$media = $this->firstMedia();
$path = $media->media_path;
$hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at);
if(config('pixelfed.cloud_storage') == true) {
$url = Storage::disk(config('filesystems.cloud'))->url($path)."?v={$hash}";
} else {
$url = Storage::url($path)."?v={$hash}";
}
$url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}");
return url($url);
return $url;
}
public function likes()
......
......@@ -10,6 +10,8 @@ class Story extends Model
{
use HasSnowflakePrimary;
public const MAX_PER_DAY = 10;
/**
* Indicates if the IDs are auto-incrementing.
*
......@@ -24,6 +26,8 @@ class Story extends Model
*/
protected $dates = ['published_at', 'expires_at'];
protected $fillable = ['profile_id'];
protected $visible = ['id'];
public function profile()
......@@ -31,16 +35,6 @@ class Story extends Model
return $this->belongsTo(Profile::class);
}
public function items()
{
return $this->hasMany(StoryItem::class);
}
public function reactions()
{
return $this->hasMany(StoryReaction::class);
}
public function views()
{
return $this->hasMany(StoryView::class);
......@@ -48,7 +42,13 @@ class Story extends Model
public function seen($pid = false)
{
$id = $pid ?? Auth::user()->profile->id;
return $this->views()->whereProfileId($id)->exists();
return StoryView::whereStoryId($this->id)
->whereProfileId(Auth::user()->profile->id)
->exists();
}
public function permalink()
{
return url("/story/$this->id");
}
}
......@@ -62,7 +62,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
public function includeMediaAttachments(Status $status)
{
return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addDays(14), function() use($status) {
return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(14), function() use($status) {
if(in_array($status->type, ['photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) {
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
......
......@@ -406,7 +406,6 @@ class Helpers {
$remoteUsername = "@{$username}@{$domain}";
abort_if(!self::validateUrl($res['inbox']), 400);
abort_if(!self::validateUrl($res['outbox']), 400);
abort_if(!self::validateUrl($res['id']), 400);
$profile = Profile::whereRemoteUrl($res['id'])->first();
......@@ -451,4 +450,20 @@ class Helpers {
$response = curl_exec($ch);
return;
}
public static function apSignedPostRequest($senderProfile, $url, $body)
{
abort_if(!self::validateUrl($url), 400);
$payload = json_encode($body);
$headers = HttpSignature::sign($senderProfile, $url, $body);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);
return;
}
}
......@@ -12,7 +12,6 @@ class RestrictedNames
'download',
'domainadmin',
'domainadministrator',
'email',
'errors',
'events',
'example',
......@@ -26,7 +25,7 @@ class RestrictedNames
'hostmaster',
'imap',
'info',
'info',
'information',
'is',
'isatap',
'it',
......@@ -142,6 +141,8 @@ class RestrictedNames
'drives',
'driver',
'e',
'email',
'emails',
'error',
'explore',
'export',
......@@ -206,6 +207,10 @@ class RestrictedNames
'news',
'news',
'newsfeed',
'newsroom',
'newsrooms',
'news-room',
'news-rooms',
'o',
'oauth',
'official',
......
......@@ -6,7 +6,7 @@ trait User {
public function isTrustedAccount()
{
return $this->created_at->lt(now()->subDays(20));
return $this->created_at->lt(now()->subDays(60));
}
public function getMaxPostsPerHourAttribute()
......@@ -98,4 +98,19 @@ trait User {
{
return 5000;
}
public function getMaxStoriesPerHourAttribute()
{
return 20;
}
public function getMaxStoriesPerDayAttribute()
{
return 30;
}
public function getMaxStoryDeletePerDayAttribute()
{
return 35;
}
}
\ No newline at end of file