From fdd0ada815dbeda6670fee022a1ce31c95b246fc Mon Sep 17 00:00:00 2001 From: Franzz Date: Thu, 28 May 2026 13:22:44 +0200 Subject: [PATCH] Implement CSRF --- composer.lock | 4 +- lib/Media.php | 13 +++--- lib/Spot.php | 37 +++++++++-------- lib/Uploader.php | 5 ++- lib/index.php | 55 +++++++++++++------------ src/app.js | 1 + src/components/admin.vue | 8 ++-- src/components/project.vue | 4 +- src/components/projectNewsletter.vue | 2 +- src/components/projectPost.vue | 2 +- src/components/upload.vue | 7 ++-- src/images/spot-logo-only.svg | 20 +++------- src/scripts/api.js | 60 ++++++++++++++++++---------- src/scripts/projects.js | 17 ++++++-- 14 files changed, 129 insertions(+), 106 deletions(-) diff --git a/composer.lock b/composer.lock index c2849a9..4becdea 100644 --- a/composer.lock +++ b/composer.lock @@ -12,7 +12,7 @@ "source": { "type": "git", "url": "https://git.lutran.fr/franzz/objects", - "reference": "e28c650f2254801caa6f9756ad30ce1244c4c0c2" + "reference": "d13fdacddec581b5cf5179b625f414b2453b6bf3" }, "type": "library", "autoload": { @@ -21,7 +21,7 @@ } }, "description": "Objects", - "time": "2026-05-22T22:11:13+00:00" + "time": "2026-05-28T10:12:19+00:00" }, { "name": "phpmailer/phpmailer", diff --git a/lib/Media.php b/lib/Media.php index a26ea94..be2592d 100644 --- a/lib/Media.php +++ b/lib/Media.php @@ -167,7 +167,7 @@ class Media extends PhpObject { '-print_format json', //output format: json '-i' //input file )); - exec('ffprobe '.$sParams.' "'.$sMediaPath.'"', $asResult); + exec('ffprobe '.$sParams.' '.escapeshellarg($sMediaPath), $asResult); $asExif = json_decode(implode('', $asResult), true); //Taken On @@ -269,10 +269,10 @@ class Media extends PhpObject { $sTempPath = self::getMediaPath(uniqid('temp_').'.png'); $asResult = array(); $sParams = implode(' ', array( - '-i "'.$sMediaPath.'"', //input file - '-ss 00:00:01.000', //Image taken after x seconds - '-vframes 1', //number of video frames to output - '"'.$sTempPath.'"', //output file + '-i '.escapeshellarg($sMediaPath), //input file + '-ss 00:00:01.000', //Image taken after x seconds + '-vframes 1', //number of video frames to output + escapeshellarg($sTempPath), //output file )); exec('ffmpeg '.$sParams, $asResult); @@ -296,7 +296,8 @@ class Media extends PhpObject { $sMediaPath = self::getMediaPath($sMediaName); $sMediaMime = mime_content_type($sMediaPath); switch($sMediaMime) { - case 'video/quicktime': $sType = 'video'; break; + case 'video/quicktime': + case 'video/mp4': $sType = 'video'; break; default: $sType = 'image'; break; } diff --git a/lib/Spot.php b/lib/Spot.php index 091825a..2099e5b 100755 --- a/lib/Spot.php +++ b/lib/Spot.php @@ -46,6 +46,19 @@ class Spot extends Main const MAIN_PAGE = 'index'; const DIST_FOLDER = '../dist/'; + const MUTATING_ACTIONS = array( + 'add_post', + 'subscribe', + 'unsubscribe', + 'update_project', + 'upload', + 'add_comment', + 'add_position', + 'admin_set', + 'admin_create', + 'admin_delete', + 'build_geojson' + ); private Project $oProject; private Media $oMedia; @@ -186,7 +199,8 @@ class Spot extends Main 'chunk_size' => self::FEED_CHUNK_SIZE, 'hash_sep' => '-', 'title' => self::PROJECT_NAME, - 'default_page' => 'project' + 'default_page' => 'project', + 'csrf_token' => $this->getCsrfToken() ) ), self::MAIN_PAGE, @@ -278,17 +292,6 @@ class Spot extends Main return $oEmail->send(); } - public function genCronFile() { - //$bSuccess = (file_put_contents('spot_cron.sh', '#!/bin/bash'."\n".'cd '.dirname($_SERVER['SCRIPT_FILENAME'])."\n".'php -f index.php a=update_feed')!==false); - $sFileName = 'spot_cron.sh'; - $sContent = - '#!/bin/bash'."\n". - 'wget -qO- '.$this->asContext['serv_name'].'index.php?a=update_project > /dev/null'."\n". - '#Crontab job: 0 * * * * . '.dirname($_SERVER['SCRIPT_FILENAME']).'/'.$sFileName.' > /dev/null'."\n"; - $bSuccess = (file_put_contents($sFileName, $sContent)!==false); - return self::getJsonResult($bSuccess, ''); - } - public function getMarkers($asMessageIds=array(), $asMediaIds=array(), $bInternal=false) { //Get messages @@ -579,10 +582,10 @@ class Spot extends Main return $bInternal?$asResult['feed']:self::getJsonResult(true, '', $asResult); } - public function getFeed($iRefId=0, $sDirection, $sSort) { - $this->oDb->cleanSql($iRefId); - $this->oDb->cleanSql($sDirection); - $this->oDb->cleanSql($sSort); + private function getFeed($iRefId, $sDirection, $sSort) { + $sRefId = is_scalar($iRefId) && preg_match('/^\d+(?:\.\d+)?$/D', (string) $iRefId) ? (string) $iRefId : '0'; + $sDirection = ($sDirection === '>')?'>':'<'; + $sSort = ($sSort === 'ASC')?'ASC':'DESC'; $sProjectIdField = Db::getId(Project::PROJ_TABLE); $sMsgIdField = Db::getId(Feed::MSG_TABLE); @@ -605,7 +608,7 @@ class Spot extends Main "FROM ".self::POST_TABLE, $this->getFeedConstraints(self::POST_TABLE, 'site_time', 'sql'), ") AS items", - ($iRefId > 0)?("WHERE ref ".$sDirection." ".$iRefId):"", + ($sRefId !== '0')?("WHERE ref ".$sDirection." ".$sRefId):"", "ORDER BY ref ".$sSort, "LIMIT ".self::FEED_CHUNK_SIZE )); diff --git a/lib/Uploader.php b/lib/Uploader.php index 1b22e73..c4aaa1b 100644 --- a/lib/Uploader.php +++ b/lib/Uploader.php @@ -46,12 +46,15 @@ class Uploader extends UploadHandler } protected function handle_file_upload($uploaded_file, $name, $size, $type, $error, $index = null, $content_range = null) { - $file = parent::handle_file_upload($uploaded_file, $name, $size, $type, $error, $index, $content_range); + $sExt = strtolower(pathinfo((string) $name, PATHINFO_EXTENSION)); + $sStoredName = bin2hex(random_bytes(16)).($sExt !== ''?'.'.$sExt:''); + $file = parent::handle_file_upload($uploaded_file, $sStoredName, $size, $type, $error, $index, $content_range); if(empty($file->error)) { $asResult = $this->oMedia->addMedia($file->name); if(!$asResult['result']) $file->error = $this->get_error_message($asResult['desc'], $asResult['data']); else { + $file->original_name = basename((string) $name); $file->id = $this->oMedia->getMediaId(); $file->thumbnail = $asResult['data']['thumb_path']; } diff --git a/lib/index.php b/lib/index.php index 7fd6666..0d699f1 100644 --- a/lib/index.php +++ b/lib/index.php @@ -9,33 +9,36 @@ ob_start(); $oLoader = require __DIR__.'/../vendor/autoload.php'; use Franzz\Objects\ToolBox; -use Franzz\Objects\Main; use Franzz\Spot\Spot; use Franzz\Spot\User; ToolBox::fixGlobalVars($argv ?? array()); //Available variables -$sAction = $_REQUEST['a'] ?? ''; -$sTimezone = $_REQUEST['t'] ?? ''; -$sName = $_GET['name'] ?? ''; -$sContent = $_GET['content'] ?? ''; -$iProjectId = $_REQUEST['id_project'] ?? 0 ; -$sField = $_REQUEST['field'] ?? ''; -$oValue = $_REQUEST['value'] ?? ''; -$iId = $_REQUEST['id'] ?? 0 ; -$sType = $_REQUEST['type'] ?? ''; -$sEmail = $_REQUEST['email'] ?? ''; -$sLat = $_REQUEST['latitude'] ?? ''; -$sLng = $_REQUEST['longitude'] ?? ''; -$iTimestamp = $_REQUEST['timestamp'] ?? 0; +$sAction = $_REQUEST['a'] ?? ''; +$sTimezone = $_REQUEST['t'] ?? ''; +$sName = $_REQUEST['name'] ?? ''; +$sContent = $_REQUEST['content'] ?? ''; +$iProjectId = Spot::validatePositiveInt($_REQUEST['id_project'] ?? 0); +$sRefId = $_REQUEST['id'] ?? 0; +$iEntityId = Spot::validatePositiveInt($_REQUEST['id'] ?? 0); +$sField = $_REQUEST['field'] ?? ''; +$oValue = $_REQUEST['value'] ?? ''; +$sType = $_REQUEST['type'] ?? ''; +$sEmail = $_REQUEST['email'] ?? ''; +$sLat = $_REQUEST['latitude'] ?? ''; +$sLng = $_REQUEST['longitude'] ?? ''; +$iTimestamp = Spot::validatePositiveInt($_REQUEST['timestamp'] ?? 0); +$sCsrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['csrf_token'] ?? ''); //Initiate class $oSpot = new Spot(__FILE__, $sTimezone); $oSpot->setProjectId($iProjectId); -$sResult = ''; -if($sAction!='') +$bValidRequest = $oSpot->validateMutationRequest($sAction, $sCsrfToken); +if(!$bValidRequest) $sResult = Spot::getJsonResult(false, Spot::UNAUTHORIZED); +elseif($sAction == '') $sResult = $oSpot->getAppMainPage(); +else { switch($sAction) { @@ -49,10 +52,10 @@ if($sAction!='') $sResult = $oSpot->getProjectGeoJson(); break; case 'next_feed': - $sResult = $oSpot->getNextFeed($iId); + $sResult = $oSpot->getNextFeed($sRefId); break; case 'new_feed': - $sResult = $oSpot->getNewFeed($iId); + $sResult = $oSpot->getNewFeed($sRefId); break; case 'add_post': $sResult = $oSpot->addPost($sName, $sContent); @@ -64,7 +67,7 @@ if($sAction!='') $sResult = $oSpot->unsubscribe(); break; case 'unsubscribe_email': - $sResult = $oSpot->unsubscribeFromEmail($iId); + $sResult = $oSpot->unsubscribeFromEmail($iEntityId); break; case 'update_project': $sResult = $oSpot->updateProject(); @@ -78,7 +81,7 @@ if($sAction!='') $sResult = $oSpot->upload(); break; case 'add_comment': - $sResult = $oSpot->addComment($iId, $sContent); + $sResult = $oSpot->addComment($iEntityId, $sContent); break; case 'add_position': $sResult = $oSpot->addPosition($sLat, $sLng, $iTimestamp); @@ -87,16 +90,13 @@ if($sAction!='') $sResult = $oSpot->getAdminSettings(); break; case 'admin_set': - $sResult = $oSpot->setAdminSettings($sType, $iId, $sField, $oValue); + $sResult = $oSpot->setAdminSettings($sType, $iEntityId, $sField, $oValue); break; case 'admin_create': $sResult = $oSpot->createAdminSettings($sType); break; case 'admin_delete': - $sResult = $oSpot->deleteAdminSettings($sType, $iId); - break; - case 'generate_cron': - $sResult = $oSpot->genCronFile(); + $sResult = $oSpot->deleteAdminSettings($sType, $iEntityId); break; case 'sql': $sResult = $oSpot->getDbBuildScript(); @@ -105,13 +105,12 @@ if($sAction!='') $sResult = $oSpot->buildGeoJSON($sName); break; default: - $sResult = Main::getJsonResult(false, Main::NOT_FOUND); + $sResult = Spot::getJsonResult(false, Spot::NOT_FOUND); } } - else $sResult = Main::getJsonResult(false, Main::NOT_FOUND); + else $sResult = Spot::getJsonResult(false, Spot::NOT_FOUND); } } -else $sResult = $oSpot->getAppMainPage(); $sDebug = ob_get_clean(); if(Settings::DEBUG && $sDebug!='') $oSpot->addUncaughtError($sDebug); diff --git a/src/app.js b/src/app.js index 6d6d116..2d994a9 100644 --- a/src/app.js +++ b/src/app.js @@ -22,6 +22,7 @@ const oApi = new Api({ server: appConfig.consts.server, processPage: appConfig.consts.process_page, timezone: oUser.timezone, + csrfToken: appConfig.consts.csrf_token, errorCode: appConfig.consts.error, lang: oLang }); diff --git a/src/components/admin.vue b/src/components/admin.vue index 5ae6a0e..48105cf 100644 --- a/src/components/admin.vue +++ b/src/components/admin.vue @@ -50,7 +50,7 @@ export default { } }, createElem(sType) { - this.api.get('admin_create', {type: sType}) + this.api.post('admin_create', {type: sType}) .then((aoNewElemTypes) => { for(const [sType, aoNewElems] of Object.entries(aoNewElemTypes)) { for(const [iKey, oNewElem] of Object.entries(aoNewElems)) { @@ -68,7 +68,7 @@ export default { id: oElem.id }; - this.api.get('admin_delete', asInputs) + this.api.post('admin_delete', asInputs) .then((asData) => { delete this.elems[asInputs.type][asInputs.id]; this.addFeedback('success', this.l('admin.delete_success'), asInputs); @@ -90,7 +90,7 @@ export default { value: sNewVal }; - this.api.get('admin_set', asInputs) + this.api.post('admin_set', asInputs) .then((asData) => { this.elems[oElem.type][oElem.id][oEvent.target.name] = sNewVal; this.addFeedback('success', this.l('admin.save_success'), asInputs); @@ -106,7 +106,7 @@ export default { this.saveTimer = setTimeout(() => {this.updateElem(oElem, oEvent);}, 2000); }, updateProject() { - this.api.get('update_project') + this.api.post('update_project') .then((asData, sMsg) => {this.addFeedback('success', sMsg, {'update':'project'});}) .catch((sMsg) => {this.addFeedback('error', sMsg, {'update':'project'});}); } diff --git a/src/components/project.vue b/src/components/project.vue index 1582da3..03003a5 100644 --- a/src/components/project.vue +++ b/src/components/project.vue @@ -514,8 +514,8 @@ export default { return { top: this.mapPadding, bottom: this.mapPadding, - left: this.mapPadding + ((!bIsMobile && this.panels.settingsOpen && this.settings)?this.settings.getWidth():0), - right: this.mapPadding + ((!bIsMobile && this.panels.feedOpen && this.feed)?this.feed.getWidth():0) + left: this.mapPadding + ((!bIsMobile && this.panels.leftOpen && this.settings)?this.settings.getWidth():0), + right: this.mapPadding + ((!bIsMobile && this.panels.rightOpen && this.feed)?this.feed.getWidth():0) }; }, updateMapPadding(iDuration=0) { diff --git a/src/components/projectNewsletter.vue b/src/components/projectNewsletter.vue index c336742..913f491 100644 --- a/src/components/projectNewsletter.vue +++ b/src/components/projectNewsletter.vue @@ -39,7 +39,7 @@ export default { const sAction = this.action; this.loading = true; - this.api.request(sAction, {'email': this.user.email, 'name': this.user.name}) + this.api.request(sAction, {'email': this.user.email, 'name': this.user.name}, 'POST') .then((asResponse) => { this.feedbacks.push({type: asResponse.result, msg: asResponse.desc}); this.user.setInfo(asResponse.data); diff --git a/src/components/projectPost.vue b/src/components/projectPost.vue index 3267304..4d82fe0 100644 --- a/src/components/projectPost.vue +++ b/src/components/projectPost.vue @@ -134,7 +134,7 @@ send() { if(this.postMessage != '' && this.user.name != '') { this.sending = true; - this.api.get( + this.api.post( 'add_post', { id_project: this.project.project.id, diff --git a/src/components/upload.vue b/src/components/upload.vue index 38780a9..6702568 100644 --- a/src/components/upload.vue +++ b/src/components/upload.vue @@ -51,6 +51,7 @@ export default { endpoint, fieldName: 'files[]', formData: true, + headers: {'X-CSRF-Token': this.consts.csrf_token}, allowedMetaFields: ['t', 'name', 'type'], getResponseData(xhr) { return JSON.parse(xhr.responseText || '{}'); @@ -65,7 +66,7 @@ export default { const uploadedFiles = response?.body?.files || []; uploadedFiles.forEach((uploadedFile) => { const hasError = Object.prototype.hasOwnProperty.call(uploadedFile, 'error'); - this.logs.push(hasError ? uploadedFile.error : this.lang.get('upload.success', [uploadedFile.name])); + this.logs.push(hasError ? uploadedFile.error : this.lang.get('upload.success', [uploadedFile.original_name || uploadedFile.name])); if(!hasError) this.files.push({...uploadedFile, content: ''}); }); }); @@ -85,7 +86,7 @@ export default { event.target.value = ''; }, addComment(oFile) { - this.api.get('add_comment', { + this.api.post('add_comment', { id: oFile.id, content: oFile.content }) @@ -98,7 +99,7 @@ export default { navigator.geolocation.getCurrentPosition( (position) => { this.logs.push('Sending position...'); - this.api.get('add_position', { + this.api.post('add_position', { 'latitude': position.coords.latitude, 'longitude': position.coords.longitude, 'timestamp': Math.round(position.timestamp / 1000) diff --git a/src/images/spot-logo-only.svg b/src/images/spot-logo-only.svg index 154056b..3be44bf 100644 --- a/src/images/spot-logo-only.svg +++ b/src/images/spot-logo-only.svg @@ -1,17 +1,7 @@ - - - - - - - - - - - - - - - \ No newline at end of file + + + + + diff --git a/src/scripts/api.js b/src/scripts/api.js index 90c2db2..997c158 100644 --- a/src/scripts/api.js +++ b/src/scripts/api.js @@ -1,44 +1,60 @@ export default class Api { - constructor({server, processPage, timezone, errorCode, lang}) { + constructor({server, processPage, timezone, csrfToken, errorCode, lang}) { this.server = server; this.processPage = processPage; this.timezone = timezone; + this.csrfToken = csrfToken; this.errorCode = errorCode; this.lang = lang; } - async get(action, params = {}) { - const response = await this.request(action, params); + async get(sAction, asParams = {}) { + const response = await this.request(sAction, asParams, 'GET'); return response.data; } - async request(action, params = {}) { - const requestParams = { - ...params, - a: action, + async post(sAction, asParams = {}) { + const response = await this.request(sAction, asParams, 'POST'); + return response.data; + } + + async request(sAction, asParams = {}, method = 'GET') { + const oUrl = new URL(this.processPage, this.server); + + const sUrlParams = new URLSearchParams({ + ...asParams, + a: sAction, t: this.timezone + }).toString(); + + const asOptions = { + method, + headers: {'Accept': 'application/json'} }; - const url = new URL(this.processPage, this.server); - url.search = new URLSearchParams(requestParams).toString(); - - const request = await fetch(url, { - method: 'GET', - headers: {'Content-Type': 'application/json'} - }); - - if(!request.ok) { - throw new Error('Error HTTP ' + request.status + ': ' + request.statusText); + if(method === 'GET') { + oUrl.search = sUrlParams; + } + else { + asOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'; + asOptions.headers['X-CSRF-Token'] = this.csrfToken; + asOptions.body = sUrlParams; } - const response = await request.json(); - response.desc = this.lang.parse(response.desc); + const oRequest = await fetch(oUrl, asOptions); - if(response.result == this.errorCode) { - throw response.desc; + if(!oRequest.ok) { + throw new Error('Error HTTP ' + oRequest.status + ': ' + oRequest.statusText); } - return response; + const oResponse = await oRequest.json(); + oResponse.desc = this.lang.parse(oResponse.desc); + + if(oResponse.result == this.errorCode) { + throw oResponse.desc; + } + + return oResponse; } } diff --git a/src/scripts/projects.js b/src/scripts/projects.js index dd38bbb..3beface 100644 --- a/src/scripts/projects.js +++ b/src/scripts/projects.js @@ -1,4 +1,5 @@ -import { LngLat } from 'maplibre-gl'; +const EARTH_RADIUS = 6371008.8; +const DEGREES_TO_RADIANS = Math.PI / 180; export default class Projects { @@ -63,12 +64,10 @@ export default class Projects { let iDistance = 0, iElevDrop = 0, iElevGain = 0, iTime = 0; for(let i = 1; i < aoCoords.length; i++) { - const oCurrPoint = new LngLat(aoCoords[i][0], aoCoords[i][1]); - const oPrevPoint = new LngLat(aoCoords[i - 1][0], aoCoords[i - 1][1]); const iCurrElev = Number(aoCoords[i][2]); const iPrevElev = Number(aoCoords[i - 1][2]); const iElevDelta = (Number.isFinite(iCurrElev) && Number.isFinite(iPrevElev))?(iCurrElev - iPrevElev):0; - const iSegDistance = oCurrPoint.distanceTo(oPrevPoint); + const iSegDistance = this.getDistance(aoCoords[i], aoCoords[i - 1]); if(iSegDistance <= 0) continue; iDistance += iSegDistance; @@ -94,4 +93,14 @@ export default class Projects { time: iTime }; } + + getDistance(aoPointA, aoPointB) { + const iLatA = aoPointA[1] * DEGREES_TO_RADIANS; + const iLatB = aoPointB[1] * DEGREES_TO_RADIANS; + const iAngle = + Math.sin(iLatA) * Math.sin(iLatB) + + Math.cos(iLatA) * Math.cos(iLatB) * Math.cos((aoPointB[0] - aoPointA[0]) * DEGREES_TO_RADIANS); + + return EARTH_RADIUS * Math.acos(Math.min(iAngle, 1)); + } }