You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

454 lines
16 KiB

7 years ago
  1. <?php
  2. namespace App\Http\Controllers;
  3. use Laravel\Lumen\Routing\Controller as BaseController;
  4. use Illuminate\Http\Request;
  5. use DB, Log, Cache;
  6. use Quartz;
  7. use DateTime, DateTimeZone, DateInterval;
  8. use App\Jobs\TripComplete;
  9. use App\Jobs\TripStarted;
  10. use App\Jobs\NotifyOfNewLocations;
  11. class Api extends BaseController
  12. {
  13. public function account(Request $request) {
  14. $token = $request->input('token');
  15. if(!$token)
  16. return response(json_encode(['error' => 'no token provided']))->header('Content-Type', 'application/json');
  17. $db = DB::table('databases')->where('write_token','=',$token)->first();
  18. if(!$db)
  19. return response(json_encode(['error' => 'invalid token']))->header('Content-Type', 'application/json');
  20. return response(json_encode(['name' => $db->name]))->header('Content-Type', 'application/json');
  21. }
  22. public function query(Request $request) {
  23. $token = $request->input('token');
  24. if(!$token)
  25. return response(json_encode(['error' => 'no token provided']))->header('Content-Type', 'application/json');
  26. $db = DB::table('databases')->where('read_token','=',$token)->first();
  27. if(!$db)
  28. return response(json_encode(['error' => 'invalid token']))->header('Content-Type', 'application/json');
  29. $qz = new Quartz\DB(env('STORAGE_DIR').$db->name, 'r');
  30. if($request->input('tz')) {
  31. $tz = $request->input('tz');
  32. } else {
  33. $tz = 'UTC';
  34. }
  35. if($date=$request->input('date')) {
  36. $start = DateTime::createFromFormat('Y-m-d H:i:s', $date.' 00:00:00', new DateTimeZone($tz));
  37. $end = DateTime::createFromFormat('Y-m-d H:i:s', $date.' 23:59:59', new DateTimeZone($tz));
  38. } elseif(($start=$request->input('start')) && ($end=$request->input('end'))) {
  39. $start = new DateTime($start, new DateTimeZone($tz));
  40. $end = new DateTime($end, new DateTimeZone($tz));
  41. } else {
  42. return response(json_encode(['error' => 'no date provided']))->header('Content-Type', 'application/json');
  43. }
  44. $results = $qz->queryRange($start, $end);
  45. $locations = [];
  46. $properties = [];
  47. $events = [];
  48. if($request->input('format') == 'linestring') {
  49. foreach($results as $id=>$record) {
  50. // When returning a linestring, separate out the "event" records from the "location" records
  51. if(is_object($record) && $record->data) {
  52. if(property_exists($record->data->properties, 'action')) {
  53. $rec = $record->data;
  54. # add a unixtime property
  55. $rec->properties->unixtime = (int)$record->date->format('U');
  56. $events[] = $rec;
  57. } else {
  58. #$record->date->format('U.u');
  59. // Ignore super inaccurate locations
  60. if(!property_exists($record->data->properties, 'horizontal_accuracy')
  61. || $record->data->properties->horizontal_accuracy <= 5000) {
  62. $locations[] = $record->data;
  63. $props = $record->data->properties;
  64. $date = $record->date;
  65. $date->setTimeZone(new DateTimeZone($tz));
  66. $props->timestamp = $date->format('c');
  67. $props->unixtime = (int)$date->format('U');
  68. $properties[] = $props;
  69. }
  70. }
  71. }
  72. }
  73. $linestring = array(
  74. 'type' => 'LineString',
  75. 'coordinates' => [],
  76. 'properties' => $properties
  77. );
  78. foreach($locations as $loc) {
  79. if(property_exists($loc, 'geometry'))
  80. $linestring['coordinates'][] = $loc->geometry->coordinates;
  81. else
  82. $linestring['coordinates'][] = null;
  83. }
  84. $response = array(
  85. 'linestring' => $linestring,
  86. 'events' => $events
  87. );
  88. } else {
  89. foreach($results as $id=>$record) {
  90. if($record->data) {
  91. $locations[] = $record->data;
  92. }
  93. }
  94. $response = [
  95. 'locations' => $locations
  96. ];
  97. }
  98. return response(json_encode($response))->header('Content-Type', 'application/json');
  99. }
  100. public function last(Request $request) {
  101. $token = $request->input('token');
  102. if(!$token)
  103. return response(json_encode(['error' => 'no token provided']))->header('Content-Type', 'application/json');
  104. $db = DB::table('databases')->where('read_token','=',$token)->first();
  105. if(!$db)
  106. return response(json_encode(['error' => 'invalid token']))->header('Content-Type', 'application/json');
  107. if($request->input('tz')) {
  108. $tz = $request->input('tz');
  109. } else {
  110. $tz = 'UTC';
  111. }
  112. if($input=$request->input('before')) {
  113. // If a specific time was requested, look up the data in the filesystem
  114. $qz = new Quartz\DB(env('STORAGE_DIR').$db->name, 'r');
  115. if(preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $input)) {
  116. // If the input date is given in YYYY-mm-dd HH:mm:ss format, interpret it in the timezone given
  117. $date = DateTime::createFromFormat('Y-m-d H:i:s', $input, new DateTimeZone($tz));
  118. } else {
  119. // Otherwise, parse the string and use the timezone in the input
  120. $date = new DateTime($input);
  121. $date->setTimeZone(new DateTimeZone($tz));
  122. }
  123. if(!$date) {
  124. return response(json_encode(['error' => 'invalid date provided']))->header('Content-Type', 'application/json');
  125. }
  126. /* ********************************************** */
  127. // TODO: move this logic into QuartzDB
  128. // Load the shard for the given date
  129. $shard = $qz->shardForDate($date);
  130. // If the shard doesn't exist, check one day before
  131. if(!$shard->exists()) {
  132. $date = $date->sub(new DateInterval('PT86400S'));
  133. $shard = $qz->shardForDate($date);
  134. }
  135. // Now if the shard doesn't exist, return an empty result
  136. if(!$shard->exists()) {
  137. return response(json_encode([
  138. 'data'=>null
  139. ]));
  140. }
  141. // Start iterating through the shard and look for the last line that is before the given date
  142. $shard->init();
  143. $record = false;
  144. foreach($shard as $r) {
  145. if($r->date > $date)
  146. break;
  147. $record = $r;
  148. }
  149. /* ********************************************** */
  150. if(!$record) {
  151. return response(json_encode([
  152. 'data'=>null
  153. ]));
  154. }
  155. $response = [
  156. 'data' => $record->data,
  157. ];
  158. } else {
  159. // If no specific time was requested, use the cached location from the database
  160. $response = [
  161. 'data' => json_decode($db->last_location),
  162. ];
  163. }
  164. if($request->input('geocode') && property_exists($response['data'], 'geometry') && property_exists($response['data']->geometry, 'coordinates')) {
  165. $coords = $response['data']->geometry->coordinates;
  166. $params = [
  167. 'latitude' => $coords[1],
  168. 'longitude' => $coords[0],
  169. 'date' => $response['data']->properties->timestamp
  170. ];
  171. $geocode = self::geocode($params);
  172. if($geocode) {
  173. $response['geocode'] = $geocode;
  174. } else {
  175. $response['geocode'] = null;
  176. }
  177. }
  178. return response(json_encode($response))->header('Content-Type', 'application/json');
  179. }
  180. public function trip(Request $request) {
  181. $token = $request->input('token');
  182. if(!$token)
  183. return response(json_encode(['error' => 'no token provided']))->header('Content-Type', 'application/json');
  184. $db = DB::table('databases')->where('read_token','=',$token)->first();
  185. if(!$db)
  186. return response(json_encode(['error' => 'invalid token']))->header('Content-Type', 'application/json');
  187. if($db->current_trip) {
  188. $response = [
  189. 'trip' => json_decode($db->current_trip)
  190. ];
  191. } else {
  192. $response = [
  193. 'trip' => null
  194. ];
  195. }
  196. return response(json_encode($response))->header('Content-Type', 'application/json');
  197. }
  198. public function input(Request $request) {
  199. $token = $request->input('token');
  200. if(!$token)
  201. return response(json_encode(['error' => 'no token provided']))->header('Content-Type', 'application/json');
  202. $db = DB::table('databases')->where('write_token','=',$token)->first();
  203. if(!$db)
  204. return response(json_encode(['error' => 'invalid token']))->header('Content-Type', 'application/json');
  205. if(!is_array($request->input('locations')))
  206. return response(json_encode(['error' => 'invalid input', 'error_description' => 'parameter "locations" must be an array of GeoJSON data with a "timestamp" property']))->header('Content-Type', 'application/json');
  207. $qz = new Quartz\DB(env('STORAGE_DIR').$db->name, 'w');
  208. $num = 0;
  209. $trips = 0;
  210. $last_location = false;
  211. foreach($request->input('locations') as $loc) {
  212. if(array_key_exists('properties', $loc)) {
  213. if(array_key_exists('timestamp', $loc['properties'])) {
  214. try {
  215. if(preg_match('/^\d+\.\d+$/', $loc['properties']['timestamp']))
  216. $date = DateTime::createFromFormat('U.u', $loc['properties']['timestamp']);
  217. elseif(preg_match('/^\d+$/', $loc['properties']['timestamp']))
  218. $date = DateTime::createFromFormat('U', $loc['properties']['timestamp']);
  219. else
  220. $date = new DateTime($loc['properties']['timestamp']);
  221. if($date) {
  222. $shouldAdd = true;
  223. // Skip adding if the timestamp is already in the cache.
  224. // Helps prevent writing duplicate data when the HTTP request is interrupted.
  225. $cacheKey = 'compass::'.$db->name.'::'.$date->format('U');
  226. if(env('CACHE_DRIVER') && Cache::has($cacheKey))
  227. $shouldAdd = false;
  228. // Ignore points at 0,0
  229. // Around November 2019, Overland on iOS started reporting 0,0 points pretty frequently, several
  230. // times per day, and sometimes for a whole hour in a row. Not sure whether the
  231. // real data from iOS was null,null or actually 0,0 but we'll ignore it here anyway
  232. if($loc['geometry']['coordinates'][0] == 0 && $loc['geometry']['coordinates'][1] == 0)
  233. $shouldAdd = false;
  234. if($shouldAdd) {
  235. $num++;
  236. $qz->add($date, $loc);
  237. if(env('CACHE_DRIVER'))
  238. Cache::put($cacheKey, 1, 360); // cache this for 6 hours
  239. $last_location = $loc;
  240. }
  241. if(array_key_exists('type', $loc['properties']) && $loc['properties']['type'] == 'trip') {
  242. try {
  243. $job = (new TripComplete($db->id, $loc))->onQueue('compass');
  244. $this->dispatch($job);
  245. $trips++;
  246. Log::info('Got a trip record');
  247. } catch(Exception $e) {
  248. Log::warning('Received invalid trip');
  249. }
  250. }
  251. } else {
  252. Log::warning('Received invalid date: ' . $loc['properties']['timestamp']);
  253. }
  254. } catch(Exception $e) {
  255. Log::warning('Received invalid date: ' . $loc['properties']['timestamp']);
  256. }
  257. }
  258. }
  259. }
  260. if($request->input('current')) {
  261. $last_location = $request->input('current');
  262. Log::info('Device sent current location');
  263. }
  264. $response = [
  265. 'result' => 'ok',
  266. 'saved' => $num,
  267. 'trips' => $trips
  268. ];
  269. if($last_location) {
  270. /*
  271. // 2017-08-22 Don't geocode cause it takes too long. Maybe make a separate route for this later.
  272. $geocode = self::geocode(['latitude'=>$last_location['geometry']['coordinates'][1], 'longitude'=>$last_location['geometry']['coordinates'][0]]);
  273. $response['geocode'] = [
  274. 'full_name' => $geocode->full_name,
  275. 'locality' => $geocode->locality,
  276. 'country' => $geocode->country
  277. ];
  278. */
  279. $response['geocode'] = null;
  280. // Cache the last location in the database
  281. if(isset($last_location['properties']['timestamp'])) {
  282. if(preg_match('/^\d+\.\d+$/', $last_location['properties']['timestamp']))
  283. $date = DateTime::createFromFormat('U.u', $last_location['properties']['timestamp']);
  284. elseif(preg_match('/^\d+$/', $last_location['properties']['timestamp']))
  285. $date = DateTime::createFromFormat('U', $last_location['properties']['timestamp']);
  286. else
  287. $date = new DateTime($last_location['properties']['timestamp']);
  288. $date->setTimeZone(new DateTimeZone('UTC'));
  289. $date = $date->format('Y-m-d H:i:s');
  290. } else {
  291. $date = null;
  292. }
  293. DB::table('databases')->where('id', $db->id)
  294. ->update([
  295. 'last_location' => json_encode($last_location, JSON_UNESCAPED_SLASHES+JSON_PRETTY_PRINT),
  296. 'last_location_date' => $date,
  297. ]);
  298. // Notify subscribers that new data is available
  299. if($db->ping_urls) {
  300. $job = (new NotifyOfNewLocations($db->id))->onQueue('compass');
  301. $this->dispatch($job);
  302. }
  303. }
  304. if($request->input('trip')) {
  305. $existing_trip = $db->current_trip;
  306. DB::table('databases')->where('id', $db->id)
  307. ->update([
  308. 'current_trip' => json_encode($request->input('trip'), JSON_UNESCAPED_SLASHES+JSON_PRETTY_PRINT)
  309. ]);
  310. if(!$existing_trip && $db->ping_urls) {
  311. $job = (new TripStarted($db->id))->onQueue('compass');
  312. $this->dispatch($job);
  313. }
  314. } else {
  315. DB::table('databases')->where('id', $db->id)->update(['current_trip' => null]);
  316. }
  317. return response(json_encode($response))->header('Content-Type', 'application/json');
  318. }
  319. public function trip_complete(Request $request) {
  320. $token = $request->input('token');
  321. if(!$token)
  322. return response(json_encode(['error' => 'no token provided']))->header('Content-Type', 'application/json');
  323. $db = DB::table('databases')->where('write_token','=',$token)->first();
  324. if(!$db)
  325. return response(json_encode(['error' => 'invalid token']))->header('Content-Type', 'application/json');
  326. if($request->input('tz')) {
  327. $tz = new DateTimeZone($request->input('tz'));
  328. } else {
  329. $tz = new DateTimeZone('UTC');
  330. }
  331. $start = new DateTime($request->input('start'), $tz);
  332. $end = new DateTime($request->input('end'), $tz);
  333. $loc = [
  334. 'properties' => [
  335. 'mode' => $request->input('mode'),
  336. 'start' => $start->format('c'),
  337. 'end' => $end->format('c'),
  338. ]
  339. ];
  340. try {
  341. $job = (new TripComplete($db->id, $loc))->onQueue('compass');
  342. $this->dispatch($job);
  343. Log::info('Got a manual trip record: '.$start->format('c').' '.$end->format('c'));
  344. } catch(Exception $e) {
  345. Log::warning('Received invalid trip');
  346. }
  347. return response(json_encode(['result' => 'ok']))->header('Content-Type', 'application/json');
  348. }
  349. public static function geocode($params) {
  350. $ch = curl_init(env('ATLAS_BASE').'api/geocode?'.http_build_query($params));
  351. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  352. curl_setopt($ch, CURLOPT_TIMEOUT, 8);
  353. $response = curl_exec($ch);
  354. if($response) {
  355. return json_decode($response);
  356. }
  357. }
  358. public function share(Request $request) {
  359. $token = $request->input('token');
  360. if(!$token)
  361. return response(json_encode(['error' => 'no token provided']))->header('Content-Type', 'application/json');
  362. $db = DB::table('databases')->where('write_token','=',$token)->first();
  363. if(!$db)
  364. return response(json_encode(['error' => 'invalid token']))->header('Content-Type', 'application/json');
  365. $expires_at = time() + $request->input('duration');
  366. $share_token = str_random(15);
  367. $share_id = DB::table('shares')->insertGetId([
  368. 'database_id' => $db->id,
  369. 'created_at' => date('Y-m-d H:i:s'),
  370. 'expires_at' => date('Y-m-d H:i:s', $expires_at),
  371. 'token' => $share_token,
  372. ]);
  373. $share_url = env('BASE_URL').'s/'.$share_token;
  374. return response(json_encode([
  375. 'url' => $share_url
  376. ]), 201)->header('Content-Type', 'application/json')
  377. ->header('Location', $share_url);
  378. }
  379. }