Selaa lähdekoodia

Merge pull request #5651 from pixelfed/staging

App Register API
daniel 5 kuukautta sitten
vanhempi
commit
925176aca4

+ 1 - 0
app/Http/Controllers/Api/ApiV1Controller.php

@@ -1720,6 +1720,7 @@ class ApiV1Controller extends Controller
                 'approval_required' => (bool) config_cache('instance.curated_registration.enabled'),
                 'approval_required' => (bool) config_cache('instance.curated_registration.enabled'),
                 'contact_account' => $contact,
                 'contact_account' => $contact,
                 'rules' => $rules,
                 'rules' => $rules,
+                'mobile_registration' => config('auth.in_app_registration'),
                 'configuration' => [
                 'configuration' => [
                     'media_attachments' => [
                     'media_attachments' => [
                         'image_matrix_limit' => 16777216,
                         'image_matrix_limit' => 16777216,

+ 243 - 0
app/Http/Controllers/AppRegisterController.php

@@ -0,0 +1,243 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Mail\InAppRegisterEmailVerify;
+use App\Models\AppRegister;
+use App\Services\AccountService;
+use App\User;
+use App\Util\Lexer\RestrictedNames;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Str;
+use Laravel\Passport\RefreshTokenRepository;
+use Purify;
+
+class AppRegisterController extends Controller
+{
+    public function index(Request $request)
+    {
+        abort_unless(config('auth.in_app_registration'), 404);
+        $open = (bool) config_cache('pixelfed.open_registration');
+        if (! $open || $request->user()) {
+            return redirect('/');
+        }
+
+        return view('auth.iar');
+    }
+
+    public function store(Request $request)
+    {
+        abort_unless(config('auth.in_app_registration'), 404);
+        $open = (bool) config_cache('pixelfed.open_registration');
+        if (! $open || $request->user()) {
+            return redirect('/');
+        }
+
+        $rules = [
+            'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|unique:app_registers,email',
+        ];
+
+        if ((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register')) {
+            $rules['h-captcha-response'] = 'required|captcha';
+        }
+
+        $this->validate($request, $rules);
+
+        $email = strtolower($request->input('email'));
+        $code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
+
+        DB::beginTransaction();
+
+        $exists = AppRegister::whereEmail($email)->where('created_at', '>', now()->subHours(24))->count();
+
+        if ($exists && $exists > 3) {
+            $errorParams = http_build_query([
+                'status' => 'error',
+                'message' => 'Too many attempts, please try again later.',
+            ]);
+            DB::rollBack();
+            return redirect()->away("pixelfed://verifyEmail?{$errorParams}");
+        }
+
+        $registration = AppRegister::create([
+            'email' => $email,
+            'verify_code' => $code,
+            'email_delivered_at' => now(),
+        ]);
+
+        try {
+            Mail::to($email)->send(new InAppRegisterEmailVerify($code));
+        } catch (\Exception $e) {
+            DB::rollBack();
+            $errorParams = http_build_query([
+                'status' => 'error',
+                'message' => 'Failed to send verification code',
+            ]);
+
+            return redirect()->away("pixelfed://verifyEmail?{$errorParams}");
+        }
+
+        DB::commit();
+
+        $queryParams = http_build_query([
+            'email' => $request->email,
+            'expires_in' => 3600,
+            'status' => 'success',
+        ]);
+
+        return redirect()->away("pixelfed://verifyEmail?{$queryParams}");
+    }
+
+    public function verifyCode(Request $request)
+    {
+        abort_unless(config('auth.in_app_registration'), 404);
+        $open = (bool) config_cache('pixelfed.open_registration');
+        if (! $open || $request->user()) {
+            return redirect('/');
+        }
+
+        $this->validate($request, [
+            'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email',
+            'verify_code' => ['required', 'digits:6', 'numeric'],
+        ]);
+
+        $email = strtolower($request->input('email'));
+        $code = $request->input('verify_code');
+
+        $exists = AppRegister::whereEmail($email)
+            ->whereVerifyCode($code)
+            ->where('created_at', '>', now()->subMinutes(60))
+            ->exists();
+
+        return response()->json([
+            'status' => $exists ? 'success' : 'error',
+        ]);
+    }
+
+    public function onboarding(Request $request)
+    {
+        abort_unless(config('auth.in_app_registration'), 404);
+        $open = (bool) config_cache('pixelfed.open_registration');
+        if (! $open || $request->user()) {
+            return redirect('/');
+        }
+
+        $this->validate($request, [
+            'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email',
+            'verify_code' => ['required', 'digits:6', 'numeric'],
+            'username' => $this->validateUsernameRule(),
+            'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'),
+            'password' => 'required|string|min:'.config('pixelfed.min_password_length'),
+        ]);
+
+        $email = strtolower($request->input('email'));
+        $code = $request->input('verify_code');
+        $username = $request->input('username');
+        $name = $request->input('name');
+        $password = $request->input('password');
+
+        $exists = AppRegister::whereEmail($email)
+            ->whereVerifyCode($code)
+            ->where('created_at', '>', now()->subMinutes(60))
+            ->exists();
+
+        if (! $exists) {
+            return response()->json([
+                'status' => 'error',
+                'message' => 'Invalid verification code, please try again later.',
+            ]);
+        }
+
+        $user = User::create([
+            'name' => Purify::clean($name),
+            'username' => $username,
+            'email' => $email,
+            'password' => Hash::make($password),
+            'app_register_ip' => request()->ip(),
+            'register_source' => 'app',
+            'email_verified_at' => now(),
+        ]);
+
+        sleep(random_int(5,10));
+        $user = User::findOrFail($user->id);
+        $token = $user->createToken('Pixelfed App', ['read', 'write', 'follow', 'push']);
+        $tokenModel = $token->token;
+        $clientId = $tokenModel->client_id;
+        $clientSecret = DB::table('oauth_clients')->where('id', $clientId)->value('secret');
+        $refreshTokenRepo = app(RefreshTokenRepository::class);
+        $refreshToken = $refreshTokenRepo->create([
+            'id' => Str::random(80),
+            'access_token_id' => $tokenModel->id,
+            'revoked' => false,
+            'expires_at' => now()->addDays(config('instance.oauth.refresh_expiration', 400)),
+        ]);
+
+        $expiresAt = $tokenModel->expires_at ?? now()->addDays(config('instance.oauth.token_expiration', 356));
+        $expiresIn = now()->diffInSeconds($expiresAt);
+
+        return response()->json([
+            'status' => 'success',
+            'token_type' => 'Bearer',
+            'domain' => config('pixelfed.domain.app'),
+            'expires_in' => $expiresIn,
+            'access_token' => $token->accessToken,
+            'refresh_token' => $refreshToken->id,
+            'client_id' => $clientId,
+            'client_secret' => $clientSecret,
+            'scope' => ['read', 'write', 'follow', 'push'],
+            'user' => [
+                'pid' => (string) $user->profile_id,
+                'username' => $user->username,
+            ],
+            'account' => AccountService::get($user->profile_id, true),
+        ]);
+    }
+
+    protected function validateUsernameRule()
+    {
+        return [
+            'required',
+            'min:2',
+            'max:30',
+            'unique:users',
+            function ($attribute, $value, $fail) {
+                $dash = substr_count($value, '-');
+                $underscore = substr_count($value, '_');
+                $period = substr_count($value, '.');
+
+                if (ends_with($value, ['.php', '.js', '.css'])) {
+                    return $fail('Username is invalid.');
+                }
+
+                if (($dash + $underscore + $period) > 1) {
+                    return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
+                }
+
+                if (! ctype_alnum($value[0])) {
+                    return $fail('Username is invalid. Must start with a letter or number.');
+                }
+
+                if (! ctype_alnum($value[strlen($value) - 1])) {
+                    return $fail('Username is invalid. Must end with a letter or number.');
+                }
+
+                $val = str_replace(['_', '.', '-'], '', $value);
+                if (! ctype_alnum($val)) {
+                    return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
+                }
+
+                if (! preg_match('/[a-zA-Z]/', $value)) {
+                    return $fail('Username is invalid. Must contain at least one alphabetical character.');
+                }
+
+                $restricted = RestrictedNames::get();
+                if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
+                    return $fail('Username cannot be used.');
+                }
+            },
+        ];
+    }
+}

+ 55 - 0
app/Mail/InAppRegisterEmailVerify.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Mail;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Mail\Mailable;
+use Illuminate\Mail\Mailables\Content;
+use Illuminate\Mail\Mailables\Envelope;
+use Illuminate\Queue\SerializesModels;
+
+class InAppRegisterEmailVerify extends Mailable
+{
+    use Queueable, SerializesModels;
+
+    public $code;
+
+    /**
+     * Create a new message instance.
+     */
+    public function __construct($code)
+    {
+        $this->code = $code;
+    }
+
+    /**
+     * Get the message envelope.
+     */
+    public function envelope(): Envelope
+    {
+        return new Envelope(
+            subject: config('pixelfed.domain.app') . __('auth.verifyYourEmailAddress'),
+        );
+    }
+
+    /**
+     * Get the message content definition.
+     */
+    public function content(): Content
+    {
+        return new Content(
+            markdown: 'emails.iar.email_verify',
+        );
+    }
+
+    /**
+     * Get the attachments for the message.
+     *
+     * @return array<int, \Illuminate\Mail\Mailables\Attachment>
+     */
+    public function attachments(): array
+    {
+        return [];
+    }
+}

+ 10 - 0
app/Models/AppRegister.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class AppRegister extends Model
+{
+    protected $guarded = [];
+}

+ 11 - 0
app/Providers/AppServiceProvider.php

@@ -30,10 +30,13 @@ use Horizon;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Pagination\Paginator;
 use Illuminate\Pagination\Paginator;
 use Illuminate\Support\Facades\Gate;
 use Illuminate\Support\Facades\Gate;
+use Illuminate\Cache\RateLimiting\Limit;
+use Illuminate\Support\Facades\RateLimiter;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\ServiceProvider;
 use Laravel\Pulse\Facades\Pulse;
 use Laravel\Pulse\Facades\Pulse;
+use Illuminate\Http\Request;
 use URL;
 use URL;
 
 
 class AppServiceProvider extends ServiceProvider
 class AppServiceProvider extends ServiceProvider
@@ -85,6 +88,14 @@ class AppServiceProvider extends ServiceProvider
             ];
             ];
         });
         });
 
 
