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));
+ }
}