6 Commits
Author SHA1 Message Date
Mark Gregory 881c7433c8 cod consolidation and style updates 2026-09-28 16:27:50 +01:00
Mark Gregory ed11b65f11 cod consolidation and style updates 2026-09-28 16:24:16 +01:00
Mark Gregory 627f578cd8 cod consolidation and style updates 2026-09-10 09:42:16 +01:00
Mark Gregory 8dd7d76b8e cod consolidation and style updates 2026-09-10 09:41:59 +01:00
Mark Gregory 2648a8f5b5 cod consolidation 2026-09-10 09:41:32 +01:00
Mark Gregory 7037ad983a new front end 2026-09-10 09:23:56 +01:00
128 changed files with 632 additions and 197 deletions
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+29 -3
View File
@@ -1,10 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
final class RsyncStudioAdminCreate extends Command {
protected $signature='rsync-studio:admin {email} {--name=Administrator}'; protected $description='Create or promote an Rsync Studio licensing administrator';
public function handle(): int { $email=strtolower(trim($this->argument('email'))); $password=Str::password(24,true,true,false,false); $u=User::firstOrNew(['email'=>$email]); $u->name=$this->option('name'); $u->is_admin=true; $u->email_verified_at=now(); if(!$u->exists){$u->password=Hash::make($password);} $u->save(); $this->info("Administrator ready: {$email}"); if(!$u->wasRecentlyCreated){$this->line('Existing password retained.');}else{$this->warn("Temporary password (shown once): {$password}");} return self::SUCCESS; }
final class RsyncStudioAdminCreate extends Command
{
protected $signature = 'rsync-studio:admin {email} {--name=Administrator}';
protected $description = 'Create or promote an Rsync Studio licensing administrator';
public function handle(): int
{
$email = strtolower(trim($this->argument('email')));
$password = Str::password(24, true, true, false, false);
$u = User::firstOrNew(['email' => $email]);
$u->name = $this->option('name');
$u->is_admin = true;
$u->email_verified_at = now();
if (! $u->exists) {
$u->password = Hash::make($password);
} $u->save();
$this->info("Administrator ready: {$email}");
if (! $u->wasRecentlyCreated) {
$this->line('Existing password retained.');
} else {
$this->warn("Temporary password (shown once): {$password}");
}
return self::SUCCESS;
}
}
+21 -2
View File
@@ -1,10 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Illuminate\Console\Command;
class RsyncStudioLicenseIssue extends Command
{
protected $signature='rsync-studio:license:issue {product} {--name=} {--email=}'; protected $description='Issue a manual Rsync Studio licence key';
public function handle(LicenseIssuer $issuer): int { $p=LicenseProduct::where('code',$this->argument('product'))->firstOrFail(); [$l,$key]=$issuer->issue($p,$this->option('name')?:null,$this->option('email')?:null); if(!$key){$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');return self::FAILURE;} $this->line('Licence ID: '.$l->id);$this->line('Licence key: '.$key);$this->warn('This is the only time the plaintext key is shown.');return self::SUCCESS; }
protected $signature = 'rsync-studio:license:issue {product} {--name=} {--email=}';
protected $description = 'Issue a manual Rsync Studio licence key';
public function handle(LicenseIssuer $issuer): int
{
$p = LicenseProduct::where('code', $this->argument('product'))->firstOrFail();
[$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null);
if (! $key) {
$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');
return self::FAILURE;
} $this->line('Licence ID: '.$l->id);
$this->line('Licence key: '.$key);
$this->warn('This is the only time the plaintext key is shown.');
return self::SUCCESS;
}
}
+15 -2
View File
@@ -1,9 +1,22 @@
<?php
namespace App\Console\Commands;
use App\Models\License;
use Illuminate\Console\Command;
class RsyncStudioLicenseRevoke extends Command
{
protected $signature='rsync-studio:license:revoke {id} {--reason=Administrative revocation}'; protected $description='Revoke a Rsync Studio licence';
public function handle(): int { $l=License::findOrFail($this->argument('id'));$l->update(['revoked_at'=>now(),'revoke_reason'=>$this->option('reason')]);$this->info('Licence revoked.');return self::SUCCESS; }
protected $signature = 'rsync-studio:license:revoke {id} {--reason=Administrative revocation}';
protected $description = 'Revoke a Rsync Studio licence';
public function handle(): int
{
$l = License::findOrFail($this->argument('id'));
$l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]);
$this->info('Licence revoked.');
return self::SUCCESS;
}
}
+14 -3
View File
@@ -1,10 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Models\LicenseProduct;
use Illuminate\Console\Command;
class RsyncStudioProductCreate extends Command
{
protected $signature='rsync-studio:product {code} {--name=Rsync Studio} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
protected $description='Create or update a Rsync Studio licensing product';
public function handle(): int { $p=LicenseProduct::updateOrCreate(['code'=>$this->argument('code')],['name'=>$this->option('name'),'edition'=>$this->option('edition'),'activation_limit'=>(int)$this->option('activations'),'max_major_version'=>$this->option('max-major')!==''?(int)$this->option('max-major'):null,'payment_provider'=>$this->option('provider')?:null,'external_product_id'=>$this->option('product-id')?:null,'external_price_id'=>$this->option('price-id')?:null,'active'=>true]); $this->info("Product {$p->code} ready."); return self::SUCCESS; }
protected $signature = 'rsync-studio:product {code} {--name=Rsync Studio} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
protected $description = 'Create or update a Rsync Studio licensing product';
public function handle(): int
{
$p = LicenseProduct::updateOrCreate(['code' => $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]);
$this->info("Product {$p->code} ready.");
return self::SUCCESS;
}
}
+42 -7
View File
@@ -1,14 +1,49 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Models\LicenseActivation;
use App\Models\User;
use Illuminate\Http\Request;
final class AdminController extends Controller {
public function index(){ return view('admin.index',['licenseCount'=>License::count(),'activeCount'=>License::whereNull('revoked_at')->count(),'activationCount'=>\App\Models\LicenseActivation::whereNull('deactivated_at')->count(),'userCount'=>User::count()]); }
public function licenses(Request $r){ $q=License::with('product')->latest(); if($s=trim((string)$r->query('q'))) $q->where(fn($x)=>$x->where('customer_email','like',"%{$s}%")->orWhere('customer_name','like',"%{$s}%")->orWhere('license_key_last4','like',"%{$s}%")); return view('admin.licenses',['licenses'=>$q->paginate(50)->withQueryString()]); }
public function show(License $license){$license->load(['product','activations']); return view('admin.license',compact('license'));}
public function revoke(Request $request,License $license){$data=$request->validate(['reason'=>['nullable','string','max:255']]);$license->update(['revoked_at'=>now(),'revoke_reason'=>$data['reason']?:'Revoked by administrator']);return back()->with('status','Licence revoked.');}
public function restore(License $license){$license->update(['revoked_at'=>null,'revoke_reason'=>null]);return back()->with('status','Licence restored.');}
final class AdminController extends Controller
{
public function index()
{
return view('admin.index', ['licenseCount' => License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => LicenseActivation::whereNull('deactivated_at')->count(), 'userCount' => User::count()]);
}
public function licenses(Request $r)
{
$q = License::with('product')->latest();
if ($s = trim((string) $r->query('q'))) {
$q->where(fn ($x) => $x->where('customer_email', 'like', "%{$s}%")->orWhere('customer_name', 'like', "%{$s}%")->orWhere('license_key_last4', 'like', "%{$s}%"));
}
return view('admin.licenses', ['licenses' => $q->paginate(50)->withQueryString()]);
}
public function show(License $license)
{
$license->load(['product', 'activations']);
return view('admin.license', compact('license'));
}
public function revoke(Request $request, License $license)
{
$data = $request->validate(['reason' => ['nullable', 'string', 'max:255']]);
$license->update(['revoked_at' => now(), 'revoke_reason' => $data['reason'] ?: 'Revoked by administrator']);
return back()->with('status', 'Licence revoked.');
}
public function restore(License $license)
{
$license->update(['revoked_at' => null, 'revoke_reason' => null]);
return back()->with('status','Licence restored.');
}
}
View File
View File
+52 -18
View File
@@ -1,12 +1,13 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent;
use App\Services\Licensing\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner;
use App\Services\Licensing\LicenseDeliveryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -15,30 +16,63 @@ final class PaymentWebhookController extends Controller
{
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse
{
$raw=(string)$request->getContent();
$verifier=match($provider){'paddle'=>app(PaddleWebhookVerifier::class),'lemon-squeezy'=>app(LemonSqueezyWebhookVerifier::class),default=>null};
if (!$verifier) return response()->json(['message'=>'Unknown webhook provider.'],404);
try { $verifier->verify($request,$raw); } catch (\Throwable $e) { report($e); return response()->json(['message'=>'Invalid webhook signature.'],400); }
try { $payload=json_decode($raw,true,flags:JSON_THROW_ON_ERROR); } catch (\Throwable) { return response()->json(['message'=>'Invalid JSON.'],400); }
$externalID=(string)($provider==='paddle'?($payload['event_id']??''):($payload['meta']['event_name']??'').':'.($payload['data']['id']??''));
$eventType=(string)($provider==='paddle'?($payload['event_type']??'unknown'):($payload['meta']['event_name']??'unknown'));
if ($externalID==='') return response()->json(['message'=>'Webhook event has no stable identifier.'],400);
$raw = (string) $request->getContent();
$verifier = match ($provider) {
'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null
};
if (! $verifier) {
return response()->json(['message' => 'Unknown webhook provider.'], 404);
}
try {
$verifier->verify($request, $raw);
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Invalid webhook signature.'], 400);
}
try {
$payload = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
} catch (\Throwable) {
return response()->json(['message' => 'Invalid JSON.'], 400);
}
$externalID = (string) ($provider === 'paddle' ? ($payload['event_id'] ?? '') : ($payload['meta']['event_name'] ?? '').':'.($payload['data']['id'] ?? ''));
$eventType = (string) ($provider === 'paddle' ? ($payload['event_type'] ?? 'unknown') : ($payload['meta']['event_name'] ?? 'unknown'));
if ($externalID === '') {
return response()->json(['message' => 'Webhook event has no stable identifier.'], 400);
}
try {
DB::transaction(function() use($provider,$externalID,$eventType,$payload,$raw,$provisioner){
$event=LicenseWebhookEvent::firstOrCreate(['provider'=>$provider,'external_event_id'=>$externalID],['event_type'=>$eventType,'payload_sha256'=>hash('sha256',$raw),'payload'=>$payload]);
if ($event->processed_at) return;
if (!hash_equals($event->payload_sha256,hash('sha256',$raw))) throw new \RuntimeException('Webhook ID was reused with a different payload.');
try { $provisioner->provision($provider,$payload); $event->update(['processed_at'=>now(),'processing_error'=>null]); }
catch (\Throwable $e) { $event->update(['processing_error'=>mb_substr($e->getMessage(),0,4000)]); throw $e; }
DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) {
$event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]);
if ($event->processed_at) {
return;
}
if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) {
throw new \RuntimeException('Webhook ID was reused with a different payload.');
}
try {
$provisioner->provision($provider, $payload);
$event->update(['processed_at' => now(), 'processing_error' => null]);
} catch (\Throwable $e) {
$event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]);
throw $e;
}
});
} catch (\Throwable $e) { report($e); return response()->json(['message'=>'Webhook could not be processed.'],500); }
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Webhook could not be processed.'], 500);
}
// Delivery is intentionally outside the webhook transaction. A temporary mail
// outage must not roll back an otherwise valid Paddle event. Pending outbox
// rows remain available for portal resend or a later webhook delivery attempt.
try { $delivery->deliverPending(10); } catch (\Throwable $e) { report($e); }
try {
$delivery->deliverPending(10);
} catch (\Throwable $e) {
report($e);
}
return response()->json(['ok'=>true]);
return response()->json(['ok' => true]);
}
}
+29 -8
View File
@@ -1,14 +1,35 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
final class AuthenticatedSessionController extends Controller {
public function create(){ return view('auth.login'); }
public function store(Request $request){
$credentials=$request->validate(['email'=>['required','email'],'password'=>['required','string']]);
if(!Auth::attempt(['email'=>strtolower($credentials['email']),'password'=>$credentials['password']], $request->boolean('remember'))){ return back()->withErrors(['email'=>'The supplied credentials are invalid.'])->onlyInput('email'); }
$request->session()->regenerate(); return redirect()->intended(route('portal.index'));
}
public function destroy(Request $request){ Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect()->route('login'); }
final class AuthenticatedSessionController extends Controller
{
public function create()
{
return view('auth.login');
}
public function store(Request $request)
{
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) {
return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email');
}
$request->session()->regenerate();
return redirect()->intended(route('portal.index'));
}
public function destroy(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
+25 -4
View File
@@ -1,10 +1,31 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\Request;
final class EmailVerificationController extends Controller {
public function notice(){ return view('auth.verify-email'); }
public function verify(EmailVerificationRequest $request){ $request->fulfill(); return redirect()->route('portal.index')->with('status','Email verified.'); }
public function resend(Request $request){ if(!$request->user()->hasVerifiedEmail()) $request->user()->sendEmailVerificationNotification(); return back()->with('status','Verification link sent.'); }
final class EmailVerificationController extends Controller
{
public function notice()
{
return view('auth.verify-email');
}
public function verify(EmailVerificationRequest $request)
{
$request->fulfill();
return redirect()->route('portal.index')->with('status', 'Email verified.');
}
public function resend(Request $request)
{
if (! $request->user()->hasVerifiedEmail()) {
$request->user()->sendEmailVerificationNotification();
}
return back()->with('status', 'Verification link sent.');
}
}
+33 -5
View File
@@ -1,5 +1,7 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
@@ -7,9 +9,35 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
final class PasswordController extends Controller {
public function requestForm(){ return view('auth.forgot-password'); }
public function email(Request $request){ $request->validate(['email'=>['required','email']]); $status=Password::sendResetLink(['email'=>strtolower($request->email)]); return $status===Password::RESET_LINK_SENT?back()->with('status',__($status)):back()->withErrors(['email'=>__($status)]); }
public function resetForm(Request $request,string $token){ return view('auth.reset-password',['token'=>$token,'email'=>$request->query('email')]); }
public function reset(Request $request){ $data=$request->validate(['token'=>['required'],'email'=>['required','email'],'password'=>['required','confirmed',PasswordRule::min(12)->letters()->mixedCase()->numbers()]]); $status=Password::reset($data,function(User $user,string $password){$user->forceFill(['password'=>$password,'remember_token'=>Str::random(60)])->save();event(new PasswordReset($user));}); return $status===Password::PASSWORD_RESET?redirect()->route('login')->with('status',__($status)):back()->withErrors(['email'=>__($status)]); }
final class PasswordController extends Controller
{
public function requestForm()
{
return view('auth.forgot-password');
}
public function email(Request $request)
{
$request->validate(['email' => ['required', 'email']]);
$status = Password::sendResetLink(['email' => strtolower($request->email)]);
return $status === Password::RESET_LINK_SENT ? back()->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
}
public function resetForm(Request $request, string $token)
{
return view('auth.reset-password', ['token' => $token, 'email' => $request->query('email')]);
}
public function reset(Request $request)
{
$data = $request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::min(12)->letters()->mixedCase()->numbers()]]);
$status = Password::reset($data, function (User $user, string $password) {
$user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save();
event(new PasswordReset($user));
});
return $status === Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
}
}
+20 -6
View File
@@ -1,15 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
final class RegisteredUserController extends Controller {
public function create(){ return view('auth.register'); }
public function store(Request $request){
$data=$request->validate(['name'=>['required','string','max:120'],'email'=>['required','email','max:255','unique:users,email'],'password'=>['required','confirmed',Password::min(12)->letters()->mixedCase()->numbers()]]);
$user=User::create(['name'=>$data['name'],'email'=>strtolower($data['email']),'password'=>$data['password']]); event(new Registered($user)); Auth::login($user); $request->session()->regenerate(); return redirect()->route('verification.notice');
}
final class RegisteredUserController extends Controller
{
public function create()
{
return view('auth.register');
}
public function store(Request $request)
{
$data = $request->validate(['name' => ['required', 'string', 'max:120'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'confirmed', Password::min(12)->letters()->mixedCase()->numbers()]]);
$user = User::create(['name' => $data['name'], 'email' => strtolower($data['email']), 'password' => $data['password']]);
event(new Registered($user));
Auth::login($user);
$request->session()->regenerate();
return redirect()->route('verification.notice');
}
}
View File
View File
+3 -3
View File
@@ -78,7 +78,7 @@ final class PortalController extends Controller
{
$this->authorise($request, $license);
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
abort_unless(!in_array($license->subscription_status, ['canceled'], true), 422);
abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422);
try {
$this->paddle->cancelSubscriptionImmediately($license->external_subscription_id);
@@ -101,7 +101,7 @@ final class PortalController extends Controller
{
$this->authorise($request, $license);
$item = $license->activations()->whereKey($activation)->firstOrFail();
if (!$item->deactivated_at) {
if (! $item->deactivated_at) {
$item->update(['deactivated_at' => now()]);
}
@@ -112,7 +112,7 @@ final class PortalController extends Controller
{
$this->authorise($request, $license);
$out = LicenseDeliveryOutbox::where('license_id', $license->id)->first();
if (!$out) {
if (! $out) {
return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
}
View File
+8 -2
View File
@@ -1,11 +1,17 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class AdminOnly {
public function handle(Request $request, Closure $next): Response {
final class AdminOnly
{
public function handle(Request $request, Closure $next): Response
{
abort_unless($request->user()?->is_admin === true, 403);
return $next($request);
}
}
Regular → Executable
+22 -5
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@@ -9,11 +10,27 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class License extends Model
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['license_product_id','license_key_hash','license_key_last4','customer_name','customer_email','edition','activation_limit','max_major_version','expires_at','revoked_at','revoke_reason','payment_provider','external_order_id','external_customer_id','external_subscription_id','external_price_id','subscription_status','current_period_ends_at','cancel_scheduled_at'];
protected $casts = ['expires_at'=>'datetime','revoked_at'=>'datetime','activation_limit'=>'integer','max_major_version'=>'integer','current_period_ends_at'=>'datetime','cancel_scheduled_at'=>'datetime'];
public function product(): BelongsTo { return $this->belongsTo(LicenseProduct::class, 'license_product_id'); }
public function activations(): HasMany { return $this->hasMany(LicenseActivation::class); }
public function getIsUsableAttribute(): bool { return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); }
protected $fillable = ['license_product_id', 'license_key_hash', 'license_key_last4', 'customer_name', 'customer_email', 'edition', 'activation_limit', 'max_major_version', 'expires_at', 'revoked_at', 'revoke_reason', 'payment_provider', 'external_order_id', 'external_customer_id', 'external_subscription_id', 'external_price_id', 'subscription_status', 'current_period_ends_at', 'cancel_scheduled_at'];
protected $casts = ['expires_at' => 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime'];
public function product(): BelongsTo
{
return $this->belongsTo(LicenseProduct::class, 'license_product_id');
}
public function activations(): HasMany
{
return $this->hasMany(LicenseActivation::class);
}
public function getIsUsableAttribute(): bool
{
return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture());
}
}
Regular → Executable
+7 -2
View File
@@ -1,9 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseActivation extends Model
{
public $timestamps = false;
protected $fillable = ['license_id','installation_id','installation_name','platform','app_version','activated_at','last_seen_at','deactivated_at'];
protected $casts = ['activated_at'=>'datetime','last_seen_at'=>'datetime','deactivated_at'=>'datetime'];
protected $fillable = ['license_id', 'installation_id', 'installation_name', 'platform', 'app_version', 'activated_at', 'last_seen_at', 'deactivated_at'];
protected $casts = ['activated_at' => 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
}
+7 -2
View File
@@ -1,9 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseDeliveryOutbox extends Model
{
protected $table = 'license_delivery_outbox';
protected $fillable = ['license_id','encrypted_license_key','recipient_email','delivered_at'];
protected $casts = ['delivered_at'=>'datetime'];
protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
protected $casts = ['delivered_at' => 'datetime'];
}
Regular → Executable
+4 -2
View File
@@ -1,10 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseProduct extends Model
{
protected $fillable = ['code','name','edition','activation_limit','max_major_version','payment_provider','external_product_id','external_price_id','active'];
protected $casts = ['active'=>'boolean','activation_limit'=>'integer','max_major_version'=>'integer'];
protected $fillable = ['code', 'name', 'edition', 'activation_limit', 'max_major_version', 'payment_provider', 'external_product_id', 'external_price_id', 'active'];
protected $casts = ['active' => 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseWebhookEvent extends Model
{
protected $fillable = ['provider','external_event_id','event_type','payload_sha256','payload','processed_at','processing_error'];
protected $casts = ['payload'=>'array','processed_at'=>'datetime'];
protected $fillable = ['provider', 'external_event_id', 'event_type', 'payload_sha256', 'payload', 'processed_at', 'processing_error'];
protected $casts = ['payload' => 'array', 'processed_at' => 'datetime'];
}
Regular → Executable
+9 -2
View File
@@ -25,8 +25,15 @@ class SupportTicket extends Model
];
}
public function user(): BelongsTo { return $this->belongsTo(User::class); }
public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assigned_to'); }
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function prunable(): Builder
{
Regular → Executable
+10 -3
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail;
@@ -9,7 +10,13 @@ use Illuminate\Notifications\Notifiable;
class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable;
protected $fillable = ['name','email','password','is_admin'];
protected $hidden = ['password','remember_token'];
protected function casts(): array { return ['email_verified_at'=>'datetime','password'=>'hashed','is_admin'=>'boolean']; }
protected $fillable = ['name', 'email', 'password', 'is_admin'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean'];
}
}
+1 -1
View File
@@ -4,9 +4,9 @@ namespace App\Notifications;
use App\Models\SupportTicket;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\AnonymousNotifiable;
class NewSupportTicket extends Notification
{
View File
+3 -1
View File
@@ -22,7 +22,9 @@ final class LicenseDeliveryService
foreach ($pending as $outbox) {
$plain = Crypt::decryptString($outbox->encrypted_license_key);
$recipient = strtolower(trim((string) $outbox->recipient_email));
if ($recipient === '') continue;
if ($recipient === '') {
continue;
}
Mail::raw(
"Thank you for choosing Rsync Studio.\n\nYour licence key is:\n\n{$plain}\n\nOpen Rsync Studio → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the Rsync Studio licence portal.",
+22 -18
View File
@@ -1,41 +1,45 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\DB;
use App\Models\LicenseProduct;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
final class LicenseIssuer
{
public function issue(LicenseProduct $product, ?string $name, ?string $email, ?string $provider = null, ?string $orderID = null, ?string $customerID = null, bool $queueDelivery = false): array
{
return DB::transaction(function () use ($product,$name,$email,$provider,$orderID,$customerID,$queueDelivery) {
return DB::transaction(function () use ($product, $name, $email, $provider, $orderID, $customerID, $queueDelivery) {
if ($provider && $orderID) {
$existing = License::where('payment_provider',$provider)->where('external_order_id',$orderID)->first();
if ($existing) return [$existing, null];
$existing = License::where('payment_provider', $provider)->where('external_order_id', $orderID)->first();
if ($existing) {
return [$existing, null];
}
}
$plain = LicenseKey::generate();
$license = License::create([
'license_product_id'=>$product->id,
'license_key_hash'=>LicenseKey::hash($plain),
'license_key_last4'=>substr(LicenseKey::normalize($plain), -4),
'customer_name'=>$name,
'customer_email'=>$email ? strtolower(trim($email)) : null,
'edition'=>$product->edition,
'activation_limit'=>$product->activation_limit,
'max_major_version'=>$product->max_major_version,
'payment_provider'=>$provider,
'external_order_id'=>$orderID,
'external_customer_id'=>$customerID,
'license_product_id' => $product->id,
'license_key_hash' => LicenseKey::hash($plain),
'license_key_last4' => substr(LicenseKey::normalize($plain), -4),
'customer_name' => $name,
'customer_email' => $email ? strtolower(trim($email)) : null,
'edition' => $product->edition,
'activation_limit' => $product->activation_limit,
'max_major_version' => $product->max_major_version,
'payment_provider' => $provider,
'external_order_id' => $orderID,
'external_customer_id' => $customerID,
]);
if ($queueDelivery) {
LicenseDeliveryOutbox::updateOrCreate(
['license_id'=>$license->id],
['encrypted_license_key'=>Crypt::encryptString($plain),'recipient_email'=>$license->customer_email,'delivered_at'=>null]
['license_id' => $license->id],
['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
);
}
return [$license, $plain];
});
}
+5 -1
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Services\Licensing;
final class LicenseKey
@@ -7,7 +8,10 @@ final class LicenseKey
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$raw = '';
for ($i=0; $i<20; $i++) $raw .= $alphabet[random_int(0, strlen($alphabet)-1)];
for ($i = 0; $i < 20; $i++) {
$raw .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return implode('-', str_split($raw, 5));
}
+10 -2
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
@@ -26,21 +27,28 @@ final class LicenseSigner
ksort($payload, SORT_STRING);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$sig = sodium_crypto_sign_detached($json, $secret);
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
}
public function publicKeyBase64(): string
{
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
return base64_encode(sodium_crypto_sign_publickey($pair));
}
private function privateSeed(): string
{
$path = config('rsync-studio.private_key_path');
if (!$path || !is_file($path)) throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
if (! $path || ! is_file($path)) {
throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
}
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
}
return $decoded;
}
+16 -4
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Services\Licensing;
use RuntimeException;
@@ -8,21 +9,32 @@ final class LicenseTokenVerifier
public function verify(string $token): array
{
$parts = explode('.', trim($token));
if (count($parts) !== 3 || $parts[0] !== 'RS3') throw new RuntimeException('Invalid licence token.');
if (count($parts) !== 3 || $parts[0] !== 'RS3') {
throw new RuntimeException('Invalid licence token.');
}
$payload = $this->decode($parts[1]);
$signature = $this->decode($parts[2]);
$public = base64_decode((string) config('rsync-studio.public_key'), true);
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) throw new RuntimeException('Server licence public key is invalid.');
if (!sodium_crypto_sign_verify_detached($signature, $payload, $public)) throw new RuntimeException('Licence token signature is invalid.');
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new RuntimeException('Server licence public key is invalid.');
}
if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
throw new RuntimeException('Licence token signature is invalid.');
}
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
}
private function decode(string $value): string
{
$value = strtr($value, '-_', '+/');
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
$out = base64_decode($value, true);
if ($out === false) throw new RuntimeException('Invalid Base64URL value.');
if ($out === false) {
throw new RuntimeException('Invalid Base64URL value.');
}
return $out;
}
}
+8 -3
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
@@ -10,8 +11,12 @@ final class LemonSqueezyWebhookVerifier implements WebhookVerifier
{
$secret = (string) config('rsync-studio.lemon_squeezy.webhook_secret');
$signature = (string) $request->header('X-Signature');
if ($secret === '' || $signature === '') throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
$expected = hash_hmac('sha256',$rawBody,$secret);
if (!hash_equals($expected,$signature)) throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
if ($secret === '' || $signature === '') {
throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
}
$expected = hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $signature)) {
throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
}
}
}
+9 -4
View File
@@ -10,12 +10,17 @@ final class PaddleClient
{
public function customer(?string $customerID): ?array
{
if (!$customerID || !config('rsync-studio.paddle.api_key')) return null;
if (! $customerID || ! config('rsync-studio.paddle.api_key')) {
return null;
}
$response = $this->request()->get('/customers/'.$customerID);
if (!$response->successful()) return null;
if (! $response->successful()) {
return null;
}
$data = $response->json('data');
return is_array($data) ? $data : null;
}
@@ -62,13 +67,13 @@ final class PaddleClient
private function subscriptionData(Response $response, string $fallback): array
{
if (!$response->successful()) {
if (! $response->successful()) {
$detail = trim((string) ($response->json('error.detail') ?? ''));
throw new RuntimeException($detail !== '' ? $detail : $fallback);
}
$data = $response->json('data');
if (!is_array($data)) {
if (! is_array($data)) {
throw new RuntimeException($fallback);
}
+20 -6
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
@@ -10,15 +11,28 @@ final class PaddleWebhookVerifier implements WebhookVerifier
{
$secret = (string) config('rsync-studio.paddle.webhook_secret');
$header = (string) $request->header('Paddle-Signature');
if ($secret === '' || $header === '') throw new RuntimeException('Paddle webhook verification is not configured.');
if ($secret === '' || $header === '') {
throw new RuntimeException('Paddle webhook verification is not configured.');
}
$parts = [];
foreach (explode(';', $header) as $part) { [$k,$v] = array_pad(explode('=',trim($part),2),2,null); if ($k && $v) $parts[$k][]=$v; }
$ts = isset($parts['ts'][0]) ? (int)$parts['ts'][0] : 0;
foreach (explode(';', $header) as $part) {
[$k,$v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k && $v) {
$parts[$k][] = $v;
}
}
$ts = isset($parts['ts'][0]) ? (int) $parts['ts'][0] : 0;
$signatures = $parts['h1'] ?? [];
$tolerance = max(1,(int)config('rsync-studio.paddle.timestamp_tolerance',300));
if (!$ts || abs(time()-$ts)>$tolerance) throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
$tolerance = max(1, (int) config('rsync-studio.paddle.timestamp_tolerance', 300));
if (! $ts || abs(time() - $ts) > $tolerance) {
throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
}
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
foreach ($signatures as $signature) if (hash_equals($expected,$signature)) return;
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return;
}
}
throw new RuntimeException('Paddle webhook signature is invalid.');
}
}
+14 -4
View File
@@ -16,11 +16,13 @@ final class PurchaseProvisioner
{
if ($provider === 'paddle') {
$this->provisionPaddle($event);
return;
}
if ($provider === 'lemon-squeezy') {
$this->provisionLemonSqueezy($event);
return;
}
@@ -34,11 +36,13 @@ final class PurchaseProvisioner
if (str_starts_with($type, 'subscription.')) {
$this->syncPaddleSubscription($type, $data);
return;
}
if ($type === 'transaction.completed') {
$this->syncPaddleTransaction($data);
return;
}
@@ -71,7 +75,7 @@ final class PurchaseProvisioner
->where('external_subscription_id', $subscriptionID)
->first();
if (!$license) {
if (! $license) {
$product = $this->mappedProduct('paddle', $priceID, $productID);
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
@@ -136,6 +140,7 @@ final class PurchaseProvisioner
->where('external_subscription_id', $subscriptionID)
->whereNull('external_order_id')
->update(['external_order_id' => $transactionID ?: null]);
return;
}
@@ -170,10 +175,11 @@ final class PurchaseProvisioner
->where('external_order_id', $order)
->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']);
return;
}
if (!in_array($type, ['order_created', 'subscription_created'], true)) {
if (! in_array($type, ['order_created', 'subscription_created'], true)) {
return;
}
@@ -210,7 +216,7 @@ final class PurchaseProvisioner
->first();
}
if (!$product) {
if (! $product) {
throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}.");
}
@@ -224,13 +230,17 @@ final class PurchaseProvisioner
return $item;
}
}
return $items[0] ?? [];
}
private function nullableString(mixed $value): ?string
{
if (!is_scalar($value)) return null;
if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}
+7 -1
View File
@@ -1,4 +1,10 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
interface WebhookVerifier { public function verify(Request $request, string $rawBody): void; }
interface WebhookVerifier
{
public function verify(Request $request, string $rawBody): void;
}
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+3 -1
View File
@@ -1,5 +1,7 @@
<?php
use App\Providers\RsyncStudioLicensingServiceProvider;
return [
App\Providers\RsyncStudioLicensingServiceProvider::class,
RsyncStudioLicensingServiceProvider::class,
];
Regular → Executable
+2 -1
View File
@@ -9,7 +9,8 @@
"php": "^8.3",
"deployer/deployer": "^8.0",
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0"
"laravel/tinker": "^3.0",
"predis/predis": "^3.6"
},
"require-dev": {
"fakerphp/faker": "^1.23",
Generated Regular → Executable
+64 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "03c9ba92496a9b294eb87b214b3c0df4",
"content-hash": "1f5b5a47e5910fdf5b2c47e1bc45e1b0",
"packages": [
{
"name": "brick/math",
@@ -2738,6 +2738,69 @@
],
"time": "2026-08-24T00:54:40+00:00"
},
{
"name": "predis/predis",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/predis/predis.git",
"reference": "2ff20c08bb63697245ffee3f198d1673086c302d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/predis/predis/zipball/2ff20c08bb63697245ffee3f198d1673086c302d",
"reference": "2ff20c08bb63697245ffee3f198d1673086c302d",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0",
"psr/http-message": "^1.0|^2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.3",
"phpstan/phpstan": "^1.9",
"phpunit/phpcov": "^6.0 || ^8.0",
"phpunit/phpunit": "^8.0 || ~9.4.4"
},
"suggest": {
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
},
"type": "library",
"autoload": {
"psr-4": {
"Predis\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Till Krüss",
"homepage": "https://till.im",
"role": "Maintainer"
}
],
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
"homepage": "http://github.com/predis/predis",
"keywords": [
"nosql",
"predis",
"redis"
],
"support": {
"issues": "https://github.com/predis/predis/issues",
"source": "https://github.com/predis/predis/tree/v3.6.0"
},
"funding": [
{
"url": "https://github.com/sponsors/tillkruss",
"type": "github"
}
],
"time": "2026-08-14T23:07:56+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
-7
View File
@@ -1,7 +0,0 @@
{
"require": {
"php": "^8.3",
"ext-sodium": "*",
"laravel/framework": "^13.0"
}
}
Regular → Executable
View File
Regular → Executable
BIN
View File
Binary file not shown.
View File
+2 -1
View File
@@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
return new class extends Migration
{
public function up(): void
{
Schema::create('license_products', function (Blueprint $table) {
View File
+9 -7
View File
@@ -10,17 +10,19 @@ class PaddleCatalogSeeder extends Seeder
public function run(): void
{
$products = [
['code'=>'personal-monthly','name'=>'Rsync Studio Personal Monthly','edition'=>'Personal','activation_limit'=>1,'external_product_id'=>config('rsync-studio.paddle.personal_product_id'),'external_price_id'=>config('rsync-studio.paddle.personal_monthly_price_id')],
['code'=>'personal-annual','name'=>'Rsync Studio Personal Annual','edition'=>'Personal','activation_limit'=>1,'external_product_id'=>config('rsync-studio.paddle.personal_product_id'),'external_price_id'=>config('rsync-studio.paddle.personal_annual_price_id')],
['code'=>'professional-monthly','name'=>'Rsync Studio Professional Monthly','edition'=>'Professional','activation_limit'=>3,'external_product_id'=>config('rsync-studio.paddle.professional_product_id'),'external_price_id'=>config('rsync-studio.paddle.professional_monthly_price_id')],
['code'=>'professional-annual','name'=>'Rsync Studio Professional Annual','edition'=>'Professional','activation_limit'=>3,'external_product_id'=>config('rsync-studio.paddle.professional_product_id'),'external_price_id'=>config('rsync-studio.paddle.professional_annual_price_id')],
['code' => 'personal-monthly', 'name' => 'Rsync Studio Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('rsync-studio.paddle.personal_product_id'), 'external_price_id' => config('rsync-studio.paddle.personal_monthly_price_id')],
['code' => 'personal-annual', 'name' => 'Rsync Studio Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('rsync-studio.paddle.personal_product_id'), 'external_price_id' => config('rsync-studio.paddle.personal_annual_price_id')],
['code' => 'professional-monthly', 'name' => 'Rsync Studio Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('rsync-studio.paddle.professional_product_id'), 'external_price_id' => config('rsync-studio.paddle.professional_monthly_price_id')],
['code' => 'professional-annual', 'name' => 'Rsync Studio Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('rsync-studio.paddle.professional_product_id'), 'external_price_id' => config('rsync-studio.paddle.professional_annual_price_id')],
];
foreach ($products as $data) {
if (!$data['external_product_id'] || !$data['external_price_id']) continue;
if (! $data['external_product_id'] || ! $data['external_price_id']) {
continue;
}
LicenseProduct::updateOrCreate(
['code'=>$data['code']],
$data + ['payment_provider'=>'paddle','max_major_version'=>config('rsync-studio.default_max_major_version'),'active'=>true]
['code' => $data['code']],
$data + ['payment_provider' => 'paddle', 'max_major_version' => config('rsync-studio.default_max_major_version'), 'active' => true]
);
}
}
Regular → Executable
+3 -3
View File
@@ -19,16 +19,16 @@ add('writable_dirs', ['storage', 'bootstrap/cache']);
// Host Definitions
host('main')
->set('hostname', '217.154.47.197')
->set('hostname', 'rsyncstudio.absdev.net')
->set('remote_user', 'mag')
->set('branch', 'main')
->set('deploy_path', '/var/www/rsyncstudio');
host('stage')
->set('hostname', 'pegasus.absdev.net')
->set('hostname', 'rsyncstudio.absdev.net')
->set('remote_user', 'mag')
->set('branch', 'stage')
->set('deploy_path', '/var/www/rsyncstudio');
->set('deploy_path', '/var/www/rsyncstudio_dev');
task('build', function (): void {
run('export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use {{node_version}} && cd {{release_path}} && npm ci --no-audit --no-fund');
View File
View File
View File
Generated Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 669 B

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Regular → Executable
View File

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 377 B

After

Width:  |  Height:  |  Size: 377 B

Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File

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