From 2ce5c31971720746891a31ebdad36b8be5de7538 Mon Sep 17 00:00:00 2001 From: Hugo Dimpfelmoser Date: Thu, 23 Nov 2023 09:37:57 +0100 Subject: [PATCH 1/3] issue #628 save_user_coords --- okapi/core/OkapiServiceRunner.php | 1 + .../caches/save_user_coords/WebService.php | 108 ++++++++++++++++++ .../services/caches/save_user_coords/docs.xml | 29 +++++ 3 files changed, 138 insertions(+) create mode 100644 okapi/services/caches/save_user_coords/WebService.php create mode 100644 okapi/services/caches/save_user_coords/docs.xml diff --git a/okapi/core/OkapiServiceRunner.php b/okapi/core/OkapiServiceRunner.php index a1d0abba..5433e5d9 100644 --- a/okapi/core/OkapiServiceRunner.php +++ b/okapi/core/OkapiServiceRunner.php @@ -39,6 +39,7 @@ class OkapiServiceRunner 'services/caches/geocaches', 'services/caches/mark', 'services/caches/save_personal_notes', + 'services/caches/save_user_coords', 'services/caches/formatters/gpx', 'services/caches/formatters/garmin', 'services/caches/formatters/ggz', diff --git a/okapi/services/caches/save_user_coords/WebService.php b/okapi/services/caches/save_user_coords/WebService.php new file mode 100644 index 00000000..bdac1be3 --- /dev/null +++ b/okapi/services/caches/save_user_coords/WebService.php @@ -0,0 +1,108 @@ + 3 + ); + } + + public static function call(OkapiRequest $request) + { + + $user_coords = $request->get_parameter('user_coords'); + if ($user_coords == null) + throw new ParamMissing('user_coords'); + $parts = explode('|', $user_coords); + if (count($parts) != 2) + throw new InvalidParam('user_coords', "Expecting 2 pipe-separated parts, got ".count($parts)."."); + foreach ($parts as &$part_ref) + { + if (!preg_match("/^-?[0-9]+(\.?[0-9]*)$/", $part_ref)) + throw new InvalidParam('user_coords', "'$part_ref' is not a valid float number."); + $part_ref = floatval($part_ref); + } + list($latitude, $longitude) = $parts; + + # Verify cache_code + + $cache_code = $request->get_parameter('cache_code'); + if ($cache_code == null) + throw new ParamMissing('cache_code'); + $geocache = OkapiServiceRunner::call( + 'services/caches/geocache', + new OkapiInternalRequest($request->consumer, $request->token, array( + 'cache_code' => $cache_code, + 'fields' => 'internal_id' + )) + ); + $cache_id = $geocache['internal_id']; + + self::update_notes($cache_id, $request->token->user_id, $latitude, $longitude); + + $ret_value = 'ok'; + + $result = array( + 'status' => $ret_value + ); + return Okapi::formatted_response($request, $result); + } + + private static function update_notes($cache_id, $user_id, $latitude, $longitude) + { + /* See: + * + * - https://github.com/OpencachingDeutschland/oc-server3/tree/development/htdocs/src/Oc/Libse/CacheNote + * - https://www.opencaching.de/okapi/devel/dbstruct + */ + + $rs = Db::query(" + select max(id) as id + from coordinates + where + type = 2 -- personal note + and cache_id = '".Db::escape_string($cache_id)."' + and user_id = '".Db::escape_string($user_id)."' + "); + $id = null; + if($row = Db::fetch_assoc($rs)) { + $id = $row['id']; + } + if ($id == null) { + Db::query(" + insert into coordinates ( + type, latitude, longitude, cache_id, user_id + ) values ( + 2, + '".Db::escape_string($latitude)."', + '".Db::escape_string($longitude)."', + '".Db::escape_string($cache_id)."', + '".Db::escape_string($user_id)."' + ) + "); + } else { + Db::query(" + update coordinates + set latitude = '".Db::escape_string($latitude)."', + longitude = '".Db::escape_string($longitude)."', + where + id = '".Db::escape_string($id)."' + and type = 2 + "); + } + } + +} diff --git a/okapi/services/caches/save_user_coords/docs.xml b/okapi/services/caches/save_user_coords/docs.xml new file mode 100644 index 00000000..9689200d --- /dev/null +++ b/okapi/services/caches/save_user_coords/docs.xml @@ -0,0 +1,29 @@ + + Update personal coordinates of a geocache + 629 + +

