diff --git a/cron.php b/cron.php index 213d07b..3b58ee7 100644 --- a/cron.php +++ b/cron.php @@ -31,6 +31,11 @@ try { $uploadsCtx->deleteUploadData($uploadInfo); $uploadsData->nukeUpload($uploadInfo); } + + // Ensure local data of DMCA'd files is gone + $deleted = $uploadsData->getUploads(dmca: true); + foreach($deleted as $uploadInfo) + $uploadsCtx->deleteUploadData($uploadInfo); } finally { sem_release($semaphore); } diff --git a/public/index.php b/public/index.php index 0bb0051..d0dbc8b 100644 --- a/public/index.php +++ b/public/index.php @@ -19,396 +19,9 @@ set_exception_handler(function(\Throwable $ex) { exit; }); -function eepromOriginAllowed(string $origin): bool { - global $cfg; +ob_start(); - $origin = mb_strtolower(parse_url($origin, PHP_URL_HOST)); +$request = \Index\Http\HttpRequest::fromRequest(); +$isApiDomain = $request->getHeaderLine('Host') === $cfg->getString('domain:api'); - if($origin === $_SERVER['HTTP_HOST']) - return true; - - $allowed = $cfg->getArray('cors:origins'); - if(empty($allowed)) - return true; - - return in_array($origin, $allowed); -} - -function eepromUploadInfo(Uploads\UploadInfo $uploadInfo): array { - global $eeprom; - - $uploadsCtx = $eeprom->getUploadsContext(); - - return [ - 'id' => $uploadInfo->getId(), - 'url' => $uploadsCtx->getFileUrlV1($uploadInfo), - 'urlf' => $uploadsCtx->getFileUrlV1($uploadInfo, true), - 'thumb' => $uploadsCtx->getThumbnailUrlV1($uploadInfo), - 'name' => $uploadInfo->getName(), - 'type' => $uploadInfo->getMediaTypeString(), - 'size' => $uploadInfo->getDataSize(), - 'user' => (int)$uploadInfo->getUserId(), - 'appl' => (int)$uploadInfo->getAppId(), - 'hash' => $uploadInfo->getHashString(), - 'created' => str_replace('+00:00', 'Z', $uploadInfo->getCreatedAt()->format(\DateTime::ATOM)), - 'accessed' => $uploadInfo->hasBeenAccessed() ? str_replace('+00:00', 'Z', $uploadInfo->getAccessedAt()->format(\DateTime::ATOM)) : null, - 'expires' => $uploadInfo->hasExpired() ? str_replace('+00:00', 'Z', $uploadInfo->getExpiredAt()->format(\DateTime::ATOM)) : null, - - // These can never be reached, and in situation where they technically could it's because of an outdated local record - 'deleted' => null, - 'dmca' => null, - ]; -} - -$isApiDomain = $_SERVER['HTTP_HOST'] === $cfg->getString('domain:api'); -$router = new HttpFx; - -$router->use('/', function($response) { - $response->setPoweredBy('EEPROM'); -}); - -$router->use('/', function($response, $request) { - $origin = $request->getHeaderLine('Origin'); - - if(!empty($origin)) { - if(!eepromOriginAllowed($origin)) - return 403; - - $response->setHeader('Access-Control-Allow-Origin', $origin); - $response->setHeader('Vary', 'Origin'); - } -}); - -if($isApiDomain) { - // this is illegal, don't do this - $userInfo = null; - - $router->use('/', function($response, $request) { - if($request->hasHeader('Origin')) - $response->setHeader('Access-Control-Allow-Credentials', 'true'); - }); - - $router->use('/', function($response, $request) { - if($request->getMethod() === 'OPTIONS') { - $response->setHeader('Access-Control-Allow-Headers', 'Authorization'); - $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET, POST, DELETE'); - return 204; - } - }); - - $router->use('/', function($response, $request) use ($db, $cfg) { - global $userInfo, $eeprom; - - $auth = $request->getHeaderLine('Authorization'); - if(empty($auth)) { - $mszAuth = (string)$request->getCookie('msz_auth'); - if(!empty($mszAuth)) - $auth = 'Misuzu ' . $mszAuth; - } - - if(!empty($auth)) { - $authParts = explode(' ', $auth, 2); - $authMethod = strval($authParts[0] ?? ''); - $authToken = strval($authParts[1] ?? ''); - - $authClients = $cfg->getArray('auth:clients'); - - foreach($authClients as $client) { - $client = new $client; - if($client->getName() !== $authMethod) - continue; - $authUserId = $client->verifyToken($authToken); - break; - } - - if(isset($authUserId) && $authUserId > 0) - $userInfo = $eeprom->getUsersContext()->getUser($authUserId); - } - }); - - $router->get('/eeprom.js', function($response) { - $response->accelRedirect('/js/eeprom-v1.0.js'); - $response->setContentType('application/javascript; charset=utf-8'); - }); - - $router->get('/stats.json', function() use ($db) { - $fileCount = 0; - $userCount = 0; - $totalSize = 0; - $uniqueTypes = 0; - - $uploadStats = $db->query('SELECT COUNT(`upload_id`) AS `amount`, SUM(`upload_size`) AS `size`, COUNT(DISTINCT `upload_type`) AS `types` FROM `prm_uploads` WHERE `upload_deleted` IS NULL AND `upload_dmca` IS NULL'); - - if($uploadStats->next()) { - $fileCount = $uploadStats->getInteger(0); - $totalSize = $uploadStats->getInteger(1); - $uniqueTypes = $uploadStats->getInteger(2); - } - - $userStats = $db->query('SELECT COUNT(`user_id`) AS `amount` FROM `prm_users` WHERE `user_restricted` IS NULL'); - - if($userStats->next()) - $userCount = $userStats->getInteger(0); - - return [ - 'size' => $totalSize, - 'files' => $fileCount, - 'types' => $uniqueTypes, - 'members' => $userCount, - ]; - }); - - $router->get('/', function($response) { - $response->accelRedirect('/index.html'); - $response->setContentType('text/html; charset=utf-8'); - }); - - $router->post('/uploads', function($response, $request) use ($db) { - global $userInfo, $eeprom; - - if(!$request->isFormContent()) - return 400; - - $content = $request->getContent(); - - try { - $appInfo = $eeprom->getAppsContext()->getApp($content->getParam('src', FILTER_VALIDATE_INT)); - } catch(RuntimeException $ex) { - return 404; - } - - if($userInfo === null) - return 401; - - if($userInfo->isRestricted()) - return 403; - - try { - $file = $content->getUploadedFile('file'); - } catch(RuntimeException $ex) { - return 400; - } - - $maxFileSize = $appInfo->getDataSizeLimit(); - if($appInfo->allowSizeMultiplier()) - $maxFileSize *= $userInfo->getDataSizeMultiplier(); - - $localFile = $file->getLocalFileName(); - $fileSize = filesize($localFile); - - if($file->getSize() !== $fileSize || $fileSize > $maxFileSize) { - $response->setHeader('Access-Control-Expose-Headers', 'X-EEPROM-Max-Size'); - $response->setHeader('X-EEPROM-Max-Size', $maxFileSize); - return 413; - } - - $uploadsCtx = $eeprom->getUploadsContext(); - $uploadsData = $uploadsCtx->getUploadsData(); - - $hash = hash_file('sha256', $localFile); - - // this is stupid: dmca status is stored as a file record rather than in a separate table requiring this hack ass garbage - $uploadInfo = $uploadsData->getUpload(appInfo: $appInfo, userInfo: $userInfo, hashString: $hash) - ?? $uploadsData->getUpload(hashString: $hash); - - if($uploadInfo !== null) { - if($uploadInfo->isCopyrightTakedown()) - return 451; - - if($uploadInfo->getUserId() !== $userInfo->getId() - || $uploadInfo->getAppId() !== $appInfo->getId()) - unset($uploadInfo); - } - - if(empty($uploadInfo)) { - $uploadInfo = $uploadsData->createUpload( - $appInfo, $userInfo, $_SERVER['REMOTE_ADDR'], - $file->getSuggestedFileName(), mime_content_type($localFile), - $fileSize, $hash, $appInfo->getBumpAmount(), true - ); - $filePath = $uploadsCtx->getFileDataPath($uploadInfo); - $file->moveTo($filePath); - } else { - $filePath = $uploadsCtx->getFileDataPath($uploadInfo); - if($uploadInfo->isDeleted()) - $uploadsData->restoreUpload($uploadInfo); - - $uploadsData->bumpUploadExpires($uploadInfo); - } - - $response->setStatusCode(201); - $response->setHeader('Content-Type', 'application/json; charset=utf-8'); - - return eepromUploadInfo($uploadInfo); - }); - - $router->delete('/uploads/:fileid', function($response, $request, $fileId) use ($db) { - global $userInfo, $eeprom; - - if($userInfo === null) - return 401; - - $uploadsData = $eeprom->getUploadsContext()->getUploadsData(); - - $uploadInfo = $uploadsData->getUpload(uploadId: $fileId); - if($uploadInfo === null) { - $response->setContent('File not found.'); - return 404; - } - - if($uploadInfo->isCopyrightTakedown()) { - $response->setContent('File is unavailable for copyright reasons.'); - return 451; - } - - if($uploadInfo->isDeleted() || $uploadInfo->hasExpired()) { - $response->setContent('File not found.'); - return 404; - } - - if($userInfo->isRestricted() || $userInfo->getId() !== $uploadInfo->getUserId()) - return 403; - - $uploadsData->deleteUpload($uploadInfo); - return 204; - }); - - $router->get('/uploads/:filename', function($response, $request, $fileName) use ($db) { - global $eeprom; - - $pathInfo = pathinfo($fileName); - $fileId = $pathInfo['filename']; - $fileExt = $pathInfo['extension'] ?? ''; - $isThumbnail = $fileExt === 't'; - $isJson = $fileExt === 'json'; - - if($fileExt !== '' && $fileExt !== 't' && $fileExt !== 'json') - return 404; - - $uploadsCtx = $eeprom->getUploadsContext(); - $uploadsData = $uploadsCtx->getUploadsData(); - - $uploadInfo = $uploadsData->getUpload(uploadId: $fileId); - if($uploadInfo === null) { - $response->setContent('File not found.'); - return 404; - } - - if($uploadInfo->isCopyrightTakedown()) { - $response->setContent('File is unavailable for copyright reasons.'); - return 451; - } - - if($uploadInfo->isDeleted() || $uploadInfo->hasExpired()) { - $response->setContent('File not found.'); - return 404; - } - - if($isJson) - return eepromUploadInfo($uploadInfo); - - $filePath = $uploadsCtx->getFileDataPath($uploadInfo); - if(!is_file($filePath)) { - $response->setContent('Data is missing.'); - return 404; - } - - if(!$isThumbnail) { - $uploadsData->bumpUploadAccess($uploadInfo); - $uploadsData->bumpUploadExpires($uploadInfo); - } - - $fileName = $uploadInfo->getName(); - $contentType = $uploadInfo->getMediaTypeString(); - - if($contentType === 'application/octet-stream' || str_starts_with($contentType, 'text/')) - $contentType = 'text/plain'; - - if($isThumbnail) { - if(!$uploadsCtx->supportsThumbnailing($uploadInfo)) - return 404; - - $contentType = 'image/jpeg'; - $accelRedirectPath = $uploadsCtx->getThumbnailDataRedirectPathOrCreate($uploadInfo); - $fileName = pathinfo($fileName, PATHINFO_FILENAME) . '-thumb.jpg'; - } else { - $accelRedirectPath = $uploadsCtx->getFileDataRedirectPath($uploadInfo); - } - - $response->accelRedirect($accelRedirectPath); - $response->setContentType($contentType); - $response->setFileName(addslashes($fileName)); - }); -} else { - $router->use('/', function($response, $request) { - if($request->getMethod() === 'OPTIONS') { - $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET'); - return 204; - } - }); - - $router->get('/:filename', function($response, $request, $fileName) use ($db) { - global $eeprom; - - $pathInfo = pathinfo($fileName); - $fileId = $pathInfo['filename']; - $fileExt = $pathInfo['extension'] ?? ''; - $isThumbnail = $fileExt === 't'; - - if($fileExt !== '' && $fileExt !== 't') - return 404; - - $uploadsCtx = $eeprom->getUploadsContext(); - $uploadsData = $uploadsCtx->getUploadsData(); - - $uploadInfo = $uploadsData->getUpload(uploadId: $fileId); - if($uploadInfo === null) { - $response->setContent('File not found.'); - return 404; - } - - if($uploadInfo->isCopyrightTakedown()) { - $response->setContent('File is unavailable for copyright reasons.'); - return 451; - } - - if($uploadInfo->isDeleted() || $uploadInfo->hasExpired()) { - $response->setContent('File not found.'); - return 404; - } - - $filePath = $uploadsCtx->getFileDataPath($uploadInfo); - if(!is_file($filePath)) { - $response->setContent('Data is missing.'); - return 404; - } - - if(!$isThumbnail) { - $uploadsData->bumpUploadAccess($uploadInfo); - $uploadsData->bumpUploadExpires($uploadInfo); - } - - $fileName = $uploadInfo->getName(); - $contentType = $uploadInfo->getMediaTypeString(); - - if($contentType === 'application/octet-stream' || str_starts_with($contentType, 'text/')) - $contentType = 'text/plain'; - - if($isThumbnail) { - if(!$uploadsCtx->supportsThumbnailing($uploadInfo)) - return 404; - - $contentType = 'image/jpeg'; - $accelRedirectPath = $uploadsCtx->getThumbnailDataRedirectPathOrCreate($uploadInfo); - $fileName = pathinfo($fileName, PATHINFO_FILENAME) . '-thumb.jpg'; - } else { - $accelRedirectPath = $uploadsCtx->getFileDataRedirectPath($uploadInfo); - } - - $response->accelRedirect($accelRedirectPath); - $response->setContentType($contentType); - $response->setFileName(addslashes($fileName)); - }); -} - -$router->dispatch(); +$eeprom->createRouting($isApiDomain)->dispatch($request); diff --git a/public/js/eeprom-v1.0.js b/public/js/eeprom-v1.0.js index 6a62ba0..2c286cf 100644 --- a/public/js/eeprom-v1.0.js +++ b/public/js/eeprom-v1.0.js @@ -110,8 +110,7 @@ EEPROM.EEPROMDeleteTask = function(authorization, fileInfo) { obj.start = function() { xhr.open('DELETE', obj.fileInfo.urlf); - if(obj.authorization) xhr.setRequestHeader('Authorization', obj.authorization); - else xhr.withCredentials = true; + xhr.setRequestHeader('Authorization', obj.authorization); xhr.send(); }; @@ -204,8 +203,7 @@ EEPROM.EEPROMUploadTask = function(srcId, endpoint, authorization, file) { obj.start = function() { xhr.open('POST', obj.endpoint); - if(obj.authorization) xhr.setRequestHeader('Authorization', obj.authorization); - else xhr.withCredentials = true; + xhr.setRequestHeader('Authorization', obj.authorization); xhr.send(fd); }; diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php new file mode 100644 index 0000000..5e2f89c --- /dev/null +++ b/src/Auth/AuthInfo.php @@ -0,0 +1,34 @@ +setInfo(); + } + + public function setInfo( + ?UserInfo $userInfo = null + ): void { + $this->userInfo = $userInfo; + } + + public function removeInfo(): void { + $this->setInfo(); + } + + public function isLoggedIn(): bool { + return $this->userInfo !== null; + } + + public function getUserId(): ?string { + return $this->userInfo?->getId(); + } + + public function getUserInfo(): ?UserInfo { + return $this->userInfo; + } +} diff --git a/src/Auth/AuthRoutes.php b/src/Auth/AuthRoutes.php new file mode 100644 index 0000000..dbb3231 --- /dev/null +++ b/src/Auth/AuthRoutes.php @@ -0,0 +1,40 @@ +getHeaderLine('Authorization'); + + if(!empty($auth)) { + $authParts = explode(' ', $auth, 2); + $authMethod = strval($authParts[0] ?? ''); + $authToken = strval($authParts[1] ?? ''); + + $authClients = $this->config->getArray('clients'); + + foreach($authClients as $client) { + $client = new $client; + if($client->getName() !== $authMethod) + continue; + $authUserId = $client->verifyToken($authToken); + break; + } + + if(isset($authUserId) && $authUserId > 0) + $this->authInfo->setInfo($this->usersCtx->getUser($authUserId)); + } + } +} diff --git a/src/EEPROMContext.php b/src/EEPROMContext.php index 3805c61..bbf0ec7 100644 --- a/src/EEPROMContext.php +++ b/src/EEPROMContext.php @@ -3,11 +3,14 @@ namespace EEPROM; use Index\Data\IDbConnection; use Syokuhou\IConfig; +use EEPROM\Auth\AuthInfo; class EEPROMContext { private IConfig $config; private DatabaseContext $dbCtx; + private AuthInfo $authInfo; + private Apps\AppsContext $appsCtx; private Uploads\UploadsContext $uploadsCtx; private Users\UsersContext $usersCtx; @@ -16,6 +19,8 @@ class EEPROMContext { $this->config = $config; $this->dbCtx = new DatabaseContext($dbConn); + $this->authInfo = new AuthInfo; + $this->appsCtx = new Apps\AppsContext($dbConn); $this->uploadsCtx = new Uploads\UploadsContext($config, $dbConn); $this->usersCtx = new Users\UsersContext($dbConn); @@ -29,6 +34,10 @@ class EEPROMContext { return $this->dbCtx; } + public function getAuthInfo(): AuthInfo { + return $this->authInfo; + } + public function getAppsContext(): Apps\AppsContext { return $this->appsCtx; } @@ -40,4 +49,27 @@ class EEPROMContext { public function getUsersContext(): Users\UsersContext { return $this->usersCtx; } + + public function createRouting(bool $isApiDomain): RoutingContext { + $routingCtx = new RoutingContext($this->config->scopeTo('cors')); + + if($isApiDomain) { + $routingCtx->register(new Auth\AuthRoutes( + $this->config->scopeTo('auth'), + $this->authInfo, + $this->usersCtx + )); + + $routingCtx->register(new LandingRoutes($this->dbCtx)); + } + + $routingCtx->register(new Uploads\UploadsRoutes( + $this->authInfo, + $this->appsCtx, + $this->uploadsCtx, + $isApiDomain + )); + + return $routingCtx; + } } diff --git a/src/LandingRoutes.php b/src/LandingRoutes.php new file mode 100644 index 0000000..aa02bec --- /dev/null +++ b/src/LandingRoutes.php @@ -0,0 +1,48 @@ +accelRedirect('/index.html'); + $response->setContentType('text/html; charset=utf-8'); + } + + #[Route('GET', '/stats.json')] + public function getStats() { + $dbConn = $this->dbCtx->getConnection(); + + $stats = new stdClass; + $stats->files = 0; + $stats->size = 0; + $stats->types = 0; + $stats->members = 0; + + $result = $dbConn->query('SELECT COUNT(upload_id), SUM(upload_size), COUNT(DISTINCT upload_type) FROM prm_uploads WHERE upload_deleted IS NULL AND upload_dmca IS NULL'); + if($result->next()) { + $stats->files = $result->getInteger(0); + $stats->size = $result->getInteger(1); + $stats->types = $result->getInteger(2); + } + + $result = $dbConn->query('SELECT COUNT(user_id) FROM prm_users WHERE user_restricted IS NULL'); + if($result->next()) + $stats->members = $result->getInteger(0); + + return $stats; + } + + #[Route('GET', '/eeprom.js')] + public function getEepromJs($response) { + $response->accelRedirect('/js/eeprom-v1.0.js'); + $response->setContentType('application/javascript; charset=utf-8'); + } +} diff --git a/src/RoutingContext.php b/src/RoutingContext.php new file mode 100644 index 0000000..b5153c8 --- /dev/null +++ b/src/RoutingContext.php @@ -0,0 +1,55 @@ +router = new HttpFx; + $this->router->use('/', $this->middleware(...)); + } + + private function middleware($response, $request) { + $response->setPoweredBy('EEPROM'); + + $origin = $request->getHeaderLine('Origin'); + + if(!empty($origin)) { + $originHost = parse_url($origin, PHP_URL_HOST); + if(empty($originHost)) + return 403; + + if($originHost !== $request->getHeaderLine('Host')) { + $allowedOrigins = $this->config->getArray('origins'); + + if(!empty($allowedOrigins)) { + $originHost = strtolower($originHost); + + if(!in_array($originHost, $allowedOrigins)) + return 403; + } + } + + $response->setHeader('Access-Control-Allow-Origin', $origin); + $response->setHeader('Vary', 'Origin'); + } + } + + public function getRouter(): IRouter { + return $this->router; + } + + public function register(IRouteHandler $handler): void { + $this->router->register($handler); + } + + public function dispatch(?HttpRequest $request = null): void { + $this->router->dispatch($request); + } +} diff --git a/src/Uploads/UploadsContext.php b/src/Uploads/UploadsContext.php index 2d01293..6363392 100644 --- a/src/Uploads/UploadsContext.php +++ b/src/Uploads/UploadsContext.php @@ -65,6 +65,28 @@ class UploadsContext { return sprintf('%s.t', $this->getFileUrlV1($uploadInfo, $forceApiDomain)); } + public function convertToClientJsonV1(UploadInfo $uploadInfo): array { + return [ + 'id' => $uploadInfo->getId(), + 'url' => $this->getFileUrlV1($uploadInfo), + 'urlf' => $this->getFileUrlV1($uploadInfo, true), + 'thumb' => $this->getThumbnailUrlV1($uploadInfo), + 'name' => $uploadInfo->getName(), + 'type' => $uploadInfo->getMediaTypeString(), + 'size' => $uploadInfo->getDataSize(), + 'user' => (int)$uploadInfo->getUserId(), + 'appl' => (int)$uploadInfo->getAppId(), + 'hash' => $uploadInfo->getHashString(), + 'created' => str_replace('+00:00', 'Z', $uploadInfo->getCreatedAt()->format(\DateTime::ATOM)), + 'accessed' => $uploadInfo->hasBeenAccessed() ? str_replace('+00:00', 'Z', $uploadInfo->getAccessedAt()->format(\DateTime::ATOM)) : null, + 'expires' => $uploadInfo->hasExpired() ? str_replace('+00:00', 'Z', $uploadInfo->getExpiredAt()->format(\DateTime::ATOM)) : null, + + // These can never be reached, and in situation where they technically could it's because of an outdated local record + 'deleted' => null, + 'dmca' => null, + ]; + } + public function supportsThumbnailing(UploadInfo $uploadInfo): bool { return $uploadInfo->isImage() || $uploadInfo->isAudio() diff --git a/src/Uploads/UploadsData.php b/src/Uploads/UploadsData.php index 7325ada..8a406b7 100644 --- a/src/Uploads/UploadsData.php +++ b/src/Uploads/UploadsData.php @@ -1,6 +1,7 @@ isApiDomain) { + Route::handleAttributes($router, $this); + } else { + $router->options('/', $this->getUpload(...)); + $router->get('/:filename', $this->getUpload(...)); + } + } + + #[Route('OPTIONS', '/uploads/:filename')] + #[Route('GET', '/uploads/:filename')] + public function getUpload($response, $request, string $fileName) { + if($this->isApiDomain) { + if($request->hasHeader('Origin')) + $response->setHeader('Access-Control-Allow-Credentials', 'true'); + + $response->setHeader('Access-Control-Allow-Headers', 'Authorization'); + $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET, DELETE'); + } else { + $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET'); + } + + if($request->getMethod() === 'OPTIONS') + return 204; + + $pathInfo = pathinfo($fileName); + $fileId = $pathInfo['filename']; + $fileExt = $pathInfo['extension'] ?? ''; + $isData = $fileExt === ''; + $isThumbnail = $fileExt === 't'; + $isJson = $this->isApiDomain && $fileExt === 'json'; + + if(!$isData && !$isThumbnail && !$isJson) + return 404; + + $uploadsData = $this->uploadsCtx->getUploadsData(); + $uploadInfo = $uploadsData->getUpload(uploadId: $fileId); + if($uploadInfo === null) { + $response->setContent('File not found.'); + return 404; + } + + if($uploadInfo->isCopyrightTakedown()) { + $response->setContent('File is unavailable for copyright reasons.'); + return 451; + } + + if($uploadInfo->isDeleted() || $uploadInfo->hasExpired()) { + $response->setContent('File not found.'); + return 404; + } + + if($isJson) + return $this->uploadsCtx->convertToClientJsonV1($uploadInfo); + + $filePath = $this->uploadsCtx->getFileDataPath($uploadInfo); + if(!is_file($filePath)) { + $response->setContent('Data is missing.'); + return 404; + } + + if(!$isThumbnail) { + $uploadsData->bumpUploadAccess($uploadInfo); + $uploadsData->bumpUploadExpires($uploadInfo); + } + + $fileName = $uploadInfo->getName(); + $contentType = $uploadInfo->getMediaTypeString(); + + if($contentType === 'application/octet-stream' || str_starts_with($contentType, 'text/')) + $contentType = 'text/plain'; + + if($isThumbnail) { + if(!$this->uploadsCtx->supportsThumbnailing($uploadInfo)) + return 404; + + $contentType = 'image/jpeg'; + $accelRedirectPath = $this->uploadsCtx->getThumbnailDataRedirectPathOrCreate($uploadInfo); + $fileName = pathinfo($fileName, PATHINFO_FILENAME) . '-thumb.jpg'; + } else { + $accelRedirectPath = $this->uploadsCtx->getFileDataRedirectPath($uploadInfo); + } + + $response->accelRedirect($accelRedirectPath); + $response->setContentType($contentType); + $response->setFileName(addslashes($fileName)); + } + + #[Route('OPTIONS', '/uploads')] + #[Route('POST', '/uploads')] + public function postUpload($response, $request) { + if($request->hasHeader('Origin')) + $response->setHeader('Access-Control-Allow-Credentials', 'true'); + + $response->setHeader('Access-Control-Allow-Headers', 'Authorization'); + $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, POST'); + + if($request->getMethod() === 'OPTIONS') + return 204; + + if(!$request->isFormContent()) + return 400; + + $content = $request->getContent(); + + try { + $appInfo = $this->appsCtx->getApp($content->getParam('src', FILTER_VALIDATE_INT)); + } catch(RuntimeException $ex) { + return 404; + } + + if(!$this->authInfo->isLoggedIn()) + return 401; + + $userInfo = $this->authInfo->getUserInfo(); + if($userInfo->isRestricted()) + return 403; + + try { + $file = $content->getUploadedFile('file'); + } catch(RuntimeException $ex) { + return 400; + } + + $maxFileSize = $appInfo->getDataSizeLimit(); + if($appInfo->allowSizeMultiplier()) + $maxFileSize *= $userInfo->getDataSizeMultiplier(); + + $localFile = $file->getLocalFileName(); + $fileSize = filesize($localFile); + + if($file->getSize() !== $fileSize || $fileSize > $maxFileSize) { + $response->setHeader('Access-Control-Expose-Headers', 'X-EEPROM-Max-Size'); + $response->setHeader('X-EEPROM-Max-Size', $maxFileSize); + return 413; + } + + $uploadsData = $this->uploadsCtx->getUploadsData(); + $hash = hash_file('sha256', $localFile); + + // this is stupid: dmca status is stored as a file record rather than in a separate table requiring this hack ass garbage + $uploadInfo = $uploadsData->getUpload(appInfo: $appInfo, userInfo: $userInfo, hashString: $hash) + ?? $uploadsData->getUpload(hashString: $hash); + + if($uploadInfo !== null) { + if($uploadInfo->isCopyrightTakedown()) + return 451; + + if($uploadInfo->getUserId() !== $userInfo->getId() + || $uploadInfo->getAppId() !== $appInfo->getId()) + $uploadInfo = null; + } + + if($uploadInfo === null) { + $uploadInfo = $uploadsData->createUpload( + $appInfo, $userInfo, $_SERVER['REMOTE_ADDR'], + $file->getSuggestedFileName(), mime_content_type($localFile), + $fileSize, $hash, $appInfo->getBumpAmount(), true + ); + $filePath = $this->uploadsCtx->getFileDataPath($uploadInfo); + $file->moveTo($filePath); + } else { + $filePath = $this->uploadsCtx->getFileDataPath($uploadInfo); + if($uploadInfo->isDeleted()) + $uploadsData->restoreUpload($uploadInfo); + + $uploadsData->bumpUploadExpires($uploadInfo); + } + + $response->setStatusCode(201); + $response->setHeader('Content-Type', 'application/json; charset=utf-8'); + + return $this->uploadsCtx->convertToClientJsonV1($uploadInfo); + } + + #[Route('DELETE', '/uploads/:fileid')] + public function deleteUpload($response, $request, string $fileId) { + if(!$this->authInfo->isLoggedIn()) + return 401; + + $uploadsData = $this->uploadsCtx->getUploadsData(); + + $uploadInfo = $uploadsData->getUpload(uploadId: $fileId); + if($uploadInfo === null) { + $response->setContent('File not found.'); + return 404; + } + + if($uploadInfo->isCopyrightTakedown()) { + $response->setContent('File is unavailable for copyright reasons.'); + return 451; + } + + if($uploadInfo->isDeleted() || $uploadInfo->hasExpired()) { + $response->setContent('File not found.'); + return 404; + } + + $userInfo = $this->authInfo->getUserInfo(); + if($userInfo->isRestricted() || $userInfo->getId() !== $uploadInfo->getUserId()) + return 403; + + $uploadsData->deleteUpload($uploadInfo); + return 204; + } +}