├── .gitignore ├── .htaccess ├── classes ├── Photo.class.php ├── PhotoNote.class.php ├── PhotoNoteParser.class.php ├── PhotoNoteFetcher.class.php └── PhotosDao.class.php ├── config.sample.php ├── LICENSE ├── upload.php ├── activate.php ├── README.md └── cleanup.php /.gitignore: -------------------------------------------------------------------------------- 1 | config.php 2 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | Order Allow,Deny 2 | 3 | 4 | Allow from all 5 | 6 | 7 | 8 | Allow from all 9 | 10 | -------------------------------------------------------------------------------- /classes/Photo.class.php: -------------------------------------------------------------------------------- 1 | '.jpg' 18 | ); 19 | 20 | const MAX_UPLOAD_FILE_SIZE_KB = 5000; 21 | const MAX_SRV_DIR_SIZE_MB = 2000; 22 | 23 | const MAX_TMP_LIFETIME_HOURS = 24; 24 | const MAX_LIFETIME_AFTER_NOTE_CLOSED_DAYS = 7; 25 | 26 | const OSM_OAUTH_TOKEN = null; 27 | 28 | /* time the cronjob should spend on photo cleanup (i.e. should be lower than 29 | * PHP timeout) */ 30 | const MAX_CRON_CLEANUP_IN_SECONDS = 300; 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 exploide 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /classes/PhotoNoteParser.class.php: -------------------------------------------------------------------------------- 1 | photos_urls = $photos_urls; 13 | } 14 | 15 | public function parse(string $json): PhotoNote 16 | { 17 | $note = json_decode($json, true); 18 | 19 | $r = new PhotoNote(); 20 | $r->note_id = $note['properties']['id']; 21 | $r->status = $note['properties']['status']; 22 | if ($r->status == 'closed') { 23 | $r->closed_at = $note['properties']['closed_at']; 24 | } 25 | 26 | $relevant_comments = ""; 27 | foreach ($note['properties']['comments'] as $comment) { 28 | if (array_key_exists('uid', $comment)) { 29 | $relevant_comments .= "\n" . $comment['text']; 30 | } 31 | } 32 | $r->photo_ids = array(); 33 | foreach ($this->photos_urls as $photo_url) { 34 | $search_regex = '~(?photo_ids = array_merge($r->photo_ids, $photo_ids); 38 | } 39 | return $r; 40 | } 41 | } -------------------------------------------------------------------------------- /classes/PhotoNoteFetcher.class.php: -------------------------------------------------------------------------------- 1 | osm_auth_token = $osm_auth_token; 18 | $this->parser = new PhotoNoteParser($photos_urls); 19 | } 20 | 21 | public function fetch(int $note_id): ?PhotoNote 22 | { 23 | $url = self::OSM_NOTES_API . strval($note_id) . '.json'; 24 | $response = $this->fetchUrl($url, $this->osm_auth_token); 25 | if ($response->code == 404 || $response->code == 410) { 26 | return null; 27 | } 28 | else if ($response->code != 200) { 29 | throw new Exception('OSM API returned error code ' . $response->code); 30 | } 31 | return $this->parser->parse($response->body); 32 | } 33 | 34 | function fetchUrl($url, string $auth_token = null) 35 | { 36 | $response = new stdClass(); 37 | $curl = curl_init($url); 38 | curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); 39 | curl_setopt($curl, CURLOPT_USERAGENT, 'StreetComplete Photo Service'); 40 | if ($auth_token !== null) { 41 | curl_setopt($curl, CURLOPT_HTTPHEADER, array("Authorization: Bearer ".$auth_token)); 42 | } 43 | $response->body = curl_exec($curl); 44 | $response->code = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); 45 | curl_close($curl); 46 | return $response; 47 | } 48 | } -------------------------------------------------------------------------------- /upload.php: -------------------------------------------------------------------------------- 1 | 0 and $content_length > $max_content_length) { 17 | returnError(413, 'Payload too large'); 18 | } 19 | 20 | $photo = file_get_contents('php://input', false, null, 0, $max_content_length); 21 | 22 | $finfo = new finfo(FILEINFO_MIME_TYPE); 23 | $file_type = $finfo->buffer($photo); 24 | 25 | if (!array_key_exists($file_type, Config::ALLOWED_FILE_TYPES)) { 26 | returnError(415, 'File type not allowed'); 27 | } 28 | 29 | $file_ext = Config::ALLOWED_FILE_TYPES[$file_type]; 30 | 31 | try { 32 | $mysqli = new mysqli(Config::DB_HOST, Config::DB_USER, Config::DB_PASS, Config::DB_NAME); 33 | $dao = new PhotosDao($mysqli); 34 | $file_id = $dao->newPhoto($file_ext); 35 | $file_name = strval($file_id) . $file_ext; 36 | $file_path = Config::PHOTOS_TMP_DIR . DIRECTORY_SEPARATOR . $file_name; 37 | $ret_val = file_put_contents($file_path, $photo); 38 | 39 | if ($ret_val === false) { 40 | $dao->deletePhoto($file_id); 41 | returnError(500, 'Cannot save file'); 42 | } 43 | $mysqli->close(); 44 | } catch (mysqli_sql_exception $e) { 45 | returnError(500, 'Database failure'); 46 | } 47 | 48 | http_response_code(200); 49 | exit(json_encode(array( 50 | 'future_url' => trim(Config::PHOTOS_SRV_URL, '/') . DIRECTORY_SEPARATOR . $file_name 51 | ))); 52 | 53 | function returnError($code, $message) 54 | { 55 | http_response_code($code); 56 | exit(json_encode(array('error' => $message))); 57 | } -------------------------------------------------------------------------------- /activate.php: -------------------------------------------------------------------------------- 1 | fetch($note_id); 30 | 31 | if (!$osm_note) { 32 | returnError(410, 'Error fetching OSM note'); 33 | } 34 | 35 | if (count($osm_note->photo_ids) === 0) { 36 | http_response_code(200); 37 | exit(json_encode(array('found_photos' => 0, 'activated_photos' => 0))); 38 | } 39 | 40 | try { 41 | $mysqli = new mysqli(Config::DB_HOST, Config::DB_USER, Config::DB_PASS, Config::DB_NAME); 42 | $dao = new PhotosDao($mysqli); 43 | $photos = $dao->getInactivePhotosByIds($osm_note->photo_ids); 44 | 45 | foreach ($photos as $photo) { 46 | $file_name = $photo->file_id . $photo->file_ext; 47 | $ret_val = rename( 48 | Config::PHOTOS_TMP_DIR . DIRECTORY_SEPARATOR . $file_name, 49 | Config::PHOTOS_SRV_DIR . DIRECTORY_SEPARATOR . $file_name 50 | ); 51 | 52 | if ($ret_val === false) { 53 | returnError(500, 'Cannot move file'); 54 | } 55 | 56 | $dao->activatePhoto($photo->file_id, $note_id); 57 | } 58 | 59 | $mysqli->close(); 60 | } catch (mysqli_sql_exception $e) { 61 | returnError(500, 'Database failure'); 62 | } 63 | 64 | http_response_code(200); 65 | exit(json_encode(array( 66 | 'found_photos' => count($osm_note->photo_ids), 67 | 'activated_photos' => count($photos) 68 | ))); 69 | 70 | function returnError($code, $message) 71 | { 72 | http_response_code($code); 73 | exit(json_encode(array('error' => $message))); 74 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SC-Photo-Service 2 | 3 | This is a photo hosting service intended for [StreetComplete](https://github.com/westnordost/StreetComplete), but it could also be used by other applications. 4 | 5 | Abuse is prevented by requiring an association between an uploaded photo and an open, publicly visible OpenStreetMap note. If the associated note is closed or deleted, photo(s) associated with the note are also removed. 6 | 7 | Therefore, the software queries the OSM API and parses photo URLs out of notes. 8 | 9 | Programming language, repository structure and other design decisions are based on the requirements imposed by the intended hosting infrastructure for StreetComplete. 10 | 11 | Requires PHP 5.6 or later. 12 | 13 | 14 | ## How does it work? 15 | 16 | - The client (i.e. the StreetComplete app) uploads a photo file to the server. 17 | - The file will be quarantined, but the client immediately receives a URL where the photo can be found once activated. 18 | - This URL can be included in an OSM note to reference the photo. 19 | - After the note is published at OSM, the client sends an activate request to the server including the OSM note ID. 20 | - The server will retrieve the note and looks for URLs referencing the photo. 21 | - If found, the photo will be released from quarantine and hence is accessible via the given URL. 22 | 23 | A cleanup cron job periodically fetches notes associated with photos and checks if the note is closed or if the URL vanished (moderated). Photos which are no longer necessary are deleted after a configurable delay. 24 | It is also possible to specify a maximum storage size, which, when exhausted, will lead to the deletion of the oldest photos. 25 | 26 | 27 | ## Deployment 28 | 29 | - Copy this repository to a webserver 30 | - Perform appropriate file protection measures if included `.htaccess` file is not used 31 | - Create `config.php` from `config.sample.php` template and fill with production settings 32 | - Create the respective database and folders for file storage 33 | - Configure a cron job (e.g. daily) that executes the `cleanup.php` script 34 | 35 | 36 | ## API Usage 37 | 38 | ### Uploading a File 39 | 40 | In order to upload a photo, POST a request to `upload.php` that contains the raw file data in the body. 41 | 42 | On success, it returns a JSON response including the URL where the photo will be reachable once it got activated: 43 | 44 | `{'future_url': 'https://example.org/pics/42.jpg'}` 45 | 46 | On failure, the JSON will contain an error key, f.e.: 47 | 48 | `{'error': 'File type not allowed'}` 49 | 50 | ### Activating a Photo 51 | 52 | To activate photos contained in a certain OSM note, POST a request to `activate.php`, giving it the note ID: 53 | 54 | `{"osm_note_id": 1337}` 55 | 56 | On success, it will return a JSON with information about the number of found and activated photo URLs: 57 | 58 | `{'found_photos': 2, 'activated_photos': 1}` 59 | 60 | On failure, the JSON will contain an error key, e.g.: 61 | 62 | `{'error': 'Error fetching OSM note'}` 63 | 64 | ## Example Client Code 65 | 66 | In StreetComplete, the following class communicates with the mentioned API, you can take this as an example 67 | 68 | [PhotoServiceApiClient.kt](https://github.com/streetcomplete/StreetComplete/blob/master/app/src/main/java/de/westnordost/streetcomplete/data/osmnotes/PhotoServiceApiClient.kt) 69 | -------------------------------------------------------------------------------- /classes/PhotosDao.class.php: -------------------------------------------------------------------------------- 1 | mysqli = $mysqli; 12 | $this->createTable(); 13 | } 14 | 15 | private function createTable() 16 | { 17 | $this->mysqli->query( 18 | 'CREATE TABLE IF NOT EXISTS photos( 19 | file_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, 20 | file_ext VARCHAR(10) NOT NULL, 21 | creation_time DATETIME NOT NULL, 22 | note_id BIGINT UNSIGNED, 23 | last_checked_time DATETIME 24 | )' 25 | ); 26 | } 27 | 28 | /** Create a new photo. Returns the id of the new photo */ 29 | public function newPhoto(string $file_ext): int 30 | { 31 | $stmt = $this->mysqli->prepare('INSERT INTO photos(file_ext, creation_time) VALUES (?, NOW())'); 32 | $stmt->bind_param('s', $file_ext); 33 | $stmt->execute(); 34 | return $this->mysqli->insert_id; 35 | } 36 | 37 | /** Activate photo */ 38 | public function activatePhoto(int $photo_id, int $note_id) 39 | { 40 | $stmt = $this->mysqli->prepare('UPDATE photos SET note_id = ? WHERE file_id = ?'); 41 | $stmt->bind_param('ii', $note_id, $photo_id); 42 | $stmt->execute(); 43 | } 44 | 45 | /** Delete photo from database */ 46 | public function deletePhoto(int $file_id) 47 | { 48 | $stmt = $this->mysqli->prepare('DELETE FROM photos WHERE file_id=?'); 49 | $stmt->bind_param('i', $file_id); 50 | $stmt->execute(); 51 | } 52 | 53 | public function touchPhoto(int $photo_id) 54 | { 55 | $stmt = $this->mysqli->prepare('UPDATE photos SET last_checked_time = NOW() WHERE file_id = ?'); 56 | $stmt->bind_param('i', $photo_id); 57 | $stmt->execute(); 58 | } 59 | 60 | /** Return Photos that haven't been activated yet but were created more than x hours ago */ 61 | public function getOldInactivePhotos(int $max_tmp_lifetime_hours): array 62 | { 63 | $stmt = $this->mysqli->prepare( 64 | 'SELECT file_id, file_ext, note_id 65 | FROM photos 66 | WHERE note_id IS NULL 67 | AND creation_time < ADDDATE(NOW(), INTERVAL -? HOUR)' 68 | ); 69 | $stmt->bind_param('i', $max_tmp_lifetime_hours); 70 | $stmt->execute(); 71 | $result = $stmt->get_result(); 72 | return $this->getResultAsPhotos($result); 73 | } 74 | 75 | public function getOldestActivePhotos(int $num): array 76 | { 77 | $stmt = $this->mysqli->prepare( 78 | 'SELECT file_id, file_ext, note_id 79 | FROM photos 80 | WHERE note_id IS NOT NULL 81 | ORDER BY creation_time 82 | LIMIT ?' 83 | ); 84 | $stmt->bind_param('i', $num); 85 | $stmt->execute(); 86 | $result = $stmt->get_result(); 87 | return $this->getResultAsPhotos($result); 88 | } 89 | 90 | public function getActivePhotos(): array 91 | { 92 | $result = $this->mysqli->query( 93 | 'SELECT file_id, file_ext, note_id FROM photos 94 | WHERE note_id IS NOT NULL 95 | ORDER BY last_checked_time' 96 | ); 97 | return $this->getResultAsPhotos($result); 98 | } 99 | 100 | public function getInactivePhotos(): array 101 | { 102 | $result = $this->mysqli->query( 103 | 'SELECT file_id, file_ext, note_id FROM photos 104 | WHERE note_id IS NULL 105 | ORDER BY last_checked_time' 106 | ); 107 | return $this->getResultAsPhotos($result); 108 | } 109 | 110 | public function getInactivePhotosByIds(array $photo_ids): array 111 | { 112 | $in = str_repeat('?,', count($photo_ids) - 1) . '?'; 113 | $stmt = $this->mysqli->prepare( 114 | "SELECT file_id, file_ext FROM photos 115 | WHERE file_id IN ($in) 116 | AND note_id IS NULL" 117 | ); 118 | $stmt->bind_param(str_repeat('i', count($photo_ids)), ...$photo_ids); 119 | $stmt->execute(); 120 | $result = $stmt->get_result(); 121 | return $this->getResultAsPhotos($result); 122 | } 123 | 124 | private function getResultAsPhotos($result): array { 125 | $objs = array(); 126 | while ($obj = $result->fetch_object("Photo")) { 127 | $objs[] = $obj; 128 | } 129 | $result->close(); 130 | return $objs; 131 | } 132 | } -------------------------------------------------------------------------------- /cleanup.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getSize(); 27 | } 28 | } 29 | return $size; 30 | } 31 | 32 | function deleteNonReferencedPhotos($photos, $files_dir) 33 | { 34 | $photo_file_ids = array(); 35 | foreach ($photos as $photo) { 36 | $photo_file_ids[$photo->file_id] = $photo; 37 | } 38 | 39 | $file_names = scandir($files_dir); 40 | $file_ids = array(); 41 | foreach ($file_names as $file_name) { 42 | $file_id = substr($file_name, 0, strrpos( $file_name, '.' )); 43 | if (is_numeric($file_id)) { 44 | $file_ids[$file_id] = $file_name; 45 | } 46 | } 47 | 48 | foreach ($file_ids as $file_id => $file_name) { 49 | if(!array_key_exists($file_id, $photo_file_ids)) { 50 | deleteFile($files_dir . DIRECTORY_SEPARATOR . $file_name); 51 | } 52 | } 53 | foreach ($photo_file_ids as $file_id => $photo) { 54 | if(!array_key_exists($file_id, $file_ids)) { 55 | deletePhotoFromDB($photo->file_id); 56 | } 57 | } 58 | } 59 | 60 | function deletePhotos($photos) 61 | { 62 | foreach($photos as $photo) deletePhoto($photo); 63 | } 64 | 65 | function deletePhoto($photo) 66 | { 67 | $photos_dir = $photo->note_id == NULL ? Config::PHOTOS_TMP_DIR : Config::PHOTOS_SRV_DIR; 68 | $file_path = $photos_dir . DIRECTORY_SEPARATOR . $photo->file_id . $photo->file_ext; 69 | deletePhotoFromDB($photo->file_id); 70 | deleteFile($file_path); 71 | } 72 | 73 | function deleteFile($file_name) 74 | { 75 | unlink($file_name); 76 | } 77 | 78 | function deletePhotoFromDB($file_id) 79 | { 80 | global $dao; 81 | $dao->deletePhoto($file_id); 82 | info($file_id); 83 | } 84 | 85 | $start = time(); 86 | 87 | $photos_urls = array(Config::PHOTOS_SRV_URL, ...Config::ALTERNATIVE_PHOTOS_SRV_URLS); 88 | $fetcher = new PhotoNoteFetcher($photos_urls, Config::OSM_OAUTH_TOKEN); 89 | $mysqli = new mysqli(Config::DB_HOST, Config::DB_USER, Config::DB_PASS, Config::DB_NAME); 90 | $dao = new PhotosDao($mysqli); 91 | 92 | $active_photos = $dao->getActivePhotos(); 93 | $inactive_photos = $dao->getInactivePhotos(); 94 | 95 | // delete photo files that are not known in DB and vice-versa 96 | // = clean up after problems/bugs in the past or manual deletions in file system/DB 97 | info("Deleting non-referenced active photos"); 98 | deleteNonReferencedPhotos($active_photos, Config::PHOTOS_SRV_DIR); 99 | info("Deleting non-referenced inactive photos"); 100 | deleteNonReferencedPhotos($inactive_photos, Config::PHOTOS_TMP_DIR); 101 | 102 | // delete photos that have never been activated 103 | info("Deleting never activated photos"); 104 | $old_inactive_photos = $dao->getOldInactivePhotos(Config::MAX_TMP_LIFETIME_HOURS); 105 | deletePhotos($old_inactive_photos); 106 | 107 | $active_notes = array(); // active_photos associated by photo->note_id 108 | foreach ($active_photos as $photo) { 109 | if (!array_key_exists($photo->note_id, $active_notes)) { 110 | $active_notes[$photo->note_id] = array(); 111 | } 112 | $active_notes[$photo->note_id][] = $photo; 113 | } 114 | // delete activated photos whose associated note has been closed or deleted 115 | info("Deleting photos whose note has been closed or deleted"); 116 | foreach ($active_notes as $note_id => $photos) { 117 | // timeout: we did enough for today... 118 | if (time() - $start > Config::MAX_CRON_CLEANUP_IN_SECONDS) break; 119 | 120 | $osm_note = $fetcher->fetch($note_id); 121 | if (!$osm_note) { 122 | deletePhotos($photos); 123 | continue; 124 | } 125 | if ($osm_note->status === 'closed' and strtotime($osm_note->closed_at . ' +' . Config::MAX_LIFETIME_AFTER_NOTE_CLOSED_DAYS . ' days') < strtotime('now')) { 126 | deletePhotos($photos); 127 | continue; 128 | } 129 | foreach ($photos as $photo) { 130 | if (!in_array($photo->file_id, $osm_note->photo_ids)) { 131 | deletePhoto($photo); 132 | } else { 133 | $dao->touchPhoto($photo->file_id); 134 | } 135 | } 136 | 137 | } 138 | 139 | // finally, delete oldest photos first if there is not enough space 140 | info("Deleting oldest photos if above quota"); 141 | while (directorySize(Config::PHOTOS_SRV_DIR) > Config::MAX_SRV_DIR_SIZE_MB * 1000000) { 142 | $oldest_active_photos = $dao->getOldestActivePhotos(10); 143 | deletePhotos($oldest_active_photos); 144 | } 145 | 146 | $mysqli->close(); --------------------------------------------------------------------------------