Merge branch 'dev' into maxlength

This commit is contained in:
Marcin Mikołajczak 2018-08-26 18:52:12 +02:00 committed by GitHub
commit a1588a82c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
185 changed files with 10708 additions and 1524 deletions

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

View file

@ -1,46 +1,52 @@
APP_NAME=Laravel
APP_NAME="PixelFed Test"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
ADMIN_DOMAIN="localhost"
APP_DOMAIN="localhost"
LOG_CHANNEL=stack
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=homestead
DB_PASSWORD=secret
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
SESSION_DRIVER=file
CACHE_DRIVER=redis
SESSION_DRIVER=redis
SESSION_LIFETIME=120
QUEUE_DRIVER=sync
QUEUE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_DRIVER=smtp
MAIL_DRIVER=log
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="pixelfed@example.com"
MAIL_FROM_NAME="Pixelfed"
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
SESSION_DOMAIN=".pixelfed.dev"
SESSION_DOMAIN="${APP_DOMAIN}"
SESSION_SECURE_COOKIE=true
API_BASE="/api/1/"
API_SEARCH="/api/search"
OPEN_REGISTRATION=true
RECAPTCHA_ENABLED=false
ENFORCE_EMAIL_VERIFICATION=true
MAX_PHOTO_SIZE=15000
MAX_CAPTION_LENGTH=150
MAX_ALBUM_LENGTH=4
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

View file

@ -5,7 +5,7 @@ ARG COMPOSER_CHECKSUM="67bebe9df9866a795078bb2cf21798d8b0214f2e0b2fd81f2e907a8ef
RUN apk add --no-cache --virtual .build build-base autoconf imagemagick-dev libtool && \
apk --no-cache add imagemagick git && \
docker-php-ext-install pdo_mysql pcntl && \
docker-php-ext-install pdo_mysql pcntl bcmath && \
pecl install imagick && \
docker-php-ext-enable imagick pcntl imagick && \
curl -LsS https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar -o /tmp/composer.phar && \

View file

@ -1,4 +1,76 @@
# PixelFed
Federated Image Sharing
# PixelFed: Federated Image Sharing
> This project is still in active development and not yet ready for use.
PixelFed is a federated social image sharing platform, similar to Instagram.
Federation is done using the [ActivityPub](https://activitypub.rocks/) protocol,
which is used by [Mastodon](http://joinmastodon.org/), [PeerTube](https://joinpeertube.org/en/),
[Pleroma](https://pleroma.social/), and more. Through ActivityPub PixelFed can share
and interact with these platforms, as well as other instances of PixelFed.
**_Please note this is alpha software, not recommended for production use,
and federation is not supported yet._**
PixelFed is very early into the development stage. If you would like to have a
permanent instance with minimal breakage, **do not use this software until
there is a stable release**. The following setup instructions are intended for
testing and development.
## Requirements
- PHP >= 7.1.3 (7.2+ recommended for stable version)
- MySQL >= 5.7, Postgres (MariaDB and sqlite are not supported yet)
- Redis
- Composer
- GD or ImageMagick
- OpenSSL PHP Extension
- PDO PHP Extension
- Mbstring PHP Extension
- Tokenizer PHP Extension
- XML PHP Extension
- Ctype PHP Extension
- JSON PHP Extension
- JpegOptim
- Optipng
- Pngquant 2
- SVGO
- Gifsicle
## Installation
This guide assumes you have NGINX/Apache installed, along with the dependencies.
Those will not be covered in these early docs.
```bash
git clone https://github.com/dansup/pixelfed.git
cd pixelfed
composer install
cp .env.example .env
```
**Edit .env file with proper values**
```bash
php artisan key:generate
```
```bash
php artisan storage:link
php artisan migrate
php artisan horizon
php artisan serve --host=localhost --port=80
```
Check your browser at http://localhost
## Communication
The ways you can communicate on the project are below. Before interacting, please
read through the [Code Of Conduct](CODE_OF_CONDUCT.md).
* IRC: #pixelfed on irc.freenode.net ([#freenode_#pixelfed:matrix.org through
Matrix](https://matrix.to/#/#freenode_#pixelfed:matrix.org)
* Project on Mastodon: [@pixelfed@mastodon.social](https://mastodon.social/@pixelfed)
* E-mail: [hello@pixelfed.org](mailto:hello@pixelfed.org)
## Support
The lead maintainer is on Patreon! You can become a Patron at
https://www.patreon.com/dansup

10
app/AccountLog.php Normal file
View file

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

View file

@ -3,8 +3,16 @@
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Avatar extends Model
{
use SoftDeletes;
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = ['deleted_at'];
}

15
app/EmailVerification.php Normal file
View file

@ -0,0 +1,15 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class EmailVerification extends Model
{
public function url()
{
$base = config('app.url');
$path = '/i/confirm-email/' . $this->user_token . '/' . $this->random_token;
return "{$base}{$path}";
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use App\{User, UserSetting};
class AuthLoginEvent
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct()
{
//
}
public function handle(User $user)
{
if(empty($user->settings)) {
$settings = new UserSetting;
$settings->user_id = $user->id;
$settings->save();
}
}
}

View file

@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
class Hashtag extends Model
{
protected $fillable = ['name','slug'];
public $fillable = ['name','slug'];
public function posts()
{

View file

@ -3,11 +3,24 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Auth, Cache, Redis;
use App\{Notification, Profile, User};
use Carbon\Carbon;
use App\Mail\ConfirmEmail;
use Auth, DB, Cache, Mail, Redis;
use App\{
EmailVerification,
Notification,
Profile,
User,
UserFilter
};
class AccountController extends Controller
{
protected $filters = [
'user.mute',
'user.block'
];
public function __construct()
{
$this->middleware('auth');
@ -15,13 +28,98 @@ class AccountController extends Controller
public function notifications(Request $request)
{
$this->validate($request, [
'page' => 'nullable|min:1|max:3',
'a' => 'nullable|alpha_dash',
]);
$profile = Auth::user()->profile;
//$notifications = $this->fetchNotifications($profile->id);
$notifications = Notification::whereProfileId($profile->id)
->orderBy('id','desc')->take(30)->simplePaginate();
$action = $request->input('a');
$timeago = Carbon::now()->subMonths(6);
if($action && in_array($action, ['comment', 'follow', 'mention'])) {
$notifications = Notification::whereProfileId($profile->id)
->whereAction($action)
->whereDate('created_at', '>', $timeago)
->orderBy('id','desc')
->simplePaginate(30);
} else {
$notifications = Notification::whereProfileId($profile->id)
->whereDate('created_at', '>', $timeago)
->orderBy('id','desc')
->simplePaginate(30);
}
return view('account.activity', compact('profile', 'notifications'));
}
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');
$timeago = Carbon::now()->subMonths(1);
$following = $profile->following->pluck('id');
$notifications = Notification::whereIn('actor_id', $following)
->where('profile_id', '!=', $profile->id)
->whereDate('created_at', '>', $timeago)
->orderBy('notifications.id','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)
{
$timeLimit = Carbon::now()->subDays(1)->toDateTimeString();
$recentAttempt = EmailVerification::whereUserId(Auth::id())
->where('created_at', '>', $timeLimit)->count();
$exists = EmailVerification::whereUserId(Auth::id())->count();
if($recentAttempt == 1 && $exists == 1) {
return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.');
} elseif ($recentAttempt == 0 && $exists !== 0) {
// Delete old verification and send new one.
EmailVerification::whereUserId(Auth::id())->delete();
}
$user = User::whereNull('email_verified_at')->find(Auth::id());
$utoken = hash('sha512', $user->id);
$rtoken = str_random(40);
$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) {
$user = User::find(Auth::id());
$user->email_verified_at = Carbon::now();
$user->save();
return redirect('/');
}
}
public function fetchNotifications($id)
{
$key = config('cache.prefix') . ":user.{$id}.notifications";
@ -46,4 +144,98 @@ class AccountController extends Controller
}
return $notifications;
}
public function messages()
{
return view('account.messages');
}
public function showMessage(Request $request, $id)
{
return view('account.message');
}
public function mute(Request $request)
{
$this->validate($request, [
'type' => 'required|string',
'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'
]);
return redirect()->back();
}
public function block(Request $request)
{
$this->validate($request, [
'type' => 'required|string',
'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);
$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' => 'block'
]);
return redirect()->back();
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Admin;
use Illuminate\Http\Request;
use Carbon\Carbon;
use App\{Comment, Like, Media, Profile, Report, Status, User};
use App\Http\Controllers\Controller;
trait AdminReportController
{
public function updateReport(Request $request, $id)
{
$this->validate($request, [
'action' => 'required|string'
]);
$action = $request->input('action');
$actions = [
'ignore',
'cw',
'unlist',
'delete',
'shadowban',
'ban'
];
if(!in_array($action, $actions)) {
return abort(403);
}
$report = Report::findOrFail($id);
$this->handleReportAction($report, $action);
return response()->json(['msg'=> 'Success']);
}
public function handleReportAction(Report $report, $action)
{
$item = $report->reported();
$report->admin_seen = Carbon::now();
switch ($action) {
case 'ignore':
$report->not_interested = true;
break;
case 'cw':
$item->is_nsfw = true;
$item->save();
$report->nsfw = true;
break;
case 'unlist':
$item->visibility = 'unlisted';
$item->save();
break;
case 'delete':
// Todo: fire delete job
$report->admin_seen = null;
break;
case 'shadowban':
// Todo: fire delete job
$report->admin_seen = null;
break;
case 'ban':
// Todo: fire delete job
$report->admin_seen = null;
break;
default:
$report->admin_seen = null;
break;
}
$report->save();
return $this;
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Api;
use Auth, Cache;
use App\{
Avatar,
Like,
Profile,
Status
};
use League\Fractal;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Http\Controllers\AvatarController;
use App\Util\Webfinger\Webfinger;
use App\Transformer\Api\{
AccountTransformer,
StatusTransformer
};
use App\Jobs\AvatarPipeline\AvatarOptimize;
use League\Fractal\Serializer\ArraySerializer;
class BaseApiController extends Controller
{
protected $fractal;
public function __construct()
{
$this->middleware('auth');
$this->fractal = new Fractal\Manager();
$this->fractal->setSerializer(new ArraySerializer());
}
public function accounts(Request $request, $id)
{
$profile = Profile::findOrFail($id);
$resource = new Fractal\Resource\Item($profile, new AccountTransformer);
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
public function accountFollowers(Request $request, $id)
{
$profile = Profile::findOrFail($id);
$followers = $profile->followers;
$resource = new Fractal\Resource\Collection($followers, new AccountTransformer);
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
public function accountFollowing(Request $request, $id)
{
$profile = Profile::findOrFail($id);
$following = $profile->following;
$resource = new Fractal\Resource\Collection($following, new AccountTransformer);
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
public function accountStatuses(Request $request, $id)
{
$profile = Profile::findOrFail($id);
$statuses = $profile->statuses()->orderBy('id', 'desc')->paginate(20);
$resource = new Fractal\Resource\Collection($statuses, new StatusTransformer);
$res = $this->fractal->createData($resource)->toArray();
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
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, [
'upload' => 'required|mimes:jpeg,png,gif|max:2000',
]);
try {
$user = Auth::user();
$profile = $user->profile;
$file = $request->file('upload');
$path = (new AvatarController())->getPath($user, $file);
$dir = $path['root'];
$name = $path['name'];
$public = $path['storage'];
$currentAvatar = storage_path('app/'.$profile->avatar->media_path);
$loc = $request->file('upload')->storeAs($public, $name);
$avatar = Avatar::whereProfileId($profile->id)->firstOrFail();
$opath = $avatar->media_path;
$avatar->media_path = "$public/$name";
$avatar->thumb_path = null;
$avatar->change_count = ++$avatar->change_count;
$avatar->last_processed_at = null;
$avatar->save();
Cache::forget("avatar:{$profile->id}");
AvatarOptimize::dispatch($user->profile, $currentAvatar);
} catch (Exception $e) {
}
return response()->json([
'code' => 200,
'msg' => 'Avatar successfully updated'
]);
}
}

View file

@ -2,16 +2,13 @@
namespace App\Http\Controllers;
use Auth;
use App\Like;
use Auth, Cache;
use App\{Like, Status};
use Illuminate\Http\Request;
use App\Http\Controllers\Api\BaseApiController;
class ApiController extends Controller
class ApiController extends BaseApiController
{
public function __construct()
{
$this->middleware('auth');
}
public function hydrateLikes(Request $request)
{
@ -21,12 +18,18 @@ class ApiController extends Controller
]);
$profile = Auth::user()->profile;
$likes = Like::whereProfileId($profile->id)
$res = Cache::remember('api:like-ids:user:'.$profile->id, 1440, function() use ($profile) {
return Like::whereProfileId($profile->id)
->orderBy('id', 'desc')
->take(1000)
->pluck('status_id');
});
return response()->json($likes);
return response()->json($res);
}
public function loadMoreComments(Request $request)
{
return;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Auth;
use App\{AccountLog, User};
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
@ -25,7 +26,7 @@ class LoginController extends Controller
*
* @var string
*/
protected $redirectTo = '/home';
protected $redirectTo = '/';
/**
* Create a new controller instance.
@ -56,4 +57,25 @@ class LoginController extends Controller
$this->validate($request, $rules);
}
/**
* The user has been authenticated.
*
* @param \Illuminate\Http\Request $request
* @param mixed $user
* @return mixed
*/
protected function authenticated($request, $user)
{
$log = new AccountLog;
$log->user_id = $user->id;
$log->item_id = $user->id;
$log->item_type = 'App\User';
$log->action = 'auth.login';
$log->message = 'Account Login';
$log->link = null;
$log->ip_address = $request->ip();
$log->user_agent = $request->userAgent();
$log->save();
}
}

View file

@ -51,11 +51,22 @@ class RegisterController extends Controller
protected function validator(array $data)
{
$this->validateUsername($data['username']);
$usernameRules = [
'required',
'alpha_dash',
'min:2',
'max:15',
'unique:users',
function($attribute, $value, $fail) {
if(!ctype_alpha($value[0])) {
return $fail($attribute . ' is invalid. Username must be alpha-numeric and start with a letter.');
}
}
];
$rules = [
'name' => 'required|string|max' . config('pixelfed.max_name_length'),
'username' => 'required|alpha_dash|min:2|max:15|unique:users',
'username' => $usernameRules,
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:6|confirmed',
];

View file

@ -3,8 +3,93 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Auth, Cache, Log, Storage;
use App\Avatar;
use App\Jobs\AvatarPipeline\AvatarOptimize;
class AvatarController extends Controller
{
//
public function __construct()
{
return $this->middleware('auth');
}
public function store(Request $request)
{
$this->validate($request, [
'avatar' => 'required|mimes:jpeg,png|max:2000'
]);
try {
$user = Auth::user();
$profile = $user->profile;
$file = $request->file('avatar');
$path = $this->getPath($user, $file);
$dir = $path['root'];
$name = $path['name'];
$public = $path['storage'];
$currentAvatar = storage_path('app/'.$profile->avatar->media_path);
$loc = $request->file('avatar')->storeAs($public, $name);
$avatar = Avatar::whereProfileId($profile->id)->firstOrFail();
$opath = $avatar->media_path;
$avatar->media_path = "$public/$name";
$avatar->thumb_path = null;
$avatar->change_count = ++$avatar->change_count;
$avatar->last_processed_at = null;
$avatar->save();
Cache::forget("avatar:{$profile->id}");
AvatarOptimize::dispatch($user->profile, $currentAvatar);
} catch (Exception $e) {
}
return redirect()->back()->with('status', 'Avatar updated successfully. It may take a few minutes to update across the site.');
}
public function getPath($user, $file)
{
$basePath = storage_path('app/public/avatars');
$this->checkDir($basePath);
$id = $user->profile->id;
$path = $this->buildPath($id);
$dir = storage_path('app/'.$path);
$this->checkDir($dir);
$name = 'avatar.' . $file->guessExtension();
$res = ['root' => 'storage/app/' . $path, 'name' => $name, 'storage' => $path];
return $res;
}
public function checkDir($path)
{
if(!is_dir($path)) {
mkdir($path);
}
}
public function buildPath($id)
{
$padded = str_pad($id, 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]);
$this->checkDir($prefix);
}
if($k == 1) {
$prefix = storage_path('app/public/avatars/'.$parts[0].'/'.$parts[1]);
$this->checkDir($prefix);
}
if($k == 2) {
$prefix = storage_path('app/public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2]);
$this->checkDir($prefix);
}
if($k == 3) {
$avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3];
$prefix = storage_path('app/'.$avatarpath);
$this->checkDir($prefix);
}
}
return $avatarpath;
}
}

View file

@ -16,23 +16,27 @@ class BookmarkController extends Controller
public function store(Request $request)
{
$this->validate($request, [
'item' => 'required|integer|min:1'
'item' => 'required|integer|min:1'
]);
$profile = Auth::user()->profile;
$status = Status::findOrFail($request->input('item'));
$bookmark = Bookmark::firstOrCreate(
['status_id' => $status->id], ['profile_id' => $profile->id]
['status_id' => $status->id], ['profile_id' => $profile->id]
);
if(!$bookmark->wasRecentlyCreated) {
$bookmark->delete();
}
if($request->ajax()) {
$response = ['code' => 200, 'msg' => 'Bookmark saved!'];
} else {
} else {
$response = redirect()->back();
}
}
return $response;
}
return $response;
}
}

View file

@ -18,6 +18,14 @@ class CommentController extends Controller
return view('status.reply', compact('user', 'status'));
}
public function showAll(Request $request, $username, int $id)
{
$user = Profile::whereUsername($username)->firstOrFail();
$status = Status::whereProfileId($user->id)->findOrFail($id);
$replies = Status::whereInReplyToId($id)->paginate(40);
return view('status.comments', compact('user', 'status', 'replies'));
}
public function store(Request $request)
{
if(Auth::check() === false) { abort(403); }
@ -34,8 +42,8 @@ class CommentController extends Controller
$reply = new Status();
$reply->profile_id = $profile->id;
$reply->caption = $comment;
$reply->rendered = e($comment);
$reply->caption = e($comment);
$reply->rendered = $comment;
$reply->in_reply_to_id = $status->id;
$reply->in_reply_to_profile_id = $status->profile_id;
$reply->save();
@ -44,7 +52,7 @@ class CommentController extends Controller
CommentPipeline::dispatch($status, $reply);
if($request->ajax()) {
$response = ['code' => 200, 'msg' => 'Comment saved', 'username' => $profile->username, 'url' => $reply->url(), 'profile' => $profile->url()];
$response = ['code' => 200, 'msg' => 'Comment saved', 'username' => $profile->username, 'url' => $reply->url(), 'profile' => $profile->url(), 'comment' => $reply->caption];
} else {
$response = redirect($status->url());
}

View file

@ -15,17 +15,44 @@ class DiscoverController extends Controller
public function home()
{
$following = Follower::whereProfileId(Auth::user()->profile->id)->pluck('following_id');
$people = Profile::inRandomOrder()->where('id', '!=', Auth::user()->profile->id)->whereNotIn('id', $following)->take(3)->get();
$posts = Status::whereHas('media')->where('profile_id', '!=', Auth::user()->profile->id)->whereNotIn('profile_id', $following)->orderBy('created_at', 'desc')->take('21')->get();
$pid = Auth::user()->profile->id;
$following = Follower::whereProfileId($pid)
->pluck('following_id');
$people = Profile::inRandomOrder()
->where('id', '!=', $pid)
->whereNotIn('id', $following)
->take(3)
->get();
$posts = Status::whereHas('media')
->where('profile_id', '!=', $pid)
->whereNotIn('profile_id', $following)
->orderBy('created_at', 'desc')
->simplePaginate(21);
return view('discover.home', compact('people', 'posts'));
}
public function showTags(Request $request, $hashtag)
{
$tag = Hashtag::whereSlug($hashtag)->firstOrFail();
$posts = $tag->posts()->has('media')->orderBy('id','desc')->paginate(12);
$count = $tag->posts()->has('media')->orderBy('id','desc')->count();
return view('discover.tags.show', compact('tag', 'posts', 'count'));
$this->validate($request, [
'page' => 'nullable|integer|min:1|max:10'
]);
$tag = Hashtag::with('posts')
->withCount('posts')
->whereSlug($hashtag)
->firstOrFail();
$posts = $tag->posts()
->whereIsNsfw(false)
->whereVisibility('public')
->has('media')
->orderBy('id','desc')
->simplePaginate(12);
return view('discover.tags.show', compact('tag', 'posts'));
}
}

View file

@ -2,8 +2,9 @@
namespace App\Http\Controllers;
use Auth;
use Auth, Cache;
use App\Profile;
use Carbon\Carbon;
use League\Fractal;
use Illuminate\Http\Request;
use App\Util\Lexer\Nickname;
@ -13,15 +14,26 @@ use App\Transformer\ActivityPub\{
ProfileTransformer
};
use App\Jobs\RemoteFollowPipeline\RemoteFollowPipeline;
use App\Jobs\InboxPipeline\InboxWorker;
class FederationController extends Controller
{
public function authCheck()
{
if(!Auth::check()) {
abort(403);
return abort(403);
}
return;
}
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'));
}
public function remoteFollow()
@ -64,61 +76,58 @@ class FederationController extends Controller
public function nodeinfo()
{
$res = [
'metadata' => [
'nodeName' => config('app.name'),
'software' => [
'homepage' => 'https://pixelfed.org',
'github' => 'https://github.com/pixelfed',
'follow' => 'https://mastodon.social/@pixelfed'
],
/*
TODO: Custom Features for Trending
'customFeatures' => [
'trending' => [
'description' => 'Trending API for federated discovery',
'api' => [
'url' => null,
'docs' => null
],
$res = Cache::remember('api:nodeinfo', 60, function() {
return [
'metadata' => [
'nodeName' => config('app.name'),
'software' => [
'homepage' => 'https://pixelfed.org',
'github' => 'https://github.com/pixelfed',
'follow' => 'https://mastodon.social/@pixelfed'
],
],
*/
],
'openRegistrations' => config('pixelfed.open_registration'),
'protocols' => [
'activitypub'
],
'services' => [
'inbound' => [],
'outbound' => []
],
'software' => [
'name' => 'pixelfed',
'version' => config('pixelfed.version')
],
'usage' => [
'localPosts' => \App\Status::whereLocal(true)->count(),
'users' => [
'total' => \App\User::count()
]
],
'version' => '2.0'
];
return response()->json($res);
'openRegistrations' => config('pixelfed.open_registration'),
'protocols' => [
'activitypub'
],
'services' => [
'inbound' => [],
'outbound' => []
],
'software' => [
'name' => 'pixelfed',
'version' => config('pixelfed.version')
],
'usage' => [
'localPosts' => \App\Status::whereLocal(true)->whereHas('media')->count(),
'localComments' => \App\Status::whereLocal(true)->whereNotNull('in_reply_to_id')->count(),
'users' => [
'total' => \App\User::count(),
'activeHalfyear' => \App\User::where('updated_at', '>', Carbon::now()->subMonths(6)->toDateTimeString())->count(),
'activeMonth' => \App\User::where('updated_at', '>', Carbon::now()->subMonths(1)->toDateTimeString())->count(),
]
],
'version' => '2.0'
];
});
return response()->json($res, 200, [], JSON_PRETTY_PRINT);
}
public function webfinger(Request $request)
{
$this->validate($request, ['resource'=>'required']);
$resource = $request->input('resource');
$parsed = Nickname::normalizeProfileUrl($resource);
$username = $parsed['username'];
$user = Profile::whereUsername($username)->firstOrFail();
$webfinger = (new Webfinger($user))->generate();
return response()->json($webfinger);
$this->validate($request, ['resource'=>'required|string|min:3|max:255']);
$hash = hash('sha256', $request->input('resource'));
$webfinger = Cache::remember('api:webfinger:'.$hash, 1440, function() use($request) {
$resource = $request->input('resource');
$parsed = Nickname::normalizeProfileUrl($resource);
$username = $parsed['username'];
$user = Profile::whereUsername($username)->firstOrFail();
return (new Webfinger($user))->generate();
});
return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT);
}
public function userOutbox(Request $request, $username)
@ -132,7 +141,23 @@ class FederationController extends Controller
$fractal = new Fractal\Manager();
$resource = new Fractal\Resource\Item($user, new ProfileOutbox);
$res = $fractal->createData($resource)->toArray();
return response()->json($res['data']);
return response(json_encode($res['data']))->header('Content-Type', 'application/activity+json');
}
public function userInbox(Request $request, $username)
{
if(config('pixelfed.activitypub_enabled') == false) {
abort(403);
}
$mimes = [
'application/activity+json',
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
];
if(!in_array($request->header('Content-Type'), $mimes)) {
abort(500, 'Invalid request');
}
$profile = Profile::whereUsername($username)->firstOrFail();
InboxWorker::dispatch($request, $profile, $request->all());
}
}

