"Push to my OUYA" support
authorChristian Weiske <cweiske@cweiske.de>
Thu, 14 May 2020 05:09:02 +0000 (07:09 +0200)
committerChristian Weiske <cweiske@cweiske.de>
Thu, 14 May 2020 08:51:04 +0000 (10:51 +0200)
12 files changed:
.gitignore
README.rst
bin/build-html.php
config.php.dist
data/templates/game.tpl.php
src/push-to-my-ouya-helpers.php [new file with mode: 0644]
www/.htaccess
www/api/v1/queued_downloads.php [new file with mode: 0644]
www/api/v1/queued_downloads_delete.php [new file with mode: 0644]
www/ouya-game.css
www/push-to-my-ouya.php [new file with mode: 0644]
www/push-to-my-ouya.png [new file with mode: 0644]

index cd66da5..4827cbd 100644 (file)
@@ -1,4 +1,5 @@
 /config.php
 /config.php
+/data/push-to-my-ouya.sqlite3
 /README.html
 www/api/v1/apps/
 www/api/v1/details-data/
 /README.html
 www/api/v1/apps/
 www/api/v1/details-data/
index 91d490f..d06a382 100644 (file)
@@ -36,6 +36,7 @@ Apache setup
 Virtual host configuration::
 
   Script PUT /empty-json.php
 Virtual host configuration::
 
   Script PUT /empty-json.php
+  Script DELETE /api/v1/queued_downloads_delete.php
 
 ``mod_actions`` need to be enabled for apache 2.4.
 
 
 ``mod_actions`` need to be enabled for apache 2.4.
 
index b4561e6..f7f9154 100755 (executable)
@@ -7,6 +7,13 @@
  */
 require_once __DIR__ . '/functions.php';
 
  */
 require_once __DIR__ . '/functions.php';
 
+//default configuration values
+$GLOBALS['pushToMyOuyaUrl'] = '../push-to-my-ouya.php';
+$cfgFile = __DIR__ . '/../config.php';
+if (file_exists($cfgFile)) {
+    include $cfgFile;
+}
+
 $wwwDir = __DIR__ . '/../www/';
 $discoverDir = __DIR__ . '/../www/api/v1/discover-data/';
 $wwwDiscoverDir = $wwwDir . 'discover/';
 $wwwDir = __DIR__ . '/../www/';
 $discoverDir = __DIR__ . '/../www/api/v1/discover-data/';
 $wwwDiscoverDir = $wwwDir . 'discover/';
@@ -111,6 +118,8 @@ function renderGameFile($gameDataFile)
         )
     );
     $apkDownloadUrl = $downloadJson->app->downloadLink;
         )
     );
     $apkDownloadUrl = $downloadJson->app->downloadLink;
