Creando un Map en PHP y Drive

Idea

La idea es bien simple. Vamos a usar una carpeta de Google Drive, donde habremos subido fotos de diferentes sitios y que contienen el metadata GPS, para crear una página html que las ubique en un map

Como la mayoría de los provedores de hosting ofrecen PHP gratuito vamos a crear un pequeño API que sirva para realizar la integración con Google (proceso de autentificación y obtención de las fotos)

De esta forma los requisitos de la aplicación son mínimos, pues las fotos serán servidas por un enlace (read-only) de google y el php tendrá una carga mínima.

Además, vamos a implementar un pequeño caché para evitar llamadas excesivas a Google.

Consola Google

Lo primero (aparte de tener cuenta en Google y una carpeta con fotos en Google Drive) es crear un proyecto en la consola de Google, por ejemplo misandanzas:

  • Una vez creado (y seleccionado) debemos habilitar el API de Drive, con lo que iremos a "APIs and services", "Library", buscaremos Drive y lo añadiremos al proyecto

  • Lo siguiente es crear una "Oauth Screen consent". Como la aplicación será de consumo "propio" y su objetivo no es ser usada por el público nos servirá el estado "testing" en que se crea y que no requiere aprobación por parte de Google (Si vas a Audience verás que está en estado Testing)

  • En la pantalla de Audience añadiremos (abajo del todo) nuestro correo como usuario de test

  • En la pantalla de Data access añadiremos un nuevo scope pulsando "Add or remove scopes". Añadiremos "…​/auth/drive.readonly" (tal cual) en el campo de entrada inferior y pulsamos añadir.

  • Por último crearemos un Oauth Client en la pantalla de "Clients". El cliente será del tipo "Web application" y deberemos añadir nuestra web como "Authorised Javascript" (https://maps.jagedn.dev en mi caso) y en "Authorised redirect URIs"" (https://maps.jagedn.dev/api/callback)

    INFO

    Si el mapa lo vas a "consumir" en local puedes usar http://localhost:xxxx

Una vez creado el OAUTH descargaremos el JSON que nos proporciona. ¡OJO! Hay que descargarlo en el modal recién generado. Si no lo descargas justo en este momento te toca generar otro OAuth de nuevo

Drive

Como ya hemos mencionado en nuestro Google Drive crearemos una carpeta y subiremos fotos que tengan el metadata de GPS para poder ubicarlas en el mapa.

INFO

Lo interesante del proyecto es que podamos añadir fotos posteriormente y que aparezcan sin tener que hacer nada, pero para probar que todo va bien deberíamos subir al menos un par de ellas al principio

En mi caso la carpeta la he llamado "MisAndanzas" pero puede ser cualquier nombre

Proyecto

Para proteger los datos sensibles (el fichero json bajado con las credenciales) vamos a crear dos carpetas en nuestro hosting: public y private

Public será la carpeta que configuraremos como root de la aplicación web y private estará "fuera" de ella. Yo las he puesto las dos en el mismo directorio y configurado "public" como root en el Apache

A su vez en la carpeta public crearemos otra llamada "api" y dentro de ella otra llamada "src"

|-- root
|   |-- public
|       |-- api
|           |-- src
|   |-- private
|       |-- credentials.json

La carpeta root/public/api será donde estará nuestro api PHP. Usaremos Slim como framework por ser ligero y sencillo:

En la carpeta root/public/api creamos un fichero composer.json:

root/public/api/composer.json
{
    "require": {
        "slim/slim": "^4.0",
        "slim/psr7": "^1.8"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}

y ejecutaremos composer install (desde esta carpeta) para instalar las dependencias.

En la misma carpeta crearemos un .htaccess que nos sirva para proteger nuestro api:

root/public/api/.htaccess
<IfModule mod_rewrite.c>
    RewriteEngine On

    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d

    # Todo lo que llegue aquí lo procesa index.php
    RewriteRule ^ index.php [QSA,L]
</IfModule>

(De esta forma todas las peticiones se resuelven en el index.php que crearemos)

Y por último crearemos nuestro index.php:

root/public/api/index.php
<?php
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
use App\GooglePhotos;

// El autoload está en la misma carpeta api/
require __DIR__ . '/vendor/autoload.php';

$app = AppFactory::create();

// IMPORTANTE
$app->setBasePath('/api');

$app->addRoutingMiddleware();
$app->addErrorMiddleware(true, true, true);

$google = new GooglePhotos();

// Rutas...
$app->get('/setup', function (Request $request, Response $response) use ($google) {
    return $response->withHeader('Location', $google->getAuthUrl())->withStatus(302);
});

$app->get('/callback', function (Request $request, Response $response) use ($google) {
    $params = $request->getQueryParams();
    $google->authenticate($params['code']);
    $response->getBody()->write("Conectado con éxito.");
    return $response;
});

$app->get('/photos', function (Request $request, Response $response) use ($google) {
    $params = $request->getQueryParams();
    $token = $params['nextPageToken'] ?? null;
    $photos = $google->getPhotos($token);
    $response->getBody()->write(json_encode($photos));
    return $response
        ->withHeader('Content-Type', 'application/json')
        ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
        ->withHeader('Pragma', 'no-cache');
});

$app->run();

Como puedes ver el index.php "simplemente" indica las rutas de nuestra aplicación y las dirige a una clase GooglePhotos que crearemos a continuación (en la subcarpeta src)

Básicamente, la API tendrá una llamada setup que ejecutaremos una vez para obtener un token de Google (que nos la proporcionará en el endpoint callback) y una llamada photos para devolver un json con el detalle de las fotos

Service

Toda la lógica (tampoco es que sea mucha) la ubicaremos en el fichero public/api/src/GooglePhotos.php

Vamos a verla por partes

public/api/src/GooglePhotos.php
<?php
namespace App;

class GooglePhotos {
    private $tokenPath;
    private $credentialsPath;
    private $scope = "https://www.googleapis.com/auth/drive.readonly";

    public function __construct() {
        $this->tokenPath = __DIR__ . '/../../../private/token.json';
        $this->credentialsPath = __DIR__ . '/../../../private/credentials.json';
    }

Ajusta las rutas si no has usado las mismas que yo. Simplemente, indicamos donde leer las credenciales y donde guardar el token que se obtenga

public/api/src/GooglePhotos.php
    private function getCredentials() {
        if (!file_exists($this->credentialsPath)) throw new \Exception("Falta credentials.json");
        $data = json_decode(file_get_contents($this->credentialsPath), true);
        return $data['web'] ?? $data['installed'] ?? $data;
    }

    public function getAuthUrl() {
        $creds = $this->getCredentials();

        if (empty($creds['client_id'])) {
            die("ERROR: client_id está vacío. Estructura del JSON: " . print_r($creds, true));
        }

        $params = [
            'client_id'     => $creds['client_id'],
            'redirect_uri'  => $creds['redirect_uris'][0],
            'scope'         => $this->scope,
            'response_type' => 'code',
            'access_type'   => 'offline',
            'prompt'        => 'consent'
        ];
        return "https://accounts.google.com/o/oauth2/v2/auth?" . http_build_query($params);
    }

Cuando se llama al método getAuthUrl se construye una URL para iniciar el flujo OAUTH contra Google indicandole los parámetros necesarios (como client_id y scope)

Una vez que Google ha validado el proceso y autentificado al usuario (a tí) nos proporciona un code que debemos aceptar:

    public function authenticate($code) {
        $creds = $this->getCredentials();
        return $this->postToTokenEndpoint([
            'client_id'     => $creds['client_id'],
            'client_secret' => $creds['client_secret'],
            'code'          => $code,
            'redirect_uri'  => $creds['redirect_uris'][0],
            'grant_type'    => 'authorization_code'
        ]);
    }

    private function postToTokenEndpoint($params) {
        $ch = curl_init('https://oauth2.googleapis.com/token');
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
        $response = json_decode(curl_exec($ch), true);
        curl_close($ch);

        $tokenToSave = [
            'access_token'  => $response['access_token'],
            'expires_at'    => time() + $response['expires_in'],
            'refresh_token' => $response['refresh_token'] ?? (json_decode(file_get_contents($this->tokenPath), true)['refresh_token'] ?? null)
        ];
        file_put_contents($this->tokenPath, json_encode($tokenToSave));
        return $tokenToSave['access_token'];
    }

Si todo ha ido bien deberíamos tener en nuestra carpeta privada un fichero token.json que nos servirá para identificar las sucesivas llamadas a Drive. Además, el PHP se encargará de ir renovándolo cuando sea necesario

    private function getValidAccessToken() {
        if (!file_exists($this->tokenPath)) throw new \Exception("Falta token.json");
        $tokenData = json_decode(file_get_contents($this->tokenPath), true);
        if (time() > ($tokenData['expires_at'] - 30)) {
            return $this->refreshAccessToken($tokenData['refresh_token']);
        }
        return $tokenData['access_token'];
    }

    private function refreshAccessToken($refreshToken) {
        $creds = $this->getCredentials();
        return $this->postToTokenEndpoint([
            'client_id'     => $creds['client_id'],
            'client_secret' => $creds['client_secret'],
            'refresh_token' => $refreshToken,
            'grant_type'    => 'refresh_token'
        ]);
    }

API Photos

La lógica principal, una vez que podemos "dialogar" con Google la ubicamos en una método getPhotos

Este método comprueba si ya hemos generado un fichero de cache y si no existe o es un poco antiguo lo recrea

Para ello:

  • obtenemos el token

  • obtenemos el ID de Drive a partir del nombre de la carpeta y el token

  • iteramos llamando al api de google hasta que nextPageToken nos indique que no hay más fotos

  • para cada iteración extraemos la información y la vamos añadiendo a un json nuestro

    public function getPhotos($nextPageToken = null, $folderName = "MisAndanzas", $seconds = 30) {
        $cacheFile = __DIR__ . '/../../../private/photos_cache.json';

        // Si el archivo de caché existe y es reciente, lo devolvemos
        if (file_exists($cacheFile) && (time() - filemtime($cacheFile) < $seconds)) {
            return json_decode(file_get_contents($cacheFile), true);
        }

        $accessToken = $this->getValidAccessToken();

        $folderId = $this->getFolderIdByName($folderName, $accessToken);
        if (!$folderId) return ["error" => "No se encontró la carpeta '$folderName'"];

        $allPhotos = [];
        $pageToken = null;

        do {
            $query = urlencode("'$folderId' in parents and mimeType contains 'image/' and trashed = false");
            // Pedimos el máximo por página (100) para terminar antes
            $url = "https://www.googleapis.com/drive/v3/files?q=$query"
                . "&fields=nextPageToken,files(id,name,thumbnailLink,imageMediaMetadata,createdTime)"
                . "&pageSize=100";

            if ($pageToken) {
                $url .= "&pageToken=" . $pageToken;
            }

            $ch = curl_init($url);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $accessToken]);
            $res = json_decode(curl_exec($ch), true);
            curl_close($ch);

            if (isset($res['files'])) {
                foreach ($res['files'] as $file) {
                    $meta = $file['imageMediaMetadata'] ?? null;
                    $location = $meta['location'] ?? null;

                    // Solo guardamos las que tienen GPS para no llenar el JSON de basura
                    if ($location) {
                        $allPhotos[] = [
                            'id'      => $file['id'],
                            'name'    => $file['name'],
                            'url'     => str_replace('=s220', '=s800', $file['thumbnailLink'] ?? ''),
                            'lat'     => (float)$location['latitude'],
                            'lng'     => (float)$location['longitude'],
                            'date'    => $meta['time'] ?? $file['createdTime'] ?? null,
                            'date_human' => isset($meta['time']) ? date("d/m/Y H:i", strtotime($meta['time'])) : null
                        ];
                    }
                }
            }

            // Si hay un token, el bucle se repite; si no, termina.
            $pageToken = $res['nextPageToken'] ?? null;

        } while ($pageToken);

        // Ordenamos por fecha antes de enviar al cliente
        usort($allPhotos, function($a, $b) {
            return strtotime($a['date']) - strtotime($b['date']);
        });

        return $allPhotos;
    }

    private function getFolderIdByName($name, $accessToken) {
        $query = urlencode("name = '$name' and mimeType = 'application/vnd.google-apps.folder' and trashed = false");
        $url = "https://www.googleapis.com/drive/v3/files?q=$query&fields=files(id)";

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $accessToken]);
        $res = json_decode(curl_exec($ch), true);
        curl_close($ch);

        return $res['files'][0]['id'] ?? null;
    }