+        RateLimiter::for('app-signup', function (Request $request) {
+            return Limit::perDay(10)->by($request->ip());
+        });
+
+        RateLimiter::for('app-code-verify', function (Request $request) {
+            return Limit::perHour(10)->by($request->ip());
+        });
+
         // Model::preventLazyLoading(true);
         // Model::preventLazyLoading(true);
     }
     }
 
 

+ 1 - 0
app/Util/Site/Config.php

@@ -82,6 +82,7 @@ class Config
                         'network' => (bool) config('federation.network_timeline'),
                         'network' => (bool) config('federation.network_timeline'),
                     ],
                     ],
                     'mobile_apis' => (bool) config_cache('pixelfed.oauth_enabled'),
                     'mobile_apis' => (bool) config_cache('pixelfed.oauth_enabled'),
+                    'mobile_registration' => config('auth.in_app_registration'),
                     'stories' => (bool) config_cache('instance.stories.enabled'),
                     'stories' => (bool) config_cache('instance.stories.enabled'),
                     'video' => Str::contains(config_cache('pixelfed.media_types'), 'video/mp4'),
                     'video' => Str::contains(config_cache('pixelfed.media_types'), 'video/mp4'),
                     'import' => [
                     'import' => [

+ 1 - 0
app/Util/Site/Nodeinfo.php

@@ -21,6 +21,7 @@ class Nodeinfo
             $statuses = InstanceService::totalLocalStatuses();
             $statuses = InstanceService::totalLocalStatuses();
 
 
             $features = ['features' => \App\Util\Site\Config::get()['features']];
             $features = ['features' => \App\Util\Site\Config::get()['features']];
+            unset($features['features']['hls']);
 
 
             return [
             return [
                 'metadata' => [
                 'metadata' => [

+ 1 - 0
config/auth.php

@@ -112,4 +112,5 @@ return [
         ],
         ],
     ],
     ],
 
 
+    'in_app_registration' => (bool) env('APP_REGISTER', true),
 ];
 ];

+ 32 - 0
database/migrations/2025_01_28_102016_create_app_registers_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('app_registers', function (Blueprint $table) {
+            $table->id();
+            $table->string('email');
+            $table->string('verify_code');
+            $table->unique(['email', 'verify_code']);
+            $table->timestamp('email_delivered_at')->nullable();
+            $table->timestamp('email_verified_at')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('app_registers');
+    }
+};

+ 1 - 1
resources/lang/en/auth.php

@@ -15,5 +15,5 @@ return [
 
 
     'failed'   => 'These credentials do not match our records.',
     'failed'   => 'These credentials do not match our records.',
     'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
     'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
-
+    'verifyYourEmailAddress' => ' - Verify Your Email Address',
 ];
 ];