This method allows your users to update the coordinates of their + personal geocache coordinates.

+ +

Current personal coordinates for the geocache can be retrieved + using the alt_wpts field in the + services/caches/geocache + method.

+
+ +

Code of the geocache

+
+ +

The coordinates are defined by a string in the format "lat|lon"

+

Use positive numbers for latitudes in the northern hemisphere and longitudes + in the eastern hemisphere (and negative for southern and western hemispheres + accordingly). These are full degrees with a dot as a decimal point (ex. "48.7|15.89").

+
+ + +

A dictionary of the following structure:

+
    +
  • status - ok
  • +
+
+
From 68dcca84057f26129660983f8c955529f91888a6 Mon Sep 17 00:00:00 2001 From: Hugo Dimpfelmoser Date: Sat, 25 Nov 2023 10:39:29 +0100 Subject: [PATCH 2/3] issue #601 upload fieldnotes --- okapi/core/OkapiServiceRunner.php | 1 + .../apisrv/installation/WebService.php | 31 +++ .../upload_fieldnotes/WebService.php | 249 ++++++++++++++++++ .../draftlogs/upload_fieldnotes/docs.xml | 66 +++++ 4 files changed, 347 insertions(+) create mode 100644 okapi/services/draftlogs/upload_fieldnotes/WebService.php create mode 100644 okapi/services/draftlogs/upload_fieldnotes/docs.xml diff --git a/okapi/core/OkapiServiceRunner.php b/okapi/core/OkapiServiceRunner.php index 5433e5d9..ed5149c3 100644 --- a/okapi/core/OkapiServiceRunner.php +++ b/okapi/core/OkapiServiceRunner.php @@ -44,6 +44,7 @@ class OkapiServiceRunner 'services/caches/formatters/garmin', 'services/caches/formatters/ggz', 'services/caches/map/tile', + 'services/draftlogs/upload_fieldnotes', 'services/logs/capabilities', 'services/logs/delete', 'services/logs/edit', diff --git a/okapi/services/apisrv/installation/WebService.php b/okapi/services/apisrv/installation/WebService.php index a995e984..a63e162b 100644 --- a/okapi/services/apisrv/installation/WebService.php +++ b/okapi/services/apisrv/installation/WebService.php @@ -33,7 +33,38 @@ public static function call(OkapiRequest $request) $result['has_image_positions'] = Settings::get('OC_BRANCH') == 'oc.de'; $result['has_ratings'] = Settings::get('OC_BRANCH') == 'oc.pl'; $result['geocache_passwd_max_length'] = Db::field_length('caches', 'logpw'); + if (Settings::get('OC_BRANCH') == 'oc.de') { + $result['has_draft_logs'] = true; + $result['has_lists'] = true; + $result['cache_types'] = self::getCacheTypes(); + $result['log_types'] = self::getLogTypes(); + + } return Okapi::formatted_response($request, $result); } + + private static function getCacheTypes() { + $rs = Db::query(" + SELECT name + FROM cache_type; + "); + $cache_types = []; + while ($row = Db::fetch_assoc($rs)) { + $cache_types[] = $row['name']; + } + return $cache_types; + } + + private static function getLogTypes() { + $rs = Db::query(" + SELECT name + FROM log_types; + "); + $log_types = []; + while ($row = Db::fetch_assoc($rs)) { + $log_types[] = $row['name']; + } + return $log_types; + } } diff --git a/okapi/services/draftlogs/upload_fieldnotes/WebService.php b/okapi/services/draftlogs/upload_fieldnotes/WebService.php new file mode 100644 index 00000000..7997ecd4 --- /dev/null +++ b/okapi/services/draftlogs/upload_fieldnotes/WebService.php @@ -0,0 +1,249 @@ + 3 + ); + } + + public static function call(OkapiRequest $request) + { + if (Settings::get('OC_BRANCH') != 'oc.de') + throw new BadRequest('This method is not supported in this OKAPI installation. See the has_draftlogs field in services/apisrv/installation method.'); + + $field_notes = $request->get_parameter('field_notes'); + if (!$field_notes) throw new ParamMissing('field_notes'); + + // In order to understand the following, some serious explanations are in order. We are dealing here with a + // string that resembles multiple CSV records. It is important to understand, that a line, identified by a line + // termination character /n is not a 1:1 match withe a CSV record. In fact multiple such lines can be part of one + // CSV record so this input variable has to treated very carefully. What complicates this further is that we cannot + // dictate the character encoding "by design" as there are legacy applictions which have a hardcoded behaviour of + // using UTF-16LE with no BOM. This encoding has been devised by Garmin and Groundspeak a very long time ago. More + // modern applications use UTF-8 but we're best advised if we're tolerant to the character encoding which means + // we must reliably detect it and convert it to UTF-8 ourselves. + // + // Further we accept input data as a base64 encoded string. This primarily because the OKAPI Browser (a Windows application) + // cannot deal with multiline string inputs, however, debugging a webservice like this is hardly possible without having + // the OKAPI browser at hands, so we just accept the input string either plain oder base64 encoded. + + //First figure out whether it is base64 or not. If it is, decode it. + + if (self::isBase64($field_notes)) { + $input = base64_decode($field_notes, true); + } else { + $input = $field_notes; + } + + // At this point we're dealing with the plain $input string, we need to figure out the encoding and convert + // to UTF-8. There is no single library function which proved to reliably identify the character encoding + // for instance mb_detect_encoding() miserably failed identifying UTF-LE w/o BOM correctly, consequently + // it is the safest approach to do this manually with just a few lines of code which can be understood + // by looking at it at a glance. + + switch (true) { + case $input[0] === "\xEF" && $input[1] === "\xBB" && $input[2] === "\xBF": // UTF-8 BOM + $output = substr($input, 3); + break; + case $input[0] === "\xFE" && $input[1] === "\xFF": // UTF-16BE BOM + case $input[0] === "\x00" && $input[2] === "\x00": + $output = mb_convert_encoding($input, 'UTF-8', 'UTF-16BE'); + break; + case $input[0] === "\xFF" && $input[1] === "\xFE": // UTF-16LE BOM + case $input[1] === "\x00": + $output = mb_convert_encoding($input, 'UTF-8', 'UTF-16LE'); + break; + default: + $output = $input; + } + + // Uncomment the following line in a debug environemnt to visually inspect the $input data + // in the final form in which we will from now on process the data. If the data doesn't + // look right at this stage, there is no point in processing it any further as doing so + // will inevitably fail. + // + //return self::debug($request, bin2hex($output)); + + $notes = self::parse_notes($output); + foreach ($notes['records'] as $n) + { + $geocache = OkapiServiceRunner::call( + 'services/caches/geocache', + new OkapiInternalRequest($request->consumer, $request->token, array( + 'cache_code' => $n['code'], + 'fields' => 'internal_id' + )) + ); + + try { + $type = Okapi::logtypename2id($n['type']); + } catch (\Exception $e) { + throw new InvalidParam('Type', 'Invalid log type provided.'); + } + + $dateString = strtotime($n['date']); + if ($dateString === false) { + throw new InvalidParam('`Date` field in log record', "Input data not recognized."); + } else { + $date = date("Y-m-d H:i:s", $dateString); + } + + $user_id = $request->token->user_id; + $geocache_id = $geocache['internal_id']; + $text = $n['log']; + + Db::query(" + insert into field_note ( + user_id, geocache_id, type, date, text + ) values ( + '".Db::escape_string($user_id)."', + '".Db::escape_string($geocache_id)."', + '".Db::escape_string($type)."', + '".Db::escape_string($date)."', + '".Db::escape_string($text)."' + ) + "); + + } + + // totalRecords is the number of parsed draft logs that were in the + // input data. Some logs may have been discarded because they may + // contain logs for other platforms than opencaching.de. In addition + // to discarding "foreign" logs, we also discard logs which contain a + // log type that is not understood by the platform. + // As a result, processedRecords can be smaller than or equal to + // totalRecords. + + $result = array( + 'success' => true, + 'totalRecords' => $notes['totalRecords'], + 'processedRecords' => $notes['processedRecords'] + ); + return Okapi::formatted_response($request, $result); + } + + // ------------------------------------------------------------------ + // Operates on a sanitized utf-8 string of what is known as "Fieldnotes" + // A fieldnotes are a list of CSV formatted records condensed into a + // single string stretching across multiple "lines" where lines are marked + // and terminated by linefeed characters \n. In its simplest form a record + // matches a line, e.g.: + // + // OC1012,2023-11-27T08:27:48Z,Found it,"Thx to Retriever12 for the cache" + // + // This example shows that each record consist of four fields: + // cache_code, log date, log type, and a draft log text + // + // What makes this challenging to parse is that the draft log can be very + // long and it can itself contain line control characters so it stretches + // across multiple lines in string. + + private static function parse_notes($field_notes) + { + $lines = self::parseCSV($field_notes); + $submittable_logtype_names = Okapi::get_submittable_logtype_names(); + $records = []; + $totalRecords = 0; + $processedRecords = 0; + + foreach ($lines as $line) { + $totalRecords++; + $line = trim($line); + $fields = str_getcsv($line); + + $code = $fields[0]; + $date = $fields[1]; + $type = $fields[2]; + + if (!in_array($type, $submittable_logtype_names)) continue; + + $log = nl2br($fields[3]); + + $records[] = [ + 'code' => $code, + 'date' => $date, + 'type' => $type, + 'log' => $log, + ]; + $processedRecords++; + } + return ['success' => true, 'records' => $records, 'totalRecords' => $totalRecords, 'processedRecords' => $processedRecords]; + } + + + // ------------------------------------------------------------------ + // Split lines into an array of records. Each element in the $output + // array will then contain a string, which can strech across multiple + // lines, each terminated with a linefeed \n. + // + // In this process we also skip records that will not be understood + // by the platform, where platform is one of: geocaching.com, opencaching.{de,pl,...} + // + // In this function we ony take log records which start with "OC" (for opencaching.de) + + private static function parseCSV($fieldnotes) + { + $output = []; + $buffer = ''; + $start = true; + + $lines = explode("\n", $fieldnotes); + $lines = array_filter($lines); // Drop empty lines + + foreach ($lines as $line) { + if ($start) { + $buffer = $line; + $start = false; + } else { + if (strpos($line, 'OC') !== 0) { + $buffer .= "\n" . $line; + } else { + $output[] = trim($buffer); + $buffer = $line; + } + } + } + + if (!$start) { + $output[] = trim($buffer); + } + return $output; + } + + // ------------------------------------------------------------------ + // Check whether a string ($s) is base64 encoded or not. + + private static function isBase64($s) + { + return (bool) preg_match('/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $s); + } + + // ------------------------------------------------------------------ + // This is actually a debug routine to assist in debugging the webservice + // by generating an http response such that a php object can be visualized + // in the absence of using functions such as var_dump() or echo. + // + // It could be deleted but it may be useful for debugging in case of any + // doubts with respect to the correct function of this webservice. + + private static function debug($request, $debug) + { + $result = array('debug'=> json_encode($debug)); + return Okapi::formatted_response($request, $result); + } +} diff --git a/okapi/services/draftlogs/upload_fieldnotes/docs.xml b/okapi/services/draftlogs/upload_fieldnotes/docs.xml new file mode 100644 index 00000000..09ab28eb --- /dev/null +++ b/okapi/services/draftlogs/upload_fieldnotes/docs.xml @@ -0,0 +1,66 @@ + + Upload Fieldnotes + 630 + +

