' . htmlspecialchars($exception->getMessage()) . '
'; + } +} diff --git a/config/environments/.keep b/app/Http/Controllers/Admin/.keep similarity index 100% rename from config/environments/.keep rename to app/Http/Controllers/Admin/.keep diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php new file mode 100644 index 000000000..dfcea39af --- /dev/null +++ b/app/Http/Controllers/Api/UserController.php @@ -0,0 +1,170 @@ +validated(); + + // Create the user using the service + $user = $this->userService->register($validated); + + return $this->json([ + 'success' => true, + 'message' => 'User registered successfully', + 'data' => [ + 'id' => $user->getKey(), + 'username' => $user->username, + 'email' => $user->user_email, + 'registered_at' => now()->toISOString() + ] + ], 201); + + } catch (ValidationException $e) { + return $this->json([ + 'success' => false, + 'message' => 'Validation failed', + 'errors' => $e->errors() + ], 422); + + } catch (\Exception $e) { + return $this->json([ + 'success' => false, + 'message' => 'Registration failed', + 'error' => $e->getMessage() + ], 500); + } + } + + /** + * Get user profile + */ + public function show(Request $request, int $userId): JsonResponse + { + try { + $user = User::find($userId); + + if (!$user) { + return $this->json([ + 'success' => false, + 'message' => 'User not found' + ], 404); + } + + $stats = $user->getStats(); + + return $this->json([ + 'success' => true, + 'data' => [ + 'id' => $user->getKey(), + 'username' => $user->username, + 'level' => $user->user_level, + 'active' => $user->isActive(), + 'registered' => now()->createFromTimestamp($user->user_regdate)->toISOString(), + 'last_visit' => now()->createFromTimestamp($user->user_lastvisit)->diffForHumans(), + 'stats' => [ + 'uploaded' => $stats['u_up_total'] ?? 0, + 'downloaded' => $stats['u_down_total'] ?? 0, + 'ratio' => $user->getRatio(), + ], + 'permissions' => [ + 'is_admin' => $user->isAdmin(), + 'is_moderator' => $user->isModerator(), + ] + ] + ]); + + } catch (\Exception $e) { + return $this->json([ + 'success' => false, + 'message' => 'Failed to retrieve user', + 'error' => $e->getMessage() + ], 500); + } + } + + /** + * List users with filtering and search + */ + public function index(Request $request): JsonResponse + { + try { + // Use Laravel-style parameter handling + $page = (int)$request->get('page', 1); + $perPage = min((int)$request->get('per_page', 20), 100); + $search = Str::limit(trim($request->get('search', '')), 50); + $level = $request->get('level'); + + // Get users using collection helpers + $users = collect(User::all()) + ->when(!empty($search), function ($collection) use ($search) { + return $collection->filter(function ($user) use ($search) { + return Str::contains(Str::lower($user->username), Str::lower($search)) || + Str::contains(Str::lower($user->user_email), Str::lower($search)); + }); + }) + ->when($level !== null, function ($collection) use ($level) { + return $collection->where('user_level', $level); + }) + ->where('user_active', 1) + ->sortBy('username') + ->forPage($page, $perPage) + ->map(function ($user) { + return [ + 'id' => $user->getKey(), + 'username' => $user->username, + 'level' => $user->user_level, + 'registered' => now()->createFromTimestamp($user->user_regdate)->format('Y-m-d'), + 'is_admin' => $user->isAdmin(), + 'is_moderator' => $user->isModerator(), + ]; + }) + ->values(); + + return $this->json([ + 'success' => true, + 'data' => $users->toArray(), + 'meta' => [ + 'page' => $page, + 'per_page' => $perPage, + 'search' => (string)$search, + 'level_filter' => $level + ] + ]); + + } catch (\Exception $e) { + return $this->json([ + 'success' => false, + 'message' => 'Failed to retrieve users', + 'error' => $e->getMessage() + ], 500); + } + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 000000000..c1db9aba9 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,94 @@ +validator = $validator; + } + /** + * Render a view with data + */ + protected function view(string $view, array $data = []): Response + { + $viewPath = $this->resolveViewPath($view); + + if (!file_exists($viewPath)) { + return new Response("View not found: {$view}", 404); + } + + // Extract data for use in view + extract($data); + + // Capture view output + ob_start(); + require $viewPath; + $content = ob_get_clean(); + + return new Response($content); + } + + /** + * Return a JSON response + */ + protected function json(array $data, int $status = 200, array $headers = []): JsonResponse + { + return new JsonResponse($data, $status, $headers); + } + + /** + * Redirect to a URL + */ + protected function redirect(string $url, int $status = 302): RedirectResponse + { + return new RedirectResponse($url, $status); + } + + /** + * Return a plain response + */ + protected function response(string $content = '', int $status = 200, array $headers = []): Response + { + return new Response($content, $status, $headers); + } + + /** + * Validate request data using Illuminate validation + */ + protected function validate(Request $request, array $rules, array $messages = [], array $attributes = []): array + { + $validator = $this->validator->make($request->all(), $rules, $messages, $attributes); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + return $validator->validated(); + } + + + /** + * Resolve view file path + */ + private function resolveViewPath(string $view): string + { + $view = str_replace('.', '/', $view); + return resource_path('views/' . $view . '.php'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/HelloWorldController.php b/app/Http/Controllers/Web/HelloWorldController.php new file mode 100644 index 000000000..108ef970c --- /dev/null +++ b/app/Http/Controllers/Web/HelloWorldController.php @@ -0,0 +1,153 @@ +config->get('sitename', 'TorrentPier'); + $currentTime = now()->format('Y-m-d H:i:s'); + + $data = [ + 'siteName' => $siteName, + 'currentTime' => $currentTime, + 'request' => (object) [ + 'uri' => $request->fullUrl(), + 'method' => $request->method() + ], + 'architecture' => 'MVC with Illuminate HTTP' + ]; + + return $this->view('hello', $data); + } + + /** + * Return JSON response for hello world + */ + public function jsonResponse(Request $request): JsonResponse + { + $siteName = $this->config->get('sitename', 'TorrentPier'); + + return $this->json([ + 'message' => 'Hello World from TorrentPier!', + 'site' => $siteName, + 'timestamp' => now()->timestamp, + 'datetime' => now()->toISOString(), + 'route' => [ + 'uri' => $request->fullUrl(), + 'method' => $request->method(), + 'controller' => self::class, + ], + 'architecture' => [ + 'pattern' => 'Laravel-style MVC', + 'router' => 'Custom Laravel-style Router', + 'http' => 'Illuminate HTTP', + 'support' => 'Illuminate Support', + 'di' => 'Illuminate Container' + ], + 'features' => [ + 'illuminate_http' => 'Response and Request handling', + 'illuminate_support' => 'Collections, Str, Arr helpers', + 'carbon' => 'Date manipulation with now() and today()', + 'validation' => 'Laravel-style request validation', + 'collections' => 'collect() helper for data manipulation' + ] + ]); + } + + /** + * Demonstrate modern Laravel-style features + */ + public function features(Request $request): JsonResponse + { + // Demonstrate collections + $users = collect([ + ['name' => 'Alice', 'age' => 25], + ['name' => 'Bob', 'age' => 30], + ['name' => 'Charlie', 'age' => 35] + ]); + + $adults = $users->where('age', '>=', 18)->pluck('name'); + + // Demonstrate string helpers + $title = str('hello world')->title()->append('!'); + + // Demonstrate array helpers + $config = [ + 'app' => [ + 'name' => 'TorrentPier', + 'version' => '3.0' + ] + ]; + + $appName = data_get($config, 'app.name', 'Unknown'); + + return $this->json([ + 'collections_demo' => [ + 'original_users' => $users->toArray(), + 'adult_names' => $adults->toArray() + ], + 'string_demo' => [ + 'original' => 'hello world', + 'transformed' => (string) $title + ], + 'array_helpers_demo' => [ + 'config' => $config, + 'app_name' => $appName + ], + 'date_helpers' => [ + 'now' => now()->toISOString(), + 'today' => today()->toDateString(), + 'timestamp' => now()->timestamp + ] + ]); + } + + /** + * Extended features demonstration + */ + public function extended(Request $request): JsonResponse + { + $siteName = $this->config->get('sitename', 'TorrentPier'); + + return $this->json([ + 'message' => 'Extended Laravel-style features!', + 'site' => $siteName, + 'timestamp' => now()->timestamp, + 'datetime' => now()->toISOString(), + 'request_info' => [ + 'url' => $request->fullUrl(), + 'method' => $request->method(), + 'ip' => $request->ip(), + 'user_agent' => $request->userAgent(), + ], + 'laravel_features' => [ + 'collections' => 'Native Laravel collections', + 'request' => 'Pure Illuminate Request', + 'response' => 'Pure Illuminate JsonResponse', + 'validation' => 'Built-in validation', + 'helpers' => 'Laravel helper functions' + ] + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Web/LegacyController.php b/app/Http/Controllers/Web/LegacyController.php new file mode 100644 index 000000000..06684d128 --- /dev/null +++ b/app/Http/Controllers/Web/LegacyController.php @@ -0,0 +1,219 @@ +config = $config; + } + + public function index(Request $request): Response + { + return $this->handleController($request, 'index'); + } + + public function ajax(Request $request): Response + { + return $this->handleController($request, 'ajax'); + } + + public function dl(Request $request): Response + { + return $this->handleController($request, 'dl'); + } + + public function dl_list(Request $request): Response + { + return $this->handleController($request, 'dl_list'); + } + + public function feed(Request $request): Response + { + return $this->handleController($request, 'feed'); + } + + public function filelist(Request $request): Response + { + return $this->handleController($request, 'filelist'); + } + + public function group(Request $request): Response + { + return $this->handleController($request, 'group'); + } + + public function group_edit(Request $request): Response + { + return $this->handleController($request, 'group_edit'); + } + + public function info(Request $request): Response + { + return $this->handleController($request, 'info'); + } + + public function login(Request $request): Response + { + return $this->handleController($request, 'login'); + } + + public function memberlist(Request $request): Response + { + return $this->handleController($request, 'memberlist'); + } + + public function modcp(Request $request): Response + { + return $this->handleController($request, 'modcp'); + } + + public function playback_m3u(Request $request): Response + { + return $this->handleController($request, 'playback_m3u'); + } + + public function poll(Request $request): Response + { + return $this->handleController($request, 'poll'); + } + + public function posting(Request $request): Response + { + return $this->handleController($request, 'posting'); + } + + public function privmsg(Request $request): Response + { + return $this->handleController($request, 'privmsg'); + } + + public function profile(Request $request): Response + { + return $this->handleController($request, 'profile'); + } + + public function search(Request $request): Response + { + return $this->handleController($request, 'search'); + } + + public function terms(Request $request): Response + { + return $this->handleController($request, 'terms'); + } + + public function tracker(Request $request): Response + { + return $this->handleController($request, 'tracker'); + } + + public function viewforum(Request $request): Response + { + return $this->handleController($request, 'viewforum'); + } + + public function viewtopic(Request $request): Response + { + return $this->handleController($request, 'viewtopic'); + } + + + public function handleController(Request $request, string $controller): Response + { + $rootPath = dirname(__DIR__, 4); + $controllerPath = $rootPath . '/controllers/' . $controller . '.php'; + + if (!file_exists($controllerPath)) { + return new Response( + "Legacy controller '{$controller}' not found
", + 404, + ['Content-Type' => 'text/html'] + ); + } + + // Capture the legacy controller output + $output = ''; + $originalObLevel = ob_get_level(); + + try { + // Ensure legacy common.php is loaded for legacy controllers + if (!defined('BB_PATH')) { + require_once $rootPath . '/common.php'; + } + + ob_start(); + + // No need to save/restore superglobals - legacy controllers may modify them intentionally + + // Signal to legacy code that we're running through modern routing + if (!defined('MODERN_ROUTING')) { + define('MODERN_ROUTING', true); + } + + // Import essential legacy globals into local scope + global $bb_cfg, $config, $user, $template, $datastore, $lang, $userdata, $userinfo, $images, + $tracking_topics, $tracking_forums, $theme, $bf, $attach_config, $gen_simple_header, + $client_ip, $user_ip, $log_action, $html, $wordCensor, $search_id, + $session_id, $items_found, $per_page, $topic_id, $req_topics, $forum_id, $mode, + $is_auth, $t_data, $postrow, $group_id, $group_info, $post_id, $folder, $post_info, + $tor, $post_data, $privmsg, $forums, $redirect, $attachment, $forum_data, $search_all, + $redirect_url, $topic_csv, $poster_id, $emailer, $s_hidden_fields, $opt, $msg, $stats, + $page_cfg, $ads, $cat_forums, $last_session_data, $announce_interval, $auth_pages, + $lastvisit, $current_time, $excluded_forums_csv, $sphinx, $dl_link_css, $dl_status_css, + $upload_dir, $topic_data, $attachments; + + // GPC variables created dynamically via $GLOBALS in tracker.php and search.php + global $all_words_key, $all_words_val, $active_key, $active_val, $cat_key, $cat_val, + $dl_cancel_key, $dl_cancel_val, $dl_compl_key, $dl_compl_val, $dl_down_key, $dl_down_val, + $dl_will_key, $dl_will_val, $forum_key, $forum_val, $my_key, $my_val, $new_key, $new_val, + $title_match_key, $title_match_val, $order_key, $order_val, $poster_id_key, $poster_id_val, + $poster_name_key, $poster_name_val, $user_releases_key, $user_releases_val, $sort_key, $sort_val, + $seed_exist_key, $seed_exist_val, $show_author_key, $show_author_val, $show_cat_key, $show_cat_val, + $show_forum_key, $show_forum_val, $show_speed_key, $show_speed_val, $s_rg_key, $s_rg_val, + $s_not_seen_key, $s_not_seen_val, $time_key, $time_val, $tor_type_key, $tor_type_val, + $hash_key, $hash_val, $chars_key, $chars_val, $display_as_key, $display_as_val, + $dl_user_id_key, $dl_user_id_val, $my_topics_key, $my_topics_val, $new_topics_key, $new_topics_val, + $text_match_key, $text_match_val, $title_only_key, $title_only_val, $topic_key, $topic_val; + + // Include the legacy controller + // Note: We don't use require_once to allow multiple includes if needed + include $controllerPath; + + // Get the captured output - make sure we only clean our own buffer + $output = ob_get_clean(); + + // Return the output as HTML response + return new Response($output, 200, ['Content-Type' => 'text/html']); + + } catch (\Throwable $e) { + // Clean up any extra output buffers that were started, but preserve original level + while (ob_get_level() > $originalObLevel) { + ob_end_clean(); + } + + // Return error response + $errorHtml = " +Controller: {$controller}
+Error: " . htmlspecialchars($e->getMessage()) . "
+File: " . htmlspecialchars($e->getFile()) . ":" . $e->getLine() . "
+ "; + + if (function_exists('dev') && dev()->isDebugEnabled()) { + $errorHtml .= "" . htmlspecialchars($e->getTraceAsString()) . ""; + } + + return new Response($errorHtml, 500, ['Content-Type' => 'text/html']); + } + } +} diff --git a/app/Http/Middleware/AdminMiddleware.php b/app/Http/Middleware/AdminMiddleware.php new file mode 100644 index 000000000..7115f0b9b --- /dev/null +++ b/app/Http/Middleware/AdminMiddleware.php @@ -0,0 +1,52 @@ +attributes->get('authenticated_user_id'); + + if (!$userId) { + if ($request->expectsJson()) { + return new JsonResponse(['error' => 'Authentication required'], 401); + } + + return new Response('Authentication required', 401); + } + + // TODO: Implement actual admin role check + // For now, accept user ID 1 as admin + if ($userId !== 1) { + if ($request->expectsJson()) { + return new JsonResponse(['error' => 'Admin access required'], 403); + } + + return new Response('Admin access required', 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/AuthMiddleware.php b/app/Http/Middleware/AuthMiddleware.php new file mode 100644 index 000000000..9e92b9989 --- /dev/null +++ b/app/Http/Middleware/AuthMiddleware.php @@ -0,0 +1,55 @@ +bearerToken() ?? $request->input('api_token'); + + if (!$token) { + if ($request->expectsJson()) { + return new JsonResponse(['error' => 'Authentication required'], 401); + } + + return new Response('Authentication required', 401); + } + + // TODO: Implement actual token validation + // For now, accept any token that starts with 'valid_' + if (!str_starts_with($token, 'valid_')) { + if ($request->expectsJson()) { + return new JsonResponse(['error' => 'Invalid token'], 401); + } + + return new Response('Invalid token', 401); + } + + // Add user info to request for use in controllers + $request->attributes->set('authenticated_user_id', 1); + + return $next($request); + } +} diff --git a/app/Http/Middleware/BaseMiddleware.php b/app/Http/Middleware/BaseMiddleware.php new file mode 100644 index 000000000..97bf454ca --- /dev/null +++ b/app/Http/Middleware/BaseMiddleware.php @@ -0,0 +1,38 @@ +before($request); + $response = $next($request); + return $this->after($request, $response); + } + + /** + * Process request before passing to next middleware + */ + protected function before(Request $request): Request + { + return $request; + } + + /** + * Process response after middleware chain + */ + protected function after(Request $request, Response $response): Response + { + return $response; + } +} diff --git a/app/Http/Middleware/CorsMiddleware.php b/app/Http/Middleware/CorsMiddleware.php new file mode 100644 index 000000000..871b4fa38 --- /dev/null +++ b/app/Http/Middleware/CorsMiddleware.php @@ -0,0 +1,58 @@ +allowedOrigins = $allowedOrigins; + $this->allowedHeaders = $allowedHeaders; + $this->allowedMethods = $allowedMethods; + } + + public function handle(Request $request, Closure $next): Response + { + if ($request->getMethod() === 'OPTIONS') { + return $this->createPreflightResponse($request); + } + + $response = $next($request); + return $this->addCorsHeaders($response, $request); + } + + private function createPreflightResponse(Request $request): Response + { + $response = new Response('', 200); + return $this->addCorsHeaders($response, $request); + } + + private function addCorsHeaders(Response $response, Request $request): Response + { + $origin = $request->headers->get('Origin', ''); + + if (in_array('*', $this->allowedOrigins) || in_array($origin, $this->allowedOrigins)) { + $response->headers->set('Access-Control-Allow-Origin', $origin ?: '*'); + } + + $response->headers->set('Access-Control-Allow-Methods', implode(', ', $this->allowedMethods)); + $response->headers->set('Access-Control-Allow-Headers', implode(', ', $this->allowedHeaders)); + $response->headers->set('Access-Control-Max-Age', '86400'); + + return $response; + } +} diff --git a/app/Http/Requests/FormRequest.php b/app/Http/Requests/FormRequest.php new file mode 100644 index 000000000..1bc1fbd1f --- /dev/null +++ b/app/Http/Requests/FormRequest.php @@ -0,0 +1,116 @@ +request = $request; + $this->runValidation(); + } + + /** + * Get the validation rules that apply to the request + */ + abstract public function rules(): array; + + /** + * Get custom validation messages + */ + public function messages(): array + { + return []; + } + + /** + * Get custom attributes for validator errors + */ + public function attributes(): array + { + return []; + } + + /** + * Determine if the user is authorized to make this request + */ + public function authorize(): bool + { + return true; + } + + /** + * Get validated data + */ + public function validated(): array + { + return $this->validated; + } + + /** + * Get specific validated field + */ + public function get(string $key, mixed $default = null): mixed + { + return data_get($this->validated, $key, $default); + } + + /** + * Get all request data + */ + public function all(): array + { + return $this->request->all(); + } + + /** + * Get only specific fields from request + */ + public function only(array $keys): array + { + return $this->request->only($keys); + } + + /** + * Get request data except specific fields + */ + public function except(array $keys): array + { + return $this->request->except($keys); + } + + /** + * Run the validation + */ + protected function runValidation(): void + { + if (!$this->authorize()) { + throw new \Illuminate\Auth\Access\AuthorizationException('This action is unauthorized.'); + } + + $validator = Validator::make( + $this->request->all(), + $this->rules(), + $this->messages(), + $this->attributes() + ); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $this->validated = $validator->validated(); + } +} \ No newline at end of file diff --git a/app/Http/Requests/RegisterUserRequest.php b/app/Http/Requests/RegisterUserRequest.php new file mode 100644 index 000000000..c821b2fe7 --- /dev/null +++ b/app/Http/Requests/RegisterUserRequest.php @@ -0,0 +1,120 @@ + [ + 'required', + 'string', + 'min:3', + 'max:25', + 'regex:/^[a-zA-Z0-9_-]+$/', + 'unique:bb_users,username' + ], + 'email' => [ + 'required', + 'email', + 'max:255', + 'unique:bb_users,user_email' + ], + 'password' => [ + 'required', + 'string', + 'min:6', + 'max:72' + ], + 'password_confirmation' => [ + 'required', + 'same:password' + ], + 'terms' => [ + 'accepted' + ] + ]; + } + + /** + * Get custom validation messages + */ + public function messages(): array + { + return [ + 'username.required' => 'Username is required.', + 'username.min' => 'Username must be at least 3 characters.', + 'username.max' => 'Username cannot exceed 25 characters.', + 'username.regex' => 'Username can only contain letters, numbers, hyphens, and underscores.', + 'username.unique' => 'This username is already taken.', + + 'email.required' => 'Email address is required.', + 'email.email' => 'Please enter a valid email address.', + 'email.unique' => 'This email address is already registered.', + + 'password.required' => 'Password is required.', + 'password.min' => 'Password must be at least 6 characters.', + 'password.max' => 'Password cannot exceed 72 characters.', + + 'password_confirmation.required' => 'Password confirmation is required.', + 'password_confirmation.same' => 'Password confirmation does not match.', + + 'terms.accepted' => 'You must accept the terms and conditions.' + ]; + } + + /** + * Get custom attributes for validator errors + */ + public function attributes(): array + { + return [ + 'username' => 'username', + 'email' => 'email address', + 'password' => 'password', + 'password_confirmation' => 'password confirmation', + 'terms' => 'terms and conditions' + ]; + } + + /** + * Get the username + */ + public function getUsername(): string + { + return str($this->get('username'))->trim()->lower(); + } + + /** + * Get the email + */ + public function getEmail(): string + { + return str($this->get('email'))->trim()->lower(); + } + + /** + * Get the password + */ + public function getPassword(): string + { + return $this->get('password'); + } + + /** + * Check if terms are accepted + */ + public function hasAcceptedTerms(): bool + { + return (bool) $this->get('terms'); + } +} \ No newline at end of file diff --git a/app/Http/Routing/Router.php b/app/Http/Routing/Router.php new file mode 100644 index 000000000..b13d2be56 --- /dev/null +++ b/app/Http/Routing/Router.php @@ -0,0 +1,223 @@ +container = $container; + + // Create event dispatcher if not already bound + if (!$container->bound('events')) { + $container->singleton('events', function () { + return new Dispatcher($this->container); + }); + } + + // Create the Illuminate Router + $this->router = new LaravelRouter($container->make('events'), $container); + + // Register middleware aliases + $this->registerMiddleware(); + + // Bind router instance + $container->instance('router', $this->router); + $container->instance(LaravelRouter::class, $this->router); + + // Create and bind URL generator + $request = $container->bound('request') ? $container->make('request') : Request::capture(); + $container->instance('request', $request); + + $url = new UrlGenerator($this->router->getRoutes(), $request); + $container->instance('url', $url); + $container->instance(UrlGenerator::class, $url); + } + + /** + * Get the underlying Laravel Router instance + */ + public function getRouter(): LaravelRouter + { + return $this->router; + } + + /** + * Register a GET route + */ + public function get(string $uri, $action): \Illuminate\Routing\Route + { + return $this->router->get($uri, $this->parseAction($action)); + } + + /** + * Register a POST route + */ + public function post(string $uri, $action): \Illuminate\Routing\Route + { + return $this->router->post($uri, $this->parseAction($action)); + } + + /** + * Register a PUT route + */ + public function put(string $uri, $action): \Illuminate\Routing\Route + { + return $this->router->put($uri, $this->parseAction($action)); + } + + /** + * Register a PATCH route + */ + public function patch(string $uri, $action): \Illuminate\Routing\Route + { + return $this->router->patch($uri, $this->parseAction($action)); + } + + /** + * Register a DELETE route + */ + public function delete(string $uri, $action): \Illuminate\Routing\Route + { + return $this->router->delete($uri, $this->parseAction($action)); + } + + /** + * Register an OPTIONS route + */ + public function options(string $uri, $action): \Illuminate\Routing\Route + { + return $this->router->options($uri, $this->parseAction($action)); + } + + /** + * Register a route for any HTTP verb + */ + public function any(string $uri, $action): \Illuminate\Routing\Route + { + return $this->router->any($uri, $this->parseAction($action)); + } + + /** + * Register a route group + */ + public function group(array $attributes, \Closure $callback): void + { + $this->router->group($attributes, $callback); + } + + /** + * Register a route prefix + */ + public function prefix(string $prefix): \Illuminate\Routing\RouteRegistrar + { + return $this->router->prefix($prefix); + } + + /** + * Register middleware + */ + public function middleware($middleware): \Illuminate\Routing\RouteRegistrar + { + return $this->router->middleware($middleware); + } + + /** + * Register a resource controller + */ + public function resource(string $name, string $controller, array $options = []): \Illuminate\Routing\PendingResourceRegistration + { + return $this->router->resource($name, $controller, $options); + } + + /** + * Dispatch the request to the application + */ + public function dispatch(Request $request): Response|JsonResponse + { + try { + $response = $this->router->dispatch($request); + + // Ensure we always return a Response object + if (!$response instanceof Response && !$response instanceof JsonResponse) { + if (is_array($response) || is_object($response)) { + return new JsonResponse($response); + } + return new Response($response); + } + + return $response; + } catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException $e) { + return new Response('Not Found', 404); + } catch (\Exception $e) { + // Log the error if logger is available + if ($this->container->bound('log')) { + $this->container->make('log')->error($e->getMessage(), ['exception' => $e]); + } + + return new Response('Internal Server Error', 500); + } + } + + /** + * Parse the action to convert Class::method to array format + */ + private function parseAction($action) + { + if (is_string($action) && str_contains($action, '::')) { + return str_replace('::', '@', $action); + } + + return $action; + } + + /** + * Get all registered routes + */ + public function getRoutes(): \Illuminate\Routing\RouteCollection + { + return $this->router->getRoutes(); + } + + /** + * Set the fallback route + */ + public function fallback($action): \Illuminate\Routing\Route + { + return $this->router->fallback($this->parseAction($action)); + } + + /** + * Register middleware aliases + */ + private function registerMiddleware(): void + { + $middlewareAliases = [ + 'auth' => \App\Http\Middleware\AuthMiddleware::class, + 'admin' => \App\Http\Middleware\AdminMiddleware::class, + 'cors' => \App\Http\Middleware\CorsMiddleware::class, + ]; + + foreach ($middlewareAliases as $alias => $middleware) { + $this->router->aliasMiddleware($alias, $middleware); + } + } +} \ No newline at end of file diff --git a/app/Listeners/SendWelcomeEmail.php b/app/Listeners/SendWelcomeEmail.php new file mode 100644 index 000000000..b06971f5d --- /dev/null +++ b/app/Listeners/SendWelcomeEmail.php @@ -0,0 +1,41 @@ +getUsername(), + $event->getUserId(), + $event->getEmail() + )); + } + } + + /** + * Determine whether the listener should be queued + */ + public function shouldQueue(UserRegistered $event): bool + { + return true; + } +} \ No newline at end of file diff --git a/app/Listeners/UpdateUserStatistics.php b/app/Listeners/UpdateUserStatistics.php new file mode 100644 index 000000000..10bb51d22 --- /dev/null +++ b/app/Listeners/UpdateUserStatistics.php @@ -0,0 +1,32 @@ +getUploaderId(), + $event->getTorrentName(), + $event->getTorrentId(), + $event->getSize() + )); + } + } +} \ No newline at end of file diff --git a/app/Models/Model.php b/app/Models/Model.php new file mode 100644 index 000000000..5a7e3cb7c --- /dev/null +++ b/app/Models/Model.php @@ -0,0 +1,223 @@ +fill($attributes); + $this->syncOriginal(); + } + + /** + * Find a model by its primary key + */ + public static function find(int|string $id): ?static + { + $instance = new static(DB()); + $data = $instance->db->table($instance->table) + ->where($instance->primaryKey, $id) + ->fetch(); + + if (!$data) { + return null; + } + + return new static(DB(), (array) $data); + } + + /** + * Find a model by a specific column + */ + public static function findBy(string $column, mixed $value): ?static + { + $instance = new static(DB()); + $data = $instance->db->table($instance->table) + ->where($column, $value) + ->fetch(); + + if (!$data) { + return null; + } + + return new static(DB(), (array) $data); + } + + /** + * Get all models + */ + public static function all(): array + { + $instance = new static(DB()); + $rows = $instance->db->table($instance->table)->fetchAll(); + + $models = []; + foreach ($rows as $row) { + $models[] = new static(DB(), (array) $row); + } + + return $models; + } + + /** + * Fill the model with an array of attributes + */ + public function fill(array $attributes): self + { + foreach ($attributes as $key => $value) { + $this->attributes[$key] = $value; + } + + return $this; + } + + /** + * Save the model to the database + */ + public function save(): bool + { + if ($this->exists()) { + return $this->update(); + } + + return $this->insert(); + } + + /** + * Insert a new record + */ + protected function insert(): bool + { + $this->db->table($this->table)->insert($this->attributes); + + if (!isset($this->attributes[$this->primaryKey])) { + $this->attributes[$this->primaryKey] = $this->db->getInsertId(); + } + + $this->syncOriginal(); + return true; + } + + /** + * Update an existing record + */ + protected function update(): bool + { + $dirty = $this->getDirty(); + + if (empty($dirty)) { + return true; + } + + $this->db->table($this->table) + ->where($this->primaryKey, $this->getKey()) + ->update($dirty); + + $this->syncOriginal(); + return true; + } + + /** + * Delete the model + */ + public function delete(): bool + { + if (!$this->exists()) { + return false; + } + + $this->db->table($this->table) + ->where($this->primaryKey, $this->getKey()) + ->delete(); + + return true; + } + + /** + * Check if the model exists in the database + */ + public function exists(): bool + { + return isset($this->original[$this->primaryKey]); + } + + /** + * Get the primary key value + */ + public function getKey(): int|string|null + { + return $this->attributes[$this->primaryKey] ?? null; + } + + /** + * Get attributes that have been changed + */ + public function getDirty(): array + { + $dirty = []; + + foreach ($this->attributes as $key => $value) { + if (!array_key_exists($key, $this->original) || $value !== $this->original[$key]) { + $dirty[$key] = $value; + } + } + + return $dirty; + } + + /** + * Sync the original attributes with the current + */ + protected function syncOriginal(): void + { + $this->original = $this->attributes; + } + + /** + * Get an attribute + */ + public function __get(string $key): mixed + { + return $this->attributes[$key] ?? null; + } + + /** + * Set an attribute + */ + public function __set(string $key, mixed $value): void + { + $this->attributes[$key] = $value; + } + + /** + * Check if an attribute exists + */ + public function __isset(string $key): bool + { + return isset($this->attributes[$key]); + } + + /** + * Convert the model to an array + */ + public function toArray(): array + { + return $this->attributes; + } +} \ No newline at end of file diff --git a/app/Models/Torrent.php b/app/Models/Torrent.php new file mode 100644 index 000000000..f9da5d7c9 --- /dev/null +++ b/app/Models/Torrent.php @@ -0,0 +1,109 @@ +db->table('bb_bt_tracker') + ->where('topic_id', $this->getKey()) + ->where('complete', 0) + ->fetchAll(); + } + + /** + * Get completed peers (seeders) for this torrent + */ + public function getSeeders(): array + { + return $this->db->table('bb_bt_tracker') + ->where('topic_id', $this->getKey()) + ->where('complete', 1) + ->fetchAll(); + } + + /** + * Get torrent statistics + */ + public function getStats(): array + { + $stats = $this->db->table('bb_bt_tracker_snap') + ->where('topic_id', $this->getKey()) + ->fetch(); + + return $stats ? (array) $stats : [ + 'seeders' => 0, + 'leechers' => 0, + 'complete' => 0 + ]; + } + + /** + * Get the user who uploaded this torrent + */ + public function getUploader(): ?User + { + return User::find($this->poster_id); + } + + /** + * Get the forum topic associated with this torrent + */ + public function getTopic(): array + { + $topic = $this->db->table('bb_topics') + ->where('topic_id', $this->getKey()) + ->fetch(); + + return $topic ? (array) $topic : []; + } + + /** + * Check if torrent is active + */ + public function isActive(): bool + { + return (int) $this->tor_status === 0; + } + + /** + * Find torrent by info hash + */ + public static function findByInfoHash(string $infoHash): ?self + { + return self::findBy('info_hash', $infoHash); + } + + /** + * Get recent torrents + */ + public static function getRecent(int $limit = 10): array + { + $instance = new static(DB()); + $rows = $instance->db->table($instance->table) + ->orderBy('reg_time DESC') + ->limit($limit) + ->fetchAll(); + + $torrents = []; + foreach ($rows as $row) { + $torrents[] = new static(DB(), (array) $row); + } + + return $torrents; + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 000000000..b54b7eaf3 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,139 @@ +db->table('bb_bt_torrents') + ->where('poster_id', $this->getKey()) + ->orderBy('reg_time DESC') + ->fetchAll(); + } + + /** + * Get user's posts + */ + public function getPosts(int $limit = 10): array + { + return $this->db->table('bb_posts') + ->where('poster_id', $this->getKey()) + ->orderBy('post_time DESC') + ->limit($limit) + ->fetchAll(); + } + + /** + * Get user's groups + */ + public function getGroups(): array + { + return $this->db->table('bb_user_group') + ->where('user_id', $this->getKey()) + ->where('user_pending', 0) + ->fetchAll(); + } + + /** + * Check if user is admin + */ + public function isAdmin(): bool + { + return (int) $this->user_level === 1; + } + + /** + * Check if user is moderator + */ + public function isModerator(): bool + { + return (int) $this->user_level === 2; + } + + /** + * Check if user is active + */ + public function isActive(): bool + { + return (int) $this->user_active === 1; + } + + /** + * Get user's upload/download statistics + */ + public function getStats(): array + { + $stats = $this->db->table('bb_bt_users') + ->where('user_id', $this->getKey()) + ->fetch(); + + return $stats ? (array) $stats : [ + 'u_up_total' => 0, + 'u_down_total' => 0, + 'u_up_release' => 0, + 'u_up_bonus' => 0 + ]; + } + + /** + * Get user's ratio + */ + public function getRatio(): float + { + $stats = $this->getStats(); + $downloaded = (int) $stats['u_down_total']; + $uploaded = (int) $stats['u_up_total']; + + if ($downloaded === 0) { + return 0.0; + } + + return round($uploaded / $downloaded, 2); + } + + /** + * Verify password + */ + public function verifyPassword(string $password): bool + { + return password_verify($password, $this->user_password); + } + + /** + * Update password + */ + public function updatePassword(string $newPassword): void + { + $this->user_password = password_hash($newPassword, PASSWORD_BCRYPT); + } +} \ No newline at end of file diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 000000000..c6c849bda --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,66 @@ +registerEvents(); + $this->registerValidation(); + } + + /** + * Bootstrap any application services + */ + public function boot(): void + { + // Bootstrap services that need the application to be fully loaded + } + + + /** + * Register the event dispatcher + */ + protected function registerEvents(): void + { + $this->app->singleton('events', function ($app) { + return new \Illuminate\Events\Dispatcher($app); + }); + + $this->app->alias('events', \Illuminate\Events\Dispatcher::class); + $this->app->alias('events', \Illuminate\Contracts\Events\Dispatcher::class); + } + + /** + * Register the validation factory + */ + protected function registerValidation(): void + { + $this->app->singleton('validator', function ($app) { + $loader = new \Illuminate\Translation\ArrayLoader(); + $translator = new \Illuminate\Translation\Translator($loader, 'en'); + return new \Illuminate\Validation\Factory($translator, $app); + }); + + $this->app->bind(\Illuminate\Validation\Factory::class, function ($app) { + return $app['validator']; + }); + + $this->app->alias('validator', \Illuminate\Validation\Factory::class); + $this->app->alias('validator', \Illuminate\Contracts\Validation\Factory::class); + } +} \ No newline at end of file diff --git a/app/Providers/ConsoleServiceProvider.php b/app/Providers/ConsoleServiceProvider.php new file mode 100644 index 000000000..2409f3125 --- /dev/null +++ b/app/Providers/ConsoleServiceProvider.php @@ -0,0 +1,76 @@ +registerCommands(); + } + + /** + * Register console commands + */ + protected function registerCommands(): void + { + $commands = $this->discoverCommands(); + + $this->app->bind('console.commands', function () use ($commands) { + return $commands; + }); + + // Register each command in the container + foreach ($commands as $command) { + $this->app->bind($command, function ($app) use ($command) { + return new $command(); + }); + } + } + + /** + * Discover commands in the Commands directory + */ + protected function discoverCommands(): array + { + $commands = []; + $commandsPath = $this->app->make('path.app') . '/Console/Commands'; + + if (!is_dir($commandsPath)) { + return $commands; + } + + $finder = new Finder(); + $finder->files()->name('*Command.php')->in($commandsPath); + + foreach ($finder as $file) { + $relativePath = $file->getRelativePathname(); + $className = 'App\\Console\\Commands\\' . str_replace(['/', '.php'], ['\\', ''], $relativePath); + + if (class_exists($className)) { + $reflection = new \ReflectionClass($className); + + // Only include concrete command classes that extend our base Command + if (!$reflection->isAbstract() && + $reflection->isSubclassOf(\App\Console\Commands\Command::class)) { + $commands[] = $className; + } + } + } + + return $commands; + } +} \ No newline at end of file diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php new file mode 100644 index 000000000..589769256 --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,64 @@ + [ + SendWelcomeEmail::class, + ], + TorrentUploaded::class => [ + UpdateUserStatistics::class, + ], + ]; + + /** + * The event subscriber classes to register + */ + protected array $subscribe = [ + // Add event subscribers here + ]; + + /** + * Register any events for your application + */ + public function boot(): void + { + $events = $this->app->make('events'); + + foreach ($this->listen as $event => $listeners) { + foreach ($listeners as $listener) { + $events->listen($event, $listener); + } + } + + foreach ($this->subscribe as $subscriber) { + $events->subscribe($subscriber); + } + } + + /** + * Determine if events and listeners should be automatically discovered + */ + public function shouldDiscoverEvents(): bool + { + return false; + } +} \ No newline at end of file diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php new file mode 100644 index 000000000..7c3c19484 --- /dev/null +++ b/app/Providers/RouteServiceProvider.php @@ -0,0 +1,123 @@ +app->singleton(Router::class, function ($app) { + return new Router($app); + }); + + // Alias for convenience + $this->app->alias(Router::class, 'router'); + } + + /** + * Define your route model bindings, pattern filters, etc. + */ + public function boot(): void + { + $this->configureRateLimiting(); + + $this->routes(function () { + $this->mapApiRoutes(); + $this->mapWebRoutes(); + $this->mapAdminRoutes(); + }); + } + + /** + * Configure the rate limiters for the application + */ + protected function configureRateLimiting(): void + { + // Rate limiting can be configured here when needed + // Example: + // RateLimiter::for('api', function (Request $request) { + // return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + // }); + } + + /** + * Define the routes for the application + */ + protected function routes(\Closure $callback): void + { + $callback(); + } + + /** + * Define the "web" routes for the application + */ + protected function mapWebRoutes(): void + { + $router = $this->app->make(Router::class); + $routeFile = $this->app->make('path.base') . '/routes/web.php'; + + if (file_exists($routeFile)) { + $router->group([], function () use ($routeFile) { + require $routeFile; + }); + } + } + + /** + * Define the "api" routes for the application + */ + protected function mapApiRoutes(): void + { + $router = $this->app->make(Router::class); + $routeFile = $this->app->make('path.base') . '/routes/api.php'; + + if (file_exists($routeFile)) { + $router->group([ + 'prefix' => 'api', + ], function () use ($routeFile) { + require $routeFile; + }); + } + } + + /** + * Define the "admin" routes for the application + */ + protected function mapAdminRoutes(): void + { + $router = $this->app->make(Router::class); + $routeFile = $this->app->make('path.base') . '/routes/admin.php'; + + if (file_exists($routeFile)) { + $router->group([ + 'prefix' => 'admin', + 'middleware' => [ + 'auth', + 'admin', + ] + ], function () use ($routeFile) { + require $routeFile; + }); + } + } +} diff --git a/app/Providers/ServiceProvider.php b/app/Providers/ServiceProvider.php new file mode 100644 index 000000000..b7dff9204 --- /dev/null +++ b/app/Providers/ServiceProvider.php @@ -0,0 +1,17 @@ +validateTorrentData($data); + + // Create torrent record + $torrent = new Torrent($this->db); + $torrent->fill([ + 'info_hash' => $data['info_hash'], + 'poster_id' => $user->getKey(), + 'size' => $data['size'], + 'reg_time' => time(), + 'tor_status' => 0, + 'checked_user_id' => 0, + 'checked_time' => 0, + 'tor_type' => 0, + 'speed_up' => 0, + 'speed_down' => 0, + ]); + $torrent->save(); + + // Clear cache + $this->cache->delete('recent_torrents'); + + return $torrent; + } + + /** + * Update torrent information + */ + public function update(Torrent $torrent, array $data): bool + { + $torrent->fill($data); + $result = $torrent->save(); + + // Clear relevant caches + $this->cache->delete('torrent:' . $torrent->info_hash); + $this->cache->delete('recent_torrents'); + + return $result; + } + + /** + * Delete a torrent + */ + public function delete(Torrent $torrent): bool + { + // Delete related data + $this->db->table('bb_bt_tracker') + ->where('topic_id', $torrent->getKey()) + ->delete(); + + $this->db->table('bb_bt_tracker_snap') + ->where('topic_id', $torrent->getKey()) + ->delete(); + + // Delete torrent + $result = $torrent->delete(); + + // Clear cache + $this->cache->delete('torrent:' . $torrent->info_hash); + $this->cache->delete('recent_torrents'); + + return $result; + } + + /** + * Get paginated torrents using Laravel-style collections + */ + public function paginate(int $page = 1, ?string $category = null, int $perPage = 20): array + { + $offset = ($page - 1) * $perPage; + + $query = $this->db->table('bb_bt_torrents as t') + ->select('t.*, ts.seeders, ts.leechers, ts.complete') + ->leftJoin('bb_bt_tracker_snap as ts', 'ts.topic_id = t.topic_id') + ->where('t.tor_status', 0) + ->orderBy('t.reg_time DESC') + ->limit($perPage) + ->offset($offset); + + if ($category !== null) { + $query->where('t.tor_type', $category); + } + + $rows = $query->fetchAll(); + + // Use Laravel-style collection for better data manipulation + $torrents = collect($rows) + ->map(fn($row) => new Torrent($this->db, (array) $row)) + ->values() + ->toArray(); + + return [ + 'data' => $torrents, + 'page' => $page, + 'per_page' => $perPage, + 'total' => $this->countTorrents($category), + 'from' => $offset + 1, + 'to' => min($offset + $perPage, $this->countTorrents($category)), + 'last_page' => ceil($this->countTorrents($category) / $perPage) + ]; + } + + /** + * Search torrents using collections and modern string helpers + */ + public function search(string $query, array $filters = []): array + { + $cacheKey = 'search:' . str($query . serialize($filters))->hash('md5'); + + return $this->cache->remember($cacheKey, 300, function() use ($query, $filters) { + $qb = $this->db->table('bb_bt_torrents as t') + ->select('t.*, ts.seeders, ts.leechers') + ->leftJoin('bb_bt_tracker_snap as ts', 'ts.topic_id = t.topic_id') + ->leftJoin('bb_topics as top', 'top.topic_id = t.topic_id') + ->where('t.tor_status', 0); + + // Search in topic title (cleaned query) + if (!empty($query)) { + $cleanQuery = str($query)->trim()->lower()->limit(100); + $qb->where('LOWER(top.topic_title) LIKE ?', '%' . $cleanQuery . '%'); + } + + // Apply filters using data_get helper + if ($category = data_get($filters, 'category')) { + $qb->where('t.tor_type', $category); + } + + if ($minSeeders = data_get($filters, 'min_seeders')) { + $qb->where('ts.seeders >= ?', $minSeeders); + } + + $rows = $qb->limit(100)->fetchAll(); + + // Use collection to transform and filter results + $torrents = collect($rows) + ->map(fn($row) => new Torrent($this->db, (array) $row)) + ->when(data_get($filters, 'sort') === 'popular', function ($collection) { + return $collection->sortByDesc(fn($torrent) => $torrent->seeders ?? 0); + }) + ->when(data_get($filters, 'sort') === 'recent', function ($collection) { + return $collection->sortByDesc('reg_time'); + }) + ->values() + ->toArray(); + + return $torrents; + }); + } + + /** + * Get torrent statistics + */ + public function getStatistics(): array + { + return $this->cache->remember('torrent_stats', 3600, function() { + $stats = []; + + // Total torrents + $stats['total_torrents'] = $this->db->table('bb_bt_torrents') + ->where('tor_status', 0) + ->count('*'); + + // Total size + $totalSize = $this->db->table('bb_bt_torrents') + ->where('tor_status', 0) + ->sum('size'); + $stats['total_size'] = $totalSize ?: 0; + + // Active peers + $stats['active_peers'] = $this->db->table('bb_bt_tracker') + ->count('*'); + + // Completed downloads + $stats['total_completed'] = $this->db->table('bb_bt_torrents') + ->where('tor_status', 0) + ->sum('complete_count'); + + return $stats; + }); + } + + /** + * Validate torrent data + */ + private function validateTorrentData(array $data): void + { + if (empty($data['info_hash']) || strlen($data['info_hash']) !== 40) { + throw new \InvalidArgumentException('Invalid info hash'); + } + + if (empty($data['size']) || $data['size'] <= 0) { + throw new \InvalidArgumentException('Invalid torrent size'); + } + + // Check if torrent already exists + $existing = Torrent::findByInfoHash($data['info_hash']); + if ($existing !== null) { + throw new \InvalidArgumentException('Torrent already exists'); + } + } + + /** + * Count torrents + */ + private function countTorrents(?string $category = null): int + { + $query = $this->db->table('bb_bt_torrents') + ->where('tor_status', 0); + + if ($category !== null) { + $query->where('tor_type', $category); + } + + return $query->count('*'); + } +} \ No newline at end of file diff --git a/app/Services/User/UserService.php b/app/Services/User/UserService.php new file mode 100644 index 000000000..4e7730121 --- /dev/null +++ b/app/Services/User/UserService.php @@ -0,0 +1,196 @@ +validateRegistrationData($data); + + // Create user + $user = new User($this->db); + $user->fill([ + 'username' => $data['username'], + 'user_email' => $data['email'], + 'user_password' => password_hash($data['password'], PASSWORD_BCRYPT), + 'user_level' => 0, // Regular user + 'user_active' => 1, + 'user_regdate' => now()->timestamp, + 'user_lastvisit' => now()->timestamp, + 'user_timezone' => 0, + 'user_lang' => 'en', + 'user_dateformat' => 'd M Y H:i', + ]); + + $user->save(); + + // Clear user cache + $this->cache->delete('user_count'); + + return $user; + } + + /** + * Update user profile + */ + public function updateProfile(User $user, array $data): bool + { + $allowedFields = [ + 'user_timezone', + 'user_lang', + 'user_dateformat', + ]; + + $updateData = collect($data) + ->only($allowedFields) + ->filter() + ->toArray(); + + if (empty($updateData)) { + return true; + } + + $user->fill($updateData); + $result = $user->save(); + + // Clear user cache + $this->cache->delete('user:' . $user->getKey()); + + return $result; + } + + /** + * Change user password + */ + public function changePassword(User $user, string $currentPassword, string $newPassword): bool + { + if (!$user->verifyPassword($currentPassword)) { + throw new \InvalidArgumentException('Current password is incorrect'); + } + + $user->updatePassword($newPassword); + return $user->save(); + } + + /** + * Get user statistics + */ + public function getUserStats(User $user): array + { + $cacheKey = 'user_stats:' . $user->getKey(); + + return $this->cache->remember($cacheKey, 1800, function() use ($user) { + $stats = $user->getStats(); + $torrents = $user->getTorrents(); + $posts = $user->getPosts(5); + + return [ + 'upload_stats' => [ + 'total_uploaded' => $stats['u_up_total'] ?? 0, + 'total_downloaded' => $stats['u_down_total'] ?? 0, + 'ratio' => $user->getRatio(), + ], + 'activity' => [ + 'torrents_count' => count($torrents), + 'recent_posts' => count($posts), + 'last_visit' => now()->createFromTimestamp($user->user_lastvisit)->diffForHumans(), + ], + 'permissions' => [ + 'level' => $user->user_level, + 'is_admin' => $user->isAdmin(), + 'is_moderator' => $user->isModerator(), + 'is_active' => $user->isActive(), + ] + ]; + }); + } + + /** + * Search users using modern collection methods + */ + public function searchUsers(string $query, array $filters = []): array + { + $cacheKey = 'user_search:' . str($query . serialize($filters))->hash('md5'); + + return $this->cache->remember($cacheKey, 600, function() use ($query, $filters) { + // Get all active users (in a real app, this would be paginated) + $users = collect(User::all()) + ->where('user_active', 1); + + // Apply search filter + if (!empty($query)) { + $searchTerm = str($query)->lower(); + $users = $users->filter(function ($user) use ($searchTerm) { + return str($user->username)->lower()->contains($searchTerm) || + str($user->user_email)->lower()->contains($searchTerm); + }); + } + + // Apply level filter + if ($level = data_get($filters, 'level')) { + $users = $users->where('user_level', $level); + } + + // Apply sorting + $sortBy = data_get($filters, 'sort', 'username'); + $sortDirection = data_get($filters, 'direction', 'asc'); + + $users = $sortDirection === 'desc' + ? $users->sortByDesc($sortBy) + : $users->sortBy($sortBy); + + return $users->values()->toArray(); + }); + } + + /** + * Validate registration data + */ + private function validateRegistrationData(array $data): void + { + if (empty($data['username'])) { + throw new \InvalidArgumentException('Username is required'); + } + + if (empty($data['email'])) { + throw new \InvalidArgumentException('Email is required'); + } + + if (empty($data['password'])) { + throw new \InvalidArgumentException('Password is required'); + } + + // Check if username already exists + $existingUser = User::findByUsername($data['username']); + if ($existingUser) { + throw new \InvalidArgumentException('Username already exists'); + } + + // Check if email already exists + $existingEmail = User::findByEmail($data['email']); + if ($existingEmail) { + throw new \InvalidArgumentException('Email already exists'); + } + } +} \ No newline at end of file diff --git a/app/Services/UserService.php b/app/Services/UserService.php new file mode 100644 index 000000000..df199d30f --- /dev/null +++ b/app/Services/UserService.php @@ -0,0 +1,42 @@ +make($abstract, $parameters); + } +} + +if (!function_exists('config')) { + /** + * Get / set the specified configuration value + */ + function config(array|string|null $key = null, mixed $default = null): mixed + { + if (is_null($key)) { + return app('config'); + } + + if (is_array($key)) { + return app('config')->set($key); + } + + return app('config')->get($key, $default); + } +} + +if (!function_exists('event')) { + /** + * Dispatch an event and call the listeners + */ + function event(string|object $event, mixed $payload = [], bool $halt = false): array|null + { + return app('events')->dispatch($event, $payload, $halt); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 000000000..43c5862db --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,41 @@ +safeLoad(); + +// Load legacy common.php only if not already loaded (will be loaded by LegacyController when needed) +// This prevents header conflicts for modern API routes +if (!defined('BB_PATH')) { + // Only load for legacy routes - modern routes will skip this + $requestUri = $_SERVER['REQUEST_URI'] ?? ''; + $urlPath = parse_url($requestUri, PHP_URL_PATH) ?: $requestUri; + $isLegacyRoute = str_ends_with($urlPath, '.php') || $requestUri === '/' || str_contains($requestUri, 'tracker') || str_contains($requestUri, 'forum'); + + if ($isLegacyRoute) { + require_once dirname(__DIR__) . '/common.php'; + } +} + +// Define application constants +define('IN_TORRENTPIER', true); + +// Load container bootstrap +require_once __DIR__ . '/container.php'; + +// Create the application container +$container = createContainer(dirname(__DIR__)); + +// Get the Router instance (it will be created and registered by RouteServiceProvider) +$router = $container->make(\App\Http\Routing\Router::class); + +// Return the router for handling requests +return $router; \ No newline at end of file diff --git a/bootstrap/console.php b/bootstrap/console.php new file mode 100644 index 000000000..efd395b39 --- /dev/null +++ b/bootstrap/console.php @@ -0,0 +1,47 @@ +bound('console.commands')) { + $commands = $container->make('console.commands'); + foreach ($commands as $command) { + try { + $app->add($container->make($command)); + } catch (BindingResolutionException $e) { + // Skip commands that can't be resolved - console still works with built-in commands + continue; + } + } + } +} catch (BindingResolutionException $e) { + // No commands registered or service binding failed - console still works with built-in commands +} + +// Return the console application +return $app; diff --git a/bootstrap/container.php b/bootstrap/container.php new file mode 100644 index 000000000..05e7f98ad --- /dev/null +++ b/bootstrap/container.php @@ -0,0 +1,115 @@ +safeLoad(); + + $container = new Container(); + + // Set the container instance globally + Container::setInstance($container); + + // Register base paths + $container->instance('path.base', $rootPath); + $container->instance('path.app', $rootPath . '/app'); + $container->instance('path.config', $rootPath . '/config'); + $container->instance('path.database', $rootPath . '/database'); + $container->instance('path.public', $rootPath . '/public'); + $container->instance('path.resources', $rootPath . '/resources'); + $container->instance('path.storage', $rootPath . '/storage'); + + // Register the container itself + $container->instance(Container::class, $container); + $container->alias(Container::class, 'app'); + $container->alias(Container::class, Illuminate\Contracts\Container\Container::class); + $container->alias(Container::class, Psr\Container\ContainerInterface::class); + + // Load configuration + loadConfiguration($container, $rootPath); + + // Register service providers + registerServiceProviders($container); + + return $container; +} + +/** + * Load configuration files + */ +function loadConfiguration(Container $container, string $rootPath): void +{ + $configPath = $rootPath . '/config'; + + // Create unified config repository + $config = new \Illuminate\Config\Repository(); + + // Load services configuration + if (file_exists($configPath . '/services.php')) { + $services = require $configPath . '/services.php'; + foreach ($services as $abstract => $concrete) { + if (is_callable($concrete)) { + $container->bind($abstract, $concrete); + } else { + $container->bind($abstract, $concrete); + } + } + } + + // Load all config files into the repository + foreach (glob($configPath . '/*.php') as $file) { + $key = basename($file, '.php'); + $value = require $file; + $config->set($key, $value); + // Also register individual config files for backward compatibility + $container->instance("config.{$key}", $value); + } + + // Register the unified config repository + $container->instance('config', $config); + $container->bind(\Illuminate\Config\Repository::class, function() use ($config) { + return $config; + }); +} + +/** + * Register service providers + */ +function registerServiceProviders(Container $container): void +{ + $providers = [ + // Register your service providers here + \App\Providers\AppServiceProvider::class, + \App\Providers\EventServiceProvider::class, + \App\Providers\RouteServiceProvider::class, + \App\Providers\ConsoleServiceProvider::class, + ]; + + foreach ($providers as $providerClass) { + if (class_exists($providerClass)) { + $provider = new $providerClass($container); + + if (method_exists($provider, 'register')) { + $provider->register(); + } + + if (method_exists($provider, 'boot')) { + $container->call([$provider, 'boot']); + } + } + } +} \ No newline at end of file diff --git a/bt/announce.php b/bt/announce.php index ea1cca19a..f6db656be 100644 --- a/bt/announce.php +++ b/bt/announce.php @@ -18,8 +18,8 @@ if (empty($userAgent)) { die; } -$announce_interval = config()->get('announce_interval'); -$passkey_key = config()->get('passkey_key'); +$announce_interval = tp_config()->get('announce_interval'); +$passkey_key = tp_config()->get('passkey_key'); // Recover info_hash if (isset($_GET['?info_hash']) && !isset($_GET['info_hash'])) { @@ -65,10 +65,10 @@ if (strlen($peer_id) !== 20) { } // Check for client ban -if (config()->get('client_ban.enabled')) { +if (tp_config()->get('client_ban.enabled')) { $targetClient = []; - foreach (config()->get('client_ban.clients') as $clientId => $banReason) { + foreach (tp_config()->get('client_ban.clients') as $clientId => $banReason) { if (str_starts_with($peer_id, $clientId)) { $targetClient = [ 'peer_id' => $clientId, @@ -78,7 +78,7 @@ if (config()->get('client_ban.enabled')) { } } - if (config()->get('client_ban.only_allow_mode')) { + if (tp_config()->get('client_ban.only_allow_mode')) { if (empty($targetClient['peer_id'])) { msg_die('Your BitTorrent client has been banned!'); } @@ -129,7 +129,7 @@ if ( || !is_numeric($port) || ($port < 1024 && !$stopped) || $port > 0xFFFF - || (!empty(config()->get('disallowed_ports')) && in_array($port, config()->get('disallowed_ports'))) + || (!empty(tp_config()->get('disallowed_ports')) && in_array($port, tp_config()->get('disallowed_ports'))) ) { msg_die('Invalid port: ' . $port); } @@ -168,13 +168,13 @@ if (preg_match('/(Mozilla|Browser|Chrome|Safari|AppleWebKit|Opera|Links|Lynx|Bot $ip = $_SERVER['REMOTE_ADDR']; // 'ip' query handling -if (!config()->get('ignore_reported_ip') && isset($_GET['ip']) && $ip !== $_GET['ip']) { - if (!config()->get('verify_reported_ip') && isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { +if (!tp_config()->get('ignore_reported_ip') && isset($_GET['ip']) && $ip !== $_GET['ip']) { + if (!tp_config()->get('verify_reported_ip') && isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { $x_ip = $_SERVER['HTTP_X_FORWARDED_FOR']; if ($x_ip === $_GET['ip']) { $filteredIp = filter_var($x_ip, FILTER_VALIDATE_IP); - if ($filteredIp !== false && (config()->get('allow_internal_ip') || !filter_var($filteredIp, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE))) { + if ($filteredIp !== false && (tp_config()->get('allow_internal_ip') || !filter_var($filteredIp, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE))) { $ip = $filteredIp; } } @@ -270,7 +270,7 @@ if ($lp_info) { define('IS_MOD', !IS_GUEST && (int)$row['user_level'] === MOD); define('IS_GROUP_MEMBER', !IS_GUEST && (int)$row['user_level'] === GROUP_MEMBER); define('IS_USER', !IS_GUEST && (int)$row['user_level'] === USER); - define('IS_SUPER_ADMIN', IS_ADMIN && isset(config()->get('super_admins')[$user_id])); + define('IS_SUPER_ADMIN', IS_ADMIN && isset(tp_config()->get('super_admins')[$user_id])); define('IS_AM', IS_ADMIN || IS_MOD); $topic_id = $row['topic_id']; $releaser = (int)($user_id == $row['poster_id']); @@ -278,13 +278,13 @@ if ($lp_info) { $tor_status = $row['tor_status']; // Check tor status - if (!IS_AM && isset(config()->get('tor_frozen')[$tor_status]) && !(isset(config()->get('tor_frozen_author_download')[$tor_status]) && $releaser)) { + if (!IS_AM && isset(tp_config()->get('tor_frozen')[$tor_status]) && !(isset(tp_config()->get('tor_frozen_author_download')[$tor_status]) && $releaser)) { msg_die('Torrent frozen and cannot be downloaded'); } // Check hybrid status if (!empty($row['info_hash']) && !empty($row['info_hash_v2'])) { - $stat_protocol = match ((int)config()->get('tracker.hybrid_stat_protocol')) { + $stat_protocol = match ((int)tp_config()->get('tracker.hybrid_stat_protocol')) { 2 => substr($row['info_hash_v2'], 0, 20), default => $row['info_hash'] // 1 }; @@ -294,7 +294,7 @@ if ($lp_info) { } // Ratio limits - if ((RATIO_ENABLED || config()->get('tracker.limit_concurrent_ips')) && !$stopped) { + if ((RATIO_ENABLED || tp_config()->get('tracker.limit_concurrent_ips')) && !$stopped) { $user_ratio = get_bt_ratio($row); if ($user_ratio === null) { $user_ratio = 1; @@ -302,10 +302,10 @@ if ($lp_info) { $rating_msg = ''; if (!$seeder) { - foreach (config()->get('rating') as $ratio => $limit) { + foreach (tp_config()->get('rating') as $ratio => $limit) { if ($user_ratio < $ratio) { - config()->set('tracker.limit_active_tor', 1); - config()->set('tracker.limit_leech_count', $limit); + tp_config()->set('tracker.limit_active_tor', 1); + tp_config()->set('tracker.limit_leech_count', $limit); $rating_msg = " (ratio < $ratio)"; break; } @@ -313,29 +313,29 @@ if ($lp_info) { } // Limit active torrents - if (!isset(config()->get('unlimited_users')[$user_id]) && config()->get('tracker.limit_active_tor') && ((config()->get('tracker.limit_seed_count') && $seeder) || (config()->get('tracker.limit_leech_count') && !$seeder))) { + if (!isset(tp_config()->get('unlimited_users')[$user_id]) && tp_config()->get('tracker.limit_active_tor') && ((tp_config()->get('tracker.limit_seed_count') && $seeder) || (tp_config()->get('tracker.limit_leech_count') && !$seeder))) { $sql = "SELECT COUNT(DISTINCT topic_id) AS active_torrents FROM " . BB_BT_TRACKER . " WHERE user_id = $user_id AND seeder = $seeder AND topic_id != $topic_id"; - if (!$seeder && config()->get('tracker.leech_expire_factor') && $user_ratio < 0.5) { - $sql .= " AND update_time > " . (TIMENOW - 60 * config()->get('tracker.leech_expire_factor')); + if (!$seeder && tp_config()->get('tracker.leech_expire_factor') && $user_ratio < 0.5) { + $sql .= " AND update_time > " . (TIMENOW - 60 * tp_config()->get('tracker.leech_expire_factor')); } $sql .= " GROUP BY user_id"; if ($row = DB()->fetch_row($sql)) { - if ($seeder && config()->get('tracker.limit_seed_count') && $row['active_torrents'] >= config()->get('tracker.limit_seed_count')) { - msg_die('Only ' . config()->get('tracker.limit_seed_count') . ' torrent(s) allowed for seeding'); - } elseif (!$seeder && config()->get('tracker.limit_leech_count') && $row['active_torrents'] >= config()->get('tracker.limit_leech_count')) { - msg_die('Only ' . config()->get('tracker.limit_leech_count') . ' torrent(s) allowed for leeching' . $rating_msg); + if ($seeder && tp_config()->get('tracker.limit_seed_count') && $row['active_torrents'] >= tp_config()->get('tracker.limit_seed_count')) { + msg_die('Only ' . tp_config()->get('tracker.limit_seed_count') . ' torrent(s) allowed for seeding'); + } elseif (!$seeder && tp_config()->get('tracker.limit_leech_count') && $row['active_torrents'] >= tp_config()->get('tracker.limit_leech_count')) { + msg_die('Only ' . tp_config()->get('tracker.limit_leech_count') . ' torrent(s) allowed for leeching' . $rating_msg); } } } // Limit concurrent IPs - if (config()->get('tracker.limit_concurrent_ips') && ((config()->get('tracker.limit_seed_ips') && $seeder) || (config()->get('tracker.limit_leech_ips') && !$seeder))) { + if (tp_config()->get('tracker.limit_concurrent_ips') && ((tp_config()->get('tracker.limit_seed_ips') && $seeder) || (tp_config()->get('tracker.limit_leech_ips') && !$seeder))) { $sql = "SELECT COUNT(DISTINCT ip) AS ips FROM " . BB_BT_TRACKER . " WHERE topic_id = $topic_id @@ -343,16 +343,16 @@ if ($lp_info) { AND seeder = $seeder AND $ip_version != '$ip_sql'"; - if (!$seeder && config()->get('tracker.leech_expire_factor')) { - $sql .= " AND update_time > " . (TIMENOW - 60 * config()->get('tracker.leech_expire_factor')); + if (!$seeder && tp_config()->get('tracker.leech_expire_factor')) { + $sql .= " AND update_time > " . (TIMENOW - 60 * tp_config()->get('tracker.leech_expire_factor')); } $sql .= " GROUP BY topic_id"; if ($row = DB()->fetch_row($sql)) { - if ($seeder && config()->get('tracker.limit_seed_ips') && $row['ips'] >= config()->get('tracker.limit_seed_ips')) { - msg_die('You can seed only from ' . config()->get('tracker.limit_seed_ips') . " IP's"); - } elseif (!$seeder && config()->get('tracker.limit_leech_ips') && $row['ips'] >= config()->get('tracker.limit_leech_ips')) { - msg_die('You can leech only from ' . config()->get('tracker.limit_leech_ips') . " IP's"); + if ($seeder && tp_config()->get('tracker.limit_seed_ips') && $row['ips'] >= tp_config()->get('tracker.limit_seed_ips')) { + msg_die('You can seed only from ' . tp_config()->get('tracker.limit_seed_ips') . " IP's"); + } elseif (!$seeder && tp_config()->get('tracker.limit_leech_ips') && $row['ips'] >= tp_config()->get('tracker.limit_leech_ips')) { + msg_die('You can leech only from ' . tp_config()->get('tracker.limit_leech_ips') . " IP's"); } } } @@ -376,7 +376,7 @@ $up_add = ($lp_info && $uploaded > $lp_info['uploaded']) ? $uploaded - $lp_info[ $down_add = ($lp_info && $downloaded > $lp_info['downloaded']) ? $downloaded - $lp_info['downloaded'] : 0; // Gold/Silver releases -if (config()->get('tracker.gold_silver_enabled') && $down_add) { +if (tp_config()->get('tracker.gold_silver_enabled') && $down_add) { if ($tor_type == TOR_TYPE_GOLD) { $down_add = 0; } // Silver releases @@ -386,7 +386,7 @@ if (config()->get('tracker.gold_silver_enabled') && $down_add) { } // Freeleech -if (config()->get('tracker.freeleech') && $down_add) { +if (tp_config()->get('tracker.freeleech') && $down_add) { $down_add = 0; } @@ -464,8 +464,8 @@ $output = CACHE('tr_cache')->get(PEERS_LIST_PREFIX . $topic_id); if (!$output) { // Retrieve peers - $numwant = (int)config()->get('tracker.numwant'); - $compact_mode = (config()->get('tracker.compact_mode') || !empty($compact)); + $numwant = (int)tp_config()->get('tracker.numwant'); + $compact_mode = (tp_config()->get('tracker.compact_mode') || !empty($compact)); $rowset = DB()->fetch_rowset(" SELECT ip, ipv6, port @@ -510,7 +510,7 @@ if (!$output) { $seeders = $leechers = $client_completed = 0; - if (config()->get('tracker.scrape')) { + if (tp_config()->get('tracker.scrape')) { $row = DB()->fetch_row(" SELECT seeders, leechers, completed FROM " . BB_BT_TRACKER_SNAP . " diff --git a/bt/includes/init_tr.php b/bt/includes/init_tr.php index 283c71ede..d0085b4ee 100644 --- a/bt/includes/init_tr.php +++ b/bt/includes/init_tr.php @@ -12,8 +12,8 @@ if (!defined('IN_TRACKER')) { } // Exit if tracker is disabled -if (config()->get('tracker.bt_off')) { - msg_die(config()->get('tracker.bt_off_reason')); +if (tp_config()->get('tracker.bt_off')) { + msg_die(tp_config()->get('tracker.bt_off_reason')); } // diff --git a/bt/scrape.php b/bt/scrape.php index dd94ab8ff..392032426 100644 --- a/bt/scrape.php +++ b/bt/scrape.php @@ -11,7 +11,7 @@ define('IN_TRACKER', true); define('BB_ROOT', './../'); require dirname(__DIR__) . '/common.php'; -if (!config()->get('tracker.scrape')) { +if (!tp_config()->get('tracker.scrape')) { msg_die('Please disable SCRAPE!'); } @@ -58,8 +58,8 @@ foreach ($info_hash_array[1] as $hash) { $info_hash_count = count($info_hashes); if (!empty($info_hash_count)) { - if ($info_hash_count > config()->get('max_scrapes')) { - $info_hashes = array_slice($info_hashes, 0, config()->get('max_scrapes')); + if ($info_hash_count > tp_config()->get('max_scrapes')) { + $info_hashes = array_slice($info_hashes, 0, tp_config()->get('max_scrapes')); } $info_hashes_sql = implode('\', \'', $info_hashes); diff --git a/cliff.toml b/cliff.toml deleted file mode 100644 index 1798567f1..000000000 --- a/cliff.toml +++ /dev/null @@ -1,126 +0,0 @@ -# git-cliff ~ TorrentPier configuration file -# https://git-cliff.org/docs/configuration -# -# Lines starting with "#" are comments. -# Configuration options are organized into tables and keys. -# See documentation for more information on available options. - -[remote.github] -owner = "torrentpier" -repo = "torrentpier" - -[changelog] -# template for the changelog header -header = """ -[](https://github.com/torrentpier)\n -# 📖 Change Log\n -""" -# template for the changelog body -# https://keats.github.io/tera/docs/#introduction -body = """ -{%- macro remote_url() -%} - https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} -{%- endmacro -%} - -{%- macro nightly_url() -%} - https://nightly.link/{{ remote.github.owner }}/{{ remote.github.repo }}/workflows/ci/master/TorrentPier-master -{%- endmacro -%} - -{% macro print_commit(commit) -%} - - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ - {% if commit.breaking %}[**breaking**] {% endif %}\ - {{ commit.message | upper_first }} - \ - ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ -{% endmacro -%} - -{% if version %}\ - {% if previous.version %}\ - ## [{{ version }}]\ - ({{ self::remote_url() }}/compare/{{ previous.version }}..{{ version }}) ({{ timestamp | date(format="%Y-%m-%d") }}) - {% else %}\ - ## {{ version }} ({{ timestamp | date(format="%Y-%m-%d") }}) - {% endif %}\ -{% else %}\ - ## [nightly]({{ self::nightly_url() }}) -{% endif %}\ - -{% for group, commits in commits | group_by(attribute="group") %} - ### {{ group | striptags | trim | upper_first }} - {% for commit in commits - | filter(attribute="scope") - | sort(attribute="scope") %} - {{ self::print_commit(commit=commit) }} - {%- endfor %} - {% for commit in commits %} - {%- if not commit.scope -%} - {{ self::print_commit(commit=commit) }} - {% endif -%} - {% endfor -%} -{% endfor -%} -{%- if github -%} -{% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} - ## New Contributors ❤️ -{% endif %}\ -{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} - * @{{ contributor.username }} made their first contribution - {%- if contributor.pr_number %} in \ - [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ - {%- endif %} -{%- endfor -%} -{%- endif %} - - -""" -# template for the changelog footer -footer = """ -""" -# remove the leading and trailing whitespace from the templates -trim = true -# postprocessors -postprocessors = [ - { pattern = '