View file

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

View file

@ -3,7 +3,7 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Auth, Hashids;
use Auth, Cache, Hashids;
use App\{Like, Profile, Status, User};
use App\Jobs\LikePipeline\LikePipeline;
@ -27,7 +27,7 @@ class LikeController extends Controller
if($status->likes()->whereProfileId($profile->id)->count() !== 0) {
$like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail();
$like->delete();
$like->forceDelete();
$count--;
} else {
$like = new Like;
@ -35,9 +35,15 @@ class LikeController extends Controller
$like->status_id = $status->id;
$like->save();
$count++;
LikePipeline::dispatch($like);
}
LikePipeline::dispatch($like);
$likes = Like::whereProfileId($profile->id)
->orderBy('id', 'desc')
->take(1000)
->pluck('status_id');
Cache::put('api:like-ids:user:'.$profile->id, $likes, 1440);
if($request->ajax()) {
$response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count];

View file

@ -18,28 +18,63 @@ class ProfileController extends Controller
public function show(Request $request, $username)
{
$user = Profile::whereUsername($username)->firstOrFail();
if($user->remote_url) {
$settings = new \StdClass;
$settings->crawlable = false;
} else {
$settings = User::whereUsername($username)->firstOrFail()->settings;
}
$mimes = [
'application/activity+json',
'application/ld+json',
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
];
if(in_array($request->header('accept'), $mimes) && config('pixelfed.activitypub_enabled')) {
if($request->wantsJson() && config('pixelfed.activitypub_enabled')) {
return $this->showActivityPub($request, $user);
}
if($user->is_private == true) {
$can_access = $this->privateProfileCheck($user);
if($can_access !== true) {
abort(403);
}
}
// TODO: refactor this mess
$owner = Auth::check() && Auth::id() === $user->user_id;
$is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
$is_admin = is_null($user->domain) ? $user->user->is_admin : false;
$timeline = $user->statuses()
->whereHas('media')
->whereNull('in_reply_to_id')
->orderBy('id','desc')
->whereNull('reblog_of_id')
->orderBy('created_at','desc')
->withCount(['comments', 'likes'])
->simplePaginate(21);
return view('profile.show', compact('user', 'owner', 'is_following', 'timeline'));
return view('profile.show', compact('user', 'settings', 'owner', 'is_following', 'is_admin', 'timeline'));
}
public function permalinkRedirect(Request $request, $username)
{
$user = Profile::whereUsername($username)->firstOrFail();
$settings = User::whereUsername($username)->firstOrFail()->settings;
if($request->wantsJson() && config('pixelfed.activitypub_enabled')) {
return $this->showActivityPub($request, $user);
}
return redirect($user->url());
}
protected function privateProfileCheck(Profile $profile)
{
if(Auth::check() === false) {
return false;
}
$follower_ids = (array) $profile->followers()->pluck('followers.profile_id');
$pid = Auth::user()->profile->id;
if(!in_array($pid, $follower_ids) && $pid !== $profile->id) {
return false;
}
return true;
}
public function showActivityPub(Request $request, $user)
@ -47,7 +82,7 @@ class ProfileController extends Controller
$fractal = new Fractal\Manager();
$resource = new Fractal\Resource\Item($user, new ProfileTransformer);
$res = $fractal->createData($resource)->toArray();
return response()->json($res['data']);
return response(json_encode($res['data']))->header('Content-Type', 'application/activity+json');
}
public function showAtomFeed(Request $request, $user)
@ -66,7 +101,8 @@ class ProfileController extends Controller
$owner = Auth::check() && Auth::id() === $user->user_id;
$is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
$followers = $profile->followers()->orderBy('created_at','desc')->simplePaginate(12);
return view('profile.followers', compact('user', 'profile', 'followers', 'owner', 'is_following'));
$is_admin = is_null($user->domain) ? $user->user->is_admin : false;
return view('profile.followers', compact('user', 'profile', 'followers', 'owner', 'is_following', 'is_admin'));
}
public function following(Request $request, $username)
@ -77,7 +113,8 @@ class ProfileController extends Controller
$owner = Auth::check() && Auth::id() === $user->user_id;
$is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
$following = $profile->following()->orderBy('created_at','desc')->simplePaginate(12);
return view('profile.following', compact('user', 'profile', 'following', 'owner', 'is_following'));
$is_admin = is_null($user->domain) ? $user->user->is_admin : false;
return view('profile.following', compact('user', 'profile', 'following', 'owner', 'is_following', 'is_admin'));
}
public function savedBookmarks(Request $request, $username)
@ -86,9 +123,12 @@ class ProfileController extends Controller
abort(403);
}
$user = Auth::user()->profile;
$settings = User::whereUsername($username)->firstOrFail()->settings;
$owner = true;
$following = false;
$timeline = $user->bookmarks()->orderBy('created_at','desc')->simplePaginate(10);
return view('profile.show', compact('user', 'owner', 'following', 'timeline'));
$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.show', compact('user', 'settings', 'owner', 'following', 'timeline', 'is_following', 'is_admin'));
}
}

View file

@ -2,12 +2,25 @@
namespace App\Http\Controllers;
use Auth;
use Illuminate\Http\Request;
use App\{Avatar, Profile, Report, Status, User};
class ReportController extends Controller
{
protected $profile;
public function __construct()
{
$this->middleware('auth');
}
public function showForm(Request $request)
{
$this->validate($request, [
'type' => 'required|alpha_dash',
'id' => 'required|integer|min:1'
]);
return view('report.form');
}
@ -35,4 +48,92 @@ class ReportController extends Controller
{
return view('report.spam.profile');
}
public function sensitiveCommentForm(Request $request)
{
return view('report.sensitive.comment');
}
public function sensitivePostForm(Request $request)
{
return view('report.sensitive.post');
}
public function sensitiveProfileForm(Request $request)
{
return view('report.sensitive.profile');
}
public function abusiveCommentForm(Request $request)
{
return view('report.abusive.comment');
}
public function abusivePostForm(Request $request)
{
return view('report.abusive.post');
}
public function abusiveProfileForm(Request $request)
{
return view('report.abusive.profile');
}
public function formStore(Request $request)
{
$this->validate($request, [
'report' => 'required|alpha_dash',
'type' => 'required|alpha_dash',
'id' => 'required|integer|min:1',
'msg' => 'nullable|string|max:150'
]);
$profile = Auth::user()->profile;
$reportType = $request->input('report');
$object_id = $request->input('id');
$object_type = $request->input('type');
$msg = $request->input('msg');
$object = null;
$types = ['spam', 'sensitive', 'abusive'];
if(!in_array($reportType, $types)) {
return redirect('/timeline')->with('error', 'Invalid report type');
}
switch ($object_type) {
case 'post':
$object = Status::findOrFail($object_id);
$object_type = 'App\Status';
$exists = Report::whereUserId(Auth::id())
->whereObjectId($object->id)
->whereObjectType('App\Status')
->count();
break;
default:
return redirect('/timeline')->with('error', 'Invalid report type');
break;
}
if($exists !== 0) {
return redirect('/timeline')->with('error', 'You have already reported this!');
}
if($object->profile_id == $profile->id) {
return redirect('/timeline')->with('error', 'You cannot report your own content!');
}
$report = new Report;
$report->profile_id = $profile->id;
$report->user_id = Auth::id();
$report->object_id = $object->id;
$report->object_type = $object_type;
$report->reported_profile_id = $object->profile_id;
$report->type = $request->input('report');
$report->message = $request->input('msg');
$report->save();
return redirect('/timeline')->with('status', 'Report successfully sent!');
}
}

View file