y básicamente esto es todo lo que necesitamos para consumir el API de Drive con PHP

Front

Esta parte la puedes hacer tan "profesional" como quieras.

Yo por mi parte voy a crear un simple index.html que use Vue y Leaflet.

Las partes mas importantes del html serian

root/public/index.html
<head>
    ...
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<div id="app">
    <div id="map"></div>
    <div v-if="selectedPhoto" class="lightbox" @click="selectedPhoto = null">
        <span class="close-btn">&times;</span>
        <img :src="getFullSizeUrl(selectedPhoto.url)" @click.stop />
        <div class="lightbox-caption">
            <span class="date-badge">&nbsp;</span>
        </div>
    </div>
</div>

<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
</body>

El html crea dos divs: map y selectedPhoto. El primero se usará para que Leaflet monte el mapa y el segundo para mostrar un modal cuando se seleccione una foto

El javascript que llama al api y monta el mapa es muy simple:

const { createApp, onMounted, ref } = Vue;

    createApp({
        setup() {
            const map = ref(null);
            const photos = ref([]);
            const selectedPhoto = ref(null);
            const getFullSizeUrl = (url) => {
                return url ? url.replace(/=s\d+/, '=s0') : '';
            };

            const openImage = (id) => {
                console.log("Intentando abrir foto ID:", id); // Para debug
                const photo = photos.value.find(p => p.id === id);
                if (photo) {
                    selectedPhoto.value = photo;
                }
            };
            window.openImage = openImage;

            const initMap = () => {
                map.value = L.map('map', {
                    minZoom: 2,
                    worldCopyJump: true
                }).setView([17, 0], 2);

                L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                    attribution: '© OpenStreetMap'
                }).addTo(map.value);
            };

            const loadPhotos = async () => {
                try {
                    const response = await fetch('/api/photos');
                    const data = await response.json();

                    // Filtrar solo las que tienen coordenadas
                    photos.value = data
                        .filter(p => p.lat && p.lng)
                        .sort((a, b) => new Array(a.date).toLocaleString().localeCompare(new Array(b.date).toLocaleString()));

                    // Añadir marcadores
                    photos.value.forEach(photo => {
                        const marker = L.marker([photo.lat, photo.lng]).addTo(map.value);

                        // Contenido del Popup
                        const popupContent = `
                            <div style="text-align:center" id="pop-${photo.id}">
                                <strong>${photo.date_human}</strong><br>
                                <img src="${photo.url}"
                                     class="img-click"
                                     style="width:150px; cursor:pointer; border-radius:4px; margin-top:5px;" />
                            </div>
                        `;
                        marker.bindPopup(popupContent);
                        marker.on('popupopen', function() {
                            const container = document.querySelector(`#pop-${photo.id} .img-click`);
                            if (container) {
                                container.onclick = () => {
                                    window.openImage(photo.id);
                                };
                            }
                        });
                    });

                } catch (error) {
                    console.error("Error cargando fotos:", error);
                }
            };

            onMounted(() => {
                initMap();
                loadPhotos();
                window.addEventListener('keydown', (e) => {
                    if (e.key === 'Escape') {
                        selectedPhoto.value = null;
                    }
                });
            });

            return { photos, selectedPhoto, openImage, getFullSizeUrl};
        }
    }).mount('#app');

Simplemente llama al api, y recorriendo el JSON crea Markups en el mapa

Conclusiones

A parte de la (nula) utilidad del proyecto, este desarrollo me ha venido bien para investigar un poco más de PHP y su integración con Google

Las primeras tortas fueron usando el SDK de PHP, en concreto el de GooglePhotos, pero "gracias" a que es un dolor de muelas usarlo al final he optado por una aproximación más simple que me ha servido para ver lo fácil que es usar PHP

Este texto ha sido escrito por un humano

This post has been written by a human

2019 - 2026 | Mixed with Bootstrap | Baked with JBake v2.6.7 | Terminos Terminos y Privacidad