Initial commit

This commit is contained in:
2026-02-03 10:38:46 +01:00
commit eb304f4b14
144 changed files with 22605 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Actions\Events;
use App\Models\Event;
use App\Models\User;
use Illuminate\Support\Str;
class CreateEventAction
{
public function execute(array $data, User $user): Event
{
return \Illuminate\Support\Facades\DB::transaction(function () use ($data, $user) {
$slug = $this->generateUniqueSlug($data['slug'] ?? Str::slug($data['name']));
$data['slug'] = $slug;
$data['user_id'] = $user->id;
$event = new Event($data);
$event->save();
return $event;
});
}
protected function generateUniqueSlug(string $base): string
{
$slug = Str::slug($base);
$original = $slug;
$count = 0;
while (Event::where('slug', $slug)->exists()) {
$count++;
$slug = $original.'-'.$count;
}
return $slug;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Actions\Events;
use App\Models\Event;
use Illuminate\Support\Str;
class UpdateEventAction
{
public function execute(Event $event, array $data): Event
{
return \Illuminate\Support\Facades\DB::transaction(function () use ($event, $data) {
if (isset($data['slug']) && $data['slug'] !== $event->slug) {
$data['slug'] = $this->generateUniqueSlug($data['slug'], $event->id);
}
$event->update($data);
return $event->fresh();
});
}
protected function generateUniqueSlug(string $base, int $excludeId): string
{
$slug = Str::slug($base);
$original = $slug;
$count = 0;
while (Event::where('slug', $slug)->where('id', '!=', $excludeId)->exists()) {
$count++;
$slug = $original.'-'.$count;
}
return $slug;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
class MakeAdminUser extends Command
{
protected $signature = 'make:admin
{--email= : Admin email address}
{--password= : Admin password}';
protected $description = 'Create an admin user (email and password required)';
public function handle(): int
{
$email = $this->option('email');
$password = $this->option('password');
if (! $email || ! $password) {
$this->error('Please provide --email and --password.');
$this->line('Example: php artisan make:admin --email=admin@example.com --password=secret');
return self::FAILURE;
}
if (User::where('email', $email)->exists()) {
$this->error("A user with email {$email} already exists.");
return self::FAILURE;
}
User::create([
'name' => explode('@', $email)[0],
'email' => $email,
'password' => $password,
]);
$this->info("Admin user created for {$email}.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Actions\Events\CreateEventAction;
use App\Actions\Events\UpdateEventAction;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\StoreEventRequest;
use App\Http\Requests\Admin\UpdateEventRequest;
use App\Models\Event;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EventsController extends Controller
{
public function index(Request $request): JsonResponse
{
$events = Event::query()
->where('user_id', $request->user()->id)
->withCount('uploads')
->orderByDesc('created_at')
->paginate($request->input('per_page', 15));
return response()->json($events);
}
public function store(StoreEventRequest $request, CreateEventAction $action): JsonResponse
{
$event = $action->execute($request->validated(), $request->user());
return response()->json($event->loadCount('uploads'), 201);
}
public function show(Event $event, Request $request): JsonResponse
{
$this->authorizeEvent($event, $request);
return response()->json($event->loadCount('uploads'));
}
public function update(UpdateEventRequest $request, Event $event, UpdateEventAction $action): JsonResponse
{
$this->authorizeEvent($event, $request);
$event = $action->execute($event, $request->validated());
return response()->json($event->loadCount('uploads'));
}
public function destroy(Event $event, Request $request): JsonResponse
{
$this->authorizeEvent($event, $request);
$event->delete();
return response()->json(null, 204);
}
public function uploads(Event $event, Request $request): JsonResponse
{
$this->authorizeEvent($event, $request);
$uploads = $event->uploads()->orderByDesc('created_at')->paginate($request->input('per_page', 20));
return response()->json($uploads);
}
protected function authorizeEvent(Event $event, Request $request): void
{
if ($event->user_id !== $request->user()->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\GoogleDrive\GoogleDriveService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class GoogleDriveController extends Controller
{
public function __construct(
protected GoogleDriveService $googleDrive
) {}
public function authUrl(): JsonResponse
{
$url = $this->googleDrive->getAuthUrl();
return response()->json(['url' => $url]);
}
public function callback(Request $request): RedirectResponse
{
$frontendUrl = config('app.frontend_admin_url', 'http://localhost:5173');
$code = $request->query('code');
if (! $code) {
return redirect($frontendUrl.'?google_drive_error=missing_code');
}
$user = Auth::user();
if (! $user) {
return redirect($frontendUrl.'?google_drive_error=not_authenticated');
}
try {
$this->googleDrive->handleCallback($code, $user);
} catch (\Throwable $e) {
Log::error('Google Drive connection failed', [
'error' => $e->getMessage(),
'user_id' => $user->id,
]);
return redirect($frontendUrl.'?google_drive_error=connection_failed');
}
return redirect($frontendUrl.'?google_drive_connected=1');
}
public function status(): JsonResponse
{
$connection = Auth::user()?->googleDriveConnections()->first();
return response()->json([
'connected' => (bool) $connection,
'account_email' => $connection?->account_email,
]);
}
public function disconnect(): JsonResponse
{
Auth::user()?->googleDriveConnections()->delete();
return response()->json(['message' => 'Disconnected']);
}
public function sharedDrives(): JsonResponse
{
$drives = $this->googleDrive->listSharedDrives(Auth::user());
return response()->json(['data' => $drives]);
}
public function folders(Request $request): JsonResponse
{
$parentId = $request->query('parent_id');
$driveId = $request->query('drive_id');
$folders = $this->googleDrive->listFolders(Auth::user(), $parentId, $driveId);
return response()->json(['data' => $folders]);
}
public function createFolder(Request $request): JsonResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'parent_id' => ['nullable', 'string'],
'drive_id' => ['nullable', 'string'],
]);
$folder = $this->googleDrive->createFolder(
Auth::user(),
$request->input('name'),
$request->input('parent_id'),
$request->input('drive_id')
);
return response()->json(['data' => $folder]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Upload;
use App\Services\GoogleDrive\GoogleDriveService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UploadsController extends Controller
{
public function show(Upload $upload, Request $request): JsonResponse
{
$this->authorizeUpload($upload, $request);
return response()->json($upload);
}
public function destroy(Upload $upload, Request $request, GoogleDriveService $googleDrive): JsonResponse
{
$this->authorizeUpload($upload, $request);
if ($upload->google_drive_file_id) {
try {
$googleDrive->deleteFile($request->user(), $upload->google_drive_file_id);
} catch (\Throwable) {
// Continue to delete record even if Drive delete fails
}
}
$upload->delete();
return response()->json(null, 204);
}
public function downloadUrl(Upload $upload, Request $request, GoogleDriveService $googleDrive): JsonResponse
{
$this->authorizeUpload($upload, $request);
if (! $upload->google_drive_file_id) {
return response()->json(['message' => 'File not yet available'], 404);
}
$url = $googleDrive->getFileLink($request->user(), $upload->google_drive_file_id);
return response()->json(['url' => $url]);
}
protected function authorizeUpload(Upload $upload, Request $request): void
{
if ($upload->event->user_id !== $request->user()->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
'remember' => ['boolean'],
]);
if (! Auth::attempt(
$request->only('email', 'password'),
$request->boolean('remember')
)) {
throw ValidationException::withMessages([
'email' => [__('auth.failed')],
]);
}
$request->session()->regenerate();
return response()->json(['user' => Auth::user()]);
}
public function logout(Request $request): JsonResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->json(['message' => 'Logged out']);
}
public function user(Request $request): JsonResponse
{
return response()->json($request->user());
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Http\Requests\Public\VerifyPasswordRequest;
use App\Jobs\ProcessEventUpload;
use App\Models\Event;
use App\Models\Upload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
class EventUploadController extends Controller
{
public function show(string $slug): JsonResponse
{
$event = Event::where('slug', $slug)->where('is_active', true)->firstOrFail();
return response()->json([
'name' => $event->name,
'description' => $event->description,
'slug' => $event->slug,
'is_active' => $event->is_active,
'upload_start_at' => $event->upload_start_at?->toIso8601String(),
'upload_end_at' => $event->upload_end_at?->toIso8601String(),
'max_file_size_mb' => $event->max_file_size_mb,
'allowed_extensions' => $event->allowed_extensions,
'require_password' => $event->require_password,
'has_password' => $event->has_password,
]);
}
public function verifyPassword(VerifyPasswordRequest $request, string $slug): JsonResponse
{
$event = Event::where('slug', $slug)->where('is_active', true)->firstOrFail();
if (! $event->require_password) {
return response()->json(['verified' => true]);
}
if (! Hash::check($request->password, $event->upload_password)) {
RateLimiter::hit('password-verify:'.$request->ip());
return response()->json(['message' => 'Invalid password'], 401);
}
return response()->json(['verified' => true]);
}
public function upload(Request $request, string $slug): JsonResponse
{
\Log::info('Upload request received', [
'slug' => $slug,
'has_file' => $request->hasFile('file'),
'files' => array_keys($request->allFiles()),
'content_length' => $request->header('Content-Length'),
]);
$event = Event::where('slug', $slug)->where('is_active', true)->firstOrFail();
if ($event->require_password) {
$password = $request->header('X-Upload-Password');
if (! $password || ! Hash::check($password, $event->upload_password)) {
return response()->json(['message' => 'Invalid or missing upload password'], 401);
}
}
$this->validateUploadWindow($event);
$request->validate([
'file' => ['required', 'file'],
]);
$file = $request->file('file');
\Log::info('File details', [
'original_name' => $file->getClientOriginalName(),
'size' => $file->getSize(),
'mime' => $file->getMimeType(),
'is_valid' => $file->isValid(),
'error' => $file->getError(),
'temp_path' => $file->getPathname(),
]);
$originalName = $file->getClientOriginalName();
$extension = strtolower($file->getClientOriginalExtension() ?: $file->guessExtension());
if (! in_array($extension, $event->allowed_extensions ?? [], true)) {
return response()->json([
'message' => 'File type not allowed. Allowed: '.implode(', ', $event->allowed_extensions),
], 422);
}
$maxBytes = $event->max_file_size_mb * 1024 * 1024;
if ($file->getSize() > $maxBytes) {
return response()->json([
'message' => 'File too large. Maximum size: '.$event->max_file_size_mb.' MB',
], 422);
}
// Ensure temp directory exists (local disk uses app/private/)
$tempDir = storage_path('app/private/uploads/temp');
if (! is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
$storedName = Str::uuid().'.'.$extension;
try {
$tempPath = $file->storeAs('uploads/temp', $storedName, ['disk' => 'local']);
if ($tempPath === false || $tempPath === null) {
\Log::error('File storeAs returned false/null', [
'original_name' => $originalName,
'stored_name' => $storedName,
'temp_dir' => $tempDir,
'temp_dir_exists' => is_dir($tempDir),
'temp_dir_writable' => is_writable($tempDir),
]);
return response()->json(['message' => 'Failed to store file'], 500);
}
} catch (\Throwable $e) {
\Log::error('File storage exception', [
'error' => $e->getMessage(),
'original_name' => $originalName,
'stored_name' => $storedName,
]);
return response()->json(['message' => 'Failed to store file: '.$e->getMessage()], 500);
}
// Local disk stores in app/private/, so construct full path accordingly
$fullPath = storage_path('app/private/'.$tempPath);
\Log::info('File stored successfully', [
'temp_path' => $tempPath,
'full_path' => $fullPath,
'file_exists' => file_exists($fullPath),
]);
$upload = $event->uploads()->create([
'original_filename' => $originalName,
'stored_filename' => $storedName,
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'status' => 'pending',
'uploader_name' => $request->input('uploader_name'),
'uploader_email' => $request->input('uploader_email'),
]);
ProcessEventUpload::dispatch($upload, $fullPath);
return response()->json([
'upload_id' => $upload->id,
'status' => $upload->status,
], 201);
}
public function uploadStatus(string $slug, int $uploadId): JsonResponse
{
$event = Event::where('slug', $slug)->where('is_active', true)->firstOrFail();
$upload = $event->uploads()->findOrFail($uploadId);
return response()->json([
'id' => $upload->id,
'status' => $upload->status,
'error_message' => $upload->error_message,
'google_drive_web_link' => $upload->google_drive_web_link,
]);
}
protected function validateUploadWindow(Event $event): void
{
if ($event->upload_start_at && now()->isBefore($event->upload_start_at)) {
abort(422, 'Uploads are not yet open.');
}
if ($event->upload_end_at && now()->isAfter($event->upload_end_at)) {
abort(422, 'Upload window has closed.');
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\Response;
class ThrottlePasswordVerification
{
public function handle(Request $request, Closure $next): Response
{
$key = 'password-verify:'.$request->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'message' => "Too many attempts. Please try again in {$seconds} seconds.",
], 429);
}
return $next($request);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class StoreEventRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'slug' => ['nullable', 'string', 'max:100', 'unique:events,slug', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'],
'google_drive_folder_id' => ['nullable', 'string', 'max:255'],
'google_drive_folder_name' => ['nullable', 'string', 'max:255'],
'is_active' => ['boolean'],
'upload_start_at' => ['nullable', 'date'],
'upload_end_at' => ['nullable', 'date', 'after:upload_start_at'],
'max_file_size_mb' => ['integer', 'min:1', 'max:2000'],
'allowed_extensions' => ['array'],
'allowed_extensions.*' => ['string', 'in:mp4,mov,avi,mkv,webm,jpg,jpeg,png'],
'require_password' => ['boolean'],
'upload_password' => ['nullable', 'string', 'min:4', 'max:100'],
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEventRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$event = $this->route('event');
$eventId = $event?->id ?? $this->route('id');
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'slug' => [
'nullable',
'string',
'max:100',
'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
Rule::unique('events', 'slug')->ignore($eventId),
],
'google_drive_folder_id' => ['nullable', 'string', 'max:255'],
'google_drive_folder_name' => ['nullable', 'string', 'max:255'],
'is_active' => ['boolean'],
'upload_start_at' => ['nullable', 'date'],
'upload_end_at' => ['nullable', 'date', 'after:upload_start_at'],
'max_file_size_mb' => ['integer', 'min:1', 'max:2000'],
'allowed_extensions' => ['array'],
'allowed_extensions.*' => ['string', 'in:mp4,mov,avi,mkv,webm,jpg,jpeg,png'],
'require_password' => ['boolean'],
'upload_password' => ['nullable', 'string', 'min:4', 'max:100'],
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Public;
use Illuminate\Foundation\Http\FormRequest;
class VerifyPasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'password' => ['required', 'string'],
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Jobs;
use App\Models\Upload;
use App\Services\GoogleDrive\GoogleDriveService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessEventUpload implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public function __construct(
public Upload $upload,
public string $tempFilePath
) {}
public function handle(GoogleDriveService $googleDrive): void
{
$upload = $this->upload;
$event = $upload->event;
$user = $event->user;
$upload->update(['status' => 'uploading', 'upload_started_at' => now()]);
try {
if (! $event->google_drive_folder_id) {
throw new \RuntimeException('Event has no Google Drive folder configured.');
}
if (! file_exists($this->tempFilePath)) {
throw new \RuntimeException('Temporary file not found.');
}
$result = $googleDrive->uploadFile(
$user,
$this->tempFilePath,
$event->google_drive_folder_id,
$upload->original_filename,
$upload->mime_type
);
$upload->update([
'google_drive_file_id' => $result['id'],
'google_drive_web_link' => $result['webViewLink'] ?? null,
'status' => 'completed',
'upload_completed_at' => now(),
'error_message' => null,
]);
} catch (\Throwable $e) {
$upload->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'upload_completed_at' => now(),
]);
throw $e;
} finally {
if (file_exists($this->tempFilePath)) {
@unlink($this->tempFilePath);
}
}
}
}

69
api/app/Models/Event.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Hash;
class Event extends Model
{
protected $hidden = ['upload_password'];
protected $appends = ['has_password'];
protected $fillable = [
'user_id',
'name',
'description',
'slug',
'google_drive_folder_id',
'google_drive_folder_name',
'is_active',
'upload_start_at',
'upload_end_at',
'max_file_size_mb',
'allowed_extensions',
'upload_password',
'require_password',
];
protected function casts(): array
{
return [
'upload_start_at' => 'datetime',
'upload_end_at' => 'datetime',
'is_active' => 'boolean',
'require_password' => 'boolean',
'allowed_extensions' => 'array',
];
}
public function getAllowedExtensionsAttribute($value): array
{
$decoded = $value ? json_decode($value, true) : null;
return is_array($decoded) ? $decoded : ['mp4', 'mov', 'avi', 'mkv', 'webm'];
}
public function getHasPasswordAttribute(): bool
{
return ! empty($this->attributes['upload_password'] ?? null);
}
public function setUploadPasswordAttribute(?string $value): void
{
$this->attributes['upload_password'] = $value ? Hash::make($value) : null;
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function uploads(): HasMany
{
return $this->hasMany(Upload::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class GoogleDriveConnection extends Model
{
protected $fillable = [
'user_id',
'access_token',
'refresh_token',
'token_expires_at',
'account_email',
];
protected function casts(): array
{
return [
'token_expires_at' => 'datetime',
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

38
api/app/Models/Upload.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Upload extends Model
{
protected $fillable = [
'event_id',
'original_filename',
'stored_filename',
'file_size',
'mime_type',
'google_drive_file_id',
'google_drive_web_link',
'status',
'error_message',
'uploader_name',
'uploader_email',
'upload_started_at',
'upload_completed_at',
];
protected function casts(): array
{
return [
'upload_started_at' => 'datetime',
'upload_completed_at' => 'datetime',
];
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
}

59
api/app/Models/User.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function googleDriveConnections(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(GoogleDriveConnection::class);
}
public function events(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Event::class);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,294 @@
<?php
namespace App\Services\GoogleDrive;
use App\Models\GoogleDriveConnection;
use App\Models\User;
use Google\Client as GoogleClient;
use Google\Http\MediaFileUpload;
use Google\Service\Drive;
use Google\Service\Drive\DriveFile;
class GoogleDriveService
{
protected const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive';
protected const DRIVE_METADATA_SCOPE = 'https://www.googleapis.com/auth/drive.metadata.readonly';
public function getAuthUrl(): string
{
$client = $this->createOAuthClient();
$client->setState(optional(request()->user())->id);
return $client->createAuthUrl();
}
public function handleCallback(string $code, User $user): GoogleDriveConnection
{
$client = $this->createOAuthClient();
$token = $client->fetchAccessTokenWithAuthCode($code);
if (isset($token['error'])) {
throw new \RuntimeException('Error fetching access token: '.($token['error_description'] ?? $token['error']));
}
$expiresAt = now();
if (isset($token['expires_in'])) {
$expiresAt = now()->addSeconds($token['expires_in']);
}
$connection = $user->googleDriveConnections()->first();
$email = $this->getTokenEmail($token, $client);
if ($connection) {
$connection->update([
'access_token' => $token['access_token'] ?? $connection->access_token,
'refresh_token' => $token['refresh_token'] ?? $connection->refresh_token,
'token_expires_at' => $expiresAt,
'account_email' => $email ?? $connection->account_email,
]);
} else {
$connection = $user->googleDriveConnections()->create([
'access_token' => $token['access_token'],
'refresh_token' => $token['refresh_token'] ?? '',
'token_expires_at' => $expiresAt,
'account_email' => $email ?? 'unknown',
]);
}
return $connection;
}
public function getClient(User $user): \Google\Client
{
$connection = $user->googleDriveConnections()->first();
if (! $connection) {
throw new \RuntimeException('No Google Drive connection found for user.');
}
$client = $this->createOAuthClient();
$token = [
'access_token' => $connection->access_token,
'refresh_token' => $connection->refresh_token,
'created' => $connection->token_expires_at->subSeconds(3600)->timestamp,
'expires_in' => 3600,
];
$client->setAccessToken($token);
if ($connection->token_expires_at->isPast() || $client->isAccessTokenExpired()) {
$newToken = $client->fetchAccessTokenWithRefreshToken($connection->refresh_token);
if (isset($newToken['error'])) {
throw new \RuntimeException('Error refreshing token: '.($newToken['error_description'] ?? $newToken['error']));
}
$expiresAt = isset($newToken['expires_in'])
? now()->addSeconds($newToken['expires_in'])
: now()->addHour();
$connection->update([
'access_token' => $newToken['access_token'],
'token_expires_at' => $expiresAt,
]);
$client->setAccessToken($newToken);
}
return $client;
}
public function getDriveService(User $user): Drive
{
$client = $this->getClient($user);
return new Drive($client);
}
/**
* List all Shared Drives (Team Drives) the user has access to.
*
* @return \Illuminate\Support\Collection<int, array{id: string, name: string, type: string}>
*/
public function listSharedDrives(User $user): \Illuminate\Support\Collection
{
$service = $this->getDriveService($user);
$optParams = [
'pageSize' => 100,
'fields' => 'drives(id, name)',
];
$results = $service->drives->listDrives($optParams);
return collect($results->getDrives())->map(fn ($d) => [
'id' => $d->getId(),
'name' => $d->getName(),
'type' => 'shared_drive',
]);
}
/**
* @return \Illuminate\Support\Collection<int, array{id: string, name: string}>
*/
public function listFolders(User $user, ?string $parentId = null, ?string $driveId = null): \Illuminate\Support\Collection
{
$service = $this->getDriveService($user);
$query = "mimeType = 'application/vnd.google-apps.folder' and trashed = false";
if ($parentId) {
// List folders inside a specific parent folder
$query .= " and '{$parentId}' in parents";
} elseif ($driveId) {
// List root folders of the Shared Drive
// For Shared Drives, we need to get folders where the drive itself is the parent
// This is done by querying within the specific drive without a parent filter
// and filtering to only top-level items (those with the drive as direct parent)
// However, the API doesn't have a simple "root" concept for Shared Drives
// So we'll fetch all and filter, or use a different approach
// Better approach: Get the drive root and list its children
// We'll query for folders where parents contains the driveId
$query .= " and '{$driveId}' in parents";
} else {
// List root folders of My Drive
$query .= " and 'root' in parents";
}
$optParams = [
'q' => $query,
'fields' => 'files(id, name)',
'orderBy' => 'name',
'supportsAllDrives' => true,
'includeItemsFromAllDrives' => true,
];
if ($driveId) {
$optParams['driveId'] = $driveId;
$optParams['corpora'] = 'drive';
}
$results = $service->files->listFiles($optParams);
return collect($results->getFiles())->map(fn ($f) => ['id' => $f->getId(), 'name' => $f->getName()]);
}
public function createFolder(User $user, string $name, ?string $parentId = null, ?string $driveId = null): array
{
$service = $this->getDriveService($user);
$file = new DriveFile;
$file->setName($name);
$file->setMimeType('application/vnd.google-apps.folder');
if ($parentId) {
$file->setParents([$parentId]);
} elseif ($driveId) {
// Creating in root of Shared Drive - no parent needed, just driveId
$file->setDriveId($driveId);
}
$optParams = [
'supportsAllDrives' => true,
];
$created = $service->files->create($file, $optParams);
return ['id' => $created->getId(), 'name' => $created->getName()];
}
/**
* Upload a file to Google Drive. Returns array with 'id' and 'webViewLink'.
*/
public function uploadFile(User $user, string $filePath, string $folderId, string $filename, string $mimeType): array
{
$client = $this->getClient($user);
$service = new Drive($client);
$file = new DriveFile;
$file->setName($filename);
$file->setParents([$folderId]);
$client->setDefer(true);
$request = $service->files->create($file, [
'mimeType' => $mimeType,
'uploadType' => 'resumable',
'fields' => 'id, webViewLink',
'supportsAllDrives' => true,
]);
$chunkSize = 5 * 1024 * 1024; // 5MB
$media = new MediaFileUpload(
$client,
$request,
$mimeType,
'',
true,
$chunkSize
);
$media->setFileSize(filesize($filePath));
$handle = fopen($filePath, 'rb');
$status = false;
while (! $status) {
$chunk = fread($handle, $chunkSize);
$status = $media->nextChunk($chunk);
}
fclose($handle);
$client->setDefer(false);
$file = $status;
if (! $file instanceof DriveFile) {
throw new \RuntimeException('Upload did not return file metadata.');
}
return [
'id' => $file->getId(),
'webViewLink' => $file->getWebViewLink() ?? $file->getWebContentLink() ?? '',
];
}
public function deleteFile(User $user, string $fileId): void
{
$service = $this->getDriveService($user);
$service->files->delete($fileId, ['supportsAllDrives' => true]);
}
/**
* Get a temporary download URL or web link for a file.
*/
public function getFileLink(User $user, string $fileId): ?string
{
$service = $this->getDriveService($user);
$file = $service->files->get($fileId, [
'fields' => 'webViewLink, webContentLink',
'supportsAllDrives' => true,
]);
return $file->getWebViewLink() ?? $file->getWebContentLink();
}
protected function createOAuthClient(): GoogleClient
{
$client = new GoogleClient;
$client->setClientId(config('services.google.client_id'));
$client->setClientSecret(config('services.google.client_secret'));
$client->setRedirectUri(config('services.google.redirect_uri'));
$client->setScopes([self::DRIVE_SCOPE, self::DRIVE_METADATA_SCOPE]);
$client->setAccessType('offline');
$client->setPrompt('consent');
return $client;
}
protected function getTokenEmail(array $token, GoogleClient $client): ?string
{
if (isset($token['id_token'])) {
$payload = $client->verifyIdToken($token['id_token']);
if ($payload && isset($payload['email'])) {
return $payload['email'];
}
}
try {
$client->setAccessToken($token);
$oauth2 = new \Google\Service\Oauth2($client);
$info = $oauth2->userinfo->get();
return $info->getEmail();
} catch (\Throwable) {
return null;
}
}
}