@ -3,19 +3,28 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\{Profile, User};
use Auth;
use App\{AccountLog, EmailVerification, Media, Profile, User};
use Auth, DB;
use App\Util\Lexer\PrettyNumber;
class SettingsController extends Controller
{
public function __construct()
{
return $this->middleware('auth');
$this->middleware('auth');
}
public function home()
{
return view('settings.home');
$id = Auth::user()->profile->id;
$storage = [];
$used = Media::whereProfileId($id)->sum('size');
$storage['limit'] = config('pixelfed.max_account_size') * 1024;
$storage['used'] = $used;
$storage['percentUsed'] = ceil($storage['used'] / $storage['limit'] * 100);
$storage['limitPretty'] = PrettyNumber::size($storage['limit']);
$storage['usedPretty'] = PrettyNumber::size($storage['used']);
return view('settings.home', compact('storage'));
}
public function homeUpdate(Request $request)
@ -23,23 +32,48 @@ class SettingsController extends Controller
$this->validate($request, [
'name' => 'required|string|max:' . config('pixelfed.max_name_length'),
'bio' => 'nullable|string|max:' . config('pixelfed.max_bio_length')
'website' => 'nullable|url',
'email' => 'nullable|email'
]);
$changes = false;
$name = $request->input('name');
$bio = $request->input('bio');
$website = $request->input('website');
$email = $request->input('email');
$user = Auth::user();
$profile = $user->profile;
if($profile->name != $name) {
$validate = config('pixelfed.enforce_email_verification');
if($user->email != $email) {
$changes = true;
$user->name = $name;
$profile->name = $name;
$user->email = $email;
if($validate) {
$user->email_verified_at = null;
// Prevent old verifications from working
EmailVerification::whereUserId($user->id)->delete();
}
}
if($profile->bio != $bio) {
$changes = true;
$profile->bio = $bio;
// Only allow email to be updated if not yet verified
if(!$validate || !$changes && $user->email_verified_at) {
if($profile->name != $name) {
$changes = true;
$user->name = $name;
$profile->name = $name;
}
if(!$profile->website || $profile->website != $website) {
$changes = true;
$profile->website = $website;
}
if(!$profile->bio || !$profile->bio != $bio) {
$changes = true;
$profile->bio = $bio;
}
}
if($changes === true) {
@ -89,6 +123,34 @@ class SettingsController extends Controller
return view('settings.avatar');
}
public function accessibility()
{
$settings = Auth::user()->settings;
return view('settings.accessibility', compact('settings'));
}
public function accessibilityStore(Request $request)
{
$settings = Auth::user()->settings;
$fields = [
'compose_media_descriptions',
'reduce_motion',
'optimize_screen_reader',
'high_contrast_mode',
'video_autoplay'
];
foreach($fields as $field) {
$form = $request->input($field);
if($form == 'on') {
$settings->{$field} = true;
} else {
$settings->{$field} = false;
}
$settings->save();
}
return redirect(route('settings.accessibility'))->with('status', 'Settings successfully updated!');
}
public function notifications()
{
return view('settings.notifications');
@ -96,12 +158,63 @@ class SettingsController extends Controller
public function privacy()
{
return view('settings.privacy');
$settings = Auth::user()->settings;
$is_private = Auth::user()->profile->is_private;
$settings['is_private'] = (bool) $is_private;
return view('settings.privacy', compact('settings'));
}
public function privacyStore(Request $request)
{
$settings = Auth::user()->settings;
$profile = Auth::user()->profile;
$fields = [
'is_private',
'crawlable',
'show_profile_follower_count',
'show_profile_following_count'
];
foreach($fields as $field) {
$form = $request->input($field);
if($field == 'is_private') {
if($form == 'on') {
$profile->{$field} = true;
$settings->show_guests = false;
$settings->show_discover = false;
$profile->save();
} else {
$profile->{$field} = false;
$profile->save();
}
} elseif($field == 'crawlable') {
if($form == 'on') {
$settings->{$field} = false;
} else {
$settings->{$field} = true;
}
} else {
if($form == 'on') {
$settings->{$field} = true;
} else {
$settings->{$field} = false;
}
}
$settings->save();
}
return redirect(route('settings.privacy'))->with('status', 'Settings successfully updated!');
}
public function security()
{
return view('settings.security');
$sessions = DB::table('sessions')
->whereUserId(Auth::id())
->limit(20)
->get();
$activity = AccountLog::whereUserId(Auth::id())
->orderBy('created_at','desc')
->limit(50)
->get();
return view('settings.security', compact('sessions', 'activity'));
}
public function applications()
@ -121,7 +234,7 @@ class SettingsController extends Controller
public function dataImportInstagram()
{
return view('settings.import.ig');
return view('settings.import.instagram.home');
}
public function developers()

View file

@ -2,11 +2,42 @@
namespace App\Http\Controllers;
use App;
use App, Auth, Cache;
use Illuminate\Http\Request;
use App\{Follower, Profile, Status, User};
use App\Util\Lexer\PrettyNumber;
class SiteController extends Controller
{
public function home()
{
if(Auth::check()) {
return $this->homeTimeline();
} else {
return $this->homeGuest();
}
}
public function homeGuest()
{
return view('site.index');
}
public function homeTimeline()
{
// TODO: Use redis for timelines
$following = Follower::whereProfileId(Auth::user()->profile->id)->pluck('following_id');
$following->push(Auth::user()->profile->id);
$timeline = Status::whereIn('profile_id', $following)
->whereHas('media')
->orderBy('id','desc')
->withCount(['comments', 'likes', 'shares'])
->simplePaginate(20);
$type = 'personal';
return view('timeline.template', compact('timeline', 'type'));
}
public function changeLocale(Request $request, $locale)
{
if(!App::isLocale($locale)) {
@ -15,4 +46,20 @@ class SiteController extends Controller
App::setLocale($locale);
return redirect()->back();
}
public function about()
{
$res = Cache::remember('site:page:about', 15, function() {
$statuses = Status::whereHas('media')
->whereNull('in_reply_to_id')
->whereNull('reblog_of_id')
->count();
$statusCount = PrettyNumber::convert($statuses);
$userCount = PrettyNumber::convert(User::count());
$remoteCount = PrettyNumber::convert(Profile::whereNotNull('remote_url')->count());
$adminContact = User::whereIsAdmin(true)->first();
return view('site.about')->with(compact('statusCount', 'userCount', 'remoteCount', 'adminContact'))->render();
});
return $res;
}
}

View file

@ -3,83 +3,180 @@
namespace App\Http\Controllers;
use Auth, Cache;
use App\Jobs\StatusPipeline\{NewStatusPipeline, StatusDelete};
use League\Fractal;
use Illuminate\Http\Request;
use App\{Media, Profile, Status, User};
use Vinkla\Hashids\Facades\Hashids;
use App\{Media, Profile, Status, User};
use App\Jobs\ImageOptimizePipeline\ImageOptimize;
use App\Transformer\ActivityPub\StatusTransformer;
use App\Jobs\StatusPipeline\{NewStatusPipeline, StatusDelete};
class StatusController extends Controller
{
public function show(Request $request, $username, int $id)
{
$user = Profile::whereUsername($username)->firstOrFail();
$status = Status::whereProfileId($user->id)
->withCount(['likes', 'comments'])
->findOrFail($id);
if(!$status->media_path && $status->in_reply_to_id) {
return redirect($status->url());
}
return view('status.show', compact('user', 'status'));
$user = Profile::whereUsername($username)->firstOrFail();
$status = Status::whereProfileId($user->id)
->withCount(['likes', 'comments', 'media'])
->findOrFail($id);
if(!$status->media_path && $status->in_reply_to_id) {
return redirect($status->url());
}
if($request->wantsJson() && config('pixelfed.activitypub_enabled')) {
return $this->showActivityPub($request, $status);
}
$replies = Status::whereInReplyToId($status->id)->simplePaginate(30);
return view('status.show', compact('user', 'status', 'replies'));
}
public function compose()
{
$this->authCheck();
return view('status.compose');
}
public function store(Request $request)
{
if(Auth::check() == false)
{
abort(403);
}
if(Auth::check() == false)
{
abort(403);
}
$user = Auth::user();
$user = Auth::user();
$this->validate($request, [
'photo' => 'required|mimes:jpeg,png,bmp,gif|max:' . config('pixelfed.max_photo_size'),
'caption' => 'string|max:' . config('pixelfed.max_caption_length')
]);
$size = Media::whereUserId($user->id)->sum('size') / 1000;
$limit = (int) config('pixelfed.max_account_size');
if($size >= $limit) {
return redirect()->back()->with('error', 'You have exceeded your storage limit. Please click <a href="#">here</a> for more info.');
}
$monthHash = hash('sha1', date('Y') . date('m'));
$userHash = hash('sha1', $user->id . (string) $user->created_at);
$storagePath = "public/m/{$monthHash}/{$userHash}";
$path = $request->photo->store($storagePath);
$profile = $user->profile;
$this->validate($request, [
'photo.*' => 'required|mimes:jpeg,png,bmp,gif|max:' . config('pixelfed.max_photo_size'),
'caption' => 'string|max:' . config('pixelfed.max_caption_length'),
'cw' => 'nullable|string',
'filter_class' => 'nullable|string',
'filter_name' => 'nullable|string',
]);
$status = new Status;
$status->profile_id = $profile->id;
$status->caption = $request->caption;
$status->save();
if(count($request->file('photo')) > config('pixelfed.max_album_length')) {
return redirect()->back()->with('error', 'Too many files, max limit per post: ' . config('pixelfed.max_album_length'));
}
$cw = $request->filled('cw') && $request->cw == 'on' ? true : false;
$monthHash = hash('sha1', date('Y') . date('m'));
$userHash = hash('sha1', $user->id . (string) $user->created_at);
$profile = $user->profile;
$media = new Media;
$media->status_id = $status->id;
$media->profile_id = $profile->id;
$media->user_id = $user->id;
$media->media_path = $path;
$media->size = $request->file('photo')->getClientSize();
$media->mime = $request->file('photo')->getClientMimeType();
$media->save();
NewStatusPipeline::dispatch($status, $media);
$status = new Status;
$status->profile_id = $profile->id;
$status->caption = strip_tags($request->caption);
$status->is_nsfw = $cw;
// TODO: Parse Caption
// TODO: Send to subscribers
$status->save();
return redirect($status->url());
$photos = $request->file('photo');
$order = 1;
foreach ($photos as $k => $v) {
$storagePath = "public/m/{$monthHash}/{$userHash}";
$path = $v->store($storagePath);
$media = new Media;
$media->status_id = $status->id;
$media->profile_id = $profile->id;
$media->user_id = $user->id;
$media->media_path = $path;
$media->size = $v->getClientSize();
$media->mime = $v->getClientMimeType();
$media->filter_class = $request->input('filter_class');
$media->filter_name = $request->input('filter_name');
$media->order = $order;
$media->save();
ImageOptimize::dispatch($media);
$order++;
}
NewStatusPipeline::dispatch($status);
// TODO: Send to subscribers
return redirect($status->url());
}
public function delete(Request $request)
{
if(!Auth::check()) {
abort(403);
}
if(!Auth::check()) {
abort(403);
}
$this->validate($request, [
'type' => 'required|string',
'item' => 'required|integer|min:1'
]);
$this->validate($request, [
'type' => 'required|string',
'item' => 'required|integer|min:1'
]);
$status = Status::findOrFail($request->input('item'));
$status = Status::findOrFail($request->input('item'));
if($status->profile_id === Auth::user()->profile->id || Auth::user()->is_admin == true) {
StatusDelete::dispatch($status);
}
if($status->profile_id === Auth::user()->profile->id || Auth::user()->is_admin == true) {
StatusDelete::dispatch($status);
}
return redirect(Auth::user()->url());
return redirect(Auth::user()->url());
}
public function storeShare(Request $request)
{
$this->validate($request, [
'item' => 'required|integer',
]);
$profile = Auth::user()->profile;
$status = Status::withCount('shares')->findOrFail($request->input('item'));
$count = $status->shares_count;
$exists = Status::whereProfileId(Auth::user()->profile->id)
->whereReblogOfId($status->id)
->count();
if($exists !== 0) {
$shares = Status::whereProfileId(Auth::user()->profile->id)
->whereReblogOfId($status->id)
->get();
foreach($shares as $share) {
$share->delete();
$count--;
}
} else {
$share = new Status;
$share->profile_id = $profile->id;
$share->reblog_of_id = $status->id;
$share->save();
$count++;
}
if($request->ajax()) {
$response = ['code' => 200, 'msg' => 'Share saved', 'count' => $count];
} else {
$response = redirect($status->url());
}
return $response;
}
public function showActivityPub(Request $request, $status)
{
$fractal = new Fractal\Manager();
$resource = new Fractal\Resource\Item($status, new StatusTransformer);
$res = $fractal->createData($resource)->toArray();
return response(json_encode($res['data']))->header('Content-Type', 'application/activity+json');
}
protected function authCheck()
{
if(Auth::check() == false)
{
abort(403);
}
}
}

View file

@ -18,24 +18,25 @@ class TimelineController extends Controller
// TODO: Use redis for timelines
$following = Follower::whereProfileId(Auth::user()->profile->id)->pluck('following_id');
$following->push(Auth::user()->profile->id);
$timeline = Status::whereHas('media')
->whereNull('in_reply_to_id')
->whereIn('profile_id', $following)
$timeline = Status::whereIn('profile_id', $following)
->orderBy('id','desc')
->withCount(['comments', 'likes'])
->simplePaginate(10);
return view('timeline.personal', compact('timeline'));
->simplePaginate(20);
$type = 'personal';
return view('timeline.template', compact('timeline', 'type'));
}
public function local()
{
// TODO: Use redis for timelines
// $timeline = Timeline::build()->local();
$timeline = Status::whereHas('media')
->whereNull('in_reply_to_id')
->orderBy('id','desc')
->withCount(['comments', 'likes'])
->simplePaginate(10);
return view('timeline.public', compact('timeline'));
->orderBy('id','desc')
->simplePaginate(20);
$type = 'local';
return view('timeline.template', compact('timeline', 'type'));
}
}

View file

@ -60,5 +60,6 @@ class Kernel extends HttpKernel
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'validemail' => \App\Http\Middleware\EmailVerificationCheck::class,
];
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Middleware;
use Auth, Closure;
class EmailVerificationCheck
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if($request->user() &&
config('pixelfed.enforce_email_verification') &&
is_null($request->user()->email_verified_at) &&
!$request->is('i/verify-email', 'log*', 'i/confirm-email/*', 'settings/home')
) {
return redirect('/i/verify-email');
}
return $next($request);
}
}

10
app/ImportJob.php Normal file
View file

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

View file

@ -0,0 +1,70 @@
<?php
namespace App\Jobs\AvatarPipeline;
use \Carbon\Carbon;
use Image as Intervention;
use App\{Avatar, Profile};
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class AvatarOptimize implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $profile;
protected $current;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Profile $profile, $current)
{
$this->profile = $profile;
$this->current = $current;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$avatar = $this->profile->avatar;
$file = storage_path("app/$avatar->media_path");
try {
$img = Intervention::make($file)->orientate();
$img->fit(200, 200, function ($constraint) {
$constraint->upsize();
});
$quality = config('pixelfed.image_quality');
$img->save($file, $quality);
$avatar = Avatar::whereProfileId($this->profile->id)->firstOrFail();
$avatar->thumb_path = $avatar->media_path;
$avatar->change_count = ++$avatar->change_count;
$avatar->last_processed_at = Carbon::now();
$avatar->save();
$this->deleteOldAvatar($avatar->media_path, $this->current);
} catch (Exception $e) {
}
}
protected function deleteOldAvatar($new, $current)
{
if(storage_path('app/' . $new) == $current) {
return;
}
if(is_file($current)) {
@unlink($current);
}
}
}

View file