+ 143 - 0
resources/views/auth/iar.blade.php

@@ -0,0 +1,143 @@
+@extends('layouts.blank')
+
+@section('content')
+    <div class="container">
+        <div class="row min-vh-100 align-items-center justify-content-center">
+            <div class="col-12 col-md-6 col-lg-5">
+                <div class="text-center mb-5">
+                    <img src="/img/pixelfed-icon-white.svg" width="90">
+                </div>
+
+                <div class="card shadow-sm">
+                    <div class="card-body p-4">
+                        <h2 class="text-center">Join Pixelfed</h2>
+                        <p class="lead text-center mb-4">Enter Your Email</p>
+
+                        @if ($errors->any())
+                            <div class="alert alert-danger">
+                                <ul class="mb-0">
+                                    @foreach ($errors->all() as $error)
+                                        <li>{{ $error }}</li>
+                                    @endforeach
+                                </ul>
+                            </div>
+                        @endif
+
+                        <form method="POST">
+                            @csrf
+
+                            <div class="form-group">
+                                <label for="email">Email address</label>
+                                <input type="email"
+                                       class="form-control @error('email') is-invalid @enderror"
+                                       id="email"
+                                       name="email"
+                                       required
+                                       autocomplete="email">
+                                @error('email')
+                                    <div class="invalid-feedback">{{ $message }}</div>
+                                @enderror
+                            </div>
+
+                            <div class="form-group text-center">
+                                {!! Captcha::display() !!}
+                            </div>
+
+                            <button type="submit" class="btn btn-primary btn-block">
+                                Send Verification Code
+                            </button>
+                        </form>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+@endsection
+
+@push('styles')
+    <style>
+        :root {
+            --bg-color: #111827;
+            --card-bg: #1f2937;
+            --text-color: #f3f4f6;
+            --text-muted: #9ca3af;
+            --input-bg: #374151;
+            --input-border: #4b5563;
+            --input-focus: #3b82f6;
+            --card-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3);
+        }
+
+        body {
+            background-color: var(--bg-color);
+            color: var(--text-color);
+            transition: background-color 0.3s, color 0.3s;
+            min-height: 100vh;
+            display: flex;
+            align-items: center;
+        }
+
+        .card {
+            background-color: var(--card-bg);
+            border: none;
+            border-radius: 1rem;
+            box-shadow: var(--card-shadow);
+        }
+
+        .benefits-list {
+            color: var(--text-muted);
+        }
+
+        .benefits-list i {
+            color: #3b82f6;
+            margin-right: 0.5rem;
+        }
+
+        .form-control {
+            background-color: var(--input-bg);
+            border-color: var(--input-border);
+            color: var(--text-color);
+            border-radius: 0.5rem;
+            padding: 0.75rem 1rem;
+            transition: all 0.2s;
+        }
+
+        .form-control:focus {
+            background-color: var(--input-bg);
+            border-color: var(--input-focus);
+            color: var(--text-color);
+            box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
+        }
+
+        .btn-primary {
+            padding: 0.75rem 1.5rem;
+            border-radius: 0.5rem;
+            font-weight: 500;
+            transition: transform 0.2s;
+            background-color: #3b82f6;
+            border-color: #3b82f6;
+        }
+
+        .btn-primary:hover {
+            transform: translateY(-1px);
+            background-color: #2563eb;
+            border-color: #2563eb;
+        }
+
+        .form-group label {
+            font-weight: 500;
+            margin-bottom: 0.5rem;
+        }
+
+        @media (prefers-color-scheme: dark) {
+            a {
+                color: #60a5fa;
+            }
+            a:hover {
+                color: #93c5fd;
+            }
+            .card {
+                border: 1px solid rgba(255, 255, 255, 0.1);
+            }
+        }
+    </style>
+@endpush

