├── .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();
--------------------------------------------------------------------------------