+    $pushUrl = $GLOBALS['pushToMyOuyaUrl']
+        . '?game=' . urlencode($json->apk->package);
 
     $navLinks = [];
     foreach ($json->genres as $genreTitle) {
 
     $navLinks = [];
     foreach ($json->genres as $genreTitle) {
index 8c23bd2..5087c22 100644 (file)
@@ -11,3 +11,4 @@ $GLOBALS['packagelists']["cweiske's picks"] = [
     'com.cosmos.babyloniantwins',
     'com.inverseblue.skyriders',
 ];
     'com.cosmos.babyloniantwins',
     'com.inverseblue.skyriders',
 ];
+$GLOBALS['pushToMyOuyaUrl'] = '../push-to-my-ouya.php';
index 36a1d09..531fb28 100644 (file)
      <?= gmdate('Y-m-d', $json->version->publishedAt) ?>
     </p>
    </div>
      <?= gmdate('Y-m-d', $json->version->publishedAt) ?>
     </p>
    </div>
+   <div>
+    <form method="post" action="<?= htmlspecialchars($pushUrl) ?>" id="push" onsubmit="pushToMyOuya();return false;">
+     <button name="push" type="submit" class="push-to-my-ouya">
+      <img src="../push-to-my-ouya.png" width="335" height="63"
+           alt="Push to my OUYA"
+      />
+     </button>
+    </form>
+   </div>
   </section>
 
   <nav>
   </section>
 
   <nav>
     <a rel="up" href="<?= htmlspecialchars($url) ?>"><?= htmlspecialchars($title) ?></a>
    <?php endforeach ?>
   </nav>
     <a rel="up" href="<?= htmlspecialchars($url) ?>"><?= htmlspecialchars($title) ?></a>
    <?php endforeach ?>
   </nav>
+
+  <div style="display: none" class="popup" id="push-success">
+   <a class="close" href="#" onclick="this.parentNode.style.display='none';return false;">⊗</a>
+   <strong><?= htmlspecialchars($json->title); ?></strong>
+   will start downloading to your OUYA within the next few minutes
+  </div>
+  <div style="display: none" class="popup" id="push-error">
+   <a class="close" href="#" onclick="this.parentNode.style.display='none';return false;">⊗</a>
+   <strong>Push error</strong>
+   <p>error message</p>
+  </div>
+
+  <script type="text/javascript">
+   function pushToMyOuya() {
+       var form = document.getElementById("push");
+       var req = new XMLHttpRequest();
+       req.addEventListener("load", pushToMyOuyaComplete);
+       req.open("POST", form.action);
+       req.send();
+   }
+   function pushToMyOuyaComplete() {
+       if (this.status / 100 == 2) {
+           document.getElementById('push-success').style.display = "";
+       } else {
+           var err = document.getElementById('push-error');
+           err.getElementsByTagName("p")[0].textContent = this.responseText;
+           err.style.display = "";
+       }
+   }
+  </script>
  </body>
 </html>
  </body>
 </html>
diff --git a/src/push-to-my-ouya-helpers.php b/src/push-to-my-ouya-helpers.php
new file mode 100644 (file)
index 0000000..a020988
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Helper methods for the push-to-my-ouya download queue
+ */
+
+/**
+ * Map local IPs to a single IP so that this the queue can be used
+ * in the home network.
+ *
+ * @see RFC 3330: Special-Use IPv4 Addresses
+ */
+function mapIp($ip)
+{
+    $private = substr($ip, 0, 3) == '10.'
+        || substr($ip, 0, 7) == '172.16.'
+        || substr($ip, 0, 7) == '172.17.'
+        || substr($ip, 0, 7) == '172.18.'
+        || substr($ip, 0, 7) == '172.19.'
+        || substr($ip, 0, 7) == '172.20.'
+        || substr($ip, 0, 7) == '172.21.'
+        || substr($ip, 0, 7) == '172.22.'
+        || substr($ip, 0, 7) == '172.23.'
+        || substr($ip, 0, 7) == '172.24.'
+        || substr($ip, 0, 7) == '172.25.'
+        || substr($ip, 0, 7) == '172.26.'
+        || substr($ip, 0, 7) == '172.27.'
+        || substr($ip, 0, 7) == '172.28.'
+        || substr($ip, 0, 7) == '172.29.'
+        || substr($ip, 0, 7) == '172.30.'
+        || substr($ip, 0, 7) == '172.31.'
+        || substr($ip, 0, 8) == '192.168.'
+        || substr($ip, 0, 8) == '169.254.';
+    $local = substr($ip, 0, 4) == '127.';
+
+    if ($private || $local) {
+        return 'local';
+    }
+    return $ip;
+}
index 1587472..e12ed60 100644 (file)
@@ -39,3 +39,9 @@ RewriteRule ^api/v1/search /api/v1/search-data/%1.json? [END]
 
 #this one wants a 204 status code
 RewriteRule ^api/v1/status$ - [R=204,L]
 
 #this one wants a 204 status code
 RewriteRule ^api/v1/status$ - [R=204,L]
+
+#push-to-my-ouya needs php scripting support
+RewriteRule ^api/v1/queued_downloads?$ /api/v1/queued_downloads.php [END]
+
+RewriteCond %{REQUEST_METHOD} DELETE
+RewriteRule ^api/v1/queued_downloads/(.+)?$ /api/v1/queued_downloads_delete.php?game=$1 [END]
diff --git a/www/api/v1/queued_downloads.php b/www/api/v1/queued_downloads.php
new file mode 100644 (file)
index 0000000..dd52bcd
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+/**
+ * List games from the "push to my OUYA" list
+ *
+ * Pushes are stored in the sqlite3 database in push-to-my-ouya.php
+ *
+ * @author Christian Weiske <cweiske@cweiske.de>
+ */
+$dbFile     = __DIR__ . '/../../../data/push-to-my-ouya.sqlite3';
+$apiGameDir = __DIR__ . '/details-data/';
+
+require_once __DIR__ . '/../../../src/push-to-my-ouya-helpers.php';
+
+$ip = $_SERVER['REMOTE_ADDR'];
+if ($ip == '' || strpos($ip, ':') !== false) {
+    //empty or IPv6
+    header('Content-type: application/json');
+    echo file_get_contents('queued_downloads');
+    exit(1);
+}
+$ip = mapIp($ip);
+
+try {
+    $db = new SQLite3($dbFile, SQLITE3_OPEN_READONLY);
+} catch (Exception $e) {
+    //db file not found
+    header('Content-type: application/json');
+    echo file_get_contents('queued_downloads');
+    exit(1);
+}
+
+$res = $db->query(
+    'SELECT * FROM pushes'
+    . ' WHERE ip = \'' . SQLite3::escapeString($ip) . '\''
+);
+$queue = [];
+while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
+    $apiGameFile = $apiGameDir . $row['game'] . '.json';
+    if (!file_exists($apiGameFile)) {
+        //game deleted?
+        continue;
+    }
+    $json = json_decode(file_get_contents($apiGameFile));
+    $queue[] = [
+        'versionUuid' => '',
+        'title'       => $json->title,
+        'source'      => 'gamer',
+        'uuid'        => $row['game'],
+    ];
+}
+
+header('Content-type: application/json');
+echo json_encode(['queue' => $queue]) . "\n";
+?>
diff --git a/www/api/v1/queued_downloads_delete.php b/www/api/v1/queued_downloads_delete.php
new file mode 100644 (file)
index 0000000..3861f93
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+/**
+ * Delete a game from the "push to my OUYA" list
+ *
+ * Pushes are stored in the sqlite3 database in push-to-my-ouya.php
+ *
+ * @author Christian Weiske <cweiske@cweiske.de>
+ */
+$dbFile     = __DIR__ . '/../../../data/push-to-my-ouya.sqlite3';
+$apiGameDir = __DIR__ . '/details-data/';
+
+require_once __DIR__ . '/../../../src/push-to-my-ouya-helpers.php';
+
+$ip = $_SERVER['REMOTE_ADDR'];
+if ($ip == '' || strpos($ip, ':') !== false) {
+    //empty or IPv6
+    header('HTTP/1.0 204 No Content');
+    exit(1);
+}
+$ip = mapIp($ip);
+
+$game = $_GET['game'];
+$cleanGame = preg_replace('#[^a-zA-Z0-9.]#', '', $game);
+if ($game != $cleanGame || $game == '') {
+    header('HTTP/1.0 400 Bad Request');
+    header('Content-type: text/plain');
+    echo 'Invalid game' . "\n";
+    exit(1);
+}
+
+try {
+    $db = new SQLite3($dbFile, SQLITE3_OPEN_READWRITE);
+} catch (Exception $e) {
+    //db file not found
+    header('HTTP/1.0 204 No Content');
+    exit(1);
+}
+
+$rowId = $db->querySingle(
+    'SELECT id FROM pushes'
+    . ' WHERE ip = \'' . SQLite3::escapeString($ip) . '\''
+    . ' AND game =\'' . SQLite3::escapeString($game) . '\''
+);
+if ($rowId === null) {
+    header('HTTP/1.0 404 Not Found');
+    header('Content-type: text/plain');
+    echo 'Game not queued' . "\n";
+    exit(1);
+}
+
+$db->exec('DELETE FROM pushes WHERE id = ' . intval($rowId));
+header('HTTP/1.0 204 No Content');
+?>
index b9886e7..ce23fdd 100644 (file)
@@ -97,10 +97,20 @@ nav {
 .buttons h2 {
     display: none;
 }
 .buttons h2 {
     display: none;
 }
+.buttons {
+    display: flex;
+    justify-content: space-between;
+}
 .buttons a {
     font-size: 1.5rem;
     color: #CCC;
 }
 .buttons a {
     font-size: 1.5rem;
     color: #CCC;
 }
+button.push-to-my-ouya {
+    cursor: pointer;
+    border: none;
+    padding: 0;
+    background-color: transparent;
+}
 
 nav {
     text-align: center;
 
 nav {
     text-align: center;
@@ -156,3 +166,31 @@ nav a {
 .average-5:before {
     content: "★★★★★";
 }
 .average-5:before {
     content: "★★★★★";
 }
+
+
+.popup {
+    position: fixed;
+    top: 2rem;
+    right: 2rem;
+    width: 20rem;
+    padding: 1rem;
+    background-color: black;
+    border: 1px solid #AAA;
+    border-radius: 0.5rem;
+}
+.popup a.close {
+    color: white;
+    font-size: 2rem;
+    text-decoration: none;
+    position: absolute;
+    top: 0;
+    right: 0.5rem;
+}
+.popup a.close:hover {
+    color: #fc4422;
+}
+.popup strong {
+    display: block;
+    color: #fc4422;
+    margin-bottom: 0.5rem;
+}
diff --git a/www/push-to-my-ouya.php b/www/push-to-my-ouya.php
new file mode 100644 (file)
index 0000000..fd39043
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+/**
+ * Click "push to my OUYA" in the browser, and the OUYA will install
+ * the game a few minutes later.
+ *
+ * Works without registration.
+ * We simply use the IP address as user identification.
+ * Pushed games are deleted after 24 hours.
+ * Maximal 30 games per IP to prevent flooding.
+ *
+ * @author Christian Weiske <cweiske@cweiske.de>
+ */
+$dbFile     = __DIR__ . '/../data/push-to-my-ouya.sqlite3';
+$apiGameDir = __DIR__ . '/api/v1/details-data/';
+
+require_once __DIR__ . '/../src/push-to-my-ouya-helpers.php';
+
+//support different ipv4-only domain
+header('Access-Control-Allow-Origin: *');
+
+if ($_SERVER['REQUEST_METHOD'] != 'POST') {
+    header('HTTP/1.0 400 Bad Request');
+    header('Content-type: text/plain');
+    echo 'POST only, please' . "\n";
+    exit(1);
+}
+
+if (!isset($_GET['game'])) {
+    header('HTTP/1.0 400 Bad Request');
+    header('Content-type: text/plain');
+    echo '"game" parameter missing' . "\n";
+    exit(1);
+}
+
+$game = $_GET['game'];
+$cleanGame = preg_replace('#[^a-zA-Z0-9.]#', '', $game);
+if ($game != $cleanGame) {
+    header('HTTP/1.0 400 Bad Request');
+    header('Content-type: text/plain');
+    echo 'Invalid game' . "\n";
+    exit(1);
+}
+
+$apiGameFile = $apiGameDir . $game . '.json';
+if (!file_exists($apiGameFile)) {
+    header('HTTP/1.0 404 Not Found');
+    header('Content-type: text/plain');
+    echo 'Game does not exist' . "\n";
+    exit(1);
+}
+
+$ip = $_SERVER['REMOTE_ADDR'];
+if ($ip == '') {
+    header('HTTP/1.0 400 Bad Request');
+    header('Content-type: text/plain');
+    echo 'Cannot detect your IP address' . "\n";
+    exit(1);
+}
+if (strpos($ip, ':') !== false) {
+    header('HTTP/1.0 400 Bad Request');
+    header('Content-type: text/plain');
+    echo 'Sorry, IPv6 is not supported' . "\n";
+    echo 'This here only works if the OUYA and your PC have the same IP address,'
+        . "\n";
+    echo 'and this is definitely not the case when using IPv6' . "\n";
+    exit(1);
+}
+$ip = mapIp($ip);
+
+try {
+    $db = new SQLite3($dbFile, SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE);
+} catch (Exception $e) {
+    header('HTTP/1.0 500 Internal server error');
+    header('Content-type: text/plain');
+    echo 'Cannot open database' . "\n";
+    echo $e->getMessage() . "\n";
+    exit(2);
+}
+
+$res = $db->querySingle(
+    'SELECT name FROM sqlite_master WHERE type = "table" AND name = "pushes"'
+);
+if ($res === null) {
+    //table does not exist yet
+    $db->exec(
+        <<<SQL
+        CREATE TABLE pushes (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            game TEXT NOT NULL,
+            ip TEXT NOT NULL,
+            created_at TEXT DEFAULT CURRENT_TIMESTAMP
+        )
+SQL
+    );
+}
+
+//clean up old pushes
+$db->exec(
+    'DELETE FROM pushes'
+    . ' WHERE created_at < \'' . gmdate('Y-m-d H:i:s', time() - 86400) . '\''
+);
+
+//check if this IP already pushed this game
+$numThisGame = $db->querySingle(
+    'SELECT COUNT(*) FROM pushes'
+    . ' WHERE ip = \'' . SQLite3::escapeString($ip) . '\''
+    . ' AND game = \'' . SQLite3::escapeString($game) . '\''
+);
+if ($numThisGame >= 1) {
+    header('HTTP/1.0 400 Bad Request');
+    header('Content-type: text/plain');
+    echo 'Already pushed.' . "\n";
+    exit(1);
+}
+
+//check number of pushes for this IP
+$numPushes = $db->querySingle(
+    'SELECT COUNT(*) FROM pushes'
+    . ' WHERE ip = \'' . SQLite3::escapeString($ip) . '\''
+);
+if ($numPushes >= 30) {
+    header('HTTP/1.0 400 Bad Request');
+    header('Content-type: text/plain');
+    echo 'Too many pushes. Come back tomorrow.' . "\n";
+    exit(1);
+}
+
+//store the push
+$stmt = $db->prepare('INSERT INTO pushes (game, ip) VALUES(:game, :ip)');
+$stmt->bindValue(':game', $game);
+$stmt->bindValue(':ip', $ip);
+$res = $stmt->execute();
+if ($res === false) {
+    header('HTTP/1.0 500 Internal server error');
+    header('Content-type: text/plain');
+    echo 'Cannot store push' . "\n";
+    exit(3);
+}
+$res->finalize();
+
+header('HTTP/1.0 200 OK');
+header('Content-type: text/plain');
+echo 'Push accepted' . "\n";
+exit(3);
+?>
diff --git a/www/push-to-my-ouya.png b/www/push-to-my-ouya.png
new file mode 100644 (file)
index 0000000..50bd0e2
Binary files /dev/null and b/www/push-to-my-ouya.png differ