+ 31 - 0
resources/views/emails/iar/email_verify.blade.php

@@ -0,0 +1,31 @@
+@component('mail::message')
+<div class="otcontainer">
+
+## Verify Your Email Address
+        
+<p class="ottext">
+    Hello,
+</p>
+
+<p class="ottext">
+    Thank you for signing up to {{config('pixelfed.domain.app')}}!
+</p>
+
+<p class="ottext">
+    To complete your registration, please enter the following verification code:
+</p>
+
+<div class="otcode">
+    {{ $code }}
+</div>
+
+<p class="ottext">
+This code will expire in 60 minutes. If you didn't request this verification, please ignore this email.
+</p>
+
+<div class="otfooter">
+<p>If you're having trouble with the verification code, please contact our <a href="{{route('site.help')}}">support team</a>.</p>
+</div>
+
+</div>
+@endcomponent

+ 31 - 0
resources/views/vendor/mail/html/themes/default.css

@@ -290,3 +290,34 @@ img {
     font-size: 15px;
     font-size: 15px;
     text-align: center;
     text-align: center;
 }
 }
+
+.otcontainer {
+    padding: 2rem;
+    max-width: 600px;
+    margin: 0 auto;
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+}
+
+.otcode {
+    letter-spacing: 0.5rem;
+    font-size: 2rem;
+    font-weight: bold;
+    background-color: #f3f4f6;
+    padding: 1rem 2rem;
+    border-radius: 0.5rem;
+    margin: 2rem 0;
+    text-align: center;
+    color: #000000;
+}
+
+.ottext {
+    color: #374151;
+    line-height: 1.6;
+    margin: 1rem 0;
+}
+
+.otfooter {
+    margin-top: 2rem;
+    font-size: 0.875rem;
+    color: #6b7280;
+}