This method allows you to upload a series of fieldnote objects in CSV format. + Fieldnote objects contain draft versions of log entries. Once uploaded, users will be able + to review, edit, and submit them via the Opencaching website.

+
+ +

CSV formatted string with no header.

+

Each record describes a geocache draft log object consisting of four fields:

+
    +
  1. Geocache Code
  2. +
  3. Date
  4. +
  5. Log Type
  6. +
  7. Log Text
  8. +
+

The first three fields are string entities that don't have line control characters in them, + the Log Text field is different as it may spread over muliple lines identified by line control + characters such as newline or linefeed and it may contain quote characters as well. +

+

The second field Date should be in ISO 8601 format (currently any format + acceptable by PHP's strtotime function also will do, but most of them don't handle + time zones properly, try to use ISO 8601!).

+

Since the log type is passed as a string, its value must match the + values supported by the platform (case sensitive!). In order to query + the names for supported log types, the service method ::services/apisrv/installation + can and should be used. +

+

Note: This service method is not supported on all installations

+
+ + +

A dictionary of the following structure:

+
    +
  • success - true
  • +
  • totalRecords - number of records in field_notes
  • +
  • processedRecords - number of records inserted into the database
  • +
+

processedRecords may be less than totalRecords (it may even be zero) and that + is the case for the following reason: Fieldnotes are created from + Geocaching client applications. Some of these, for instance cgeo support multiple + geocaching platforms from which opencaching is only one of them. Conseqently + Fieldnotes may be a "hybrid object" which may ontain records targeted at more than one + platform. For instance for geocaching.com logs, the records start with GC.... + while on opencaching.de the log records start with OC..... Other opencaching + platforms use other codes, for instance opencaching.pl uses OP....