@ -38,7 +38,10 @@ class ImageUpdate implements ShouldQueue
$thumb = storage_path('app/'. $media->thumbnail_path);
try {
ImageOptimizer::optimize($thumb);
ImageOptimizer::optimize($path);
if($media->mime !== 'image/gif')
{
ImageOptimizer::optimize($path);
}
} catch (Exception $e) {
return;
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Jobs\InboxPipeline;
use App\Profile;
use App\Util\ActivityPub\Inbox;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class InboxWorker implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $request;
protected $profile;
protected $payload;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($request, Profile $profile, $payload)
{
$this->request = $request;
$this->profile = $profile;
$this->payload = $payload;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
(new Inbox($this->request, $this->profile, $this->payload))->handle();
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Jobs\InboxPipeline;
use App\Profile;
use App\Util\ActivityPub\Inbox;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class SharedInboxWorker implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $request;
protected $profile;
protected $payload;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct($request, $payload)
{
$this->request = $request;
$this->payload = $payload;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
(new Inbox($this->request, null, $this->payload))->handleSharedInbox();
}
}

View file

@ -37,7 +37,19 @@ class LikePipeline implements ShouldQueue
$status = $this->like->status;
$actor = $this->like->actor;
if($actor->id === $status->profile_id) {
if($status->url !== null) {
// Ignore notifications to remote statuses
return;
}
$exists = Notification::whereProfileId($status->profile_id)
->whereActorId($actor->id)
->whereAction('like')
->whereItemId($status->id)
->whereItemType('App\Status')
->count();
if($actor->id === $status->profile_id || $exists !== 0) {
return true;
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Jobs\MentionPipeline;
use Cache, Log, Redis;
use App\{Mention, Notification, Profile, Status};
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class MentionPipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
protected $mention;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Status $status, Mention $mention)
{
$this->status = $status;
$this->mention = $mention;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$status = $this->status;
$mention = $this->mention;
$actor = $this->status->profile;
$target = $this->mention->profile_id;
$exists = Notification::whereProfileId($target)
->whereActorId($actor->id)
->whereAction('mention')
->whereItemId($status->id)
->whereItemType('App\Status')
->count();
if($actor->id === $target || $exists !== 0) {
return true;
}
try {
$notification = new Notification;
$notification->profile_id = $target;
$notification->actor_id = $actor->id;
$notification->action = 'mention';
$notification->message = $mention->toText();
$notification->rendered = $mention->toHtml();
$notification->item_id = $status->id;
$notification->item_type = "App\Status";
$notification->save();
} catch (Exception $e) {
}
}
}

View file

@ -16,17 +16,15 @@ class NewStatusPipeline implements ShouldQueue
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
protected $media;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct(Status $status, $media = false)
public function __construct(Status $status)
{
$this->status = $status;
$this->media = $media;
}
/**
@ -37,13 +35,10 @@ class NewStatusPipeline implements ShouldQueue
public function handle()
{
$status = $this->status;
$media = $this->media;
StatusEntityLexer::dispatch($status);
StatusActivityPubDeliver::dispatch($status);
if($media) {
ImageOptimize::dispatch($media);
}
//StatusActivityPubDeliver::dispatch($status);
Cache::forever('post.' . $status->id, $status);
$redis = Redis::connection();

View file

@ -2,7 +2,7 @@
namespace App\Jobs\StatusPipeline;
use App\{Media, StatusHashtag, Status};
use App\{Media, Notification, StatusHashtag, Status};
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
@ -58,8 +58,19 @@ class StatusDelete implements ShouldQueue
}
}
$comments = Status::where('in_reply_to_id', $status->id)->get();
foreach($comments as $comment) {
$comment->in_reply_to_id = null;
$comment->save();
Notification::whereItemType('App\Status')
->whereItemId($comment->id)
->delete();
}
$status->likes()->delete();
Notification::whereItemType('App\Status')
->whereItemId($status->id)
->delete();
StatusHashtag::whereStatusId($status->id)->delete();
$status->delete();

View file

@ -2,25 +2,32 @@
namespace App\Jobs\StatusPipeline;
use Cache;
use DB, Cache;
use App\{
Hashtag,
Media,
Mention,
Profile,
Status,
StatusHashtag
};
use App\Util\Lexer\Hashtag as HashtagLexer;
use App\Util\Lexer\{Autolink, Extractor};
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Jobs\MentionPipeline\MentionPipeline;
class StatusEntityLexer implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $status;
protected $entities;
protected $autolink;
/**
* Create a new job instance.
*
@ -39,36 +46,77 @@ class StatusEntityLexer implements ShouldQueue
public function handle()
{
$status = $this->status;
$this->parseHashtags();
$this->parseEntities();
}
public function parseHashtags()
public function parseEntities()
{
$status = $this->status;
$text = e($status->caption);
$tags = HashtagLexer::getHashtags($text);
$rendered = $text;
if(count($tags) > 0) {
$rendered = HashtagLexer::replaceHashtagsWithLinks($text);
}
$status->rendered = $rendered;
$status->save();
$this->extractEntities();
}
Cache::forever('post.' . $status->id, $status);
public function extractEntities()
{
$this->entities = Extractor::create()->extract($this->status->caption);
$this->autolinkStatus();
}
public function autolinkStatus()
{
$this->autolink = Autolink::create()->autolink($this->status->caption);
$this->storeEntities();
}
public function storeEntities()
{
$this->storeHashtags();
$this->storeMentions();
DB::transaction(function () {
$status = $this->status;
$status->rendered = $this->autolink;
$status->entities = json_encode($this->entities);
$status->save();
});
}
public function storeHashtags()
{
$tags = array_unique($this->entities['hashtags']);
$status = $this->status;
foreach($tags as $tag) {
$slug = str_slug($tag);
$htag = Hashtag::firstOrCreate(
['name' => $tag],
['slug' => $slug]
);
$stag = new StatusHashtag;
$stag->status_id = $status->id;
$stag->hashtag_id = $htag->id;
$stag->save();
DB::transaction(function () use ($status, $tag) {
$slug = str_slug($tag);
$hashtag = Hashtag::firstOrCreate(
['name' => $tag, 'slug' => $slug]
);
StatusHashtag::firstOrCreate(
['status_id' => $status->id, 'hashtag_id' => $hashtag->id]
);
});
}
}
public function storeMentions()
{
$mentions = array_unique($this->entities['mentions']);
$status = $this->status;
foreach($mentions as $mention) {
$mentioned = Profile::whereUsername($mention)->firstOrFail();
if(empty($mentioned) || !isset($mentioned->id)) {
continue;
}
DB::transaction(function () use ($status, $mentioned) {
$m = new Mention;
$m->status_id = $status->id;
$m->profile_id = $mentioned->id;
$m->save();
MentionPipeline::dispatch($status, $m);
});
}
}
}

View file

@ -3,9 +3,19 @@
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Like extends Model
{
use SoftDeletes;
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = ['deleted_at'];
public function actor()
{
return $this->belongsTo(Profile::class, 'profile_id', 'id');

34
app/Mail/ConfirmEmail.php Normal file
View file

@ -0,0 +1,34 @@
<?php
namespace App\Mail;
use App\EmailVerification;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;
class ConfirmEmail extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(EmailVerification $verify)
{
$this->verify = $verify;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
return $this->markdown('emails.confirm_email')->with(['verify'=>$this->verify]);
}
}

View file

@ -2,15 +2,32 @@
namespace App;
use Illuminate\Database\Eloquent\Model;
use Storage;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Media extends Model
{
use SoftDeletes;
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = ['deleted_at'];
public function url()
{
$path = $this->media_path;
$url = Storage::url($path);
return url($url);
}
public function thumbnailUrl()
{
$path = $this->thumbnail_path;
$url = Storage::url($path);
return url($url);
}
}

42
app/Mention.php Normal file
View file

@ -0,0 +1,42 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Mention extends Model
{
use SoftDeletes;
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = ['deleted_at'];
public function profile()
{
return $this->belongsTo(Profile::class, 'profile_id', 'id');
}
public function status()
{
return $this->belongsTo(Status::class, 'status_id', 'id');
}
public function toText()
{
$actorName = $this->status->profile->username;
return "{$actorName} " . __('notification.mentionedYou');
}
public function toHtml()
{
$actorName = $this->status->profile->username;
$actorUrl = $this->status->profile->url();
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> " .
__('notification.mentionedYou');
}
}

View file

@ -3,28 +3,37 @@
namespace App;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Notification extends Model
{
use SoftDeletes;
public function actor()
{
return $this->belongsTo(Profile::class, 'actor_id', 'id');
}
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = ['deleted_at'];
public function profile()
{
return $this->belongsTo(Profile::class, 'profile_id', 'id');
}
public function actor()
{
return $this->belongsTo(Profile::class, 'actor_id', 'id');
}
public function item()
{
return $this->morphTo();
}
public function profile()
{
return $this->belongsTo(Profile::class, 'profile_id', 'id');
}
public function status()
{
return $this->belongsTo(Status::class, 'item_id', 'id');
}
public function item()
{
return $this->morphTo();
}
public function status()
{
return $this->belongsTo(Status::class, 'item_id', 'id');
}
}

View file

@ -2,7 +2,7 @@
namespace App\Observers;
use App\{Profile, User};
use App\{Profile, User, UserSetting};
use App\Jobs\AvatarPipeline\CreateAvatar;
class UserObserver
@ -36,6 +36,12 @@ class UserObserver
CreateAvatar::dispatch($profile);
}
if(empty($user->settings)) {
$settings = new UserSetting;
$settings->user_id = $user->id;
$settings->save();
}
}
}

View file

@ -2,19 +2,42 @@
namespace App;
use Storage;
use Auth, Cache, Storage;
use App\Util\Lexer\PrettyNumber;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Profile extends Model
{
use SoftDeletes;
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = ['deleted_at'];
protected $hidden = [
'private_key',
];
protected $visible = ['id', 'username', 'name'];
public function user()
{
return $this->belongsTo(User::class);
}
public function url($suffix = '')
{
if($this->remote_url) {
return $this->remote_url;
} else {
return url($this->username . $suffix);
}
}
public function localUrl($suffix = '')
{
return url($this->username . $suffix);
}
@ -102,12 +125,55 @@ class Profile extends Model
public function avatar()
{
return $this->hasOne(Avatar::class);
return $this->hasOne(Avatar::class)->withDefault([
'media_path' => 'public/avatars/default.png'
]);
}
public function avatarUrl()
{
$url = url(Storage::url($this->avatar->media_path ?? 'public/avatars/default.png'));
$url = Cache::remember("avatar:{$this->id}", 1440, function() {
$path = optional($this->avatar)->media_path;
$version = hash('sha1', $this->avatar->created_at);
$path = "{$path}?v={$version}";
return url(Storage::url($path));
});
return $url;
}
public function statusCount()
{
return $this->statuses()->whereHas('media')->count();
}
public function recommendFollowers()
{
$follows = $this->following()->pluck('followers.id');
$following = $this->following()
->orderByRaw('rand()')
->take(3)
->pluck('following_id');
$following->push(Auth::id());
$following = Follower::whereNotIn('profile_id', $follows)
->whereNotIn('following_id', $following)
->whereNotIn('following_id', $follows)
->whereIn('profile_id', $following)
->orderByRaw('rand()')
->limit(3)
->pluck('following_id');
$recommended = [];
foreach($following as $follow) {
$recommended[] = Profile::findOrFail($follow);
}
return $recommended;
}
public function keyId()
{
if($this->remote_url) {
return;
}
return $this->permalink('#main-key');
}
}

View file

@ -16,6 +16,9 @@ class EventServiceProvider extends ServiceProvider
'App\Events\Event' => [
'App\Listeners\EventListener',
],
'auth.login' => [
'App\Events\AuthLoginEvent',
],
];
/**

View file

@ -6,5 +6,33 @@ use Illuminate\Database\Eloquent\Model;
class Report extends Model
{
//
public function url()
{
return url('/i/admin/reports/show/' . $this->id);
}
public function reporter()
{
return $this->belongsTo(Profile::class, 'profile_id');
}
public function reported()
{
$class = $this->object_type;
switch ($class) {
case 'App\Status':
$column = 'id';
break;
default:
$column = 'id';
break;
}
return (new $class())->where($column, $this->object_id)->firstOrFail();
}
public function reportedUser()
{
return $this->belongsTo(Profile::class, 'reported_profile_id', 'id');
}
}

10
app/ReportComment.php Normal file
View file

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

10
app/ReportLog.php Normal file
View file

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

View file

@ -2,12 +2,21 @@
namespace App;
use Auth, Storage;
use Illuminate\Database\Eloquent\Model;
use Storage;
use Vinkla\Hashids\Facades\Hashids;
use Illuminate\Database\Eloquent\SoftDeletes;
class Status extends Model
{
use SoftDeletes;
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = ['deleted_at'];
public function profile()
{
return $this->belongsTo(Profile::class);
@ -25,6 +34,9 @@ class Status extends Model
public function thumb()
{
if($this->media->count() == 0 || $this->is_nsfw) {
return "data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==";
}
return url(Storage::url($this->firstMedia()->thumbnail_path));
}
@ -40,6 +52,19 @@ class Status extends Model
return url($path);
}
public function permalink($suffix = '/activity')
{
$id = $this->id;
$username = $this->profile->username;
$path = config('app.url') . "/p/{$username}/{$id}{$suffix}";
return url($path);
}
public function editUrl()
{
return $this->url() . '/edit';
}
public function mediaUrl()
{
$media = $this->firstMedia();
@ -54,15 +79,42 @@ class Status extends Model
return $this->hasMany(Like::class);
}
public function liked() : bool
{
$profile = Auth::user()->profile;
return Like::whereProfileId($profile->id)->whereStatusId($this->id)->count();
}
public function comments()
{
return $this->hasMany(Status::class, 'in_reply_to_id');
}
public function bookmarked()
{
if(!Auth::check()) {
return 0;
}
$profile = Auth::user()->profile;
return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count();
}
public function shares()
{
return $this->hasMany(Status::class, 'reblog_of_id');
}
public function shared() : bool
{
$profile = Auth::user()->profile;
return Status::whereProfileId($profile->id)->whereReblogOfId($this->id)->count();
}
public function parent()
{
if(!empty($this->in_reply_to_id)) {
return Status::findOrFail($this->in_reply_to_id);
$parent = $this->in_reply_to_id ?? $this->reblog_of_id;
if(!empty($parent)) {
return Status::findOrFail($parent);
}
}
@ -83,6 +135,23 @@ class Status extends Model
);
}
public function mentions()
{
return $this->hasManyThrough(
Profile::class,
Mention::class,
'status_id',
'id',
'id',
'profile_id'
);
}
public function reportUrl()
{
return route('report.form') . "?type=post&id={$this->id}";
}
public function toActivityStream()
{
$media = $this->media;
@ -116,4 +185,9 @@ class Status extends Model
return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> " .
__('notification.commented');
}
public function recentComments()
{
return $this->comments()->orderBy('created_at','desc')->take(3);
}
}

View file

@ -6,5 +6,5 @@ use Illuminate\Database\Eloquent\Model;
class StatusHashtag extends Model
{
protected $fillable = ['status_id', 'hashtag_id'];
public $fillable = ['status_id', 'hashtag_id'];
}

View file

@ -13,7 +13,7 @@ class ProfileOutbox extends Fractal\TransformerAbstract
$count = $profile->statuses()->count();
$statuses = $profile->statuses()->has('media')->orderBy('id','desc')->take(20)->get()->map(function($i, $k) {
$item = [
'id' => $i->url(),
'id' => $i->permalink(),
// TODO: handle other types
'type' => 'Create',
'actor' => $i->profile->url(),
@ -47,10 +47,9 @@ class ProfileOutbox extends Fractal\TransformerAbstract
// TODO: add cc's
//"{$notice->getProfile()->getUrl()}/subscribers",
],
'sensitive' => null,
'sensitive' => (bool) $i->is_nsfw,
'atomUri' => $i->url(),
'inReplyToAtomUri' => null,
'conversation' => $i->url(),
'attachment' => [
// TODO: support more than 1 attachment

View file

@ -11,7 +11,16 @@ class ProfileTransformer extends Fractal\TransformerAbstract
public function transform(Profile $profile)
{
return [
'@context' => 'https://www.w3.org/ns/activitystreams',
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
"manuallyApprovesFollowers" => "as:manuallyApprovesFollowers",
"featured" => [
"https://pixelfed.org/ns#featured" => ["@type" => "@id"],
]
]
],
'id' => $profile->permalink(),
'type' => 'Person',
'following' => $profile->permalink('/following'),
@ -23,9 +32,9 @@ class ProfileTransformer extends Fractal\TransformerAbstract
'name' => $profile->name,
'summary' => $profile->bio,
'url' => $profile->url(),
'manuallyApprovesFollowers' => $profile->is_private,
'follower_count' => $profile->followers()->count(),
'following_count' => $profile->following()->count(),
'manuallyApprovesFollowers' => (bool) $profile->is_private,
// 'follower_count' => $profile->followers()->count(),
// 'following_count' => $profile->following()->count(),
'publicKey' => [
'id' => $profile->permalink() . '#main-key',
'owner' => $profile->permalink(),
@ -34,7 +43,11 @@ class ProfileTransformer extends Fractal\TransformerAbstract
'endpoints' => [
'sharedInbox' => config('routes.api.sharedInbox')
],
'icon' => [
'type' => 'Image',
'mediaType' => 'image/jpeg',
'url' => $profile->avatarUrl()
]
];
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Transformer\ActivityPub;
use App\{Profile, Status};
use League\Fractal;
class StatusTransformer extends Fractal\TransformerAbstract
{
public function transform(Status $status)
{
return [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
"manuallyApprovesFollowers" => "as:manuallyApprovesFollowers",
"featured" => [
"https://pixelfed.org/ns#featured" => ["@type" => "@id"],
]
]
],
'id' => $status->url(),
// TODO: handle other types
'type' => 'Note',
// XXX: CW Title
'summary' => null,
'content' => $status->rendered ?? $status->caption,
'inReplyTo' => null,
// TODO: fix date format
'published' => $status->created_at->toAtomString(),
'url' => $status->url(),
'attributedTo' => $status->profile->permalink(),
'to' => [
// TODO: handle proper scope
'https://www.w3.org/ns/activitystreams#Public'
],
'cc' => [
// TODO: add cc's
$status->profile->permalink('/followers'),
],
'sensitive' => (bool) $status->is_nsfw,
'atomUri' => $status->url(),
'inReplyToAtomUri' => null,
'attachment' => $status->media->map(function($media) {
return [
'type' => 'Document',
'mediaType' => $media->mime,
'url' => $media->url(),
'name' => null
];
}),
'tag' => []
];
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Transformer\Api;
use App\Profile;
use League\Fractal;
class AccountTransformer extends Fractal\TransformerAbstract
{
public function transform(Profile $profile)
{
return [
'id' => $profile->id,
'username' => $profile->username,
'acct' => $profile->username,
'display_name' => $profile->name,
'locked' => (bool) $profile->is_private,
'created_at' => $profile->created_at->format('c'),
'followers_count' => $profile->followerCount(),
'following_count' => $profile->followingCount(),
'statuses_count' => $profile->statusCount(),
'note' => $profile->bio,
'url' => $profile->url(),
'avatar' => $profile->avatarUrl(),
'avatar_static' => $profile->avatarUrl(),
'header' => '',
'header_static' => '',
'moved' => null,
'fields' => null,
'bot' => null
];
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Transformer\Api;
use League\Fractal;
class ApplicationTransformer extends Fractal\TransformerAbstract
{
public function transform()
{
return [
'name' => '',
'website' => null
];
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Transformer\Api;
use App\Hashtag;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
class HashtagTransformer extends Fractal\TransformerAbstract
{
public function transform(Hashtag $hashtag)
{
return [
'name' => $hashtag->name,
'url' => $hashtag->url(),
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Transformer\Api;
use App\Media;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
class MediaTransformer extends Fractal\TransformerAbstract
{
public function transform(Media $media)
{
return [
'id' => $media->id,
'type' => 'image',
'url' => $media->url(),
'remote_url' => null,
'preview_url' => $media->thumbnailUrl(),
'text_url' => null,
'meta' => null,
'description' => null
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Transformer\Api;
use App\Profile;
use League\Fractal;
class MentionTransformer extends Fractal\TransformerAbstract
{
public function transform(Profile $profile)
{
return [
'id' => $profile->id,
'url' => $profile->url(),
'username' => $profile->username,
'acct' => $profile->username,
];
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Transformer\Api;
use App\Status;
use League\Fractal;
class StatusTransformer extends Fractal\TransformerAbstract
{
protected $defaultIncludes = [
'account',
'mentions',
'media_attachments',
'tags'
];
public function transform(Status $status)
{
return [
'id' => $status->id,
'uri' => $status->url(),
'url' => $status->url(),
'in_reply_to_id' => $status->in_reply_to_id,
'in_reply_to_account_id' => $status->in_reply_to_profile_id,
// TODO: fixme
'reblog' => null,
'content' => "<p>$status->rendered</p>",
'created_at' => $status->created_at->format('c'),
'emojis' => [],
'reblogs_count' => $status->shares()->count(),
'favourites_count' => $status->likes()->count(),
'reblogged' => $status->shared(),
'favourited' => $status->liked(),
'muted' => null,
'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => '',
'visibility' => $status->visibility,
'application' => null,
'language' => null,
'pinned' => null
];
}
public function includeAccount(Status $status)
{
$account = $status->profile;
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)
{
$media = $status->media;
return $this->collection($media, new MediaTransformer);
}
public function includeTags(Status $status)
{
$tags = $status->hashtags;
return $this->collection($tags, new HashtagTransformer);
}
}

View file

@ -3,11 +3,19 @@
namespace App;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use Notifiable;
use Notifiable, SoftDeletes;
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = ['deleted_at', 'email_verified_at'];
/**
* The attributes that are mass assignable.
@ -36,4 +44,9 @@ class User extends Authenticatable
{
return url(config('app.url') . '/' . $this->username);
}
public function settings()
{
return $this->hasOne(UserSetting::class);
}
}

15
app/UserFilter.php Normal file
View file

@ -0,0 +1,15 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class UserFilter extends Model
{
protected $fillable = [
'user_id',
'filterable_id',
'filterable_type',
'filter_type'
];
}

10
app/UserSetting.php Normal file
View file

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

771
app/Util/Lexer/Autolink.php Executable file
View file

@ -0,0 +1,771 @@
<?php
/**
* @author Mike Cochrane <mikec@mikenz.geek.nz>
* @author Nick Pope <nick@nickpope.me.uk>
* @copyright Copyright © 2010, Mike Cochrane, Nick Pope
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
*/
namespace App\Util\Lexer;
use App\Util\Lexer\Regex;
use App\Util\Lexer\Extractor;
use App\Util\Lexer\StringUtils;
/**
* Twitter Autolink Class
*
* Parses tweets and generates HTML anchor tags around URLs, usernames,
* username/list pairs and hashtags.
*
* Originally written by {@link http://github.com/mikenz Mike Cochrane}, this
* is based on code by {@link http://github.com/mzsanford Matt Sanford} and
* heavily modified by {@link http://github.com/ngnpope Nick Pope}.
*
* @author Mike Cochrane <mikec@mikenz.geek.nz>
* @author Nick Pope <nick@nickpope.me.uk>
* @copyright Copyright © 2010, Mike Cochrane, Nick Pope
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
*/
class Autolink extends Regex
{
/**
* CSS class for auto-linked URLs.
*
* @var string
*/
protected $class_url = '';
/**
* CSS class for auto-linked username URLs.
*
* @var string
*/
protected $class_user = 'u-url mention';
/**
* CSS class for auto-linked list URLs.
*
* @var string
*/
protected $class_list = 'u-url list-slug';
/**
* CSS class for auto-linked hashtag URLs.
*
* @var string
*/
protected $class_hash = 'u-url hashtag';
/**
* CSS class for auto-linked cashtag URLs.
*
* @var string
*/
protected $class_cash = 'u-url cashtag';
/**
* URL base for username links (the username without the @ will be appended).
*
* @var string
*/
protected $url_base_user = null;
/**
* URL base for list links (the username/list without the @ will be appended).
*
* @var string
*/
protected $url_base_list = null;
/**
* URL base for hashtag links (the hashtag without the # will be appended).
*
* @var string
*/
protected $url_base_hash = null;
/**
* URL base for cashtag links (the hashtag without the $ will be appended).
*
* @var string
*/
protected $url_base_cash = null;
/**
* Whether to include the value 'nofollow' in the 'rel' attribute.
*
* @var bool
*/
protected $nofollow = true;
/**
* Whether to include the value 'noopener' in the 'rel' attribute.
*
* @var bool
*/
protected $noopener = true;
/**
* Whether to include the value 'external' in the 'rel' attribute.
*
* Often this is used to be matched on in JavaScript for dynamically adding
* the 'target' attribute which is deprecated in HTML 4.01. In HTML 5 it has
* been undeprecated and thus the 'target' attribute can be used. If this is
* set to false then the 'target' attribute will be output.
*
* @var bool
*/
protected $external = true;
/**
* The scope to open the link in.
*
* Support for the 'target' attribute was deprecated in HTML 4.01 but has
* since been reinstated in HTML 5. To output the 'target' attribute you
* must disable the adding of the string 'external' to the 'rel' attribute.
*
* @var string
*/
protected $target = '_blank';
/**
* attribute for invisible span tag
*
* @var string
*/
protected $invisibleTagAttrs = "style='position:absolute;left:-9999px;'";
/**
*
* @var Extractor
*/
protected $extractor = null;
/**
* Provides fluent method chaining.
*
* @param string $tweet The tweet to be converted.
* @param bool $full_encode Whether to encode all special characters.
*
* @see __construct()
*
* @return Autolink
*/
public static function create($tweet = null, $full_encode = false)
{
return new static($tweet, $full_encode);
}
/**
* Reads in a tweet to be parsed and converted to contain links.
*
* As the intent is to produce links and output the modified tweet to the
* user, we take this opportunity to ensure that we escape user input.
*
* @see htmlspecialchars()
*
* @param string $tweet The tweet to be converted.
* @param bool $escape Whether to escape the tweet (default: true).
* @param bool $full_encode Whether to encode all special characters.
*/
public function __construct($tweet = null, $escape = true, $full_encode = false)
{
if ($escape && !empty($tweet)) {
if ($full_encode) {
parent::__construct(htmlentities($tweet, ENT_QUOTES, 'UTF-8', false));
} else {
parent::__construct(htmlspecialchars($tweet, ENT_QUOTES, 'UTF-8', false));
}
} else {
parent::__construct($tweet);
}
$this->extractor = Extractor::create();
$this->url_base_user = config('app.url') . '/';
$this->url_base_list = config('app.url') . '/';
$this->url_base_hash = config('app.url') . "/discover/tags/";
$this->url_base_cash = config('app.url') . '/search?q=%24';
}
/**
* CSS class for auto-linked URLs.
*
* @return string CSS class for URL links.
*/
public function getURLClass()
{
return $this->class_url;
}
/**
* CSS class for auto-linked URLs.
*
* @param string $v CSS class for URL links.
*
* @return Autolink Fluid method chaining.
*/
public function setURLClass($v)
{
$this->class_url = trim($v);
return $this;
}
/**
* CSS class for auto-linked username URLs.
*
* @return string CSS class for username links.
*/
public function getUsernameClass()
{
return $this->class_user;
}
/**
* CSS class for auto-linked username URLs.
*
* @param string $v CSS class for username links.
*
* @return Autolink Fluid method chaining.
*/
public function setUsernameClass($v)
{
$this->class_user = trim($v);
return $this;
}
/**
* CSS class for auto-linked username/list URLs.
*
* @return string CSS class for username/list links.
*/
public function getListClass()
{
return $this->class_list;
}
/**
* CSS class for auto-linked username/list URLs.
*
* @param string $v CSS class for username/list links.
*
* @return Autolink Fluid method chaining.
*/
public function setListClass($v)
{
$this->class_list = trim($v);
return $this;
}
/**
* CSS class for auto-linked hashtag URLs.
*
* @return string CSS class for hashtag links.
*/
public function getHashtagClass()
{
return $this->class_hash;
}
/**
* CSS class for auto-linked hashtag URLs.
*
* @param string $v CSS class for hashtag links.
*
* @return Autolink Fluid method chaining.
*/
public function setHashtagClass($v)
{
$this->class_hash = trim($v);
return $this;
}
/**
* CSS class for auto-linked cashtag URLs.
*
* @return string CSS class for cashtag links.
*/
public function getCashtagClass()
{
return $this->class_cash;
}
/**
* CSS class for auto-linked cashtag URLs.
*
* @param string $v CSS class for cashtag links.
*
* @return Autolink Fluid method chaining.
*/
public function setCashtagClass($v)
{
$this->class_cash = trim($v);
return $this;
}
/**
* Whether to include the value 'nofollow' in the 'rel' attribute.
*
* @return bool Whether to add 'nofollow' to the 'rel' attribute.
*/
public function getNoFollow()
{
return $this->nofollow;
}
/**
* Whether to include the value 'nofollow' in the 'rel' attribute.
*
* @param bool $v The value to add to the 'target' attribute.
*
* @return Autolink Fluid method chaining.
*/
public function setNoFollow($v)
{
$this->nofollow = $v;
return $this;
}
/**
* Whether to include the value 'external' in the 'rel' attribute.
*
* Often this is used to be matched on in JavaScript for dynamically adding
* the 'target' attribute which is deprecated in HTML 4.01. In HTML 5 it has
* been undeprecated and thus the 'target' attribute can be used. If this is
* set to false then the 'target' attribute will be output.
*
* @return bool Whether to add 'external' to the 'rel' attribute.
*/
public function getExternal()
{
return $this->external;
}
/**
* Whether to include the value 'external' in the 'rel' attribute.
*
* Often this is used to be matched on in JavaScript for dynamically adding
* the 'target' attribute which is deprecated in HTML 4.01. In HTML 5 it has
* been undeprecated and thus the 'target' attribute can be used. If this is
* set to false then the 'target' attribute will be output.
*
* @param bool $v The value to add to the 'target' attribute.
*
* @return Autolink Fluid method chaining.
*/
public function setExternal($v)
{
$this->external = $v;
return $this;
}
/**
* The scope to open the link in.
*
* Support for the 'target' attribute was deprecated in HTML 4.01 but has
* since been reinstated in HTML 5. To output the 'target' attribute you
* must disable the adding of the string 'external' to the 'rel' attribute.
*
* @return string The value to add to the 'target' attribute.
*/
public function getTarget()
{
return $this->target;
}
/**
* The scope to open the link in.
*
* Support for the 'target' attribute was deprecated in HTML 4.01 but has
* since been reinstated in HTML 5. To output the 'target' attribute you
* must disable the adding of the string 'external' to the 'rel' attribute.
*
* @param string $v The value to add to the 'target' attribute.
*
* @return Autolink Fluid method chaining.
*/
public function setTarget($v)
{
$this->target = trim($v);
return $this;
}
/**
* Autolink with entities
*
* @param string $tweet
* @param array $entities
* @return string
* @since 1.1.0
*/
public function autoLinkEntities($tweet = null, $entities = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$text = '';
$beginIndex = 0;
foreach ($entities as $entity) {
if (isset($entity['screen_name'])) {
$text .= StringUtils::substr($tweet, $beginIndex, $entity['indices'][0] - $beginIndex + 1);
} else {
$text .= StringUtils::substr($tweet, $beginIndex, $entity['indices'][0] - $beginIndex);
}
if (isset($entity['url'])) {
$text .= $this->linkToUrl($entity);
} elseif (isset($entity['hashtag'])) {
$text .= $this->linkToHashtag($entity, $tweet);
} elseif (isset($entity['screen_name'])) {
$text .= $this->linkToMentionAndList($entity);
} elseif (isset($entity['cashtag'])) {
$text .= $this->linkToCashtag($entity, $tweet);
}
$beginIndex = $entity['indices'][1];
}
$text .= StringUtils::substr($tweet, $beginIndex, StringUtils::strlen($tweet));
return $text;
}
/**
* Auto-link hashtags, URLs, usernames and lists, with JSON entities.
*
* @param string The tweet to be converted
* @param mixed The entities info
* @return string that auto-link HTML added
* @since 1.1.0
*/
public function autoLinkWithJson($tweet = null, $json = null)
{
// concatenate entities
$entities = array();
if (is_object($json)) {
$json = $this->object2array($json);
}
if (is_array($json)) {
foreach ($json as $key => $vals) {
$entities = array_merge($entities, $json[$key]);
}
}
// map JSON entity to twitter-text entity
foreach ($entities as $idx => $entity) {
if (!empty($entity['text'])) {
$entities[$idx]['hashtag'] = $entity['text'];
}
}
$entities = $this->extractor->removeOverlappingEntities($entities);
return $this->autoLinkEntities($tweet, $entities);
}
/**
* convert Object to Array
*
* @param mixed $obj
* @return array
*/
protected function object2array($obj)
{
$array = (array) $obj;
foreach ($array as $key => $var) {
if (is_object($var) || is_array($var)) {
$array[$key] = $this->object2array($var);
}
}
return $array;
}
/**
* Auto-link hashtags, URLs, usernames and lists.
*
* @param string The tweet to be converted
* @return string that auto-link HTML added
* @since 1.1.0
*/
public function autoLink($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$entities = $this->extractor->extractURLWithoutProtocol(false)->extractEntitiesWithIndices($tweet);
return $this->autoLinkEntities($tweet, $entities);
}
/**
* Auto-link the @username and @username/list references in the provided text. Links to @username references will
* have the usernameClass CSS classes added. Links to @username/list references will have the listClass CSS class
* added.
*
* @return string that auto-link HTML added
* @since 1.1.0
*/
public function autoLinkUsernamesAndLists($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$entities = $this->extractor->extractMentionsOrListsWithIndices($tweet);
return $this->autoLinkEntities($tweet, $entities);
}
/**
* Auto-link #hashtag references in the provided Tweet text. The #hashtag links will have the hashtagClass CSS class
* added.
*
* @return string that auto-link HTML added
* @since 1.1.0
*/
public function autoLinkHashtags($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$entities = $this->extractor->extractHashtagsWithIndices($tweet);
return $this->autoLinkEntities($tweet, $entities);
}
/**
* Auto-link URLs in the Tweet text provided.
* <p/>
* This only auto-links URLs with protocol.
*
* @return string that auto-link HTML added
* @since 1.1.0
*/
public function autoLinkURLs($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$entities = $this->extractor->extractURLWithoutProtocol(false)->extractURLsWithIndices($tweet);
return $this->autoLinkEntities($tweet, $entities);
}
/**
* Auto-link $cashtag references in the provided Tweet text. The $cashtag links will have the cashtagClass CSS class
* added.
*
* @return string that auto-link HTML added
* @since 1.1.0
*/
public function autoLinkCashtags($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$entities = $this->extractor->extractCashtagsWithIndices($tweet);
return $this->autoLinkEntities($tweet, $entities);
}
public function linkToUrl($entity)
{
if (!empty($this->class_url)) {
$attributes['class'] = $this->class_url;
}
$attributes['href'] = $entity['url'];
$linkText = $this->escapeHTML($entity['url']);
if (!empty($entity['display_url']) && !empty($entity['expanded_url'])) {
// Goal: If a user copies and pastes a tweet containing t.co'ed link, the resulting paste
// should contain the full original URL (expanded_url), not the display URL.
//
// Method: Whenever possible, we actually emit HTML that contains expanded_url, and use
// font-size:0 to hide those parts that should not be displayed (because they are not part of display_url).
// Elements with font-size:0 get copied even though they are not visible.
// Note that display:none doesn't work here. Elements with display:none don't get copied.
//
// Additionally, we want to *display* ellipses, but we don't want them copied. To make this happen we
// wrap the ellipses in a tco-ellipsis class and provide an onCopy handler that sets display:none on
// everything with the tco-ellipsis class.
//
// As an example: The user tweets "hi http://longdomainname.com/foo"
// This gets shortened to "hi http://t.co/xyzabc", with display_url = "…nname.com/foo"
// This will get rendered as:
// <span class='tco-ellipsis'> <!-- This stuff should get displayed but not copied -->
// …
// <!-- There's a chance the onCopy event handler might not fire. In case that happens,
// we include an &nbsp; here so that the … doesn't bump up against the URL and ruin it.
// The &nbsp; is inside the tco-ellipsis span so that when the onCopy handler *does*
// fire, it doesn't get copied. Otherwise the copied text would have two spaces in a row,
// e.g. "hi http://longdomainname.com/foo".
// <span style='font-size:0'>&nbsp;</span>
// </span>
// <span style='font-size:0'> <!-- This stuff should get copied but not displayed -->
// http://longdomai
// </span>
// <span class='js-display-url'> <!-- This stuff should get displayed *and* copied -->
// nname.com/foo
// </span>
// <span class='tco-ellipsis'> <!-- This stuff should get displayed but not copied -->
// <span style='font-size:0'>&nbsp;</span>
// …
// </span>
//
// Exception: pic.socialhub.dev images, for which expandedUrl = "https://socialhub.dev/#!/username/status/1234/photo/1
// For those URLs, display_url is not a substring of expanded_url, so we don't do anything special to render the elided parts.
// For a pic.socialhub.dev URL, the only elided part will be the "https://", so this is fine.
$displayURL = $entity['display_url'];
$expandedURL = $entity['expanded_url'];
$displayURLSansEllipses = preg_replace('/…/u', '', $displayURL);
$diplayURLIndexInExpandedURL = mb_strpos($expandedURL, $displayURLSansEllipses);
if ($diplayURLIndexInExpandedURL !== false) {
$beforeDisplayURL = mb_substr($expandedURL, 0, $diplayURLIndexInExpandedURL);
$afterDisplayURL = mb_substr($expandedURL, $diplayURLIndexInExpandedURL + mb_strlen($displayURLSansEllipses));
$precedingEllipsis = (preg_match('/\A…/u', $displayURL)) ? '…' : '';
$followingEllipsis = (preg_match('/…\z/u', $displayURL)) ? '…' : '';
$invisibleSpan = "<span {$this->invisibleTagAttrs}>";
$linkText = "<span class='tco-ellipsis'>{$precedingEllipsis}{$invisibleSpan}&nbsp;</span></span>";
$linkText .= "{$invisibleSpan}{$this->escapeHTML($beforeDisplayURL)}</span>";
$linkText .= "<span class='js-display-url'>{$this->escapeHTML($displayURLSansEllipses)}</span>";
$linkText .= "{$invisibleSpan}{$this->escapeHTML($afterDisplayURL)}</span>";
$linkText .= "<span class='tco-ellipsis'>{$invisibleSpan}&nbsp;</span>{$followingEllipsis}</span>";
} else {
$linkText = $entity['display_url'];
}
$attributes['title'] = $entity['expanded_url'];
} elseif (!empty($entity['display_url'])) {
$linkText = $entity['display_url'];
}
return $this->linkToText($entity, $linkText, $attributes);
}
/**
*
* @param array $entity
* @param string $tweet
* @return string
* @since 1.1.0
*/
public function linkToHashtag($entity, $tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$this->target = false;
$attributes = array();
$class = array();
$hash = StringUtils::substr($tweet, $entity['indices'][0], 1);
$linkText = $hash . $entity['hashtag'];
$attributes['href'] = $this->url_base_hash . $entity['hashtag'] . '?src=hash';
$attributes['title'] = '#' . $entity['hashtag'];
if (!empty($this->class_hash)) {
$class[] = $this->class_hash;
}
if (preg_match(self::$patterns['rtl_chars'], $linkText)) {
$class[] = 'rtl';
}
if (!empty($class)) {
$attributes['class'] = join(' ', $class);
}
return $this->linkToText($entity, $linkText, $attributes);
}
/**
*
* @param array $entity
* @return string
* @since 1.1.0
*/
public function linkToMentionAndList($entity)
{
$attributes = array();
if (!empty($entity['list_slug'])) {
# Replace the list and username
$linkText = $entity['screen_name'] . $entity['list_slug'];
$class = $this->class_list;
$url = $this->url_base_list . $linkText;
} else {
# Replace the username
$linkText = $entity['screen_name'];
$class = $this->class_user;
$url = $this->url_base_user . $linkText;
}
if (!empty($class)) {
$attributes['class'] = $class;
}
$attributes['href'] = $url;
return $this->linkToText($entity, $linkText, $attributes);
}
/**
*
* @param array $entity
* @param string $tweet
* @return string
* @since 1.1.0
*/
public function linkToCashtag($entity, $tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$attributes = array();
$doller = StringUtils::substr($tweet, $entity['indices'][0], 1);
$linkText = $doller . $entity['cashtag'];
$attributes['href'] = $this->url_base_cash . $entity['cashtag'];
$attributes['title'] = $linkText;
if (!empty($this->class_cash)) {
$attributes['class'] = $this->class_cash;
}
return $this->linkToText($entity, $linkText, $attributes);
}
/**
*
* @param array $entity
* @param string $text
* @param array $attributes
* @return string
* @since 1.1.0
*/
public function linkToText(array $entity, $text, $attributes = array())
{
$rel = array();
if ($this->external) {
$rel[] = 'external';
}
if ($this->nofollow) {
$rel[] = 'nofollow';
}
if ($this->noopener) {
$rel[] = 'noopener';
}
if (!empty($rel)) {
$attributes['rel'] = join(' ', $rel);
}
if ($this->target) {
$attributes['target'] = $this->target;
}
$link = '<a';
foreach ($attributes as $key => $val) {
$link .= ' ' . $key . '="' . $this->escapeHTML($val) . '"';
}
$link .= '>' . $text . '</a>';
return $link;
}
/**
* html escape
*
* @param string $text
* @return string
*/
protected function escapeHTML($text)
{
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8', false);
}
}

548
app/Util/Lexer/Extractor.php Executable file
View file

@ -0,0 +1,548 @@
<?php
/**
* @author Mike Cochrane <mikec@mikenz.geek.nz>
* @author Nick Pope <nick@nickpope.me.uk>
* @copyright Copyright © 2010, Mike Cochrane, Nick Pope
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
*/
namespace App\Util\Lexer;
use App\Util\Lexer\Regex;
use App\Util\Lexer\StringUtils;
/**
* Twitter Extractor Class
*
* Parses tweets and extracts URLs, usernames, username/list pairs and
* hashtags.
*
* Originally written by {@link http://github.com/mikenz Mike Cochrane}, this
* is based on code by {@link http://github.com/mzsanford Matt Sanford} and
* heavily modified by {@link http://github.com/ngnpope Nick Pope}.
*
* @author Mike Cochrane <mikec@mikenz.geek.nz>
* @author Nick Pope <nick@nickpope.me.uk>
* @copyright Copyright © 2010, Mike Cochrane, Nick Pope
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
*/
class Extractor extends Regex
{
/**
* @var boolean
*/
protected $extractURLWithoutProtocol = true;
/**
* Provides fluent method chaining.
*
* @param string $tweet The tweet to be converted.
*
* @see __construct()
*
* @return Extractor
*/
public static function create($tweet = null)
{
return new self($tweet);
}
/**
* Reads in a tweet to be parsed and extracts elements from it.
*
* Extracts various parts of a tweet including URLs, usernames, hashtags...
*
* @param string $tweet The tweet to extract.
*/
public function __construct($tweet = null)
{
parent::__construct($tweet);
}
/**
* Extracts all parts of a tweet and returns an associative array containing
* the extracted elements.
*
* @param string $tweet The tweet to extract.
* @return array The elements in the tweet.
*/
public function extract($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
return array(
'hashtags' => $this->extractHashtags($tweet),
'urls' => $this->extractURLs($tweet),
'mentions' => $this->extractMentionedUsernames($tweet),
'replyto' => $this->extractRepliedUsernames($tweet),
'hashtags_with_indices' => $this->extractHashtagsWithIndices($tweet),
'urls_with_indices' => $this->extractURLsWithIndices($tweet),
'mentions_with_indices' => $this->extractMentionedUsernamesWithIndices($tweet),
);
}
/**
* Extract URLs, @mentions, lists and #hashtag from a given text/tweet.
*
* @param string $tweet The tweet to extract.
* @return array list of extracted entities
*/
public function extractEntitiesWithIndices($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$entities = array();
$entities = array_merge($entities, $this->extractURLsWithIndices($tweet));
$entities = array_merge($entities, $this->extractHashtagsWithIndices($tweet, false));
$entities = array_merge($entities, $this->extractMentionsOrListsWithIndices($tweet));
$entities = array_merge($entities, $this->extractCashtagsWithIndices($tweet));
$entities = $this->removeOverlappingEntities($entities);
return $entities;
}
/**
* Extracts all the hashtags from the tweet.
*
* @param string $tweet The tweet to extract.
* @return array The hashtag elements in the tweet.
*/
public function extractHashtags($tweet = null)
{
$hashtagsOnly = array();
$hashtagsWithIndices = $this->extractHashtagsWithIndices($tweet);
foreach ($hashtagsWithIndices as $hashtagWithIndex) {
$hashtagsOnly[] = $hashtagWithIndex['hashtag'];
}
return $hashtagsOnly;
}
/**
* Extracts all the cashtags from the tweet.
*
* @param string $tweet The tweet to extract.
* @return array The cashtag elements in the tweet.
*/
public function extractCashtags($tweet = null)
{
$cashtagsOnly = array();
$cashtagsWithIndices = $this->extractCashtagsWithIndices($tweet);
foreach ($cashtagsWithIndices as $cashtagWithIndex) {
$cashtagsOnly[] = $cashtagWithIndex['cashtag'];
}
return $cashtagsOnly;
}
/**
* Extracts all the URLs from the tweet.
*
* @param string $tweet The tweet to extract.
* @return array The URL elements in the tweet.
*/
public function extractURLs($tweet = null)
{
$urlsOnly = array();
$urlsWithIndices = $this->extractURLsWithIndices($tweet);
foreach ($urlsWithIndices as $urlWithIndex) {
$urlsOnly[] = $urlWithIndex['url'];
}
return $urlsOnly;
}
/**
* Extract all the usernames from the tweet.
*
* A mention is an occurrence of a username anywhere in a tweet.
*
* @param string $tweet The tweet to extract.
* @return array The usernames elements in the tweet.
*/
public function extractMentionedScreennames($tweet = null)
{
$usernamesOnly = array();
$mentionsWithIndices = $this->extractMentionsOrListsWithIndices($tweet);
foreach ($mentionsWithIndices as $mentionWithIndex) {
$screen_name = mb_strtolower($mentionWithIndex['screen_name']);
if (empty($screen_name) OR in_array($screen_name, $usernamesOnly)) {
continue;
}
$usernamesOnly[] = $screen_name;
}
return $usernamesOnly;
}
/**
* Extract all the usernames from the tweet.
*
* A mention is an occurrence of a username anywhere in a tweet.
*
* @return array The usernames elements in the tweet.
* @deprecated since version 1.1.0
*/
public function extractMentionedUsernames($tweet)
{
$this->tweet = $tweet;
return $this->extractMentionedScreennames($tweet);
}
/**
* Extract all the usernames replied to from the tweet.
*
* A reply is an occurrence of a username at the beginning of a tweet.
*
* @param string $tweet The tweet to extract.
* @return array The usernames replied to in a tweet.
*/
public function extractReplyScreenname($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$matched = preg_match(self::$patterns['valid_reply'], $tweet, $matches);
# Check username ending in
if ($matched && preg_match(self::$patterns['end_mention_match'], $matches[2])) {
$matched = false;
}
return $matched ? $matches[1] : null;
}
/**
* Extract all the usernames replied to from the tweet.
*
* A reply is an occurrence of a username at the beginning of a tweet.
*
* @return array The usernames replied to in a tweet.
* @deprecated since version 1.1.0
*/
public function extractRepliedUsernames()
{
return $this->extractReplyScreenname();
}
/**
* Extracts all the hashtags and the indices they occur at from the tweet.
*
* @param string $tweet The tweet to extract.
* @param boolean $checkUrlOverlap if true, check if extracted hashtags overlap URLs and remove overlapping ones
* @return array The hashtag elements in the tweet.
*/
public function extractHashtagsWithIndices($tweet = null, $checkUrlOverlap = true)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
if (!preg_match('/[#]/iu', $tweet)) {
return array();
}
preg_match_all(self::$patterns['valid_hashtag'], $tweet, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
$tags = array();
foreach ($matches as $match) {
list($all, $before, $hash, $hashtag, $outer) = array_pad($match, 3, array('', 0));
$start_position = $hash[1] > 0 ? StringUtils::strlen(substr($tweet, 0, $hash[1])) : $hash[1];
$end_position = $start_position + StringUtils::strlen($hash[0] . $hashtag[0]);
if (preg_match(self::$patterns['end_hashtag_match'], $outer[0])) {
continue;
}
$tags[] = array(
'hashtag' => $hashtag[0],
'indices' => array($start_position, $end_position)
);
}
if (!$checkUrlOverlap) {
return $tags;
}
# check url overlap
$urls = $this->extractURLsWithIndices($tweet);
$entities = $this->removeOverlappingEntities(array_merge($tags, $urls));
$validTags = array();
foreach ($entities as $entity) {
if (empty($entity['hashtag'])) {
continue;
}
$validTags[] = $entity;
}
return $validTags;
}
/**
* Extracts all the cashtags and the indices they occur at from the tweet.
*
* @param string $tweet The tweet to extract.
* @return array The cashtag elements in the tweet.
*/
public function extractCashtagsWithIndices($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
if (!preg_match('/\$/iu', $tweet)) {
return array();
}
preg_match_all(self::$patterns['valid_cashtag'], $tweet, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
$tags = array();
foreach ($matches as $match) {
list($all, $before, $dollar, $cash_text, $outer) = array_pad($match, 3, array('', 0));
$start_position = $dollar[1] > 0 ? StringUtils::strlen(substr($tweet, 0, $dollar[1])) : $dollar[1];
$end_position = $start_position + StringUtils::strlen($dollar[0] . $cash_text[0]);
if (preg_match(self::$patterns['end_hashtag_match'], $outer[0])) {
continue;
}
$tags[] = array(
'cashtag' => $cash_text[0],
'indices' => array($start_position, $end_position)
);
}
return $tags;
}
/**
* Extracts all the URLs and the indices they occur at from the tweet.
*
* @param string $tweet The tweet to extract.
* @return array The URLs elements in the tweet.
*/
public function extractURLsWithIndices($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$needle = $this->extractURLWithoutProtocol() ? '.' : ':';
if (strpos($tweet, $needle) === false) {
return array();
}
$urls = array();
preg_match_all(self::$patterns['valid_url'], $tweet, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
foreach ($matches as $match) {
list($all, $before, $url, $protocol, $domain, $port, $path, $query) = array_pad($match, 8, array(''));
$start_position = $url[1] > 0 ? StringUtils::strlen(substr($tweet, 0, $url[1])) : $url[1];
$end_position = $start_position + StringUtils::strlen($url[0]);
$all = $all[0];
$before = $before[0];
$url = $url[0];
$protocol = $protocol[0];
$domain = $domain[0];
$port = $port[0];
$path = $path[0];
$query = $query[0];
// If protocol is missing and domain contains non-ASCII characters,
// extract ASCII-only domains.
if (empty($protocol)) {
if (!$this->extractURLWithoutProtocol || preg_match(self::$patterns['invalid_url_without_protocol_preceding_chars'], $before)) {
continue;
}
$last_url = null;
$ascii_end_position = 0;
if (preg_match(self::$patterns['valid_ascii_domain'], $domain, $asciiDomain)) {
$asciiDomain[0] = preg_replace('/' . preg_quote($domain, '/') . '/u', $asciiDomain[0], $url);
$ascii_start_position = StringUtils::strpos($domain, $asciiDomain[0], $ascii_end_position);
$ascii_end_position = $ascii_start_position + StringUtils::strlen($asciiDomain[0]);
$last_url = array(
'url' => $asciiDomain[0],
'indices' => array($start_position + $ascii_start_position, $start_position + $ascii_end_position),
);
if (!empty($path)
|| preg_match(self::$patterns['valid_special_short_domain'], $asciiDomain[0])
|| !preg_match(self::$patterns['invalid_short_domain'], $asciiDomain[0])) {
$urls[] = $last_url;
}
}
// no ASCII-only domain found. Skip the entire URL
if (empty($last_url)) {
continue;
}
// $last_url only contains domain. Need to add path and query if they exist.
if (!empty($path)) {
// last_url was not added. Add it to urls here.
$last_url['url'] = preg_replace('/' . preg_quote($domain, '/') . '/u', $last_url['url'], $url);
$last_url['indices'][1] = $end_position;
}
} else {
// In the case of t.co URLs, don't allow additional path characters
if (preg_match(self::$patterns['valid_tco_url'], $url, $tcoUrlMatches)) {
$url = $tcoUrlMatches[0];
$end_position = $start_position + StringUtils::strlen($url);
}
$urls[] = array(
'url' => $url,
'indices' => array($start_position, $end_position),
);
}
}
return $urls;
}
/**
* Extracts all the usernames and the indices they occur at from the tweet.
*
* @param string $tweet The tweet to extract.
* @return array The username elements in the tweet.
*/
public function extractMentionedScreennamesWithIndices($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$usernamesOnly = array();
$mentions = $this->extractMentionsOrListsWithIndices($tweet);
foreach ($mentions as $mention) {
if (isset($mention['list_slug'])) {
unset($mention['list_slug']);
}
$usernamesOnly[] = $mention;
}
return $usernamesOnly;
}
/**
* Extracts all the usernames and the indices they occur at from the tweet.
*
* @return array The username elements in the tweet.
* @deprecated since version 1.1.0
*/
public function extractMentionedUsernamesWithIndices()
{
return $this->extractMentionedScreennamesWithIndices();
}
/**
* Extracts all the usernames and the indices they occur at from the tweet.
*
* @param string $tweet The tweet to extract.
* @return array The username elements in the tweet.
*/
public function extractMentionsOrListsWithIndices($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
if (!preg_match('/[@]/iu', $tweet)) {
return array();
}
preg_match_all(self::$patterns['valid_mentions_or_lists'], $tweet, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
$results = array();
foreach ($matches as $match) {
list($all, $before, $at, $username, $list_slug, $outer) = array_pad($match, 6, array('', 0));
$start_position = $at[1] > 0 ? StringUtils::strlen(substr($tweet, 0, $at[1])) : $at[1];
$end_position = $start_position + StringUtils::strlen($at[0]) + StringUtils::strlen($username[0]);
$entity = array(
'screen_name' => $username[0],
'list_slug' => $list_slug[0],
'indices' => array($start_position, $end_position),
);
if (preg_match(self::$patterns['end_mention_match'], $outer[0])) {
continue;
}
if (!empty($list_slug[0])) {
$entity['indices'][1] = $end_position + StringUtils::strlen($list_slug[0]);
}
$results[] = $entity;
}
return $results;
}
/**
* Extracts all the usernames and the indices they occur at from the tweet.
*
* @return array The username elements in the tweet.
* @deprecated since version 1.1.0
*/
public function extractMentionedUsernamesOrListsWithIndices()
{
return $this->extractMentionsOrListsWithIndices();
}
/**
* setter/getter for extractURLWithoutProtocol
*
* @param boolean $flag
* @return Extractor
*/
public function extractURLWithoutProtocol($flag = null)
{
if (is_null($flag)) {
return $this->extractURLWithoutProtocol;
}
$this->extractURLWithoutProtocol = (bool) $flag;
return $this;
}
/**
* Remove overlapping entities.
* This returns a new array with no overlapping entities.
*
* @param array $entities
* @return array
*/
public function removeOverlappingEntities($entities)
{
$result = array();
usort($entities, array($this, 'sortEntites'));
$prev = null;
foreach ($entities as $entity) {
if (isset($prev) && $entity['indices'][0] < $prev['indices'][1]) {
continue;
}
$prev = $entity;
$result[] = $entity;
}
return $result;
}
/**
* sort by entity start index
*
* @param array $a
* @param array $b
* @return int
*/
protected function sortEntites($a, $b)
{
if ($a['indices'][0] == $b['indices'][0]) {
return 0;
}
return ($a['indices'][0] < $b['indices'][0]) ? -1 : 1;
}
}

202
app/Util/Lexer/HitHighlighter.php Executable file
View file

@ -0,0 +1,202 @@
<?php
/**
* @author Nick Pope <nick@nickpope.me.uk>
* @copyright Copyright © 2010, Nick Pope
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
*/
namespace App\Util\Lexer;
use App\Util\Lexer\Regex;
use App\Util\Lexer\StringUtils;
/**
* Twitter HitHighlighter Class
*
* Performs "hit highlighting" on tweets that have been auto-linked already.
* Useful with the results returned from the search API.
*
* Originally written by {@link http://github.com/mikenz Mike Cochrane}, this
* is based on code by {@link http://github.com/mzsanford Matt Sanford} and
* heavily modified by {@link http://github.com/ngnpope Nick Pope}.
*
* @author Nick Pope <nick@nickpope.me.uk>
* @copyright Copyright © 2010, Nick Pope
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
*/
class HitHighlighter extends Regex
{
/**
* The tag to surround hits with.
*
* @var string
*/
protected $tag = 'em';
/**
* Provides fluent method chaining.
*
* @param string $tweet The tweet to be hit highlighted.
* @param bool $full_encode Whether to encode all special characters.
*
* @see __construct()
*
* @return HitHighlighter
*/
public static function create($tweet = null, $full_encode = false)
{
return new self($tweet, $full_encode);
}
/**
* Reads in a tweet to be parsed and hit highlighted.
*
* We take this opportunity to ensure that we escape user input.
*
* @see htmlspecialchars()
*
* @param string $tweet The tweet to be hit highlighted.
* @param bool $escape Whether to escape the tweet (default: true).
* @param bool $full_encode Whether to encode all special characters.
*/
public function __construct($tweet = null, $escape = true, $full_encode = false)
{
if (!empty($tweet) && $escape) {
if ($full_encode) {
parent::__construct(htmlentities($tweet, ENT_QUOTES, 'UTF-8', false));
} else {
parent::__construct(htmlspecialchars($tweet, ENT_QUOTES, 'UTF-8', false));
}
} else {
parent::__construct($tweet);
}
}
/**
* Set the highlighting tag to surround hits with. The default tag is 'em'.
*
* @return string The tag name.
*/
public function getTag()
{
return $this->tag;
}
/**
* Set the highlighting tag to surround hits with. The default tag is 'em'.
*
* @param string $v The tag name.
*
* @return HitHighlighter Fluid method chaining.
*/
public function setTag($v)
{
$this->tag = $v;
return $this;
}
/**
* Hit highlights the tweet.
*
* @param string $tweet The tweet to be hit highlighted.
* @param array $hits An array containing the start and end index pairs
* for the highlighting.
* @param bool $escape Whether to escape the tweet (default: true).
* @param bool $full_encode Whether to encode all special characters.
*
* @return string The hit highlighted tweet.
*/
public function highlight($tweet = null, array $hits = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
if (empty($hits)) {
return $tweet;
}
$highlightTweet = '';
$tags = array('<' . $this->tag . '>', '</' . $this->tag . '>');
# Check whether we can simply replace or whether we need to chunk...
if (strpos($tweet, '<') === false) {
$ti = 0; // tag increment (for added tags)
$highlightTweet = $tweet;
foreach ($hits as $hit) {
$highlightTweet = StringUtils::substrReplace($highlightTweet, $tags[0], $hit[0] + $ti, 0);
$ti += StringUtils::strlen($tags[0]);
$highlightTweet = StringUtils::substrReplace($highlightTweet, $tags[1], $hit[1] + $ti, 0);
$ti += StringUtils::strlen($tags[1]);
}
} else {
$chunks = preg_split('/[<>]/iu', $tweet);
$chunk = $chunks[0];
$chunk_index = 0;
$chunk_cursor = 0;
$offset = 0;
$start_in_chunk = false;
# Flatten the multidimensional hits array:
$hits_flat = array();
foreach ($hits as $hit) {
$hits_flat = array_merge($hits_flat, $hit);
}
# Loop over the hit indices:
for ($index = 0; $index < count($hits_flat); $index++) {
$hit = $hits_flat[$index];
$tag = $tags[$index % 2];
$placed = false;
while ($chunk !== null && $hit >= ($i = $offset + StringUtils::strlen($chunk))) {
$highlightTweet .= StringUtils::substr($chunk, $chunk_cursor);
if ($start_in_chunk && $hit === $i) {
$highlightTweet .= $tag;
$placed = true;
}
if (isset($chunks[$chunk_index + 1])) {
$highlightTweet .= '<' . $chunks[$chunk_index + 1] . '>';
}
$offset += StringUtils::strlen($chunk);
$chunk_cursor = 0;
$chunk_index += 2;
$chunk = (isset($chunks[$chunk_index]) ? $chunks[$chunk_index] : null);
$start_in_chunk = false;
}
if (!$placed && $chunk !== null) {
$hit_spot = $hit - $offset;
$highlightTweet .= StringUtils::substr($chunk, $chunk_cursor, $hit_spot - $chunk_cursor) . $tag;
$chunk_cursor = $hit_spot;
$start_in_chunk = ($index % 2 === 0);
$placed = true;
}
# Ultimate fallback - hits that run off the end get a closing tag:
if (!$placed) {
$highlightTweet .= $tag;
}
}
if ($chunk !== null) {
if ($chunk_cursor < StringUtils::strlen($chunk)) {
$highlightTweet .= StringUtils::substr($chunk, $chunk_cursor);
}
for ($index = $chunk_index + 1; $index < count($chunks); $index++) {
$highlightTweet .= ($index % 2 === 0 ? $chunks[$index] : '<' . $chunks[$index] . '>');
}
}
}
return $highlightTweet;
}
/**
* Hit highlights the tweet.
*
* @param array $hits An array containing the start and end index pairs
* for the highlighting.
*
* @return string The hit highlighted tweet.
* @deprecated since version 1.1.0
*/
public function addHitHighlighting(array $hits)
{
return $this->highlight($this->tweet, $hits);
}
}

348
app/Util/Lexer/LooseAutolink.php Executable file
View file

@ -0,0 +1,348 @@
<?php
/**
* @author Mike Cochrane <mikec@mikenz.geek.nz>
* @author Nick Pope <nick@nickpope.me.uk>
* @author Takashi Nojima
* @copyright Copyright 2014 Mike Cochrane, Nick Pope, Takashi Nojima
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
*/
namespace App\Util\Lexer;
use App\Util\Lexer\Autolink;
/**
* Twitter LooseAutolink Class
*
* Parses tweets and generates HTML anchor tags around URLs, usernames,
* username/list pairs and hashtags.
*
* Originally written by {@link http://github.com/mikenz Mike Cochrane}, this
* is based on code by {@link http://github.com/mzsanford Matt Sanford} and
* heavily modified by {@link http://github.com/ngnpope Nick Pope}.
*
* @author Mike Cochrane <mikec@mikenz.geek.nz>
* @author Nick Pope <nick@nickpope.me.uk>
* @author Takashi Nojima
* @copyright Copyright 2014 Mike Cochrane, Nick Pope, Takashi Nojima
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
* @since 1.8.0
* @deprecated since version 1.9.0
*/
class LooseAutolink extends Autolink
{
/**
* Auto-link hashtags, URLs, usernames and lists.
*
* @param string The tweet to be converted
* @return string that auto-link HTML added
* @deprecated since version 1.9.0
*/
public function autoLink($tweet = null)
{
if (!is_null($tweet)) {
$this->tweet = $tweet;
}
return $this->addLinks();
}
/**
* Auto-link the @username and @username/list references in the provided text. Links to @username references will
* have the usernameClass CSS classes added. Links to @username/list references will have the listClass CSS class
* added.
*
* @return string that auto-link HTML added
*/
public function autoLinkUsernamesAndLists($tweet = null)
{
if (!is_null($tweet)) {
$this->tweet = $tweet;
}
return $this->addLinksToUsernamesAndLists();
}
/**
* Auto-link #hashtag references in the provided Tweet text. The #hashtag links will have the hashtagClass CSS class
* added.
*
* @return string that auto-link HTML added
*/
public function autoLinkHashtags($tweet = null)
{
if (!is_null($tweet)) {
$this->tweet = $tweet;
}
return $this->addLinksToHashtags();
}
/**
* Auto-link URLs in the Tweet text provided.
* <p/>
* This only auto-links URLs with protocol.
*
* @return string that auto-link HTML added
*/
public function autoLinkURLs($tweet = null)
{
if (!is_null($tweet)) {
$this->tweet = $tweet;
}
return $this->addLinksToURLs();
}
/**
* Auto-link $cashtag references in the provided Tweet text. The $cashtag links will have the cashtagClass CSS class
* added.
*
* @return string that auto-link HTML added
*/
public function autoLinkCashtags($tweet = null)
{
if (!is_null($tweet)) {
$this->tweet = $tweet;
}
return $this->addLinksToCashtags();
}
/**
* Adds links to all elements in the tweet.
*
* @return string The modified tweet.
* @deprecated since version 1.9.0
*/
public function addLinks()
{
$original = $this->tweet;
$this->tweet = $this->addLinksToURLs();
$this->tweet = $this->addLinksToHashtags();
$this->tweet = $this->addLinksToCashtags();
$this->tweet = $this->addLinksToUsernamesAndLists();
$modified = $this->tweet;
$this->tweet = $original;
return $modified;
}
/**
* Adds links to hashtag elements in the tweet.
*
* @return string The modified tweet.
*/
public function addLinksToHashtags()
{
return preg_replace_callback(
self::$patterns['valid_hashtag'],
array($this, '_addLinksToHashtags'),
$this->tweet
);
}
/**
* Adds links to cashtag elements in the tweet.
*
* @return string The modified tweet.
*/
public function addLinksToCashtags()
{
return preg_replace_callback(
self::$patterns['valid_cashtag'],
array($this, '_addLinksToCashtags'),
$this->tweet
);
}
/**
* Adds links to URL elements in the tweet.
*
* @return string The modified tweet
*/
public function addLinksToURLs()
{
return preg_replace_callback(self::$patterns['valid_url'], array($this, '_addLinksToURLs'), $this->tweet);
}
/**
* Adds links to username/list elements in the tweet.
*
* @return string The modified tweet.
*/
public function addLinksToUsernamesAndLists()
{
return preg_replace_callback(
self::$patterns['valid_mentions_or_lists'],
array($this, '_addLinksToUsernamesAndLists'),
$this->tweet
);
}
/**
* Wraps a tweet element in an HTML anchor tag using the provided URL.
*
* This is a helper function to perform the generation of the link.
*
* @param string $url The URL to use as the href.
* @param string $class The CSS class(es) to apply (space separated).
* @param string $element The tweet element to wrap.
*
* @return string The tweet element with a link applied.
* @deprecated since version 1.1.0
*/
protected function wrap($url, $class, $element)
{
$link = '<a';
if ($class) {
$link .= ' class="' . $class . '"';
}
$link .= ' href="' . $url . '"';
$rel = array();
if ($this->external) {
$rel[] = 'external';
}
if ($this->nofollow) {
$rel[] = 'nofollow';
}
if (!empty($rel)) {
$link .= ' rel="' . implode(' ', $rel) . '"';
}
if ($this->target) {
$link .= ' target="' . $this->target . '"';
}
$link .= '>' . $element . '</a>';
return $link;
}
/**
* Wraps a tweet element in an HTML anchor tag using the provided URL.
*
* This is a helper function to perform the generation of the hashtag link.
*
* @param string $url The URL to use as the href.
* @param string $class The CSS class(es) to apply (space separated).
* @param string $element The tweet element to wrap.
*
* @return string The tweet element with a link applied.
*/
protected function wrapHash($url, $class, $element)
{
$title = preg_replace('//u', '#', $element);
$link = '<a';
$link .= ' href="' . $url . '"';
$link .= ' title="' . $title . '"';
if ($class) {
$link .= ' class="' . $class . '"';
}
$rel = array();
if ($this->external) {
$rel[] = 'external';
}
if ($this->nofollow) {
$rel[] = 'nofollow';
}
if (!empty($rel)) {
$link .= ' rel="' . implode(' ', $rel) . '"';
}
if ($this->target) {
$link .= ' target="' . $this->target . '"';
}
$link .= '>' . $element . '</a>';
return $link;
}
/**
* Callback used by the method that adds links to hashtags.
*
* @see addLinksToHashtags()
* @param array $matches The regular expression matches.
* @return string The link-wrapped hashtag.
*/
protected function _addLinksToHashtags($matches)
{
list($all, $before, $hash, $tag, $after) = array_pad($matches, 5, '');
if (preg_match(self::$patterns['end_hashtag_match'], $after)
|| (!preg_match('!\A["\']!', $before) && preg_match('!\A["\']!', $after)) || preg_match('!\A</!', $after)) {
return $all;
}
$replacement = $before;
$element = $hash . $tag;
$url = $this->url_base_hash . $tag;
$class_hash = $this->class_hash;
if (preg_match(self::$patterns['rtl_chars'], $element)) {
$class_hash .= ' rtl';
}
$replacement .= $this->wrapHash($url, $class_hash, $element);
return $replacement;
}
/**
* Callback used by the method that adds links to cashtags.
*
* @see addLinksToCashtags()
* @param array $matches The regular expression matches.
* @return string The link-wrapped cashtag.
*/
protected function _addLinksToCashtags($matches)
{
list($all, $before, $cash, $tag, $after) = array_pad($matches, 5, '');
if (preg_match(self::$patterns['end_cashtag_match'], $after)
|| (!preg_match('!\A["\']!', $before) && preg_match('!\A["\']!', $after)) || preg_match('!\A</!', $after)) {
return $all;
}
$replacement = $before;
$element = $cash . $tag;
$url = $this->url_base_cash . $tag;
$replacement .= $this->wrapHash($url, $this->class_cash, $element);
return $replacement;
}
/**
* Callback used by the method that adds links to URLs.
*
* @see addLinksToURLs()
* @param array $matches The regular expression matches.
* @return string The link-wrapped URL.
*/
protected function _addLinksToURLs($matches)
{
list($all, $before, $url, $protocol, $domain, $path, $query) = array_pad($matches, 7, '');
$url = htmlspecialchars($url, ENT_QUOTES, 'UTF-8', false);
if (!$protocol) {
return $all;
}
return $before . $this->wrap($url, $this->class_url, $url);
}
/**
* Callback used by the method that adds links to username/list pairs.
*
* @see addLinksToUsernamesAndLists()
* @param array $matches The regular expression matches.
* @return string The link-wrapped username/list pair.
*/
protected function _addLinksToUsernamesAndLists($matches)
{
list($all, $before, $at, $username, $slash_listname, $after) = array_pad($matches, 6, '');
# If $after is not empty, there is an invalid character.
if (!empty($slash_listname)) {
# Replace the list and username
$element = $username . $slash_listname;
$class = $this->class_list;
$url = $this->url_base_list . $element;
} else {
if (preg_match(self::$patterns['end_mention_match'], $after)) {
return $all;
}
# Replace the username
$element = $username;
$class = $this->class_user;
$url = $this->url_base_user . $element;
}
# XXX: Due to use of preg_replace_callback() for multiple replacements in a
# single tweet and also as only the match is replaced and we have to
# use a look-ahead for $after because there is no equivalent for the
# $' (dollar apostrophe) global from Ruby, we MUST NOT append $after.
return $before . $at . $this->wrap($url, $class, $element);
}
}

337
app/Util/Lexer/Regex.php Executable file

File diff suppressed because one or more lines are too long

View file

@ -17,7 +17,6 @@ class RestrictedNames {
"contact-us",
"contact_us",
"copyright",
"css",
"d",
"dashboard",
"dev",
@ -52,7 +51,6 @@ class RestrictedNames {
"is",
"isatap",
"it",
"js",
"localdomain",
"localhost",
"mail",
@ -118,6 +116,7 @@ class RestrictedNames {
// Static Assets
"assets",
"storage",
// Laravel Horizon
"horizon",
@ -126,19 +125,42 @@ class RestrictedNames {
"account",
"api",
"auth",
"css",
"c",
"i",
"dashboard",
"deck",
"discover",
"docs",
"fonts",
"home",
"img",
"js",
"login",
"logout",
"media",
"p",
"password",
"report",
"reports",
"search",
"settings",
"statuses",
"site",
"sites",
"timeline",
"timelines",
"tour",
"user",
"users",
"vendor",
"400",
"401",
"403",
"404",
"500",
"503",
"504",
];
public static function get()

104
app/Util/Lexer/StringUtils.php Executable file
View file

@ -0,0 +1,104 @@
<?php
/**
* @author Takashi Nojima
* @copyright Copyright 2014, Takashi Nojima
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
*/
namespace App\Util\Lexer;
/**
* String utility
*
* @author Takashi Nojima
* @copyright Copyright 2014, Takashi Nojima
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter
*/
class StringUtils
{
/**
* alias of mb_substr
*
* @param string $str
* @param integer $start
* @param integer $length
* @param string $encoding
* @return string
*/
public static function substr($str, $start, $length = null, $encoding = 'UTF-8')
{
if (is_null($length)) {
// for PHP <= 5.4.7
$length = mb_strlen($str, $encoding);
}
return mb_substr($str, $start, $length, $encoding);
}
/**
* alias of mb_strlen
*
* @param string $str
* @param string $encoding
* @return integer
*/
public static function strlen($str, $encoding = 'UTF-8')
{
return mb_strlen($str, $encoding);
}
/**
* alias of mb_strpos
*
* @param string $haystack
* @param string $needle
* @param integer $offset
* @param string $encoding
* @return integer
*/
public static function strpos($haystack, $needle, $offset = 0, $encoding = 'UTF-8')
{
return mb_strpos($haystack, $needle, $offset, $encoding);
}
/**
* A multibyte-aware substring replacement function.
*
* @param string $string The string to modify.
* @param string $replacement The replacement string.
* @param int $start The start of the replacement.
* @param int $length The number of characters to replace.
* @param string $encoding The encoding of the string.
*
* @return string The modified string.
*
* @see http://www.php.net/manual/en/function.substr-replace.php#90146
*/
public static function substrReplace($string, $replacement, $start, $length = null, $encoding = 'UTF-8')
{
if (extension_loaded('mbstring') === true) {
$string_length = static::strlen($string, $encoding);
if ($start < 0) {
$start = max(0, $string_length + $start);
} elseif ($start > $string_length) {
$start = $string_length;
}
if ($length < 0) {
$length = max(0, $string_length - $start + $length);
} elseif ((is_null($length) === true) || ($length > $string_length)) {
$length = $string_length;
}
if (($start + $length) > $string_length) {
$length = $string_length - $start;
}
$suffixOffset = $start + $length;
$suffixLength = $string_length - $start - $length;
return static::substr($string, 0, $start, $encoding) . $replacement . static::substr($string, $suffixOffset, $suffixLength, $encoding);
}
return (is_null($length) === true) ? substr_replace($string, $replacement, $start) : substr_replace($string, $replacement, $start, $length);
}
}

388
app/Util/Lexer/Validator.php Executable file
View file

@ -0,0 +1,388 @@
<?php
/**
* @author Nick Pope <nick@nickpope.me.uk>
* @copyright Copyright © 2010, Nick Pope
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
*/
namespace App\Util\Lexer;
use App\Util\Lexer\Regex;
use App\Util\Lexer\Extractor;
use App\Util\Lexer\StringUtils;
/**
* Twitter Validator Class
*
* Performs "validation" on tweets.
*
* Originally written by {@link http://github.com/mikenz Mike Cochrane}, this
* is based on code by {@link http://github.com/mzsanford Matt Sanford} and
* heavily modified by {@link http://github.com/ngnpope Nick Pope}.
*
* @author Nick Pope <nick@nickpope.me.uk>
* @copyright Copyright © 2010, Nick Pope
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache License v2.0
* @package Twitter.Text
*/
class Validator extends Regex
{
/**
* The maximum length of a tweet.
*
* @var int
*/
const MAX_LENGTH = 140;
/**
* The length of a short URL beginning with http:
*
* @var int
*/
protected $short_url_length = 23;
/**
* The length of a short URL beginning with http:
*
* @var int
*/
protected $short_url_length_https = 23;
/**
*
* @var Extractor
*/
protected $extractor = null;
/**
* Provides fluent method chaining.
*
* @param string $tweet The tweet to be validated.
* @param mixed $config Setup short URL length from Twitter API /help/configuration response.
*
* @see __construct()
*
* @return Validator
*/
public static function create($tweet = null, $config = null)
{
return new self($tweet, $config);
}
/**
* Reads in a tweet to be parsed and validates it.
*
* @param string $tweet The tweet to validate.
*/
public function __construct($tweet = null, $config = null)
{
parent::__construct($tweet);
if (!empty($config)) {
$this->setConfiguration($config);
}
$this->extractor = Extractor::create();
}
/**
* Setup short URL length from Twitter API /help/configuration response
*
* @param mixed $config
* @return Validator
* @link https://dev.twitter.com/docs/api/1/get/help/configuration
*/
public function setConfiguration($config)
{
if (is_array($config)) {
// setup from array
if (isset($config['short_url_length'])) {
$this->setShortUrlLength($config['short_url_length']);
}
if (isset($config['short_url_length_https'])) {
$this->setShortUrlLengthHttps($config['short_url_length_https']);
}
} elseif (is_object($config)) {
// setup from object
if (isset($config->short_url_length)) {
$this->setShortUrlLength($config->short_url_length);
}
if (isset($config->short_url_length_https)) {
$this->setShortUrlLengthHttps($config->short_url_length_https);
}
}
return $this;
}
/**
* Set the length of a short URL beginning with http:
*
* @param mixed $length
* @return Validator
*/
public function setShortUrlLength($length)
{
$this->short_url_length = intval($length);
return $this;
}
/**
* Get the length of a short URL beginning with http:
*
* @return int
*/
public function getShortUrlLength()
{
return $this->short_url_length;
}
/**
* Set the length of a short URL beginning with https:
*
* @param mixed $length
* @return Validator
*/
public function setShortUrlLengthHttps($length)
{
$this->short_url_length_https = intval($length);
return $this;
}
/**
* Get the length of a short URL beginning with https:
*
* @return int
*/
public function getShortUrlLengthHttps()
{
return $this->short_url_length_https;
}
/**
* Check whether a tweet is valid.
*
* @param string $tweet The tweet to validate.
* @return boolean Whether the tweet is valid.
*/
public function isValidTweetText($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$length = $this->getTweetLength($tweet);
if (!$tweet || !$length) {
return false;
}
if ($length > self::MAX_LENGTH) {
return false;
}
if (preg_match(self::$patterns['invalid_characters'], $tweet)) {
return false;
}
return true;
}
/**
* Check whether a tweet is valid.
*
* @return boolean Whether the tweet is valid.
* @deprecated since version 1.1.0
*/
public function validateTweet()
{
return $this->isValidTweetText();
}
/**
* Check whether a username is valid.
*
* @param string $username The username to validate.
* @return boolean Whether the username is valid.
*/
public function isValidUsername($username = null)
{
if (is_null($username)) {
$username = $this->tweet;
}
$length = StringUtils::strlen($username);
if (empty($username) || !$length) {
return false;
}
$extracted = $this->extractor->extractMentionedScreennames($username);
return count($extracted) === 1 && $extracted[0] === substr($username, 1);
}
/**
* Check whether a username is valid.
*
* @return boolean Whether the username is valid.
* @deprecated since version 1.1.0
*/
public function validateUsername()
{
return $this->isValidUsername();
}
/**
* Check whether a list is valid.
*
* @param string $list The list name to validate.
* @return boolean Whether the list is valid.
*/
public function isValidList($list = null)
{
if (is_null($list)) {
$list = $this->tweet;
}
$length = StringUtils::strlen($list);
if (empty($list) || !$length) {
return false;
}
preg_match(self::$patterns['valid_mentions_or_lists'], $list, $matches);
$matches = array_pad($matches, 5, '');
return isset($matches) && $matches[1] === '' && $matches[4] && !empty($matches[4]) && $matches[5] === '';
}
/**
* Check whether a list is valid.
*
* @return boolean Whether the list is valid.
* @deprecated since version 1.1.0
*/
public function validateList()
{
return $this->isValidList();
}
/**
* Check whether a hashtag is valid.
*
* @param string $hashtag The hashtag to validate.
* @return boolean Whether the hashtag is valid.
*/
public function isValidHashtag($hashtag = null)
{
if (is_null($hashtag)) {
$hashtag = $this->tweet;
}
$length = StringUtils::strlen($hashtag);
if (empty($hashtag) || !$length) {
return false;
}
$extracted = $this->extractor->extractHashtags($hashtag);
return count($extracted) === 1 && $extracted[0] === substr($hashtag, 1);
}
/**
* Check whether a hashtag is valid.
*
* @return boolean Whether the hashtag is valid.
* @deprecated since version 1.1.0
*/
public function validateHashtag()
{
return $this->isValidHashtag();
}
/**
* Check whether a URL is valid.
*
* @param string $url The url to validate.
* @param boolean $unicode_domains Consider the domain to be unicode.
* @param boolean $require_protocol Require a protocol for valid domain?
*
* @return boolean Whether the URL is valid.
*/
public function isValidURL($url = null, $unicode_domains = true, $require_protocol = true)
{
if (is_null($url)) {
$url = $this->tweet;
}
$length = StringUtils::strlen($url);
if (empty($url) || !$length) {
return false;
}
preg_match(self::$patterns['validate_url_unencoded'], $url, $matches);
$match = array_shift($matches);
if (!$matches || $match !== $url) {
return false;
}
list($scheme, $authority, $path, $query, $fragment) = array_pad($matches, 5, '');
# Check scheme, path, query, fragment:
if (($require_protocol && !(
self::isValidMatch($scheme, self::$patterns['validate_url_scheme']) && preg_match('/^https?$/i', $scheme))
) || !self::isValidMatch($path, self::$patterns['validate_url_path']) || !self::isValidMatch($query, self::$patterns['validate_url_query'], true)
|| !self::isValidMatch($fragment, self::$patterns['validate_url_fragment'], true)) {
return false;
}
# Check authority:
$authority_pattern = $unicode_domains ? 'validate_url_unicode_authority' : 'validate_url_authority';
return self::isValidMatch($authority, self::$patterns[$authority_pattern]);
}
/**
* Check whether a URL is valid.
*
* @param boolean $unicode_domains Consider the domain to be unicode.
* @param boolean $require_protocol Require a protocol for valid domain?
*
* @return boolean Whether the URL is valid.
* @deprecated since version 1.1.0
*/
public function validateURL($unicode_domains = true, $require_protocol = true)
{
return $this->isValidURL(null, $unicode_domains, $require_protocol);
}
/**
* Determines the length of a tweet. Takes shortening of URLs into account.
*
* @param string $tweet The tweet to validate.
* @return int the length of a tweet.
*/
public function getTweetLength($tweet = null)
{
if (is_null($tweet)) {
$tweet = $this->tweet;
}
$length = StringUtils::strlen($tweet);
$urls_with_indices = $this->extractor->extractURLsWithIndices($tweet);
foreach ($urls_with_indices as $x) {
$length += $x['indices'][0] - $x['indices'][1];
$length += stripos($x['url'], 'https://') === 0 ? $this->short_url_length_https : $this->short_url_length;
}
return $length;
}
/**
* Determines the length of a tweet. Takes shortening of URLs into account.
*
* @return int the length of a tweet.
* @deprecated since version 1.1.0
*/
public function getLength()
{
return $this->getTweetLength();
}
/**
* A helper function to check for a valid match. Used in URL validation.
*
* @param string $string The subject string to test.
* @param string $pattern The pattern to match against.
* @param boolean $optional Whether a match is compulsory or not.
*
* @return boolean Whether an exact match was found.
*/
protected static function isValidMatch($string, $pattern, $optional = false)
{
$found = preg_match($pattern, $string, $matches);
if (!$optional) {
return (($string || $string === '') && $found && $matches[0] === $string);
} else {
return !(($string || $string === '') && (!$found || $matches[0] !== $string));
}
}
}

View file

@ -103,6 +103,10 @@ class Image {
$ratio = $this->getAspectRatio($file, $thumbnail);
$aspect = $ratio['dimensions'];
$orientation = $ratio['orientation'];
if($media->mime === 'image/gif' && !$thumbnail)
{
return;
}
try {
$img = Intervention::make($file)->orientate();
@ -112,7 +116,8 @@ class Image {
$converted = $this->setBaseName($path, $thumbnail, $img->extension);
$newPath = storage_path('app/'.$converted['path']);
$img->save($newPath, 75);
$quality = config('pixelfed.image_quality');
$img->save($newPath, $quality);
if(!$thumbnail) {
$media->orientation = $orientation;

View file

@ -31,13 +31,9 @@ class Webfinger {
public function generateAliases()
{
$host = parse_url(config('app.url'), PHP_URL_HOST);
$username = $this->user->username;
$url = $this->user->url();
$this->aliases = [
'acct:'.$username.'@'.$host,
$url
$this->user->url(),
$this->user->permalink()
];
return $this;
}
@ -55,24 +51,12 @@ class Webfinger {
[
'rel' => 'http://schemas.google.com/g/2010#updates-from',
'type' => 'application/atom+xml',
'href' => url("/users/{$user->username}.atom")
'href' => $user->permalink('.atom')
],
[
'rel' => 'self',
'type' => 'application/activity+json',
'href' => $user->permalink()
],
[
'rel' => 'magic-public-key',
'href' => null//$user->public_key
],
[
'rel' => 'salmon',
'href' => $user->permalink('/salmon')
],
[
'rel' => 'http://ostatus.org/schema/1.0/subscribe',
'href' => url('/main/ostatussub?profile={uri}')
]
];
return $this;

View file

@ -6,18 +6,23 @@
"type": "project",
"require": {
"php": "^7.1.3",
"99designs/http-signatures-guzzlehttp": "^2.0",
"beyondcode/laravel-self-diagnosis": "^0.4.0",
"bitverse/identicon": "^1.1",
"doctrine/dbal": "^2.7",
"fideloper/proxy": "^4.0",
"greggilbert/recaptcha": "dev-master",
"intervention/image": "^2.4",
"kitetail/zttp": "^0.3.0",
"pixelfed/zttp": "^0.4",
"laravel/framework": "5.6.*",
"laravel/horizon": "^1.2",
"laravel/passport": "^6.0",
"laravel/tinker": "^1.0",
"league/fractal": "^0.17.0",
"moontoast/math": "^1.1",
"phpseclib/phpseclib": "~2.0",
"pixelfed/dotenv-editor": "^2.0",
"pixelfed/fractal": "^0.18.0",
"pixelfed/google2fa-laravel": "^2.0",
"pixelfed/http-signatures-guzzlehttp": "^4.0",
"predis/predis": "^1.1",
"spatie/laravel-backup": "^5.0.0",
"spatie/laravel-image-optimizer": "^1.1",
@ -25,6 +30,7 @@
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.1",
"beyondcode/laravel-er-diagram-generator": "^0.2.2",
"filp/whoops": "^2.0",
"fzaninotto/faker": "^1.4",
"mockery/mockery": "^1.0",

2148
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -65,7 +65,7 @@ return [
|
*/
'timezone' => 'UTC',
'timezone' => env('APP_TIMEZONE', 'UTC'),
/*
|--------------------------------------------------------------------------
@ -78,7 +78,7 @@ return [
|
*/
'locale' => 'en',
'locale' => env('APP_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
@ -91,7 +91,7 @@ return [
|
*/
'fallback_locale' => 'en',
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
@ -151,6 +151,7 @@ return [
* Package Service Providers...
*/
Greggilbert\Recaptcha\RecaptchaServiceProvider::class,
Jackiedo\DotenvEditor\DotenvEditorServiceProvider::class,
/*
* Application Service Providers...
@ -211,6 +212,7 @@ return [
'View' => Illuminate\Support\Facades\View::class,
'Recaptcha' => Greggilbert\Recaptcha\Facades\Recaptcha::class,
'DotenvEditor' => Jackiedo\DotenvEditor\Facades\DotenvEditor::class,
],
];

27
config/dotenv-editor.php Normal file
View file

@ -0,0 +1,27 @@
<?php
return array(
/*
|----------------------------------------------------------------------
| Auto backup mode
|----------------------------------------------------------------------
|
| This value is used when you save your file content. If value is true,
| the original file will be backed up before save.
*/
'autoBackup' => true,
/*
|----------------------------------------------------------------------
| Backup location
|----------------------------------------------------------------------
|
| This value is used when you backup your file. This value is the sub
| path from root folder of project application.
*/
'backupPath' => base_path('storage/dotenv-editor/backups/')
);

View file

@ -76,7 +76,7 @@ return [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'simple',
'processes' => 10,
'processes' => 20,
'tries' => 3,
],
],

View file

@ -49,5 +49,5 @@ return [
* If set to `true` all output of the optimizer binaries will be appended to the default log.
* You can also set this to a class that implements `Psr\Log\LoggerInterface`.
*/
'log_optimizer_activity' => true,
'log_optimizer_activity' => false,
];

View file

@ -23,7 +23,7 @@ return [
| This value is the version of your PixelFed instance.
|
*/
'version' => '0.1.0',
'version' => '0.1.7',
/*
|--------------------------------------------------------------------------
@ -77,6 +77,17 @@ return [
'activitypub_enabled' => env('ACTIVITY_PUB', false),
/*
|--------------------------------------------------------------------------
| Account file size limit
|--------------------------------------------------------------------------
|
| Update the max account size, the per user limit of files in KB.
|
|
*/
'max_account_size' => env('MAX_ACCOUNT_SIZE', 1000000),
/*
|--------------------------------------------------------------------------
| Photo file size limit
@ -117,4 +128,34 @@ return [
*/
'max_name_length' => env('MAX_NAME_LENGTH', 30),
/*
|--------------------------------------------------------------------------
| Album size limit
|--------------------------------------------------------------------------
|
| The max number of photos allowed per post.
|
*/
'max_album_length' => env('MAX_ALBUM_LENGTH', 4),
/*
|--------------------------------------------------------------------------
| Email Verification
|--------------------------------------------------------------------------
|
| Require email verification before a new user can do anything.
|
*/
'enforce_email_verification' => env('ENFORCE_EMAIL_VERIFICATION', true),
/*
|--------------------------------------------------------------------------
| Image Quality
|--------------------------------------------------------------------------
|
| Set the image optimization quality, must be a value between 1-100.
|
*/
'image_quality' => (int) env('IMAGE_QUALITY', 80),
];

View file

@ -7,7 +7,7 @@ server {
root /var/www/html/public;
location / {
try_files $uri $uri/ /index.php;
try_files $uri $uri/ /$is_args$args;
}
location ~ \.php$ {

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateWebSubsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('web_subs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('follower_id')->unsigned()->index();
$table->bigInteger('following_id')->unsigned()->index();
$table->string('profile_url')->index();
$table->timestamp('approved_at')->nullable();
$table->unique(['follower_id', 'following_id', 'profile_url']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('web_subs');
}
}

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateImportJobsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('import_jobs', function (Blueprint $table) {
$table->increments('id');
$table->bigInteger('profile_id')->unsigned();
$table->string('service')->default('instagram');
$table->string('uuid')->nullable();
$table->string('storage_path')->nullable();
$table->tinyInteger('stage')->unsigned()->default(0);
$table->text('media_json')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('import_jobs');
}
}

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateMentionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('mentions', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('status_id')->unsigned();
$table->bigInteger('profile_id')->unsigned();
$table->boolean('local')->default(true);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('mentions');
}
}

View file

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

View file

@ -0,0 +1,58 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddSoftDeletesToModels extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('avatars', function ($table) {
$table->softDeletes();
});
Schema::table('likes', function ($table) {
$table->softDeletes();
});
Schema::table('media', function ($table) {
$table->softDeletes();
});
Schema::table('mentions', function ($table) {
$table->softDeletes();
});
Schema::table('notifications', function ($table) {
$table->softDeletes();
});
Schema::table('profiles', function ($table) {
$table->softDeletes();
});
Schema::table('statuses', function ($table) {
$table->softDeletes();
});
Schema::table('users', function ($table) {
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
}

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateEmailVerificationsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('email_verifications', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('user_id')->unsigned();
$table->string('email')->nullable();
$table->string('user_token')->index();
$table->string('random_token')->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('email_verifications');
}
}

View file

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

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateReportLogsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('report_logs', function (Blueprint $table) {
$table->increments('id');
$table->bigInteger('profile_id')->unsigned();
$table->bigInteger('item_id')->unsigned()->nullable();
$table->string('item_type')->nullable();
$table->string('action')->nullable();
$table->boolean('system_message')->default(false);
$table->json('metadata')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('report_logs');
}
}

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateAccountLogsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('account_logs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('user_id')->unsigned()->index();
$table->bigInteger('item_id')->unsigned()->nullable();
$table->string('item_type')->nullable();
$table->string('action')->nullable();
$table->string('message')->nullable();
$table->string('link')->nullable();
$table->string('ip_address')->nullable();
$table->string('user_agent')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('account_logs');
}
}

View file

@ -0,0 +1,50 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateUserSettingsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_settings', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('user_id')->unsigned()->unique();
$table->string('role')->default('user');
$table->boolean('crawlable')->default(true);
$table->boolean('show_guests')->default(true);
$table->boolean('show_discover')->default(true);
$table->boolean('public_dm')->default(false);
$table->boolean('hide_cw_search')->default(true);
$table->boolean('hide_blocked_search')->default(true);
$table->boolean('always_show_cw')->default(false);
$table->boolean('compose_media_descriptions')->default(false);
$table->boolean('reduce_motion')->default(false);
$table->boolean('optimize_screen_reader')->default(false);
$table->boolean('high_contrast_mode')->default(false);
$table->boolean('video_autoplay')->default(false);
$table->boolean('send_email_new_follower')->default(false);
$table->boolean('send_email_new_follower_request')->default(true);
$table->boolean('send_email_on_share')->default(false);
$table->boolean('send_email_on_like')->default(false);
$table->boolean('send_email_on_mention')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_settings');
}
}

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class Add2faToUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('2fa_enabled')->default(false);
$table->string('2fa_secret')->nullable();
$table->json('2fa_backup_codes')->nullable();
$table->timestamp('2fa_setup_at')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('2fa_enabled');
$table->dropColumn('2fa_secret');
$table->dropColumn('2fa_backup_codes');
$table->dropColumn('2fa_setup_at');
});
}
}

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateUserFiltersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_filters', function (Blueprint $table) {
$table->bigIncrements('id');
$table->bigInteger('user_id')->unsigned()->index();
$table->bigInteger('filterable_id')->unsigned();
$table->string('filterable_type');
$table->string('filter_type')->default('block')->index();
$table->unique([
'user_id',
'filterable_id',
'filterable_type',
'filter_type'
], 'filter_unique');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_filters');
}
}

View file

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

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class UpdateSettingsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('user_settings', function (Blueprint $table) {
$table->boolean('show_profile_followers')->default(true);
$table->boolean('show_profile_follower_count')->default(true);
$table->boolean('show_profile_following')->default(true);
$table->boolean('show_profile_following_count')->default(true);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$table->dropColumn('show_profile_followers');
$table->dropColumn('show_profile_follower_count');
$table->dropColumn('show_profile_following');
$table->dropColumn('show_profile_following_count');
}
}

View file

@ -46,7 +46,7 @@ services:
- "mysql-data:/var/lib/mysql"
redis:
image: redis:alpine
image: redis:4-alpine
volumes:
- "redis-data:/data"
networks:

BIN
public/css/app.css vendored

Binary file not shown.

BIN
public/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

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