+ 2 - 0
routes/api.php

@@ -17,6 +17,8 @@ Route::get('.well-known/host-meta', 'FederationController@hostMeta')->name('well
 Route::redirect('.well-known/change-password', '/settings/password');
 Route::redirect('.well-known/change-password', '/settings/password');
 Route::get('api/nodeinfo/2.0.json', 'FederationController@nodeinfo');
 Route::get('api/nodeinfo/2.0.json', 'FederationController@nodeinfo');
 Route::get('api/service/health-check', 'HealthCheckController@get');
 Route::get('api/service/health-check', 'HealthCheckController@get');
+Route::post('api/auth/app-code-verify', 'AppRegisterController@verifyCode')->middleware('throttle:app-code-verify');
+Route::post('api/auth/onboarding', 'AppRegisterController@onboarding')->middleware('throttle:app-code-verify');
 
 
 Route::prefix('api/v0/groups')->middleware($middleware)->group(function () {
 Route::prefix('api/v0/groups')->middleware($middleware)->group(function () {
     Route::get('config', 'Groups\GroupsApiController@getConfig');
     Route::get('config', 'Groups\GroupsApiController@getConfig');

+ 3 - 0
routes/web.php

@@ -139,6 +139,9 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
     Route::get('discover/places/{id}/{slug}', 'PlaceController@show');
     Route::get('discover/places/{id}/{slug}', 'PlaceController@show');
     Route::get('discover/location/country/{country}', 'PlaceController@directoryCities');
     Route::get('discover/location/country/{country}', 'PlaceController@directoryCities');
 
 
+    Route::get('/i/app-email-verify', 'AppRegisterController@index');
+    Route::post('/i/app-email-verify', 'AppRegisterController@store')->middleware('throttle:app-signup');
+
     Route::group(['prefix' => 'i'], function () {
     Route::group(['prefix' => 'i'], function () {
         Route::redirect('/', '/');
         Route::redirect('/', '/');
         Route::get('compose', 'StatusController@compose')->name('compose');
         Route::get('compose', 'StatusController@compose')->name('compose');