+

+ The client application may upload one and the same Fielnotes object to all platforms and + it is within the platform's discretion + to filter out what matches their object definition. + opencaching.de discards everything that doesn't start with "OC."

+

In addition, in that hybrid object there will be Log Type, a string that + inevitably has a different definition for different platforms. For instance, what + is called a "Write note" log on geocaching.com is recognized as "Note" some + opencaching platforms or "Comment" on others. + Consequently fieldnotes records may have Log Types which are not understood + by the target platform in which case they will be discarded without notice.

+

It is the responsibility of the client application to assign the correct Log Type + string when the offline log is created. + Sending log type names which are not supported by the designated target platform + is considered a programming error within the client application. In order + to determine a target's supported log type names the service: ::services/apisrv/installation + can and should be used.

+
+
From b2378086444c602fce84003e7c6a7af4ef2c2fb8 Mon Sep 17 00:00:00 2001 From: hxdimpf Date: Thu, 12 Mar 2026 19:05:04 +0100 Subject: [PATCH 3/3] Fix fieldnotes: imports, parsing, security, style issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing BadRequest import (fatal error on oc.pl) - Remove unused LogsCommon import - Fix isBase64() false positives: use round-trip check instead of regex - Add bounds check before accessing input bytes for encoding detection - Fix parseCSV record-splitting: use regex pattern (OC\w+,\d{4}-) to detect record starts instead of plain 'OC' prefix check - Truncate field_note.text to 255 chars (VARCHAR(255) column limit) - Remove nl2br() — draft logs should store plain text - Rename variables/methods to snake_case per project conventions - Restore save_user_coords in OkapiServiceRunner (kept per PR #628) - Rename getCacheTypes/getLogTypes to snake_case in installation service --- .../apisrv/installation/WebService.php | 8 +-- .../upload_fieldnotes/WebService.php | 55 +++++++++++-------- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/okapi/services/apisrv/installation/WebService.php b/okapi/services/apisrv/installation/WebService.php index a63e162b..b32cc73a 100644 --- a/okapi/services/apisrv/installation/WebService.php +++ b/okapi/services/apisrv/installation/WebService.php @@ -36,15 +36,15 @@ public static function call(OkapiRequest $request) if (Settings::get('OC_BRANCH') == 'oc.de') { $result['has_draft_logs'] = true; $result['has_lists'] = true; - $result['cache_types'] = self::getCacheTypes(); - $result['log_types'] = self::getLogTypes(); + $result['cache_types'] = self::get_cache_types(); + $result['log_types'] = self::get_log_types(); } return Okapi::formatted_response($request, $result); } - private static function getCacheTypes() { + private static function get_cache_types() { $rs = Db::query(" SELECT name FROM cache_type; @@ -56,7 +56,7 @@ private static function getCacheTypes() { return $cache_types; } - private static function getLogTypes() { + private static function get_log_types() { $rs = Db::query(" SELECT name FROM log_types; diff --git a/okapi/services/draftlogs/upload_fieldnotes/WebService.php b/okapi/services/draftlogs/upload_fieldnotes/WebService.php index 7997ecd4..3c01456a 100644 --- a/okapi/services/draftlogs/upload_fieldnotes/WebService.php +++ b/okapi/services/draftlogs/upload_fieldnotes/WebService.php @@ -2,6 +2,7 @@ namespace okapi\services\draftlogs\upload_fieldnotes; +use okapi\core\Exception\BadRequest; use okapi\core\Exception\InvalidParam; use okapi\core\Exception\ParamMissing; use okapi\core\Db; @@ -9,7 +10,6 @@ use okapi\core\OkapiServiceRunner; use okapi\core\Request\OkapiInternalRequest; use okapi\core\Request\OkapiRequest; -use okapi\services\logs\LogsCommon; use okapi\Settings; class WebService @@ -44,7 +44,7 @@ public static function call(OkapiRequest $request) //First figure out whether it is base64 or not. If it is, decode it. - if (self::isBase64($field_notes)) { + if (self::is_base64($field_notes)) { $input = base64_decode($field_notes, true); } else { $input = $field_notes; @@ -56,6 +56,10 @@ public static function call(OkapiRequest $request) // it is the safest approach to do this manually with just a few lines of code which can be understood // by looking at it at a glance. + if (strlen($input) < 3) { + throw new InvalidParam('field_notes', "Input data is too short to be valid."); + } + switch (true) { case $input[0] === "\xEF" && $input[1] === "\xBB" && $input[2] === "\xBF": // UTF-8 BOM $output = substr($input, 3); @@ -96,11 +100,11 @@ public static function call(OkapiRequest $request) throw new InvalidParam('Type', 'Invalid log type provided.'); } - $dateString = strtotime($n['date']); - if ($dateString === false) { + $date_timestamp = strtotime($n['date']); + if ($date_timestamp === false) { throw new InvalidParam('`Date` field in log record', "Input data not recognized."); } else { - $date = date("Y-m-d H:i:s", $dateString); + $date = date("Y-m-d H:i:s", $date_timestamp); } $user_id = $request->token->user_id; @@ -130,9 +134,9 @@ public static function call(OkapiRequest $request) // totalRecords. $result = array( - 'success' => true, - 'totalRecords' => $notes['totalRecords'], - 'processedRecords' => $notes['processedRecords'] + 'success' => true, + 'total_records' => $notes['total_records'], + 'processed_records' => $notes['processed_records'] ); return Okapi::formatted_response($request, $result); } @@ -155,14 +159,14 @@ public static function call(OkapiRequest $request) private static function parse_notes($field_notes) { - $lines = self::parseCSV($field_notes); + $lines = self::parse_csv($field_notes); $submittable_logtype_names = Okapi::get_submittable_logtype_names(); - $records = []; - $totalRecords = 0; - $processedRecords = 0; + $records = []; + $total_records = 0; + $processed_records = 0; foreach ($lines as $line) { - $totalRecords++; + $total_records++; $line = trim($line); $fields = str_getcsv($line); @@ -172,7 +176,7 @@ private static function parse_notes($field_notes) if (!in_array($type, $submittable_logtype_names)) continue; - $log = nl2br($fields[3]); + $log = mb_substr($fields[3], 0, 255); $records[] = [ 'code' => $code, @@ -180,9 +184,9 @@ private static function parse_notes($field_notes) 'type' => $type, 'log' => $log, ]; - $processedRecords++; + $processed_records++; } - return ['success' => true, 'records' => $records, 'totalRecords' => $totalRecords, 'processedRecords' => $processedRecords]; + return ['success' => true, 'records' => $records, 'total_records' => $total_records, 'processed_records' => $processed_records]; } @@ -196,13 +200,13 @@ private static function parse_notes($field_notes) // // In this function we ony take log records which start with "OC" (for opencaching.de) - private static function parseCSV($fieldnotes) + private static function parse_csv($field_notes) { $output = []; $buffer = ''; $start = true; - $lines = explode("\n", $fieldnotes); + $lines = explode("\n", $field_notes); $lines = array_filter($lines); // Drop empty lines foreach ($lines as $line) { @@ -210,11 +214,12 @@ private static function parseCSV($fieldnotes) $buffer = $line; $start = false; } else { - if (strpos($line, 'OC') !== 0) { - $buffer .= "\n" . $line; - } else { + // A new record starts with a cache code followed by an ISO date + if (preg_match('/^OC\w+,\d{4}-/', $line)) { $output[] = trim($buffer); $buffer = $line; + } else { + $buffer .= "\n" . $line; } } } @@ -228,9 +233,13 @@ private static function parseCSV($fieldnotes) // ------------------------------------------------------------------ // Check whether a string ($s) is base64 encoded or not. - private static function isBase64($s) + private static function is_base64($s) { - return (bool) preg_match('/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $s); + $decoded = base64_decode($s, true); + if ($decoded === false) { + return false; + } + return base64_encode($decoded) === $s; } // ------------------------------------------------------------------