├── .gitignore ├── server ├── templates │ ├── _foot.tpl │ ├── error.tpl │ ├── index.tpl │ ├── _head.tpl │ ├── update.tpl │ ├── register.tpl │ ├── login.tpl │ ├── subscriptions.tpl │ ├── feed.tpl │ └── index_logged.tpl ├── sql │ ├── migration_20250125.sql │ ├── migration_20251211.sql │ ├── schema.sql │ └── migration_20240428.sql ├── lib │ ├── OPodSync │ │ ├── APIException.php │ │ ├── Utils.php │ │ ├── DB.php │ │ ├── Feed.php │ │ ├── GPodder.php │ │ └── API.php │ └── KD2 │ │ ├── ErrorManager.php │ │ └── Smartyer.php ├── .htaccess ├── subscriptions.php ├── update.php ├── feed.php ├── login.php ├── register.php ├── index.php ├── icon.svg ├── _inc.php └── style.css ├── .editorconfig ├── Makefile ├── test ├── tests │ ├── 00_index.php │ ├── 13_gpodder_episodes.php │ ├── 11_gpodder_devices.php │ ├── 01_register.php │ ├── 10_gpodder_login.php │ └── 12_gpodder_subscriptions.php ├── episodes.json ├── start.php ├── subscriptions.opml └── subscriptions.json ├── TODO.md ├── docker-compose.yml ├── Dockerfile ├── CHANGES.md ├── test.sh ├── config.dist.php ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | server/data/* -------------------------------------------------------------------------------- /server/templates/_foot.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.php] 2 | indent_style = tab 3 | indent_size = 4 -------------------------------------------------------------------------------- /server/sql/migration_20250125.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users ADD COLUMN token TEXT NULL; 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY : server 2 | 3 | server: 4 | php -S localhost:8080 -t server server/index.php 5 | -------------------------------------------------------------------------------- /server/lib/OPodSync/APIException.php: -------------------------------------------------------------------------------- 1 | {$message}

4 | 5 | {include file="_foot.tpl"} -------------------------------------------------------------------------------- /server/sql/migration_20251211.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users ADD COLUMN external_user_id INTEGER NULL; 2 | 3 | DROP INDEX users_name; 4 | CREATE UNIQUE INDEX users_unique ON users (name, external_user_id); 5 | 6 | -------------------------------------------------------------------------------- /test/tests/00_index.php: -------------------------------------------------------------------------------- 1 | GET('/'); 6 | 7 | Test::equals(200, $r->status); 8 | 9 | Test::assert(str_contains($r->body, 'href="register.php"'), 'no link to register page for fresh install'); 10 | -------------------------------------------------------------------------------- /server/.htaccess: -------------------------------------------------------------------------------- 1 | # Make sure Authorization header is transmitted to PHP 2 | SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0 3 | 4 | RedirectMatch 404 lib/.* 5 | RedirectMatch 404 data/.* 6 | RedirectMatch 404 templates/.* 7 | RedirectMatch 404 sql/.* 8 | 9 | FallbackResource /index.php 10 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | Would be nice: 2 | * Implement feed URL normalization (update_urls) 3 | * Implement device sync API 4 | * Provide a real app-password for NextCloud API 5 | * Handle subscriptions per device (on Subscriptions API) 6 | 7 | In the far future: 8 | * Provide a nice front-end to the web UI 9 | * Manage and play podcasts from the web UI -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | gpodder: 3 | container_name: opodsync 4 | build: 5 | context: ./ 6 | dockerfile: Dockerfile 7 | volumes: 8 | - type: bind 9 | source: ./server/data 10 | target: /var/www/server/data 11 | hostname: opodsync.localhost 12 | ports: 13 | - 8080:8080 -------------------------------------------------------------------------------- /server/subscriptions.php: -------------------------------------------------------------------------------- 1 | user) { 8 | header('Location: ./login.php'); 9 | exit; 10 | } 11 | 12 | $subscriptions = $gpodder->listActiveSubscriptions(); 13 | 14 | $tpl->assign(compact('subscriptions')); 15 | $tpl->display('subscriptions.tpl'); 16 | -------------------------------------------------------------------------------- /server/templates/index.tpl: -------------------------------------------------------------------------------- 1 | {include file="_head.tpl"} 2 | 3 | 6 | 7 |

8 | Login 9 | {if $can_subscribe} 10 | Create account 11 | {/if} 12 |

13 | 14 | {include file="_foot.tpl"} 15 | -------------------------------------------------------------------------------- /server/update.php: -------------------------------------------------------------------------------- 1 | user) { 8 | header('Location: ./login.php'); 9 | exit; 10 | } 11 | 12 | if (DISABLE_USER_METADATA_UPDATE) { 13 | throw new UserException('Metadata fetching is disabled'); 14 | } 15 | 16 | if (!empty($_POST['update'])) { 17 | $gpodder->updateAllFeeds(); 18 | exit; 19 | } 20 | 21 | $tpl->display('update.tpl'); 22 | -------------------------------------------------------------------------------- /server/templates/_head.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {$title} 8 | 9 | 10 | 11 | 12 |

{$title}

13 |
14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.5-alpine 2 | LABEL Maintainer="BohwaZ " \ 3 | Description="oPodSync" 4 | 5 | # Setup document root 6 | RUN mkdir -p /var/www/server/data 7 | 8 | # Add application 9 | WORKDIR /var/www/ 10 | COPY server /var/www/server/ 11 | 12 | EXPOSE 8080 13 | 14 | VOLUME ["/var/www/server/data"] 15 | 16 | ENV PHP_CLI_SERVER_WORKERS=2 17 | CMD ["php", "-S", "0.0.0.0:8080", "-t", "server", "server/index.php"] 18 | -------------------------------------------------------------------------------- /server/templates/update.tpl: -------------------------------------------------------------------------------- 1 | {include file="_head.tpl"} 2 | 3 |

← Back

4 | 5 | 6 |
7 | 8 |
9 | 10 | 13 | 14 | {include file="_foot.tpl"} -------------------------------------------------------------------------------- /server/feed.php: -------------------------------------------------------------------------------- 1 | user) { 8 | header('Location: ./login.php'); 9 | exit; 10 | } 11 | 12 | $id = intval($_GET['id'] ?? null); 13 | $feed = $gpodder->getFeedForSubscription($id); 14 | $actions = $gpodder->listActions($id); 15 | 16 | if (!$feed && !$actions) { 17 | throw new UserException('Feed not found or empty'); 18 | } 19 | 20 | $tpl->assign(compact('feed', 'actions')); 21 | $tpl->display('feed.tpl'); 22 | -------------------------------------------------------------------------------- /test/episodes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "action": "download", 4 | "device": "test-device", 5 | "episode": "http://example.com/files/s01e20.mp3", 6 | "podcast": "http://example.com/feed.rss", 7 | "timestamp": "2009-12-12T09:00:00" 8 | }, 9 | { 10 | "action": "play", 11 | "device": "test-device", 12 | "episode": "http://ftp.example.org/foo.ogg", 13 | "podcast": "http://example.org/podcast.php", 14 | "position": 120, 15 | "started": 15, 16 | "total": 500 17 | } 18 | ] -------------------------------------------------------------------------------- /test/tests/13_gpodder_episodes.php: -------------------------------------------------------------------------------- 1 | POST('/api/2/episodes/demo.json', $data, HTTP::JSON); 9 | Test::equals(200, $r->status, $r); 10 | 11 | $r = $http->GET('/api/2/episodes/demo.json'); 12 | Test::equals(200, $r->status, $r); 13 | 14 | $r = json_decode($r); 15 | Test::assert(is_object($r)); 16 | Test::assert(isset($r->actions)); 17 | Test::assert(count($r->actions) === 2); 18 | -------------------------------------------------------------------------------- /server/login.php: -------------------------------------------------------------------------------- 1 | logout(); 9 | header('Location: ./'); 10 | exit; 11 | } 12 | 13 | $token = isset($_GET['token']) ? '?oktoken' : ''; 14 | $error = $gpodder->login(); 15 | 16 | if ($gpodder->isLogged()) { 17 | header('Location: ./' . $token); 18 | exit; 19 | } 20 | 21 | if ($error) { 22 | http_response_code(401); 23 | } 24 | 25 | $token = isset($_GET['token']) ? true : false; 26 | 27 | $tpl->assign(compact('error', 'token')); 28 | $tpl->display('login.tpl'); 29 | -------------------------------------------------------------------------------- /test/tests/11_gpodder_devices.php: -------------------------------------------------------------------------------- 1 | GET('/api/2/devices/demo.json'); 7 | 8 | Test::equals(200, $r->status, $r); 9 | Test::assert(json_decode($r->body, true) === []); 10 | 11 | $r = $http->POST('/api/2/devices/demo/test-device.json', ['caption' => 'My device', 'type' => 'mobile'], HTTP::JSON); 12 | 13 | Test::equals(200, $r->status, $r); 14 | 15 | $r = $http->GET('/api/2/devices/demo.json'); 16 | 17 | Test::equals(200, $r->status, $r); 18 | $r = json_decode($r->body); 19 | Test::assert(is_array($r)); 20 | $r = $r[0]; 21 | Test::assert(is_object($r)); 22 | Test::assert($r->type === 'mobile'); 23 | Test::assert($r->caption === 'My device'); 24 | 25 | -------------------------------------------------------------------------------- /test/tests/01_register.php: -------------------------------------------------------------------------------- 1 | GET('/register.php'); 6 | 7 | Test::equals(200, $r->status, $r); 8 | 9 | $dom = dom($r->body); 10 | $cc = $dom->querySelector('input[name="cc"]'); 11 | $codes = $dom->querySelectorAll('label[for="captcha"] i'); 12 | 13 | Test::assert($cc); 14 | Test::assert($codes->length === 4); 15 | 16 | $code = ''; 17 | 18 | foreach ($codes as $c) { 19 | $code .= $c->textContent; 20 | } 21 | 22 | $form = ['login' => 'demo', 'password' => 'demodemo', 'captcha' => $code, 'cc' => $cc->getAttribute('value')]; 23 | 24 | $r = $http->POST('/register.php', $form); 25 | 26 | Test::equals(200, $r->status); 27 | Test::assert(str_contains($r->body, 'Logged in as demo')); 28 | -------------------------------------------------------------------------------- /test/tests/10_gpodder_login.php: -------------------------------------------------------------------------------- 1 | url_prefix = $url; 9 | $http->http_options['timeout'] = 2; 10 | 11 | // Make sure we can't login with a wrong password 12 | $r = $http->POST('/api/2/auth/demo/login.json', [], HTTP::FORM, ['Authorization' => 'Basic ' . base64_encode('demo:falsepassword')]); 13 | 14 | Test::equals(401, $r->status, $r); 15 | 16 | $r = $http->POST('/api/2/auth/demo/login.json', [], HTTP::FORM, ['Authorization' => 'Basic ' . base64_encode('demo:demodemo')]); 17 | 18 | Test::equals(200, $r->status, $r); 19 | Test::assert(count($r->cookies) === 1); 20 | Test::assert(!empty($r->cookies['sessionid'])); 21 | -------------------------------------------------------------------------------- /server/register.php: -------------------------------------------------------------------------------- 1 | canSubscribe()) { 8 | throw new UserException('Subscriptions are disabled.'); 9 | } 10 | 11 | $error = null; 12 | 13 | if (!empty($_POST)) { 14 | if (!$gpodder->checkCaptcha($_POST['captcha'] ?? '', $_POST['cc'] ?? '')) { 15 | $error = 'Invalid captcha'; 16 | } 17 | else { 18 | $error = $gpodder->subscribe($_POST['login'] ?? '', $_POST['password'] ?? ''); 19 | 20 | if (!$error) { 21 | $gpodder->login(); 22 | header('Location: ./'); 23 | exit; 24 | } 25 | } 26 | } 27 | 28 | $captcha = $gpodder->generateCaptcha(); 29 | $tpl->assign(compact('error', 'captcha')); 30 | $tpl->display('register.tpl'); 31 | -------------------------------------------------------------------------------- /test/tests/12_gpodder_subscriptions.php: -------------------------------------------------------------------------------- 1 | PUT('/subscriptions/demo/test-device.opml', $fp); 11 | Test::equals(501, $r->status, $r); 12 | 13 | $r = $http->PUT('/subscriptions/demo/test-device.json', $fp); 14 | Test::equals(400, $r->status, $r); 15 | fclose($fp); 16 | 17 | $r = $http->PUT('/subscriptions/demo/test-device.json', __DIR__ . '/../subscriptions.json'); 18 | Test::equals(200, $r->status, $r); 19 | 20 | $r = $http->GET('/subscriptions/demo/test-device.opml'); 21 | 22 | Test::equals(200, $r->status, $r); 23 | Test::assert(str_contains($r->body, 'xmlUrl="https://april.org/lav.xml"')); 24 | 25 | -------------------------------------------------------------------------------- /server/lib/OPodSync/Utils.php: -------------------------------------------------------------------------------- 1 | ', "\n\n", $str); 10 | $str = preg_replace_callback('!]*href=(".*?"|\'.*?\'|\S+)[^>]*>(.*?)!i', function ($match) { 11 | $url = trim($match[1], '"\''); 12 | if ($url === $match[2]) { 13 | return $match[1]; 14 | } 15 | else { 16 | return '[' . $match[2] . '](' . $url . ')'; 17 | } 18 | }, $str); 19 | $str = htmlspecialchars(strip_tags($str)); 20 | $str = preg_replace("!(?:\r?\n){3,}!", "\n\n", $str); 21 | $str = preg_replace('!\[([^\]]+)\]\(([^\)]+)\)!', '$1', $str); 22 | $str = preg_replace(';(?$0', $str); 23 | $str = nl2br($str); 24 | return $str; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/templates/register.tpl: -------------------------------------------------------------------------------- 1 | {include file="_head.tpl"} 2 | 3 | {if $error} 4 |

{$error}

5 | {/if} 6 | 7 |
8 |
9 | Create an account 10 |
11 |
12 |
13 |
14 |
15 |
Captcha
16 |
17 |
18 |
19 |

20 |
21 |
22 | 23 | {include file="_foot.tpl"} -------------------------------------------------------------------------------- /server/templates/login.tpl: -------------------------------------------------------------------------------- 1 | {include file="_head.tpl"} 2 | 3 | {if $error} 4 |

{$error}

5 | {/if} 6 | 7 | {if $token} 8 |

An app is asking to access your account.

9 | {/if} 10 | 11 |
12 |
13 | Please login 14 |
15 |
16 |
17 |
18 |
19 |
20 |

21 |
22 |
23 | 24 | {include file="_foot.tpl"} 25 | -------------------------------------------------------------------------------- /server/templates/subscriptions.tpl: -------------------------------------------------------------------------------- 1 | {include file="_head.tpl"} 2 | 3 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {foreach from=$subscriptions item="row"} 24 | last_change); 26 | $date = date('d/m/Y H:i', $row->last_change); 27 | $title = $row->title ?? str_replace(['http://', 'https://'], '', $row->url); 28 | ?> 29 | 30 | 31 | 32 | 33 | 34 | {/foreach} 35 | 36 |
Podcast URLLast actionActions
{$title}{$row.count}
37 | 38 | {include file="_foot.tpl"} -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 0.5.0 (11 December 2025) 2 | 3 | * Add URL of instance on homepage, with copy button 4 | * Add support for being used as an external app inside KaraDAV 5 | 6 | ## 0.4.7 (29 November 2025) 7 | 8 | * Add some client tests (still incomplete) 9 | * Refactor API error and route handling 10 | 11 | ## 0.4.6 (28 November 2025) 12 | 13 | * Fix register button was hidden when no user existed 14 | * Fix login after registration 15 | * Fix various warnings for PHP 8.5 compatibility (please report any issue) 16 | 17 | ## 0.4.5 (November 2025) 18 | 19 | * Implement various small cosmetic PRs 20 | 21 | ## 0.4.4 22 | 23 | * Fix login.php redirect (thanks @mx1up) 24 | 25 | ## 0.4.3 26 | 27 | * Fix GPodder token parsing (thanks @sezuan) 28 | 29 | ## 0.4.2 30 | 31 | * Fix DATA_ROOT env 32 | 33 | ## 0.4.1 34 | 35 | * Fix CSS loading issue with php-cli webserver 36 | * Fix session_destroy call during migration 37 | 38 | ## 0.4.0 39 | 40 | Warning: If you had an old `config.local.php` from pre-0.4 version, it will not work anymore (the namespace has changed). You should edit it to suit the new format, see `config.dist.php` for an example. 41 | 42 | * Refactor code of HTML display to use Smartyer templates 43 | * Add more configuration constants 44 | * Allow to enable/disable the secret gpodder username 45 | * Add feed title, description and website URL to OPML (if metadata fetching was done) 46 | -------------------------------------------------------------------------------- /server/templates/feed.tpl: -------------------------------------------------------------------------------- 1 | {include file="_head.tpl"} 2 | 3 |

4 | ← Back 5 |

6 | 7 | {if isset($feed->url, $feed->title, $feed->description)} 8 |
9 |

{$feed.title}

10 |

{$feed.description|raw|format_description}

11 |
12 |

Note: episodes titles might be missing because of trackers/ads used by some podcast providers.

13 | {else} 14 |

No information is available on this feed.

15 | {/if} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {foreach from=$actions item="row"} 28 | url), '?'); 30 | strtok(''); 31 | $title = $row->title ?? $url; 32 | $iso_date = date(DATE_ISO8601, $row->changed); 33 | $date = date('d/m/Y H:i', $row->changed); 34 | ?> 35 | 36 | 37 | 38 | 39 | 40 | 41 | {/foreach} 42 | 43 |
ActionDeviceDateEpisode
{$row.action}{$row.device_name}{$title}
44 | 45 | {include file="_foot.tpl"} -------------------------------------------------------------------------------- /test/start.php: -------------------------------------------------------------------------------- 1 | /dev/null 2>&1 & echo $!', 27 | escapeshellarg($data_root), 28 | escapeshellarg($server), 29 | escapeshellarg($root), 30 | escapeshellarg($root . '/index.php') 31 | ); 32 | 33 | $pid = shell_exec($cmd); 34 | 35 | sleep(1); 36 | 37 | declare(ticks = 1); 38 | 39 | pcntl_signal(SIGINT, function() use ($pid) { 40 | shell_exec('kill ' . $pid); 41 | exit; 42 | }); 43 | 44 | $http = new HTTP; 45 | $http->url_prefix = $url; 46 | $http->http_options['timeout'] = 2; 47 | $list = glob(__DIR__ . '/tests/*.php'); 48 | natcasesort($list); 49 | 50 | try { 51 | foreach ($list as $file) { 52 | require $file; 53 | } 54 | } 55 | finally { 56 | shell_exec('kill ' . $pid); 57 | } 58 | 59 | function dom(string $html) { 60 | $doc = new HTMLDocument; 61 | $doc->loadHTML($html); 62 | return $doc; 63 | } 64 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | USER="testtest" 6 | HOST="localhost:8083" 7 | 8 | cleanup () { 9 | echo "Cleaning..." 10 | rm -f server/data/data.sqlite 11 | [ -f server/data/data2.sqlite ] && mv server/data/data2.sqlite server/data/data.sqlite 12 | [ -f cookies.txt ] && rm -f cookies.txt 13 | kill $PID 14 | } 15 | 16 | function requote() { 17 | local res="" 18 | for x in "${@}" ; do 19 | # try to figure out if quoting was required for the $x: 20 | grep -q "[[:space:]]" <<< "$x" && res="${res} '${x}'" || res="${res} ${x}" 21 | done 22 | # remove first space and print: 23 | sed -e 's/^ //' <<< "${res}" 24 | } 25 | 26 | r() { 27 | URL="$BASE_URL$1" 28 | shift 29 | ARGS=$(requote "${@}") 30 | CMD="curl -s -v -c cookies.txt $ARGS $URL" 31 | echo $CMD 32 | $CMD 33 | echo 34 | } 35 | 36 | trap "cleanup" ERR 37 | 38 | [ -f server/data/data.sqlite ] && mv server/data/data.sqlite server/data/data2.sqlite 39 | 40 | php -S $HOST -t server server/index.php > /dev/null 2>&1 & 41 | PID=$! 42 | 43 | sleep 0.5 44 | 45 | BASE_URL="http://${USER}:testtest@${HOST}/" 46 | 47 | r / -X GET > /dev/null 48 | 49 | sqlite3 server/data/data.sqlite "INSERT INTO users (name, password) VALUES ('${USER}', '\$2y\$10\$taowFf8qdr23Rx13cblkQ.IBHcj2yB.ESR9Hb8OOEEDwkyVSxkiMe');" 50 | 51 | echo "Login... " 52 | r api/2/auth/${USER}/login.json -X POST 53 | 54 | echo "Create device... " 55 | r api/2/devices/${USER}/antennapod.json -d '{"caption": "Bla bla", "type": "mobile"}' -X POST 56 | 57 | echo "Logout... " 58 | r api/2/auth/${USER}/logout.json -X POST 59 | 60 | exit 0 -------------------------------------------------------------------------------- /server/index.php: -------------------------------------------------------------------------------- 1 | getRequestURI(); 21 | 22 | if ($api->handleRequest($uri)) { 23 | return; 24 | } 25 | } 26 | catch (APIException $e) { 27 | $api->error($e); 28 | return; 29 | } 30 | 31 | if (PHP_SAPI === 'cli') { 32 | $gpodder->updateAllFeeds(true); 33 | exit(0); 34 | } 35 | 36 | $uri = trim($uri, '/'); 37 | 38 | // Return 404 is URI is invalid 39 | if (!in_array($uri, ['', 'index.php'], true)) { 40 | http_response_code(404); 41 | echo '

404 Not Found

'; 42 | exit; 43 | } 44 | 45 | if (KARADAV_URL && isset($_GET['ext_sessionid'])) { 46 | $gpodder->loginExternal($_GET['ext_sessionid']); 47 | $tpl->assign('user', $gpodder->user); 48 | } 49 | 50 | if ($gpodder->user) { 51 | if (!empty($_POST['enable_token'])) { 52 | $gpodder->enableToken(); 53 | header('Location: ./'); 54 | exit; 55 | } 56 | elseif (!empty($_POST['disable_token'])) { 57 | $gpodder->disableToken(); 58 | header('Location: ./'); 59 | exit; 60 | } 61 | 62 | $tpl->assign('oktoken', isset($_GET['oktoken'])); 63 | $tpl->assign('gpodder_token', $gpodder->getUserToken()); 64 | $tpl->assign('subscriptions_count', $gpodder->countActiveSubscriptions()); 65 | $tpl->display('index_logged.tpl'); 66 | } 67 | else { 68 | $tpl->assign('can_subscribe', $gpodder->canSubscribe()); 69 | $tpl->display('index.tpl'); 70 | } 71 | 72 | -------------------------------------------------------------------------------- /server/templates/index_logged.tpl: -------------------------------------------------------------------------------- 1 | {include file="_head.tpl"} 2 | 3 | {if $oktoken} 4 |

You are logged in, you can close this and go back to the app.

5 | {/if} 6 | 7 |

8 |

Logged in as {$user.name}

9 |

You have {$subscriptions_count} active subscriptions.

10 | 18 | 19 |
20 |
21 | Secret GPodder username 22 | {if $gpodder_token} 23 |

Your secret GPodder username: {$gpodder_token}

24 |

(Use this username in GPodder desktop, as it does not support passwords.)

25 | 26 | {else} 27 |

GPodder desktop app has a bug, it does not support passwords.
28 | Click below to create a secret unique username that can be used in GPodder: 29 |

30 | 31 | {/if} 32 |
33 | 34 |
35 | Sync URL 36 |

Use this address in your podcast application:

37 |

38 |
39 |
40 | 41 | 42 | {include file="_foot.tpl"} 43 | -------------------------------------------------------------------------------- /server/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/subscriptions.opml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | My Feeds 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/subscriptions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "feed": "https:\/\/april.org\/lav.xml", 4 | "title": "Libre \u00e0 vous ! Les cl\u00e9s du logiciel libre", 5 | "website": "https:\/\/libreavous.org", 6 | "description": "LibreOffice, OpenOffice, Firefox, VLC, GIMP, GNU, Linux, Wikip\u00e9dia, OpenStreetMap, Debian, Wordpress, etc. Des logiciels ou services que vous avez d\u00e9j\u00e0 utilis\u00e9 ou que vous connaissez de nom. Savez-vous que ce sont des logiciels libres ou des services libres ?\n\nChaque mardi de 15 h 30 \u00e0 17 h 00, l'April, l'association de promotion et de d\u00e9fense du logiciel libre, propose une \u00e9mission sur radio Cause Commune pour vous faire d\u00e9couvrir les enjeux et l\u2019actualit\u00e9 du logiciel Libre, vous permettre de prendre le contr\u00f4le de vos libert\u00e9s informatiques; et agir en faveur d'une soci\u00e9t\u00e9 num\u00e9rique libre, durable, souveraine, ouverte, inclusive.\n\nD\u00e9couvrez :\n\n- des alternatives libres aux logiciels privateurs, aux services des GAFAM (les grandes entreprises du secteur informatique qui font payer leurs services avec nos libert\u00e9s) \n\n- les formats ouverts, les donn\u00e9es ouvertes\n\n- les atouts du logiciel libre pour l'\u00e9ducation, les collectivit\u00e9s, les associations, les entreprises etc\n\n- les aspect sociaux, \u00e9conomiques, politiques, juridiques du logiciel libre\n\n- l'\u00e9thique du logiciel libre au-del\u00e0 des avantages pratiques mis en avant par l'open source\n\nRetrouvez les dossiers politiques et juridiques trait\u00e9s par l\u2019April, les \u00e9changes avec des personnes invit\u00e9es, et bien entendu de la musique sous licence libre.\n\nDonner \u00e0 chacun et chacune, de mani\u00e8re simple et accessible les clefs pour comprendre les enjeux des logiciels et de la cultures libres mais aussi proposer des moyens d'action, tel est l'objectif de l'\u00e9mission. \n\nL'\u00e9mission est diffus\u00e9e en direct sur radio Cause Commune sur la bande FM en \u00cele-de-France (93.1) et sur le site web de la radio https:\/\/cause-commune.fm\n\nLe site web de l'\u00e9mission : https:\/\/libreavous.org" 7 | }, 8 | { 9 | "feed": "https:\/\/feeds.feedburner.com\/Cpu-Programmes", 10 | "title": "CPU \u2b1c Carr\u00e9 Petit Utile", 11 | "website": "https:\/\/cpu.dascritch.net\/", 12 | "description": "CPU \u2b1c Carr\u00e9 Petit Utile : Le programme radio des gens du num\u00e9rique, tous les Jeudi \u00e0 11h sur Radio FMR" 13 | } 14 | ] -------------------------------------------------------------------------------- /server/sql/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE feeds ( 2 | id INTEGER NOT NULL PRIMARY KEY, 3 | feed_url TEXT NOT NULL, 4 | image_url TEXT NULL, 5 | url TEXT NULL, 6 | language TEXT NULL CHECK (language IS NULL OR LENGTH(language) = 2), 7 | title TEXT NULL, 8 | description TEXT NULL, 9 | pubdate TEXT NULL DEFAULT CURRENT_TIMESTAMP CHECK (pubdate IS NULL OR datetime(pubdate) = pubdate), 10 | last_fetch INTEGER NOT NULL 11 | ); 12 | 13 | CREATE UNIQUE INDEX feed_url ON feeds (feed_url); 14 | 15 | CREATE TABLE episodes ( 16 | id INTEGER NOT NULL PRIMARY KEY, 17 | feed INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE, 18 | media_url TEXT NOT NULL, 19 | url TEXT NULL, 20 | image_url TEXT NULL, 21 | duration INTEGER NULL, 22 | title TEXT NULL, 23 | description TEXT NULL, 24 | pubdate TEXT NULL DEFAULT CURRENT_TIMESTAMP CHECK (pubdate IS NULL OR datetime(pubdate) = pubdate) 25 | ); 26 | 27 | CREATE UNIQUE INDEX episodes_unique ON episodes (feed, media_url); 28 | 29 | CREATE TABLE users ( 30 | id INTEGER NOT NULL PRIMARY KEY, 31 | name TEXT NOT NULL, 32 | password TEXT NOT NULL, 33 | token TEXT NULL, 34 | external_user_id INTEGER NULL 35 | ); 36 | 37 | CREATE UNIQUE INDEX users_unique ON users (name, external_user_id); 38 | 39 | CREATE TABLE devices ( 40 | id INTEGER NOT NULL PRIMARY KEY, 41 | user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, 42 | deviceid TEXT NOT NULL, 43 | name TEXT NULL, 44 | data TEXT 45 | ); 46 | 47 | CREATE UNIQUE INDEX deviceid ON devices (deviceid, user); 48 | 49 | CREATE TABLE subscriptions ( 50 | id INTEGER NOT NULL PRIMARY KEY, 51 | user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, 52 | feed INTEGER NULL REFERENCES feeds (id) ON DELETE SET NULL, 53 | url TEXT NOT NULL, 54 | deleted INTEGER NOT NULL DEFAULT 0, 55 | changed INTEGER NOT NULL, 56 | data TEXT 57 | ); 58 | 59 | CREATE UNIQUE INDEX subscription_url ON subscriptions (url, user); 60 | CREATE INDEX subscription_feed ON subscriptions (feed); 61 | 62 | CREATE TABLE episodes_actions ( 63 | id INTEGER NOT NULL PRIMARY KEY, 64 | user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, 65 | subscription INTEGER NOT NULL REFERENCES subscriptions (id) ON DELETE CASCADE, 66 | episode INTEGER NULL REFERENCES episodes (id) ON DELETE SET NULL, 67 | device INTEGER NULL REFERENCES devices (id) ON DELETE SET NULL, 68 | url TEXT NOT NULL, 69 | changed INTEGER NOT NULL, 70 | action TEXT NOT NULL, 71 | data TEXT 72 | ); 73 | 74 | CREATE INDEX episodes_idx ON episodes_actions (user, action, changed); 75 | CREATE INDEX episodes_actions_link ON episodes_actions (episode); 76 | -------------------------------------------------------------------------------- /server/sql/migration_20240428.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE feeds ( 2 | id INTEGER NOT NULL PRIMARY KEY, 3 | feed_url TEXT NOT NULL, 4 | image_url TEXT NULL, 5 | url TEXT NULL, 6 | language TEXT NULL CHECK (language IS NULL OR LENGTH(language) = 2), 7 | title TEXT NULL, 8 | description TEXT NULL, 9 | pubdate TEXT NULL DEFAULT CURRENT_TIMESTAMP CHECK (pubdate IS NULL OR datetime(pubdate) = pubdate), 10 | last_fetch INTEGER NOT NULL 11 | ); 12 | 13 | CREATE UNIQUE INDEX feed_url ON feeds (feed_url); 14 | 15 | CREATE TABLE episodes ( 16 | id INTEGER NOT NULL PRIMARY KEY, 17 | feed INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE, 18 | media_url TEXT NOT NULL, 19 | url TEXT NULL, 20 | image_url TEXT NULL, 21 | duration INTEGER NULL, 22 | title TEXT NULL, 23 | description TEXT NULL, 24 | pubdate TEXT NULL DEFAULT CURRENT_TIMESTAMP CHECK (pubdate IS NULL OR datetime(pubdate) = pubdate) 25 | ); 26 | 27 | CREATE UNIQUE INDEX episodes_unique ON episodes (feed, media_url); 28 | 29 | ALTER TABLE subscriptions RENAME TO subscriptions_old; 30 | DROP INDEX subscription_url; 31 | 32 | CREATE TABLE subscriptions ( 33 | id INTEGER NOT NULL PRIMARY KEY, 34 | user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, 35 | feed INTEGER NULL REFERENCES feeds (id) ON DELETE SET NULL, 36 | url TEXT NOT NULL, 37 | deleted INTEGER NOT NULL DEFAULT 0, 38 | changed INTEGER NOT NULL, 39 | data TEXT 40 | ); 41 | 42 | CREATE UNIQUE INDEX subscription_url ON subscriptions (url, user); 43 | 44 | INSERT INTO subscriptions SELECT id, user, NULL, url, deleted, changed, data FROM subscriptions_old; 45 | 46 | ALTER TABLE devices RENAME TO devices_old; 47 | DROP INDEX deviceid; 48 | 49 | -- Add new column for device name 50 | CREATE TABLE devices ( 51 | id INTEGER NOT NULL PRIMARY KEY, 52 | user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, 53 | deviceid TEXT NOT NULL, 54 | name TEXT NULL, 55 | data TEXT 56 | ); 57 | 58 | CREATE UNIQUE INDEX deviceid ON devices (deviceid, user); 59 | 60 | INSERT INTO devices SELECT id, user, deviceid, json_extract(data, '$.caption'), data FROM devices_old; 61 | 62 | ALTER TABLE episodes_actions RENAME TO episodes_actions_old; 63 | DROP INDEX episodes_idx; 64 | 65 | -- Add new column for device 66 | CREATE TABLE episodes_actions ( 67 | id INTEGER NOT NULL PRIMARY KEY, 68 | user INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, 69 | subscription INTEGER NOT NULL REFERENCES subscriptions (id) ON DELETE CASCADE, 70 | episode INTEGER NULL REFERENCES episodes (id) ON DELETE SET NULL, 71 | device INTEGER NULL REFERENCES devices (id) ON DELETE SET NULL, 72 | url TEXT NOT NULL, 73 | changed INTEGER NOT NULL, 74 | action TEXT NOT NULL, 75 | data TEXT 76 | ); 77 | 78 | CREATE INDEX episodes_idx ON episodes_actions (user, action, changed); 79 | 80 | INSERT INTO episodes_actions 81 | SELECT a.id, a.user, a.subscription, e.id, d.id, a.url, a.changed, a.action, a.data 82 | FROM episodes_actions_old a 83 | LEFT JOIN episodes e ON e.media_url = a.url 84 | LEFT JOIN devices d ON d.deviceid = json_extract(a.data, '$.device'); 85 | 86 | DROP TABLE episodes_actions_old; 87 | DROP TABLE devices_old; 88 | DROP TABLE subscriptions_old; 89 | 90 | CREATE INDEX subscription_feed ON subscriptions (feed); 91 | CREATE INDEX episodes_actions_link ON episodes_actions (episode); -------------------------------------------------------------------------------- /server/_inc.php: -------------------------------------------------------------------------------- 1 | false, 33 | 'ENABLE_SUBSCRIPTION_CAPTCHA' => true, 34 | 'DISABLE_USER_METADATA_UPDATE' => false, 35 | 'KARADAV_URL' => null, 36 | 'DATA_ROOT' => $data_root, 37 | 'CACHE_ROOT' => $data_root . '/cache', 38 | 'DB_FILE' => $data_root . '/data.sqlite', 39 | 'SQLITE_JOURNAL_MODE' => 'TRUNCATE', 40 | 'ERRORS_SHOW' => true, 41 | 'ERRORS_EMAIL' => null, 42 | 'ERRORS_LOG' => $data_root . '/error.log', 43 | 'ERRORS_REPORT_URL' => null, 44 | 'TITLE' => 'My oPodSync server', 45 | 'DEBUG_LOG' => null, 46 | ]; 47 | 48 | foreach ($defaults as $const => $value) { 49 | if (!defined(__NAMESPACE__ . '\\' . $const)) { 50 | define(__NAMESPACE__ . '\\' . $const, $value); 51 | } 52 | } 53 | 54 | if (!defined(__NAMESPACE__ . '\WWW_URL')) { 55 | $https = (!empty($_SERVER['HTTPS']) || $_SERVER['SERVER_PORT'] == 443) ? 's' : ''; 56 | $name = $_SERVER['SERVER_NAME']; 57 | $port = !in_array($_SERVER['SERVER_PORT'], [80, 443]) ? ':' . $_SERVER['SERVER_PORT'] : ''; 58 | $root = '/'; 59 | 60 | define(__NAMESPACE__ . '\WWW_URL', sprintf('http%s://%s%s%s', $https, $name, $port, $root)); 61 | } 62 | 63 | if (!ERRORS_SHOW) { 64 | ErrorManager::setEnvironment(ErrorManager::PRODUCTION); 65 | } 66 | 67 | if (ERRORS_EMAIL) { 68 | ErrorManager::setEmail(ERRORS_EMAIL); 69 | } 70 | 71 | if (ERRORS_LOG) { 72 | ErrorManager::setLogFile(ERRORS_LOG); 73 | } 74 | elseif (is_writeable(ROOT . 'data/error.log')) { 75 | ErrorManager::setLogFile(ROOT . 'data/error.log'); 76 | } 77 | 78 | if (ERRORS_REPORT_URL) { 79 | ErrorManager::setRemoteReporting(ERRORS_REPORT_URL, true); 80 | } 81 | 82 | if (!is_dir(DATA_ROOT)) { 83 | if (!@mkdir(DATA_ROOT, fileperms(ROOT), true)) { 84 | throw new \RuntimeException('Unable to create directory, please create it and allow this program to write inside: ' . DATA_ROOT); 85 | } 86 | } 87 | 88 | // Fix issues with badly configured web servers 89 | if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) && !empty($_SERVER['HTTP_AUTHORIZATION'])) { 90 | @list($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']) = explode(':', base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6))); 91 | } 92 | 93 | $gpodder = new GPodder; 94 | 95 | $tpl = new Smartyer; 96 | $tpl->setNamespace(__NAMESPACE__); 97 | $tpl->setCompiledDir(CACHE_ROOT . '/templates'); 98 | $tpl->setTemplatesDir(ROOT . '/templates'); 99 | $tpl->assign('title', TITLE); 100 | $tpl->assign('can_update_feeds', !DISABLE_USER_METADATA_UPDATE); 101 | $tpl->assign('user', $gpodder->user); 102 | $tpl->assign('url', WWW_URL); 103 | $tpl->register_modifier('format_description', [Utils::class, 'format_description']); 104 | 105 | 106 | ErrorManager::setCustomExceptionHandler(__NAMESPACE__. '\\UserException', function ($e) use ($tpl) { 107 | $tpl->assign('message', $e->getMessage()); 108 | $tpl->display('error.tpl'); 109 | exit; 110 | }); 111 | -------------------------------------------------------------------------------- /config.dist.php: -------------------------------------------------------------------------------- 1 | h1 { 9 | background: linear-gradient(to bottom, #005c97, #363795); 10 | padding: .5em; 11 | text-align: center; 12 | color: #fff; 13 | font-size: 4rem; 14 | } 15 | 16 | a { 17 | color: #005c97; 18 | } 19 | 20 | input[type=text], input[type=password], input[type=number] { 21 | border: 1px solid #666; 22 | padding: .5em; 23 | border-radius: .5em; 24 | font-size: 1.2em; 25 | max-width: 50vw; 26 | } 27 | 28 | input[size] { 29 | min-width: 0; 30 | } 31 | 32 | .btn { 33 | border: none; 34 | cursor: pointer; 35 | padding: .5em; 36 | border-radius: .5em; 37 | background: linear-gradient(to bottom, #2c3e50, #3498db); 38 | color: #fff; 39 | font-size: 1.5em; 40 | text-decoration: none; 41 | display: inline-block; 42 | margin: .5em 0; 43 | } 44 | 45 | svg { 46 | vertical-align: middle; 47 | margin: 0 .2em; 48 | } 49 | 50 | input:focus { 51 | box-shadow: 0 0 10px orange; 52 | border-color: darkred; 53 | outline: none; 54 | } 55 | 56 | .btn:hover { 57 | box-shadow: 0 0 10px orange; 58 | } 59 | 60 | main { 61 | background: #fff; 62 | border-radius: 1em; 63 | padding: 2em; 64 | max-width: 50em; 65 | margin: 2rem auto; 66 | } 67 | 68 | fieldset { 69 | text-align: center; 70 | border: 3px solid #005c97; 71 | border-radius: 1em; 72 | padding: 1em; 73 | margin: 2em; 74 | } 75 | 76 | legend { 77 | font-size: 1.3em; 78 | padding: 0 1em; 79 | } 80 | 81 | footer { 82 | color: #999; 83 | text-align: center; 84 | } 85 | 86 | footer a { 87 | color: #fff; 88 | } 89 | 90 | dl dt { 91 | font-weight: bold; 92 | margin: .8em 0; 93 | margin-top: 2em; 94 | } 95 | 96 | dl dd { 97 | margin: .8em 0; 98 | } 99 | 100 | 101 | progress[value] { 102 | appearance: none; 103 | border: none; 104 | width: 70%; 105 | height: 20px; 106 | background-color: #ddd; 107 | border-radius: 3px; 108 | box-shadow: 0 2px 3px rgba(0,0,0,.5) inset; 109 | position: relative; 110 | } 111 | 112 | progress[value]::-webkit-progress-bar { 113 | background-color: #ddd; 114 | border-radius: 3px; 115 | box-shadow: 0 2px 3px rgba(0,0,0,.5) inset; 116 | } 117 | 118 | progress[value]::-webkit-progress-value { 119 | position: relative; 120 | background-size: 35px 20px, 100% 100%, 100% 100%; 121 | border-radius:3px; 122 | background-image: 123 | linear-gradient(135deg, transparent, transparent 33%, rgba(0,0,0,.1) 33%, rgba(0,0,0,.1) 66%, transparent 66%), 124 | linear-gradient(to top, rgba(255, 255, 255, .25), rgba(0,0,0,.2)), 125 | linear-gradient(to right, #0c9, #f44); 126 | } 127 | 128 | .btn.sm { 129 | padding: .3em .5em; 130 | font-size: 1em; 131 | } 132 | 133 | .ca b { opacity: 0; } 134 | .ca i { font-style: normal; font-weight: normal; } 135 | .ca { user-select: none; } 136 | 137 | .error, .success { 138 | font-size: 1.5em; 139 | padding: .5em; 140 | } 141 | 142 | .error { color: red; } 143 | .success { color: darkgreen; } 144 | 145 | .center { 146 | text-align: center; 147 | } 148 | 149 | .center a { 150 | margin: 0 .5em; 151 | } 152 | 153 | h1 img { 154 | width: 70px; 155 | vertical-align: middle; 156 | } 157 | 158 | main h1, main h2, main p { 159 | margin: .8em 0; 160 | } 161 | 162 | table { 163 | width: 100%; 164 | border-collapse: collapse; 165 | margin: 1em 0; 166 | } 167 | 168 | table tbody tr:nth-child(even) { 169 | background: #eee; 170 | } 171 | 172 | table th, table td { 173 | text-align: left; 174 | padding: .5em; 175 | } 176 | 177 | table thead { 178 | background: #333; 179 | color: #fff; 180 | } 181 | 182 | table progress[value] { 183 | height: 10px; 184 | } 185 | 186 | table a { 187 | overflow: hidden; 188 | text-overflow: ellipsis; 189 | max-width: 50vw; 190 | display: block; 191 | } 192 | 193 | .feed img { 194 | max-width: 150px; 195 | } 196 | 197 | .feed figure { 198 | float: right; 199 | } 200 | 201 | .help { 202 | color: #666; 203 | margin: 1em; 204 | } 205 | 206 | time { 207 | white-space: pre; 208 | } 209 | 210 | nav ul { 211 | list-style-type: none; 212 | display: flex; 213 | align-items: center; 214 | justify-content: center; 215 | gap: 1em; 216 | margin: 2em 0; 217 | font-size: 1.2em; 218 | } 219 | 220 | @media screen and (max-width: 900px) { 221 | body > h1 { 222 | font-size: 2rem; 223 | } 224 | 225 | main { 226 | border-radius: 0; 227 | margin-top: 0; 228 | padding: 1em .5em; 229 | font-size: .9em; 230 | } 231 | 232 | h2.myfiles { 233 | float: none; 234 | margin: 1em 0; 235 | text-align: center; 236 | } 237 | } -------------------------------------------------------------------------------- /server/lib/OPodSync/DB.php: -------------------------------------------------------------------------------- 1 | querySingle('PRAGMA journal_mode;'); 30 | $set_mode = strtoupper($set_mode); 31 | 32 | if ($set_mode !== $mode) { 33 | // WAL = performance enhancement 34 | // see https://www.cs.utexas.edu/~jaya/slides/apsys17-sqlite-slides.pdf 35 | // https://ericdraken.com/sqlite-performance-testing/ 36 | $this->exec(sprintf( 37 | 'PRAGMA journal_mode = %s; PRAGMA synchronous = NORMAL; PRAGMA journal_size_limit = %d;', 38 | $mode, 39 | 32 * 1024 * 1024 40 | )); 41 | } 42 | 43 | if ($setup) { 44 | $this->install(); 45 | } 46 | else { 47 | $this->migrate(); 48 | } 49 | } 50 | 51 | public function install() { 52 | $this->exec(file_get_contents(ROOT . '/sql/schema.sql')); 53 | $this->simple(sprintf('PRAGMA user_version = %d;', self::VERSION)); 54 | } 55 | 56 | public function migrate() { 57 | $v = $this->firstColumn('PRAGMA user_version;'); 58 | 59 | if ($v < self::VERSION) { 60 | $list = glob(ROOT . '/sql/migration_*.sql'); 61 | sort($list); 62 | 63 | foreach ($list as $file) { 64 | if (!preg_match('!/migration_(.*?)\.sql$!', $file, $match)) { 65 | continue; 66 | } 67 | 68 | $file_version = $match[1]; 69 | 70 | if ($file_version && $file_version <= self::VERSION && $file_version > $v) { 71 | $this->exec(file_get_contents($file)); 72 | } 73 | } 74 | 75 | // Destroy session, just to make sure that there is no bug between user data in session 76 | // and user data in DB 77 | $gpodder = new GPodder; 78 | $gpodder->logout(); 79 | } 80 | 81 | $this->simple(sprintf('PRAGMA user_version = %d;', self::VERSION)); 82 | } 83 | 84 | public function upsert(string $table, array $params, array $conflict_columns) 85 | { 86 | $sql = sprintf( 87 | 'INSERT INTO %s (%s) VALUES (%s) ON CONFLICT (%s) DO UPDATE SET %s;', 88 | $table, 89 | implode(', ', array_keys($params)), 90 | ':' . implode(', :', array_keys($params)), 91 | implode(', ', $conflict_columns), 92 | implode(', ', array_map(fn($a) => $a . ' = :' . $a, array_keys($params))) 93 | ); 94 | 95 | return $this->simple($sql, $params); 96 | } 97 | 98 | public function prepare2(string $sql, ...$params) 99 | { 100 | $hash = md5($sql); 101 | 102 | if (!array_key_exists($hash, $this->statements)) { 103 | $st = $this->statements[$hash] = $this->prepare($sql); 104 | } 105 | else { 106 | $st = $this->statements[$hash]; 107 | $st->reset(); 108 | $st->clear(); 109 | } 110 | 111 | if (isset($params[0]) && is_array($params[0])) { 112 | $params = $params[0]; 113 | } 114 | 115 | foreach ($params as $key => $value) { 116 | if (is_int($key)) { 117 | $st->bindValue($key + 1, $value); 118 | } 119 | else { 120 | $st->bindValue(':' . $key, $value); 121 | } 122 | } 123 | 124 | return $st; 125 | } 126 | 127 | public function simple(string $sql, ...$params): ?\SQLite3Result 128 | { 129 | $res = $this->prepare2($sql, ...$params)->execute(); 130 | 131 | if (is_bool($res)) { 132 | return null; 133 | } 134 | 135 | return $res; 136 | } 137 | 138 | public function firstRow(string $sql, ...$params): ?\stdClass 139 | { 140 | $row = $this->simple($sql, ...$params)->fetchArray(\SQLITE3_ASSOC); 141 | return $row ? (object) $row : null; 142 | } 143 | 144 | public function firstColumn(string $sql, ...$params) 145 | { 146 | return $this->simple($sql, ...$params)->fetchArray(\SQLITE3_NUM)[0] ?? null; 147 | } 148 | 149 | public function rowsFirstColumn(string $sql, ...$params): array 150 | { 151 | $res = $this->simple($sql, ...$params); 152 | $out = []; 153 | 154 | while ($row = $res->fetchArray(\SQLITE3_NUM)) { 155 | $out[] = $row[0]; 156 | } 157 | 158 | $res->finalize(); 159 | return $out; 160 | } 161 | 162 | public function iterate(string $sql, ...$params): \Generator 163 | { 164 | $res = $this->simple($sql, ...$params); 165 | 166 | while ($row = $res->fetchArray(\SQLITE3_ASSOC)) { 167 | yield (object) $row; 168 | } 169 | 170 | $res->finalize(); 171 | } 172 | 173 | public function all(string $sql, ...$params): array 174 | { 175 | return iterator_to_array($this->iterate($sql, ...$params)); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /server/lib/OPodSync/Feed.php: -------------------------------------------------------------------------------- 1 | feed_url = $url; 20 | } 21 | 22 | public function load(\stdClass $data): void 23 | { 24 | foreach ($data as $key => $value) { 25 | if ($key === 'id') { 26 | continue; 27 | } 28 | elseif ($key === 'pubdate' && $value) { 29 | $this->$key = new \DateTime($value); 30 | } 31 | else { 32 | $this->$key = $value; 33 | } 34 | } 35 | } 36 | 37 | public function sync(): void 38 | { 39 | $db = DB::getInstance(); 40 | $db->exec('BEGIN;'); 41 | $db->upsert('feeds', $this->export(), ['feed_url']); 42 | $feed_id = $db->firstColumn('SELECT id FROM feeds WHERE feed_url = ?;', $this->feed_url); 43 | $db->simple('UPDATE subscriptions SET feed = ? WHERE url = ?;', $feed_id, $this->feed_url); 44 | 45 | foreach ($this->episodes as $episode) { 46 | $episode = (array) $episode; 47 | $episode['pubdate'] = $episode['pubdate']->format('Y-m-d H:i:s \U\T\C'); 48 | $episode['feed'] = $feed_id; 49 | $db->upsert('episodes', $episode, ['feed', 'media_url']); 50 | $id = $db->firstColumn('SELECT id FROM episodes WHERE media_url = ?;', $episode['media_url']); 51 | $db->simple('UPDATE episodes_actions SET episode = ? WHERE url = ?;', $id, $episode['media_url']); 52 | } 53 | 54 | $db->exec('END'); 55 | } 56 | 57 | public function fetch(): bool 58 | { 59 | if (function_exists('curl_exec')) { 60 | $ch = curl_init($this->feed_url); 61 | curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); 62 | curl_setopt($ch, CURLOPT_HTTPHEADER, ['User-Agent: oPodSync']); 63 | curl_setopt($ch, CURLOPT_TIMEOUT, 10); 64 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 65 | curl_setopt($ch, CURLOPT_MAXREDIRS, 5); 66 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 67 | 68 | $body = @curl_exec($ch); 69 | 70 | if (false === $body) { 71 | $error = curl_error($ch); 72 | } 73 | 74 | if (PHP_VERSION_ID < 80500) { 75 | curl_close($ch); 76 | } 77 | } 78 | else { 79 | $ctx = stream_context_create([ 80 | 'http' => [ 81 | 'header' => 'User-Agent: oPodSync', 82 | 'max_redirects' => 5, 83 | 'follow_location' => true, 84 | 'timeout' => 10, 85 | 'ignore_errors' => true, 86 | ], 87 | 'ssl' => [ 88 | 'verify_peer' => true, 89 | 'verify_peer_name' => true, 90 | 'allow_self_signed' => true, 91 | 'SNI_enabled' => true, 92 | ], 93 | ]); 94 | 95 | $body = @file_get_contents($this->feed_url, false, $ctx); 96 | } 97 | 98 | $this->last_fetch = time(); 99 | 100 | if (!$body) { 101 | return false; 102 | } 103 | 104 | while (preg_match('!]*>(.*?)!s', $body, $match)) { 105 | $body = str_replace($match[0], '', $body); 106 | $item = $match[1]; 107 | $pubdate = $this->getTagValue($item, 'pubDate'); 108 | $url = $this->getTagAttribute($item, 'enclosure', 'url'); 109 | 110 | // Not an episode, just a regular blog post, ignore 111 | if (!$url) { 112 | continue; 113 | } 114 | 115 | $this->episodes[] = (object) [ 116 | 'image_url' => $this->getTagAttribute($item, 'itunes:image', 'href') ?? $this->getTagValue($item, 'image', 'url'), 117 | 'url' => $this->getTagValue($item, 'link'), 118 | 'media_url' => $url, 119 | 'pubdate' => $pubdate ? new \DateTime($pubdate) : null, 120 | 'title' => $this->getTagValue($item, 'title'), 121 | 'description' => $this->getTagValue($item, 'description') ?? $this->getTagValue($item, 'content:encoded'), 122 | 'duration' => $this->getDuration($this->getTagValue($item, 'itunes:duration') ?? $this->getTagAttribute($item, 'enclosure', 'length')), 123 | ]; 124 | } 125 | 126 | $pubdate = $this->getTagValue($body, 'pubDate'); 127 | $language = $this->getTagValue($body, 'language'); 128 | 129 | $this->title = $this->getTagValue($body, 'title'); 130 | 131 | if (!$this->title) { 132 | return false; 133 | } 134 | 135 | $this->url = $this->getTagValue($body, 'link'); 136 | $this->description = $this->getTagValue($body, 'description'); 137 | $this->language = $language ? substr($language, 0, 2) : null; 138 | $this->image_url = $this->getTagAttribute($body, 'itunes:image', 'href') ?? $this->getTagValue($body, 'image', 'url'); 139 | $this->pubdate = $pubdate ? new \DateTime($pubdate) : null; 140 | 141 | return true; 142 | } 143 | 144 | protected function getDuration(?string $str): ?int 145 | { 146 | if (!$str) { 147 | return null; 148 | } 149 | 150 | if (false !== strpos($str, ':') && ctype_digit(str_replace(':', '', trim($str)))) { 151 | $parts = explode(':', $str); 152 | $parts = array_map('intval', $parts); 153 | $duration = ($parts[2] ?? 0) * 3600 + ($parts[1] ?? 0) * 60 + $parts[0] ?? 0; 154 | } 155 | else { 156 | $duration = (int) $str; 157 | } 158 | 159 | // Duration is less than 20 seconds? probably an error 160 | if ($duration <= 20) { 161 | return null; 162 | } 163 | 164 | return $duration; 165 | } 166 | 167 | public function getTagValue(string $str, string $name, ?string $sub = null): ?string 168 | { 169 | if (!preg_match('!<' . $name . '[^>]*>(.*?)!is', $str, $match)) { 170 | return null; 171 | } 172 | 173 | $str = $match[1]; 174 | 175 | if ($sub !== null) { 176 | return $this->getTagValue($str, $sub); 177 | } 178 | 179 | $str = trim(html_entity_decode($str)); 180 | 181 | if ($str === '') { 182 | return null; 183 | } 184 | 185 | $str = str_replace([''], '', $str); 186 | return $str; 187 | } 188 | 189 | public function getTagAttribute(string $str, string $name, string $attr): ?string 190 | { 191 | if (!preg_match('!<' . $name . '[^>]+' . $attr . '=(".*?"|\'.*?\'|[^\s]+)[^>]*>!is', $str, $match)) { 192 | return null; 193 | } 194 | 195 | $value = trim($match[1], '"\''); 196 | 197 | return trim(rawurldecode(htmlspecialchars_decode($value))) ?: null; 198 | } 199 | 200 | public function export(): array 201 | { 202 | $out = get_object_vars($this); 203 | $out['pubdate'] = $out['pubdate'] ? $out['pubdate']->format('Y-m-d H:i:s \U\T\C') : null; 204 | unset($out['episodes']); 205 | return $out; 206 | } 207 | 208 | public function listEpisodes(): array 209 | { 210 | return $this->episodes; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oPodSync - a minimalist GPodder-compatible server 2 | 3 | *(Previously known as Micro GPodder server)* 4 | 5 | This is a minimalist podcast synchronization server, for self-hosting your podcast listening / download history. 6 | 7 | This allows you to keep track of which episodes have been listened to. 8 | 9 | Requires PHP 7.4+ and SQLite3 with JSON1 extension. 10 | 11 | * Main development happens on Fossil: 12 | * Github mirror: (PR and issues accepted) 13 | 14 | ## Features 15 | 16 | * Compatible with [GPodder](https://gpoddernet.readthedocs.io/en/latest/api/reference/) and NextCloud [gPodder Sync](https://apps.nextcloud.com/apps/gpoddersync) APIs 17 | * Stores history of subscriptions and episodes (plays, downloads, etc.) 18 | * Sync between devices 19 | * Compatible with gPodder desktop client 20 | * Self-registration 21 | * See subscriptions and history on web interface 22 | * Fetch feeds and episodes metadata and store them locally (optional) 23 | * Can be used as an app inside [KaraDAV](https://fossil.kd2.org/karadav/) (alternative WebDAV server with support for NextCloud clients) 24 | 25 | ## Roadmap 26 | 27 | * Support [Podcasting 2.0 GUID](https://podcasting2.org/podcast-namespace/tags/guid) 28 | * Unit tests 29 | * Implement the [Open Podcast API](https://openpodcastapi.org) 30 | * Download, archive and listen to podcasts from the web UI (optional feature) 31 | 32 | ## Screenshots 33 | 34 | 35 | 36 | ## Installation 37 | 38 | Just copy the files from the `server` directory into a new directory of your webserver. 39 | 40 | This should work in most cases. Exceptions are: 41 | 42 | * If you are not using Apache, but Caddy, or nginx, make sure to adapt the rules from the `.htaccess` file to your own server. 43 | * If you are using Apache, but have set-up this server in a sub-folder of your document root, then you will have to adapt the `.htaccess` to your configuration. 44 | 45 | ### First account 46 | 47 | When installed, the server will allow to create a first account. Just go to the server URL and you will be able to create an account and login. 48 | 49 | After that first account, account creation is disabled by default. 50 | 51 | If you want to allow more accounts, you'll have to configure the server (see "Configuration" below). 52 | 53 | ### Docker 54 | 55 | There is an unofficial [Docker image available](https://hub.docker.com/r/ganeshlab/opodsync). Please report any Docker issue [there](https://github.com/ganeshlab/opodsync). 56 | 57 | If this image stops being maintained, see [Docker Hub](https://hub.docker.com/search?q=opodsync) to find other community distribution for Docker. 58 | 59 | **Please don't report Docker issues here, this repository is only for software development.** 60 | 61 | ### Configuration 62 | 63 | You can copy the `config.dist.php` to `data/config.local.php`, and edit it to suit your needs. 64 | 65 | ### Fetching and updating feeds metadata 66 | 67 | Version 0.3.0 brings support for fetching metadata for feed subscriptions. 68 | 69 | Feeds will only be fetched/updated if an action has been created on the linked subscription since the last fetch. 70 | 71 | To update feeds metadata, users can click the **Update all feeds metadata** button in the subscription list, unless you did set `DISABLE_USER_METADATA_UPDATE` to `TRUE`. 72 | 73 | You can also just set a crontab to run `index.php` every hour for example: 74 | 75 | ``` 76 | @hourly php /var/www/opodsync/server/index.php 77 | ``` 78 | 79 | This requires to set the `BASE_URL` either in `config.local.php` or in an environment variable. 80 | 81 | 82 | *Note: episodes titles may not appear in the list of actions, as the media URL may differ between what your podcast apps reports and what the RSS feed providers. This is because some podcast providers will provide a different URL for each user/app, for adding tracking or advertisement.* 83 | 84 | ## Configuring your podcast client 85 | 86 | Just use the domain name where you installed the server, and the login and password you have chosen. 87 | 88 | ### gPodder (desktop client) 89 | 90 | gPodder (the [desktop client](https://gpodder.github.io), not the gpodder.net service) doesn't support any kind of authentication (!!), see this [bug report](https://github.com/gpodder/gpodder/issues/1358) for details. 91 | 92 | This means that you have to use a unique secret token as the username. 93 | 94 | This token can be created when you log in. Use it as the username in gPodder configuration. Be warned that this username is replacing your password, so it lowers the security of your account. 95 | 96 | ## APIs 97 | 98 | This server supports the following APIs: 99 | 100 | * [Authentication](https://gpoddernet.readthedocs.io/en/latest/api/reference/auth.html) 101 | * [Episodes actions](https://gpoddernet.readthedocs.io/en/latest/api/reference/events.html) 102 | * [Subscriptions](https://gpoddernet.readthedocs.io/en/latest/api/reference/subscriptions.html) 103 | * [Device synchronization](https://gpoddernet.readthedocs.io/en/latest/api/reference/sync.html) 104 | * [Devices API](https://gpoddernet.readthedocs.io/en/latest/api/reference/devices.html) 105 | 106 | It also supports endpoints defined by the [NextCloud GPodder app](https://github.com/thrillfall/nextcloud-gpodder). 107 | 108 | The endpoint `/api/2/updates/(username)/(deviceid).json` (from the Devices API) is not implemented. 109 | 110 | ### API implementation differences 111 | 112 | This server only supports JSON format, except: 113 | 114 | * `PUT /subscriptions/(username)/(deviceid).txt` 115 | * `GET /subscriptions/(username)/(deviceid).opml` 116 | * `GET /subscriptions/(username).opml` 117 | 118 | Trying to use a different format on other endpoints will result in a 501 error. JSONP is not supported either. 119 | 120 | Please also note: the username "current" always points to the currently logged-in user. The deviceid "default" points to the first deviceid found. 121 | 122 | ## Compatible apps 123 | 124 | This server has been tested so far with: 125 | 126 | * [AntennaPod](https://github.com/AntennaPod/AntennaPod) 2.6.1 (both GPodder API and NextCloud API) - Android 127 | * [gPodder](https://gpodder.github.io/) 3.10.17 - Debian (requires a specific token, see above!) 128 | * [Kasts](https://invent.kde.org/multimedia/kasts) 21.08 - Linux/Windows/Android 129 | * [PinePods](https://github.com/madeofpendletonwool/PinePods) 0.6.1 - WebServer 130 | * [Music Assistant](https://www.music-assistant.io/music-providers/gpodder/) (according to their documentation) 131 | 132 | Please report if apps work (or not) with other clients. 133 | 134 | It doesn't work with: 135 | 136 | * Clementine 1.4.0rc1 - Debian (not possible to choose the server: [bug report](https://github.com/clementine-player/Clementine/issues/7202)) 137 | 138 | ## Other interesting resources 139 | 140 | * [Podcast Index](https://podcastindex.org/) to find podcasts and apps 141 | * [Castopod](https://castopod.org/) to share your podcast in the Fediverse 142 | * [Podcasting 2.0](https://podcasting2.org/) to find modern listening apps and update your podcast feeds to the latest features 143 | 144 | ## License 145 | 146 | GNU AGPLv3 147 | 148 | ## Author 149 | 150 | * [BohwaZ](https://bohwaz.net/) 151 | -------------------------------------------------------------------------------- /server/lib/OPodSync/GPodder.php: -------------------------------------------------------------------------------- 1 | startSession(); 14 | } 15 | 16 | public function startSession(bool $force = false, bool $external = false): void 17 | { 18 | if (isset($_SESSION)) { 19 | return; 20 | } 21 | 22 | $options = [ 23 | 'secure' => true, 24 | 'httponly' => true, 25 | ]; 26 | 27 | if ($external) { 28 | $options['samesite'] = 'None'; 29 | } 30 | 31 | session_set_cookie_params($options); 32 | 33 | session_name('sessionid'); 34 | 35 | if ($force || isset($_COOKIE[session_name()])) { 36 | if (isset($_GET['token']) && ctype_alnum($_GET['token'])) { 37 | session_id($_GET['token']); 38 | } 39 | 40 | @session_start(); 41 | 42 | if (!empty($_SESSION['user'])) { 43 | $this->user = $_SESSION['user']; 44 | } 45 | } 46 | } 47 | 48 | public function loginExternal(string $id): void 49 | { 50 | $r = file_get_contents(rtrim(KARADAV_URL, '/') . '/session.php?id=' . rawurlencode($id)); 51 | $r = json_decode($r); 52 | 53 | if (!$r || !isset($r->user->login, $r->user->id)) { 54 | return; 55 | } 56 | 57 | $db = DB::getInstance(); 58 | 59 | $user = $db->firstRow('SELECT * FROM users WHERE external_user_id = ?;', $r->user->id); 60 | 61 | if (!$user) { 62 | $db->simple('INSERT INTO users (name, password, external_user_id) VALUES (?, ?, ?);', 63 | trim($r->user->login), 64 | '', 65 | $r->user->id 66 | ); 67 | } 68 | elseif ($user->name !== $r->user->login) { 69 | $db->simple('UPDATE users SET name = ? WHERE external_user_id = ?;', 70 | trim($r->user->login), 71 | $r->user->id 72 | ); 73 | } 74 | 75 | $user ??= $db->firstRow('SELECT * FROM users WHERE external_user_id = ?;', $r->user->id); 76 | 77 | $this->startSession(true, true); 78 | $_SESSION['user'] = $this->user = $user; 79 | } 80 | 81 | public function login(): ?string 82 | { 83 | if (empty($_POST['login']) || empty($_POST['password'])) { 84 | return null; 85 | } 86 | 87 | $db = DB::getInstance(); 88 | $user = $db->firstRow('SELECT * FROM users WHERE name = ? AND external_user_id IS NULL;', trim($_POST['login'])); 89 | 90 | if (!$user || !password_verify(trim($_POST['password']), $user->password ?? '')) { 91 | return 'Invalid username/password'; 92 | } 93 | 94 | $this->startSession(true); 95 | $_SESSION['user'] = $this->user = $user; 96 | 97 | if (!empty($_GET['token'])) { 98 | $_SESSION['app_password'] = sprintf('%s:%s', $_GET['token'], sha1($user->password . $_GET['token'])); 99 | } 100 | 101 | return null; 102 | } 103 | 104 | protected function refreshSession(): void 105 | { 106 | $_SESSION['user'] = $this->user = DB::getInstance()->firstRow('SELECT * FROM users WHERE id = ?;', $this->user->id); 107 | } 108 | 109 | public function isLogged(): bool 110 | { 111 | return !empty($_SESSION['user']); 112 | } 113 | 114 | public function logout(): void 115 | { 116 | @session_destroy(); 117 | } 118 | 119 | public function enableToken(): void 120 | { 121 | $token = substr(sha1(random_bytes(16)), 0, 10); 122 | DB::getInstance()->simple('UPDATE users SET token = ? WHERE id = ?;', $token, $this->user->id); 123 | $this->refreshSession(); 124 | } 125 | 126 | public function disableToken(): void 127 | { 128 | DB::getInstance()->simple('UPDATE users SET token = NULL WHERE id = ?;', $this->user->id); 129 | $this->refreshSession(); 130 | } 131 | 132 | public function getUserToken(): ?string 133 | { 134 | if (null === $this->user->token) { 135 | return null; 136 | } 137 | 138 | return $this->user->name . '__' . $this->user->token; 139 | } 140 | 141 | public function validateToken(string $username): bool 142 | { 143 | $pos = strrpos($username, '__'); 144 | 145 | if ($pos === false) { 146 | return false; 147 | } 148 | 149 | $login = substr($username, 0, $pos); 150 | $token = substr($username, $pos+2); 151 | 152 | $db = DB::getInstance(); 153 | $this->user = $db->firstRow('SELECT * FROM users WHERE name = ? AND token = ?;', $login, $token); 154 | 155 | return $this->user !== null; 156 | } 157 | 158 | public function canSubscribe(): bool 159 | { 160 | if (ENABLE_SUBSCRIPTIONS) { 161 | return true; 162 | } 163 | 164 | $db = DB::getInstance(); 165 | if (!$db->firstColumn('SELECT COUNT(*) FROM users;')) { 166 | return true; 167 | } 168 | 169 | return false; 170 | } 171 | 172 | public function subscribe(string $name, string $password): ?string 173 | { 174 | if (trim($name) === '' || !preg_match('/^\w[\w_-]+$/', $name)) { 175 | return 'Invalid username. Allowed is: \w[\w\d_-]+'; 176 | } 177 | 178 | if ($name === 'current') { 179 | return 'This username is locked, please choose another one.'; 180 | } 181 | 182 | $password = trim($password); 183 | $db = DB::getInstance(); 184 | 185 | if (strlen($password) < 8) { 186 | return 'Password is too short'; 187 | } 188 | 189 | if ($db->firstColumn('SELECT 1 FROM users WHERE name = ? AND external_user_id IS NULL;', $name)) { 190 | return 'Username already exists'; 191 | } 192 | 193 | $db->simple('INSERT INTO users (name, password) VALUES (?, ?);', trim($name), password_hash($password, PASSWORD_DEFAULT)); 194 | return null; 195 | } 196 | 197 | /** 198 | * @throws Exception 199 | */ 200 | public function generateCaptcha(): string 201 | { 202 | $n = ''; 203 | $c = ''; 204 | 205 | for ($i = 0; $i < 4; $i++) { 206 | $j = random_int(0, 9); 207 | $c .= $j; 208 | $n .= sprintf('%d%d', random_int(0, 9), $j); 209 | } 210 | 211 | $n .= sprintf('', sha1($c . __DIR__)); 212 | 213 | return $n; 214 | } 215 | 216 | public function checkCaptcha(string $captcha, string $check): bool 217 | { 218 | $captcha = trim($captcha); 219 | return sha1($captcha . __DIR__) === $check; 220 | } 221 | 222 | public function countActiveSubscriptions(): int 223 | { 224 | $db = DB::getInstance(); 225 | return $db->firstColumn('SELECT COUNT(*) FROM subscriptions WHERE user = ? AND deleted = 0;', $this->user->id); 226 | } 227 | 228 | public function listActiveSubscriptions(): array 229 | { 230 | $db = DB::getInstance(); 231 | return $db->all('SELECT s.*, COUNT(a.rowid) AS count, f.title, COALESCE(MAX(a.changed), s.changed) AS last_change 232 | FROM subscriptions s 233 | LEFT JOIN episodes_actions a ON a.subscription = s.id 234 | LEFT JOIN feeds f ON f.id = s.feed 235 | WHERE s.user = ? AND s.deleted = 0 236 | GROUP BY s.id 237 | ORDER BY last_change DESC;', $this->user->id); 238 | } 239 | 240 | public function listActions(int $subscription): array 241 | { 242 | $db = DB::getInstance(); 243 | return $db->all('SELECT a.*, 244 | d.name AS device_name, 245 | e.title, 246 | e.url AS episode_url 247 | FROM episodes_actions a 248 | LEFT JOIN devices d ON d.id = a.device AND a.user = d.user 249 | LEFT JOIN episodes e ON e.id = a.episode 250 | WHERE a.user = ? AND a.subscription = ? 251 | ORDER BY changed DESC;', $this->user->id, $subscription); 252 | } 253 | 254 | public function updateFeedForSubscription(int $subscription): ?Feed 255 | { 256 | $db = DB::getInstance(); 257 | $url = $db->firstColumn('SELECT url FROM subscriptions WHERE id = ?;', $subscription); 258 | 259 | if (!$url) { 260 | return null; 261 | } 262 | 263 | $feed = new Feed($url); 264 | 265 | if (!$feed->fetch()) { 266 | return null; 267 | } 268 | 269 | $feed->sync(); 270 | 271 | return $feed; 272 | } 273 | 274 | public function getFeedForSubscription(int $subscription): ?Feed 275 | { 276 | $db = DB::getInstance(); 277 | $data = $db->firstRow('SELECT f.* 278 | FROM subscriptions s INNER JOIN feeds f ON f.id = s.feed 279 | WHERE s.id = ?;', $subscription); 280 | 281 | if (!$data) { 282 | return null; 283 | } 284 | 285 | $feed = new Feed($data->feed_url); 286 | $feed->load($data); 287 | return $feed; 288 | } 289 | 290 | public function updateAllFeeds(bool $cli = false): void 291 | { 292 | $sql = 'SELECT s.id AS subscription, s.url, MAX(a.changed) AS changed 293 | FROM subscriptions s 294 | LEFT JOIN episodes_actions a ON a.subscription = s.id 295 | LEFT JOIN feeds f ON f.id = s.feed 296 | WHERE f.last_fetch IS NULL OR f.last_fetch < s.changed OR f.last_fetch < a.changed 297 | GROUP BY s.id'; 298 | 299 | @ini_set('max_execution_time', 3600); 300 | @ob_end_flush(); 301 | @ob_implicit_flush(true); 302 | $i = 0; 303 | 304 | $db = DB::getInstance(); 305 | 306 | foreach ($db->iterate($sql) as $row) { 307 | @set_time_limit(30); // Extend running time; 308 | 309 | if ($cli) { 310 | printf("Updating %s\n", $row->url); 311 | } 312 | else { 313 | printf("

Updating %s

", $row->url); 314 | echo str_pad(' ', 4096); 315 | flush(); 316 | } 317 | 318 | $this->updateFeedForSubscription($row->subscription); 319 | $i++; 320 | } 321 | 322 | if (!$i) { 323 | echo "Nothing to update\n"; 324 | } 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /server/lib/OPodSync/API.php: -------------------------------------------------------------------------------- 1 | base_path = parse_url($url, PHP_URL_PATH) ?? ''; 48 | $this->base_url = $url; 49 | } 50 | 51 | public function url(string $path = ''): string 52 | { 53 | return $this->base_url . $path; 54 | } 55 | 56 | public function debug(string $message, ...$params): void 57 | { 58 | if (!DEBUG_LOG) { 59 | return; 60 | } 61 | 62 | $this->log .= date('Y-m-d H:i:s ') . vsprintf($message, $params) . PHP_EOL; 63 | } 64 | 65 | public function __destruct() 66 | { 67 | if (!DEBUG_LOG || $this->log === '') { 68 | return; 69 | } 70 | 71 | file_put_contents(DEBUG_LOG, $this->log, FILE_APPEND); 72 | } 73 | 74 | public function queryWithData(string $sql, ...$params): array 75 | { 76 | $db = DB::getInstance(); 77 | $result = $db->iterate($sql, ...$params); 78 | $out = []; 79 | 80 | foreach ($result as $row) { 81 | $row = array_merge(json_decode($row->data, true, 512, JSON_THROW_ON_ERROR), (array) $row); 82 | unset($row['data']); 83 | $out[] = $row; 84 | } 85 | 86 | return $out; 87 | } 88 | 89 | public function error(APIException $e): void 90 | { 91 | $code = $e->getCode(); 92 | $message = $e->getMessage(); 93 | $this->debug('RETURN: %d - %s', $code, $message); 94 | 95 | http_response_code($code); 96 | header('Content-Type: application/json', true); 97 | 98 | if ($code === 401) { 99 | header('WWW-Authenticate: Basic realm="Please login"'); 100 | } 101 | 102 | try { 103 | echo json_encode(compact('code', 'message'), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 104 | } 105 | catch (\JsonException $e) { 106 | echo json_encode($e->getMessage()); 107 | } 108 | } 109 | 110 | public function requireMethod(string $method): void 111 | { 112 | if ($method !== $this->method) { 113 | throw new APIException('Invalid HTTP method: ' . $this->method, 405); 114 | } 115 | } 116 | 117 | public function validateURL(string $url): void 118 | { 119 | if (!preg_match('!^https?://[^/]+!', $url)) { 120 | throw new APIException('Invalid URL: ' . $url, 400); 121 | } 122 | } 123 | 124 | public function getInput() 125 | { 126 | if ($this->format === 'txt') { 127 | return array_filter(file('php://input'), 'trim'); 128 | } 129 | elseif ($this->format === 'opml' 130 | || $this->format === 'xml' 131 | || $this->format === 'jsonp') { 132 | throw new APIException('Only JSON format is supported for input', 501); 133 | } 134 | 135 | $input = file_get_contents('php://input'); 136 | 137 | try { 138 | return json_decode($input, false, 512, JSON_THROW_ON_ERROR); 139 | } 140 | catch (\JsonException $e) { 141 | throw new APIException('Malformed JSON: ' . $e->getMessage(), 400); 142 | } 143 | } 144 | 145 | /** 146 | * @see https://gpoddernet.readthedocs.io/en/latest/api/reference/auth.html 147 | */ 148 | public function handleAuth(): null 149 | { 150 | $this->requireMethod('POST'); 151 | 152 | strtok($this->path, '/'); 153 | $action = strtok(''); 154 | 155 | if ($action === 'logout') { 156 | $_SESSION = []; 157 | @session_destroy(); 158 | throw new APIException('Logged out', 200); 159 | } 160 | elseif ($action !== 'login') { 161 | throw new APIException('Unknown login action: ' . $action, 404); 162 | } 163 | 164 | if (empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW'])) { 165 | throw new APIException('No username or password provided', 401); 166 | } 167 | 168 | $this->requireAuth(); 169 | 170 | throw new APIException('Logged in!', 200); 171 | } 172 | 173 | public function login(): ?stdClass 174 | { 175 | $login = $_SERVER['PHP_AUTH_USER']; 176 | list($login) = explode('__', $login, 2); 177 | 178 | $db = DB::getInstance(); 179 | $user = $db->firstRow('SELECT id, password FROM users WHERE name = ?;', $login); 180 | 181 | if(!$user) { 182 | throw new APIException('Invalid username', 401); 183 | } 184 | 185 | if (!password_verify($_SERVER['PHP_AUTH_PW'], $user->password ?? '')) { 186 | throw new APIException('Invalid username/password', 401); 187 | } 188 | 189 | $this->debug('Logged user: %s', $login); 190 | 191 | @session_start(); 192 | $_SESSION['user'] = $user; 193 | $this->user = $user; 194 | return $user; 195 | } 196 | 197 | public function requireAuth(?string $username = null): ?stdClass 198 | { 199 | if (isset($this->user)) { 200 | return null; 201 | } 202 | 203 | // For gPodder desktop 204 | if ($username && false !== strpos($username, '__')) { 205 | $gpodder = new GPodder; 206 | if (!$gpodder->validateToken($username)) { 207 | throw new APIException('Invalid gpodder token', 401); 208 | } 209 | 210 | $this->user = $gpodder->user; 211 | return null; 212 | } 213 | 214 | if (isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) { 215 | return $this->login(); 216 | } 217 | 218 | if (empty($_COOKIE['sessionid'])) { 219 | throw new APIException('session cookie is required', 401); 220 | } 221 | 222 | @session_start(); 223 | 224 | if (empty($_SESSION['user'])) { 225 | throw new APIException('Expired sessionid cookie, and no Authorization header was provided', 401); 226 | } 227 | 228 | $db = DB::getInstance(); 229 | 230 | if (!$db->firstColumn('SELECT 1 FROM users WHERE id = ?;', $_SESSION['user']->id)) { 231 | throw new APIException('User does not exist', 401); 232 | } 233 | 234 | $this->user = $_SESSION['user']; 235 | $this->debug('Cookie user ID: %s', $this->user->id); 236 | return $this->user; 237 | } 238 | 239 | public function route(string $url): ?array 240 | { 241 | switch ($this->section) { 242 | // Not implemented 243 | case 'tag': 244 | case 'tags': 245 | case 'data': 246 | case 'toplist': 247 | case 'suggestions': 248 | case 'favorites': 249 | return []; 250 | case 'devices': 251 | return $this->devices(); 252 | case 'updates': 253 | return $this->updates(); 254 | case 'subscriptions': 255 | return $this->subscriptions($url); 256 | case 'episodes': 257 | return $this->episodes(); 258 | case 'settings': 259 | case 'lists': 260 | case 'sync-device': 261 | throw new APIException('Not implemented', 503); 262 | default: 263 | return null; 264 | } 265 | } 266 | 267 | /** 268 | * Map NextCloud endpoints to GPodder 269 | * @see https://github.com/thrillfall/nextcloud-gpodder 270 | * @throws APIException 271 | * @return bool TRUE if the routing should be stopped 272 | */ 273 | public function routeNextCloud(string &$url): bool 274 | { 275 | $nextcloud_path = 'index.php/apps/gpoddersync/'; 276 | 277 | if ($url === 'index.php/login/v2') { 278 | $this->requireMethod('POST'); 279 | 280 | $id = sha1(random_bytes(16)); 281 | 282 | $r = [ 283 | 'poll' => [ 284 | 'token' => $id, 285 | 'endpoint' => $this->url('index.php/login/v2/poll'), 286 | ], 287 | 'login' => $this->url('login.php?token=' . $id), 288 | ]; 289 | } 290 | elseif ($url === 'index.php/login/v2/poll') { 291 | $this->requireMethod('POST'); 292 | 293 | if (empty($_POST['token']) || !ctype_alnum($_POST['token'])) { 294 | throw new APIException('Invalid token', 400); 295 | } 296 | 297 | session_id($_POST['token']); 298 | session_start(); 299 | 300 | if (empty($_SESSION['user']) || empty($_SESSION['app_password'])) { 301 | throw new APIException('Not logged in yet, using token: ' . $_POST['token'], 404); 302 | } 303 | 304 | $r = [ 305 | 'server' => $this->url(), 306 | 'loginName' => $_SESSION['user']->name, 307 | 'appPassword' => $_SESSION['app_password'], // FIXME provide a real app-password here 308 | ]; 309 | } 310 | // This is not a nextcloud route 311 | elseif (0 !== strpos($url, $nextcloud_path)) { 312 | return false; 313 | } 314 | 315 | if (null !== $r) { 316 | echo json_encode($return, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 317 | return true; 318 | } 319 | 320 | if (empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW'])) { 321 | throw new APIException('No username or password provided', 401); 322 | } 323 | 324 | $this->debug('Nextcloud compatibility: %s / %s', $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']); 325 | 326 | $db = DB::getInstance(); 327 | $user = $db->firstRow('SELECT id, password FROM users WHERE name = ?;', $_SERVER['PHP_AUTH_USER']); 328 | 329 | if (!$user) { 330 | throw new APIException('Invalid username', 401); 331 | } 332 | 333 | // FIXME store a real app password instead of this hack 334 | $token = strtok($_SERVER['PHP_AUTH_PW'], ':'); 335 | $password = strtok(''); 336 | $app_password = sha1($user->password . $token); 337 | 338 | if ($app_password !== $password) { 339 | throw new APIException('Invalid username/password', 401); 340 | } 341 | 342 | $this->user = $_SESSION['user'] = $user; 343 | 344 | $path = substr($url, strlen($nextcloud_path)); 345 | 346 | // Modify the URL to match regular Gpodder endpoints 347 | if ($path === 'subscriptions') { 348 | $url = 'api/2/subscriptions/current/default.json'; 349 | } 350 | elseif ($path === 'subscription_change/create') { 351 | $url = 'api/2/subscriptions/current/default.json'; 352 | } 353 | elseif ($path === 'episode_action' || $path === 'episode_action/create') { 354 | $url = 'api/2/episodes/current.json'; 355 | } 356 | else { 357 | throw new APIException('Undefined Nextcloud API endpoint', 404); 358 | } 359 | 360 | return false; 361 | } 362 | 363 | public function getRequestURI(): string 364 | { 365 | $url = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH); 366 | $url = '/' . trim($url, '/'); 367 | $url = substr($url, strlen($this->base_path)); 368 | return $url; 369 | } 370 | 371 | public function handleRequest(string $url): bool 372 | { 373 | $this->method = $_SERVER['REQUEST_METHOD'] ?? null; 374 | 375 | $this->debug('Got a %s request on %s', $this->method, $url); 376 | 377 | $stop = $this->routeNextCloud($url); 378 | 379 | if ($stop) { 380 | return true; 381 | } 382 | 383 | if (!preg_match('!^(?:api|subscriptions|suggestions|toplist)/?!', $url)) { 384 | return false; 385 | } 386 | 387 | if (!preg_match('!^(suggestions|subscriptions|toplist|api/2/(auth|subscriptions|devices|updates|episodes|favorites|settings|lists|sync-devices|tags?|data)?)/!', $url, $match)) { 388 | throw new APIException('Unknown or malformed API request', 404); 389 | } 390 | 391 | $this->section = $match[2] ?? $match[1]; 392 | $this->path = substr($url, strlen($match[0])); 393 | $username = null; 394 | 395 | if (preg_match('/\.(json|opml|txt|jsonp|xml)$/', $url, $match)) { 396 | $this->format = $match[1]; 397 | $this->path = substr($this->path, 0, -strlen($match[0])); 398 | } 399 | 400 | if (!in_array($this->format, ['json', 'opml', 'txt'])) { 401 | throw new APIException('output format is not implemented', 501); 402 | } 403 | 404 | // For gPodder 405 | if (preg_match('!(\w+__\w{10})!i', $this->path, $match)) { 406 | $username = $match[1]; 407 | } 408 | 409 | if ($this->section === 'auth') { 410 | $this->handleAuth(); 411 | } 412 | 413 | $this->requireAuth($username); 414 | 415 | $return = $this->route($url); 416 | 417 | $this->debug("RETURN:\n%s", json_encode($return, JSON_PRETTY_PRINT)); 418 | 419 | if ($this->format === 'opml') { 420 | if ($this->section !== 'subscriptions') { 421 | throw new APIException('output format is not implemented', 501); 422 | } 423 | 424 | header('Content-Type: text/x-opml; charset=utf-8'); 425 | echo $this->opml($return); 426 | } 427 | else { 428 | header('Content-Type: application/json'); 429 | 430 | if ($return !== null) { 431 | echo json_encode($return, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 432 | } 433 | } 434 | 435 | return true; 436 | } 437 | 438 | public function devices(): array 439 | { 440 | if ($this->method === 'GET') { 441 | return $this->queryWithData('SELECT deviceid as id, user, deviceid, name, data FROM devices WHERE user = ?;', $this->user->id); 442 | } 443 | 444 | if ($this->method === 'POST') { 445 | $deviceid = explode('/', $this->path)[1] ?? null; 446 | 447 | if (!$deviceid || !preg_match('/^[\w.-]+$/', $deviceid)) { 448 | throw new APIException('Invalid device ID', 400); 449 | } 450 | 451 | $json = $this->getInput(); 452 | $json ??= []; 453 | $json->subscriptions = 0; 454 | 455 | $params = [ 456 | 'deviceid' => $deviceid, 457 | 'data' => json_encode($json), 458 | 'name' => $json->caption ?? null, 459 | 'user' => $this->user->id, 460 | ]; 461 | 462 | $db = DB::getInstance(); 463 | $db->upsert('devices', $params, ['deviceid', 'user']); 464 | throw new APIException('Device updated', 200); 465 | } 466 | 467 | throw new APIException('Wrong request method', 400); 468 | } 469 | 470 | public function subscriptions(string $url): ?array 471 | { 472 | $db = DB::getInstance(); 473 | $v2 = strpos($url, 'api/2/') !== false; 474 | 475 | // We don't care about deviceid yet (FIXME) 476 | $deviceid = explode('/', $this->path)[1] ?? null; 477 | 478 | if ($this->method === 'GET' && !$v2) { 479 | return $db->all('SELECT s.url AS feed, f.title, f.url AS website, f.description 480 | FROM subscriptions s 481 | LEFT JOIN feeds f ON f.id = s.feed 482 | WHERE s.user = ?;', $this->user->id); 483 | } 484 | 485 | if (!$deviceid || !preg_match('/^[\w.-]+$/', $deviceid)) { 486 | throw new APIException('Invalid device ID', 400); 487 | } 488 | 489 | // Get Subscription Changes 490 | if ($v2 && $this->method === 'GET') { 491 | $timestamp = (int)($_GET['since'] ?? 0); 492 | 493 | return [ 494 | 'add' => $db->rowsFirstColumn('SELECT url FROM subscriptions WHERE user = ? AND deleted = 0 AND changed >= ?;', $this->user->id, $timestamp), 495 | 'remove' => $db->rowsFirstColumn('SELECT url FROM subscriptions WHERE user = ? AND deleted = 1 AND changed >= ?;', $this->user->id, $timestamp), 496 | 'update_urls' => [], 497 | 'timestamp' => time(), 498 | ]; 499 | } 500 | elseif ($this->method === 'PUT') { 501 | $lines = $this->getInput(); 502 | 503 | if (!is_array($lines)) { 504 | throw new APIException('Invalid input: requires an array with one line per feed', 400); 505 | } 506 | 507 | $db->exec('BEGIN;'); 508 | $st = $db->prepare('INSERT OR IGNORE INTO subscriptions (user, url, changed) VALUES (:user, :url, strftime(\'%s\', \'now\'));'); 509 | 510 | foreach ($lines as $url) { 511 | $url = is_object($url) ? $url->feed : $url; 512 | $this->validateURL($url); 513 | 514 | $st->bindValue(':url', $url); 515 | $st->bindValue(':user', $this->user->id); 516 | $st->execute(); 517 | $st->reset(); 518 | $st->clear(); 519 | } 520 | 521 | $db->exec('END;'); 522 | return null; 523 | } 524 | elseif ($this->method === 'POST') { 525 | $input = $this->getInput(); 526 | 527 | $db->exec('BEGIN;'); 528 | 529 | $ts = time(); 530 | 531 | if (!empty($input->add) && is_array($input->add)) { 532 | foreach ($input->add as $url) { 533 | $this->validateURL($url); 534 | 535 | $db->upsert('subscriptions', [ 536 | 'user' => $this->user->id, 537 | 'url' => $url, 538 | 'changed' => $ts, 539 | 'deleted' => 0, 540 | ], ['user', 'url']); 541 | } 542 | } 543 | 544 | if (!empty($input->remove) && is_array($input->remove)) { 545 | foreach ($input->remove as $url) { 546 | $this->validateURL($url); 547 | 548 | $db->upsert('subscriptions', [ 549 | 'user' => $this->user->id, 550 | 'url' => $url, 551 | 'changed' => $ts, 552 | 'deleted' => 1, 553 | ], ['user', 'url']); 554 | } 555 | } 556 | 557 | $db->exec('END;'); 558 | return ['timestamp' => $ts, 'update_urls' => []]; 559 | } 560 | 561 | throw new APIException('Not implemented yet', 501); 562 | } 563 | 564 | public function updates(): mixed 565 | { 566 | throw new APIException('Not implemented yet', 501); 567 | } 568 | 569 | public function episodes(): array 570 | { 571 | if ($this->method === 'GET') { 572 | $since = isset($_GET['since']) ? (int)$_GET['since'] : 0; 573 | 574 | return [ 575 | 'timestamp' => time(), 576 | 'actions' => $this->queryWithData('SELECT e.url AS episode, e.action, e.data, s.url AS podcast, 577 | strftime(\'%Y-%m-%dT%H:%M:%SZ\', e.changed, \'unixepoch\') AS timestamp 578 | FROM episodes_actions e 579 | INNER JOIN subscriptions s ON s.id = e.subscription 580 | WHERE e.user = ? AND e.changed >= ?;', $this->user->id, $since) 581 | ]; 582 | } 583 | 584 | $this->requireMethod('POST'); 585 | 586 | $input = $this->getInput(); 587 | 588 | if (!is_array($input)) { 589 | throw new APIException('No valid array found', 400); 590 | } 591 | 592 | $db = DB::getInstance(); 593 | $db->exec('BEGIN;'); 594 | 595 | $timestamp = time(); 596 | $st = $db->prepare('INSERT INTO episodes_actions (user, subscription, url, changed, action, data) VALUES (:user, :subscription, :url, :changed, :action, :data);'); 597 | 598 | foreach ($input as $action) { 599 | if (!isset($action->podcast, $action->action, $action->episode)) { 600 | throw new APIException('Missing required key in action', 400); 601 | } 602 | 603 | $this->validateURL($action->podcast); 604 | $this->validateURL($action->episode); 605 | 606 | $id = $db->firstColumn('SELECT id FROM subscriptions WHERE url = ? AND user = ?;', $action->podcast, $this->user->id); 607 | 608 | if (!$id) { 609 | $db->simple('INSERT OR IGNORE INTO subscriptions (user, url, changed) VALUES (?, ?, ?);', $this->user->id, $action->podcast, $timestamp); 610 | $id = $db->lastInsertRowID(); 611 | } 612 | 613 | if (!empty($action->timestamp)) { 614 | $changed = new \DateTime($action->timestamp, new \DateTimeZone('UTC')); 615 | $changed = $changed->getTimestamp(); 616 | } 617 | else { 618 | $changed = null; 619 | } 620 | 621 | $st->bindValue(':user', $this->user->id); 622 | $st->bindValue(':subscription', $id); 623 | $st->bindValue(':url', $action->episode); 624 | $st->bindValue(':changed', $changed ?? $timestamp); 625 | $st->bindValue(':action', strtolower($action->action)); 626 | unset($action->action, $action->episode, $action->podcast); 627 | $st->bindValue(':data', json_encode($action, JSON_THROW_ON_ERROR)); 628 | $st->execute(); 629 | $st->reset(); 630 | $st->clear(); 631 | } 632 | 633 | $db->exec('END;'); 634 | 635 | return compact('timestamp') + ['update_urls' => []]; 636 | } 637 | 638 | public function opml(array $data): string 639 | { 640 | $out = ''; 641 | $out .= PHP_EOL . 'My Feeds'; 642 | 643 | foreach ($data as $row) { 644 | $url = $row->website ?? $row->feed; 645 | $out .= PHP_EOL . sprintf('', 646 | htmlspecialchars($row->feed, ENT_XML1 | ENT_QUOTES), 647 | htmlspecialchars($row->title ?? $url, ENT_XML1 | ENT_QUOTES), 648 | htmlspecialchars($url, ENT_XML1 | ENT_QUOTES), 649 | htmlspecialchars($row->description ?? '', ENT_XML1 | ENT_QUOTES), 650 | ); 651 | } 652 | 653 | $out .= PHP_EOL . ''; 654 | return $out; 655 | } 656 | } 657 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /server/lib/KD2/ErrorManager.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | Copyright (c) 2001-2019 BohwaZ 6 | All rights reserved. 7 | 8 | KD2FW is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU Affero General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | Foobar is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU Affero General Public License for more details. 17 | 18 | You should have received a copy of the GNU Affero General Public License 19 | along with Foobar. If not, see . 20 | */ 21 | 22 | namespace KD2; 23 | 24 | /** 25 | * Simple error and exception handler 26 | * 27 | * When enabled (with ErrorManager::enable(ErrorManager::DEVELOPMENT)) it will 28 | * catch any error, warning or exception and display it along with useful debug 29 | * information. If enabled it will also log the errors to a file and/or send 30 | * every error by email. 31 | * 32 | * In production mode no details are given, but a unique reference to the log 33 | * or email is displayed. 34 | * 35 | * This is similar in a way to http://tracy.nette.org/ 36 | * 37 | * @author bohwaz 38 | */ 39 | class ErrorManager 40 | { 41 | /** 42 | * Prod/dev modes 43 | */ 44 | const PRODUCTION = 1; 45 | const DEVELOPMENT = 2; 46 | const CLI_DEVELOPMENT = 4; 47 | 48 | /** 49 | * Term colors 50 | */ 51 | const RED = '[1;41m'; 52 | const RED_FAINT = '[1m'; 53 | const YELLOW = '[33m'; 54 | 55 | /** 56 | * true = catch exceptions, false = do nothing 57 | * @var null 58 | */ 59 | static protected $enabled = null; 60 | 61 | /** 62 | * HTML template used for displaying production errors 63 | * @var string 64 | */ 65 | static protected $production_error_template = 'Internal server error 66 |

Server error

Sorry but the server encountered an internal error and was unable 74 | to complete your request. Please try again later.

75 |

The webmaster has been noticed and this will be fixed ASAP.

76 | Error reference: {$ref} 77 |
78 |

← Go back to the homepage

79 | '; 80 | 81 | /** 82 | * E-Mail address where to send errors 83 | * @var boolean 84 | */ 85 | static protected $email_errors = false; 86 | 87 | /** 88 | * Reporting URL 89 | */ 90 | static protected $report_url = null; 91 | 92 | /** 93 | * Reporting automatically? 94 | */ 95 | static protected $report_auto = true; 96 | 97 | /** 98 | * Custom context 99 | */ 100 | static protected $context = []; 101 | 102 | /** 103 | * Custom exception handlers 104 | * @var array 105 | */ 106 | static protected $custom_handlers = []; 107 | 108 | /** 109 | * Does the terminal support ANSI colors 110 | * @var boolean 111 | */ 112 | static protected $term_color = false; 113 | 114 | /** 115 | * Will be incremented when catching an exception to avoid double catching 116 | * with the shutdown function 117 | */ 118 | static protected int $catching = 0; 119 | 120 | /** 121 | * Used to store timers and memory consumption 122 | * @var array 123 | */ 124 | static protected $run_trace = []; 125 | 126 | /** 127 | * Handles PHP shutdown on fatal error to be able to catch the error 128 | * @return void 129 | */ 130 | static public function shutdownHandler() 131 | { 132 | // Stop here if disabled or if the script ended with an exception 133 | if (!self::$enabled || self::$catching) { 134 | return; 135 | } 136 | 137 | $error = error_get_last(); 138 | 139 | if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE, E_RECOVERABLE_ERROR, E_USER_ERROR], TRUE)) 140 | { 141 | // Don't exit at the end, as there might be other shutdown handlers 142 | // after this one 143 | self::exceptionHandler(new \ErrorException($error['message'], 0, $error['type'], $error['file'], $error['line']), false); 144 | } 145 | } 146 | 147 | /** 148 | * Internal error handler to throw them as exceptions 149 | * (private use) 150 | */ 151 | static public function errorHandler($severity, $message, $file, $line) 152 | { 153 | if (!(error_reporting() & $severity)) { 154 | // Don't report this error (for example @unlink) 155 | return; 156 | } 157 | 158 | $types = [ 159 | E_ERROR => 'Fatal error', 160 | E_USER_ERROR => 'User error', 161 | E_RECOVERABLE_ERROR => 'Recoverable error', 162 | E_CORE_ERROR => 'Core error', 163 | E_COMPILE_ERROR => 'Compile error', 164 | E_PARSE => 'Parse error', 165 | E_WARNING => 'Warning', 166 | E_CORE_WARNING => 'Core warning', 167 | E_COMPILE_WARNING => 'Compile warning', 168 | E_USER_WARNING => 'User warning', 169 | E_NOTICE => 'Notice', 170 | E_USER_NOTICE => 'User notice', 171 | E_DEPRECATED => 'Deprecated', 172 | E_USER_DEPRECATED => 'User deprecated', 173 | ]; 174 | 175 | $type = array_key_exists($severity, $types) ? $types[$severity] : 'Unknown error'; 176 | $message = $type . ': ' . $message; 177 | 178 | // Catch ASSERT_BAIL errors differently because throwing an exception 179 | // in this case results in an execution shutdown, and shutdown handler 180 | // isn't even called. See https://bugs.php.net/bug.php?id=53619 181 | // TODO: remove when minimum supported version is 8.0+ 182 | if (PHP_VERSION_ID < 80000 && assert_options(ASSERT_ACTIVE) && assert_options(ASSERT_BAIL) && substr($message, 0, 18) == 'Warning: assert():') 183 | { 184 | $message .= ' (ASSERT_BAIL detected)'; 185 | self::exceptionHandler(new \ErrorException($message, 0, $severity, $file, $line)); 186 | return; 187 | } 188 | 189 | throw new \ErrorException($message, 0, $severity, $file, $line); 190 | return; 191 | } 192 | 193 | /** 194 | * Main exception handler 195 | */ 196 | static public function exceptionHandler(\Throwable $e, bool $exit = true): void 197 | { 198 | try { 199 | self::reportException($e, $exit); 200 | } 201 | catch (\Throwable|\Exception $e2) { 202 | echo $e2; 203 | echo PHP_EOL . PHP_EOL . $e; 204 | exit(1); 205 | } 206 | } 207 | 208 | /** 209 | * Main exception handler 210 | */ 211 | static public function reportException(\Throwable $e, bool $exit = true): void 212 | { 213 | self::$catching++; 214 | 215 | if (self::$catching === 1) { 216 | foreach (self::$custom_handlers as $class => $callback) { 217 | if ($e instanceOf $class) { 218 | call_user_func($callback, $e); 219 | $e = false; 220 | break; 221 | } 222 | } 223 | } 224 | 225 | extract(self::buildExceptionReport($e, false)); 226 | unset($e); 227 | 228 | // Log exception to file 229 | if (ini_get('log_errors')) { 230 | error_log($log); 231 | } 232 | 233 | // Disable any output if it was buffering 234 | if (ob_get_level()) { 235 | ob_end_clean(); 236 | } 237 | 238 | $is_curl = 0 === strpos($_SERVER['HTTP_USER_AGENT'] ?? '', 'curl/'); 239 | $is_cli = PHP_SAPI == 'cli'; 240 | 241 | if (!$is_cli) { 242 | @http_response_code(500); 243 | } 244 | 245 | if ($is_curl && !headers_sent()) { 246 | header('Content-Type: text/plain; charset=utf-8', true); 247 | } 248 | 249 | $text_mode_dev = ($is_curl && self::$enabled & self::DEVELOPMENT) 250 | || ($is_cli && self::$enabled & self::DEVELOPMENT) 251 | || ($is_cli && self::$enabled & self::CLI_DEVELOPMENT); 252 | 253 | if ($text_mode_dev) 254 | { 255 | foreach ($report->errors as $e) 256 | { 257 | self::termPrint(sprintf(' /!\\ %s ', $e->type), self::RED); 258 | self::termPrint($e->message, self::RED_FAINT); 259 | 260 | if (isset($e->line)) 261 | { 262 | self::termPrint(sprintf('Line %d in %s', $e->line, $e->file), self:: 263 | YELLOW); 264 | } 265 | 266 | // Ignore the error stack belonging to ErrorManager 267 | foreach ($e->backtrace as $i=>$t) 268 | { 269 | $file = !empty($t->file) ? $t->file : '[internal function]'; 270 | $line = !empty($t->line) ? '(' . $t->line . ')' : ''; 271 | 272 | if (isset($t->args)) 273 | { 274 | $args = $t->args; 275 | 276 | foreach ($args as &$arg) 277 | { 278 | if (strlen($arg) > 20) 279 | { 280 | $arg = substr($arg, 0, 19) . '…'; 281 | } 282 | } 283 | 284 | unset($arg); 285 | 286 | self::termPrint(sprintf('#%d %s%s: %s(%s)', $i, $file, $line, $t->function, implode(', ', $args))); 287 | } 288 | else 289 | { 290 | self::termPrint(sprintf('#%d %s%s', $i, $file, $line)); 291 | } 292 | } 293 | } 294 | } 295 | else if (($is_cli || $is_curl) && self::$enabled & self::PRODUCTION) { 296 | self::termPrint(' /!\\ An internal server error occurred ', self::RED); 297 | self::termPrint(' Error reference was: ' . $report->context->id, self::YELLOW); 298 | } 299 | else if (self::$enabled & self::PRODUCTION) 300 | { 301 | @header_remove('Content-Disposition'); 302 | @header('Content-Type: text/html; charset=utf-8', true); 303 | self::htmlProduction($report); 304 | } 305 | else 306 | { 307 | if (!headers_sent()) { 308 | header_remove(); 309 | header('Content-Type: text/html; charset=UTF-8', true); 310 | header('HTTP/1.1 500 Internal Server Error', true); 311 | } 312 | 313 | echo $html_report; 314 | } 315 | 316 | // Log exception to email 317 | if (self::$email_errors) { 318 | self::sendEmail($title, $report, $log, $html_report); 319 | } 320 | 321 | // Send report to URL 322 | if (self::$report_auto && self::$report_url) { 323 | self::sendReport($report, self::$report_url); 324 | } 325 | 326 | if ($exit) 327 | { 328 | exit(1); 329 | } 330 | } 331 | 332 | static public function reportExceptionSilent(\Throwable $e): string 333 | { 334 | $report = self::logException($e); 335 | extract($report); 336 | 337 | if (self::$email_errors) { 338 | self::sendEmail($title, $report, $log, $html_report); 339 | } 340 | 341 | return $report->context->id; 342 | } 343 | 344 | static public function logException(\Throwable $e): array 345 | { 346 | $report = self::buildExceptionReport($e); 347 | 348 | // Log exception to file 349 | if (ini_get('log_errors')) { 350 | error_log($report['log']); 351 | } 352 | 353 | // Send report to URL 354 | if (self::$report_auto && self::$report_url) { 355 | self::sendReport($report['report'], self::$report_url); 356 | } 357 | 358 | return $report; 359 | } 360 | 361 | static protected function sendEmail(string $title, \stdClass $report, string $log, string $html): void 362 | { 363 | // From: sender 364 | $from = !empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : basename($report->context->root_directory ?? __FILE__); 365 | $msgid = $report->context->id . '@' . $from; 366 | 367 | $boundary = sprintf('-----=%s', md5(uniqid(rand()))); 368 | 369 | $header = sprintf("MIME-Version: 1.0\r\nFrom: \"%s\" <%s>\r\nIn-Reply-To: <%s>\r\nMessage-Id: <%s>\r\n", $from, self::$email_errors, $msgid, $msgid); 370 | $header.= sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", $boundary); 371 | $header.= "\r\n"; 372 | 373 | $msg = "This message contains multiple MIME parts.\r\n\r\n"; 374 | $msg.= sprintf("--%s\r\n", $boundary); 375 | $msg.= "Content-Type: text/plain; charset=\"utf-8\"\r\n"; 376 | $msg.= "Content-Transfer-Encoding: 8bit\r\n\r\n"; 377 | $msg.= wordwrap($log, 990) . "\r\n\r\n"; 378 | $msg.= sprintf("--%s\r\n", $boundary); 379 | $msg.= "Content-Type: text/html; charset=\"utf-8\"\r\n"; 380 | $msg.= "Content-Transfer-Encoding: 8bit\r\n\r\n"; 381 | $msg.= wordwrap($html, 990) . "\r\n\r\n"; 382 | $msg.= sprintf("--%s--", $boundary); 383 | 384 | $msg = str_replace("\0", "", $msg); 385 | $header = str_replace("\0", "", $header); 386 | 387 | mail(self::$email_errors, sprintf('Error #%s: %s', $report->context->id, $title), $msg, $header); 388 | } 389 | 390 | /** 391 | * Prints a line to STDERR, eventually using a color 392 | */ 393 | static public function termPrint($message, $color = null) 394 | { 395 | if (!defined('\STDERR')) { 396 | echo $message . PHP_EOL; 397 | return; 398 | } 399 | 400 | if ($color && self::$term_color) 401 | { 402 | $message = chr(27) . $color . $message . chr(27) . "[0m"; 403 | } 404 | 405 | fwrite(\STDERR, $message . PHP_EOL); 406 | } 407 | 408 | /** 409 | * Return file location without the document root 410 | */ 411 | static protected function getFileLocation($file) 412 | { 413 | if (!empty(self::$context['root_directory']) && strpos($file, self::$context['root_directory']) === 0) 414 | { 415 | return '...' . substr($file, strlen(self::$context['root_directory'])); 416 | } 417 | 418 | return $file; 419 | } 420 | 421 | static public function buildExceptionReport(\Throwable $e, bool $force_html = false): array 422 | { 423 | $report = self::makeReport($e); 424 | $log = sprintf('=========== Error ref. %s ===========', $report->context->id) 425 | . PHP_EOL . PHP_EOL . (string) $e . PHP_EOL . PHP_EOL 426 | . '' . PHP_EOL . json_encode($report, \JSON_PRETTY_PRINT) 427 | . PHP_EOL . '' . PHP_EOL; 428 | 429 | $html_report = null; 430 | 431 | if ($force_html || self::$enabled & self::DEVELOPMENT || self::$email_errors) { 432 | $html_report = self::htmlReport($report); 433 | } 434 | 435 | $title = $e->getMessage(); 436 | 437 | return compact('report', 'log', 'html_report', 'title'); 438 | } 439 | 440 | /** 441 | * Generates a report from an exception 442 | */ 443 | static public function makeReport($e): \stdClass 444 | { 445 | $report = (object) [ 446 | 'errors' => [], 447 | ]; 448 | 449 | while ($e !== null) 450 | { 451 | $class = get_class($e); 452 | 453 | $error = (object) [ 454 | 'message' => $e->getMessage(), 455 | 'errorCode' => $e->getCode(), 456 | 'type' => in_array($class, ['ErrorException', 'Error']) ? 'PHP error' : $class, 457 | 'backtrace' => [ 458 | (object) [ 459 | 'file' => $e->getFile() ? self::getFileLocation($e->getFile()) : null, 460 | 'line' => $e->getLine(), 461 | 'code' => $e->getFile() && $e->getLine() ? self::getSource($e->getFile(), $e->getLine()) : null, 462 | ], 463 | ], 464 | ]; 465 | 466 | foreach ($e->getTrace() as $t) 467 | { 468 | // Ignore the error stack from ErrorManager 469 | if (isset($t['class']) && $t['class'] === __CLASS__ 470 | && ($t['function'] === 'shutdownHandler' || $t['function'] === 'errorHandler')) 471 | { 472 | continue; 473 | } 474 | 475 | $args = []; 476 | 477 | // Display call arguments 478 | if (!empty($t['args'])) 479 | { 480 | // Find arguments variables names via reflection 481 | try { 482 | if (isset($t['class'])) 483 | { 484 | $r = new \ReflectionMethod($t['class'], $t['function']); 485 | } 486 | else 487 | { 488 | $r = new \ReflectionFunction($t['function']); 489 | } 490 | 491 | $params = $r->getParameters(); 492 | } 493 | catch (\Exception $_ignore) { 494 | $params = []; 495 | } 496 | 497 | foreach ($t['args'] as $name => $value) 498 | { 499 | if (array_key_exists($name, $params)) 500 | { 501 | $name = '$' . $params[$name]->name; 502 | } 503 | 504 | if (is_string($value)) 505 | { 506 | $value = self::getFileLocation($value); 507 | } 508 | 509 | $args[$name] = self::dump($value); 510 | 511 | if (strlen($args[$name]) > 2000) 512 | { 513 | $args[$name] = substr($args[$name], 0, 1999) . '…'; 514 | } 515 | } 516 | } 517 | 518 | $trace = (object) [ 519 | // Add class name to function 520 | 'function' => isset($t['class']) ? $t['class'] . $t['type'] . $t['function'] : $t['function'], 521 | ]; 522 | 523 | if (isset($t['file'])) 524 | { 525 | $trace->file = self::getFileLocation($t['file']); 526 | } 527 | 528 | if (isset($t['line'])) 529 | { 530 | $trace->line = (int) $t['line']; 531 | } 532 | 533 | if (count($args)) 534 | { 535 | $trace->args = $args; 536 | } 537 | 538 | if (isset($trace->file) && isset($trace->line)) 539 | { 540 | $trace->code = self::getSource($t['file'], $t['line']); 541 | } 542 | 543 | $error->backtrace[] = $trace; 544 | } 545 | 546 | $report->errors[] = $error; 547 | $e = $e->getPrevious(); 548 | } 549 | 550 | unset($error, $e, $params, $t); 551 | 552 | $context = array_merge([ 553 | 'id' => base_convert(substr(sha1(json_encode($report->errors)), 0, 10), 16, 36), 554 | 'date' => date(DATE_ATOM), 555 | 'os' => PHP_OS, 556 | 'language' => 'PHP ' . PHP_VERSION, 557 | 'environment' => self::$enabled & self::DEVELOPMENT ? 'development' : 'production:' . self::$enabled, 558 | 'php_sapi' => PHP_SAPI, 559 | 'remote_ip' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null, 560 | 'http_method' => isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : null, 561 | 'http_files' => self::dump($_FILES), 562 | 'http_post' => self::dump($_POST, true), 563 | 'duration' => isset(self::$context['request_started']) ? (microtime(true) - self::$context['request_started'])*1000 : null, 564 | 'memory_peak' => memory_get_peak_usage(true), 565 | 'memory_used' => memory_get_usage(true), 566 | ], self::$context); 567 | 568 | ksort($context); 569 | 570 | unset($context['request_started']); 571 | 572 | $report->context = (object) $context; 573 | 574 | if (!empty($_SERVER['HTTP_HOST']) && !empty($_SERVER['REQUEST_URI'])) 575 | { 576 | $proto = empty($_SERVER['HTTPS']) ? 'http' : 'https'; 577 | $report->context->url = $proto . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; 578 | } 579 | 580 | return $report; 581 | } 582 | 583 | /** 584 | * Displays an exception as HTML debug page 585 | */ 586 | static public function htmlException(\stdClass $e): string 587 | { 588 | $out = sprintf('

%s

%s

', 589 | $e->type, nl2br(htmlspecialchars($e->message))); 590 | 591 | foreach ($e->backtrace as $t) 592 | { 593 | $out .= '
'; 594 | 595 | if (isset($t->file) && isset($t->line)) 596 | { 597 | $dir = dirname($t->file); 598 | $dir = $dir == '/' ? $dir : $dir . '/'; 599 | 600 | $out .= sprintf('

in %s%s:%d

', htmlspecialchars($dir), htmlspecialchars(basename($t->file)), $t->line); 601 | } 602 | 603 | if (isset($t->function)) 604 | { 605 | $out .= sprintf('

→ %s (%d arg.)

', htmlspecialchars($t->function), isset($t->args) ? count($t->args) : 0); 606 | 607 | // Display call arguments 608 | if (!empty($t->args)) 609 | { 610 | $out .= ''; 611 | 612 | foreach ($t->args as $name => $value) 613 | { 614 | $out .= sprintf('', htmlspecialchars($name), htmlspecialchars($value)); 615 | } 616 | 617 | $out .= '
%s
%s
'; 618 | } 619 | } 620 | 621 | // Display source code 622 | if (isset($t->code) && isset($t->line)) 623 | { 624 | $out .= self::htmlSource($t->code, $t->line); 625 | } 626 | 627 | $out .= '
'; 628 | } 629 | 630 | $out .= '
'; 631 | 632 | return $out; 633 | } 634 | 635 | 636 | static public function htmlSource(array $source, $line) 637 | { 638 | $out = ''; 639 | 640 | foreach ($source as $i => $code) 641 | { 642 | $html = '' . ($i) . '' . htmlspecialchars($code, ENT_QUOTES); 643 | 644 | if ($i == $line) 645 | { 646 | $html = '' . $html . ''; 647 | } 648 | 649 | $out .= $html . PHP_EOL; 650 | } 651 | 652 | return '
' . $out . '
'; 653 | } 654 | 655 | /** 656 | * Get source code 657 | * @param string $file File location 658 | * @param integer $line Line to highlight 659 | * @return array 660 | */ 661 | static public function getSource($file, $line) 662 | { 663 | $out = []; 664 | $start = max(0, $line - 5); 665 | 666 | try { 667 | // Make sure we ignore errors here, as file_exists can trigger an error from open_basedir 668 | $exists = file_exists($file); 669 | } 670 | catch (\Throwable $e) { 671 | $exists = false; 672 | } 673 | 674 | if (!$exists) { 675 | return [$line => 'Source file not found']; 676 | } 677 | 678 | $file = new \SplFileObject($file); 679 | $file->seek($start); 680 | 681 | for ($i = $start + 1; $i < $start+10; $i++) 682 | { 683 | if ($file->eof()) 684 | { 685 | break; 686 | } 687 | 688 | $out[$i] = trim($file->current(), "\r\n"); 689 | $file->next(); 690 | } 691 | 692 | unset($file); 693 | 694 | return $out; 695 | } 696 | 697 | static public function htmlProduction(\stdClass $report) 698 | { 699 | if (!headers_sent()) { 700 | header_remove(); 701 | header('HTTP/1.1 500 Internal Server Error', true, 500); 702 | header('Content-Type: text/html; charset=UTF-8', true); 703 | } 704 | 705 | echo self::htmlTemplate(self::$production_error_template, $report); 706 | } 707 | 708 | static public function htmlTemplate($str, \stdClass $report) 709 | { 710 | $str = strtr($str, [ 711 | '{$ref}' => $report->context->id, 712 | '{$report_json}' => htmlspecialchars(base64_encode(json_encode($report)), ENT_QUOTES), 713 | '{$report_url}' => htmlspecialchars((string) self::$report_url), 714 | ]); 715 | 716 | $str = preg_replace_callback('!(.*?)!is', function ($match) { 717 | switch ($match[1]) { 718 | case 'sent': 719 | case 'email': 720 | return self::$email_errors || (self::$report_auto && self::$report_url) ? $match[2] : ''; 721 | case 'logged': 722 | case 'log': 723 | return ini_get('error_log') ? $match[2] : ''; 724 | case 'report': 725 | return (!self::$report_auto && self::$report_url) ? $match[2] : ''; 726 | } 727 | }, $str); 728 | 729 | return $str; 730 | } 731 | 732 | static public function htmlReport(\stdClass $report): string 733 | { 734 | $out = ''; 735 | 736 | // Display debug 737 | $out .= self::htmlTemplate(ini_get('error_prepend_string'), $report); 738 | 739 | foreach ($report->errors as $e) 740 | { 741 | $out .= self::htmlException($e); 742 | } 743 | 744 | $out .= '

Context

'; 745 | 746 | foreach ($report->context as $name => $value) 747 | { 748 | $out .= sprintf('', 749 | htmlspecialchars($name), 750 | htmlspecialchars($value ?? '')); 751 | } 752 | 753 | $out .= '
%s%s
'; 754 | 755 | $out .= self::htmlTemplate(ini_get('error_append_string'), $report); 756 | 757 | return $out; 758 | } 759 | 760 | static public function setEnvironment(int $environment): void 761 | { 762 | self::$enabled = $environment; 763 | error_reporting($environment & self::DEVELOPMENT ? -1 : E_ALL & ~E_DEPRECATED); 764 | 765 | if ($environment & self::DEVELOPMENT && PHP_SAPI != 'cli') { 766 | self::setHtmlHeader(' 779 |
 \__/
(xx)
//||\\\\
'); 780 | } 781 | } 782 | 783 | /** 784 | * Enable error manager 785 | * @param integer $environment Type of error management (ErrorManager::PRODUCTION or ErrorManager::DEVELOPMENT) 786 | * You can also use ErrorManager::PRODUCTION | ErrorManager::CLI_DEVELOPMENT to get error messages in CLI but still hide errors 787 | * on web front-end. 788 | * @return void 789 | */ 790 | static public function enable(int $environment = self::DEVELOPMENT): void 791 | { 792 | if (self::$enabled) { 793 | return; 794 | } 795 | 796 | self::$context['request_started'] = $_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true); 797 | 798 | self::$term_color = function_exists('posix_isatty') && defined('\STDOUT') && @posix_isatty(\STDOUT); 799 | 800 | ini_set('display_errors', false); 801 | ini_set('log_errors', false); 802 | ini_set('html_errors', false); 803 | ini_set('zend.exception_ignore_args', false); // We want to get the args in exceptions (since PHP 7.4) 804 | 805 | self::setEnvironment($environment); 806 | 807 | register_shutdown_function([self::class, 'shutdownHandler']); 808 | set_exception_handler([__CLASS__, 'exceptionHandler']); 809 | set_error_handler([__CLASS__, 'errorHandler']); 810 | 811 | if ($environment & self::DEVELOPMENT) { 812 | self::startTimer('_global'); 813 | } 814 | 815 | // Assign default context 816 | static $defaults = [ 817 | 'hostname' => 'SERVER_NAME', 818 | 'http_user_agent' => 'HTTP_USER_AGENT', 819 | 'http_referrer' => 'HTTP_REFERER', 820 | 'user_addr' => 'REMOTE_ADDR', 821 | 'server_addr' => 'SERVER_ADDR', 822 | 'root_directory' => 'DOCUMENT_ROOT', 823 | ]; 824 | 825 | foreach ($defaults as $a => $b) { 826 | if (isset($_SERVER[$b]) && !isset(self::$context[$a])) { 827 | self::$context[$a] = $_SERVER[$b]; 828 | } 829 | } 830 | } 831 | 832 | /** 833 | * Reset error management to PHP defaults 834 | * @return boolean 835 | */ 836 | static public function disable() 837 | { 838 | self::$enabled = false; 839 | 840 | ini_set('error_prepend_string', null); 841 | ini_set('error_append_string', null); 842 | ini_set('log_errors', false); 843 | ini_set('display_errors', false); 844 | ini_set('error_reporting', E_ALL & ~E_DEPRECATED); 845 | 846 | restore_error_handler(); 847 | return restore_exception_handler(); 848 | } 849 | 850 | /** 851 | * Sets a microsecond timer to track time and memory usage 852 | * @param string $name Timer name 853 | */ 854 | static public function startTimer($name) 855 | { 856 | self::$run_trace[$name] = [microtime(true), memory_get_usage()]; 857 | } 858 | 859 | /** 860 | * Stops a timer and return time spent and memory used 861 | * @param string $name Timer name 862 | */ 863 | static public function stopTimer($name) 864 | { 865 | self::$run_trace[$name] = [ 866 | microtime(true) - self::$run_trace[$name][0], 867 | memory_get_usage() - self::$run_trace[$name][1], 868 | ]; 869 | return self::$run_trace[$name]; 870 | } 871 | 872 | /** 873 | * Sets a log file to record errors 874 | * @param string $file Error log file 875 | */ 876 | static public function setLogFile($file) 877 | { 878 | ini_set('log_errors', true); 879 | return ini_set('error_log', $file); 880 | } 881 | 882 | /** 883 | * Sets an email address that should receive the logs 884 | * Set to FALSE to disable email sending (default) 885 | * @param string $email Email address 886 | */ 887 | static public function setEmail($email) 888 | { 889 | self::$email_errors = $email; 890 | } 891 | 892 | /** 893 | * @deprecated 894 | */ 895 | static public function setExtraDebugEnv($env) 896 | { 897 | self::setContext($env); 898 | } 899 | 900 | /** 901 | * Set the report context 902 | * @param mixed $env Variable content, could be application version, or an array of information... 903 | */ 904 | static public function setContext(array $context) 905 | { 906 | self::$context = array_merge(self::$context, $context); 907 | } 908 | 909 | /** 910 | * Enable or disable reporting of errors to a remote URL 911 | * @param null|string $url Reporting URL 912 | * @param boolean $auto Automatic reporting? If not users will be able to report the error by clicking a button on the error page 913 | */ 914 | static public function setRemoteReporting($url, $auto) 915 | { 916 | self::$report_url = empty($url) ? null : $url; 917 | self::$report_auto = (bool) $auto; 918 | } 919 | 920 | /** 921 | * Set the HTML header used by the debug error page 922 | * @param string $html HTML header 923 | */ 924 | static public function setHtmlHeader($html) 925 | { 926 | ini_set('error_prepend_string', $html); 927 | } 928 | 929 | /** 930 | * Set the HTML footer used by the debug error page 931 | * @param string $html HTML footer 932 | */ 933 | static public function setHtmlFooter($html) 934 | { 935 | ini_set('error_append_string', $html); 936 | } 937 | 938 | /** 939 | * Set the content of the HTML template used to display an error in production 940 | * {$ref} will be replaced by the error reference if log or email is enabled 941 | * ... block will be removed if email reporting is disabled 942 | * ... block will be removed if log reporting is disabled 943 | * @param string $html HTML template 944 | */ 945 | static public function setProductionErrorTemplate($html) 946 | { 947 | self::$production_error_template = $html; 948 | } 949 | 950 | static public function setCustomExceptionHandler($class, Callable $callback) 951 | { 952 | self::$custom_handlers[$class] = $callback; 953 | } 954 | 955 | static public function debug(...$vars) 956 | { 957 | echo '
';
 958 | 		foreach ($vars as $var) {
 959 | 			echo self::dump($var);
 960 | 			echo '
'; 961 | } 962 | echo '
'; 963 | } 964 | 965 | /** 966 | * Copy of var_dump but returns a string instead of a variable 967 | * @param mixed $var variable to dump 968 | * @param bool $hide_values Do not return values if set to TRUE 969 | * @param integer $level Indentation level (internal use) 970 | * @return string 971 | */ 972 | static public function dump($var, bool $hide_values = false, int $level = 0, array $stack = []): string 973 | { 974 | if ($level > 10) { 975 | return '*REACHED_MAX_RECURSION_LEVEL*'; 976 | } 977 | 978 | switch (gettype($var)) 979 | { 980 | case 'boolean': 981 | return 'bool(' . ($var ? 'true' : 'false') . ')'; 982 | case 'integer': 983 | return 'int(' . $var . ')'; 984 | case 'double': 985 | return 'float(' . $var . ')'; 986 | case 'string': 987 | return 'string(' . strlen($var) . ') "' . ($hide_values ? '***HIDDEN***' : $var) . '"'; 988 | case 'NULL': 989 | return 'NULL'; 990 | case 'resource': 991 | return 'resource(' . (int)$var . ') of type (' . get_resource_type($var) . ')'; 992 | case 'array': 993 | case 'object': 994 | if (is_object($var)) { 995 | $id = spl_object_id($var); 996 | $out = sprintf('object(%s)#%d (%d) {' . PHP_EOL, get_class($var), $id, count((array) $var)); 997 | } 998 | else { 999 | $out = 'array(' . count((array) $var) . ') {' . PHP_EOL; 1000 | } 1001 | 1002 | $level++; 1003 | 1004 | if ($var instanceof \Traversable && method_exists($var, 'valid')) { 1005 | $var2 = []; 1006 | 1007 | try { 1008 | // Iterate as long as we can 1009 | while (@$var->valid()) { 1010 | $var2[] = $var->current(); 1011 | $var->next(); 1012 | } 1013 | } 1014 | catch (\Exception $e) { 1015 | $var2[] = '**' . $e->getMessage() . '**'; 1016 | } 1017 | 1018 | $var = $var2; 1019 | } 1020 | 1021 | $stack[] =& $var; 1022 | 1023 | foreach ((array)$var as $key => $value) 1024 | { 1025 | $out .= str_repeat(' ', $level * 2); 1026 | $out .= is_string($key) ? '["' . $key . '"]' : '[' . $key . ']'; 1027 | 1028 | if ($value === $var || in_array($value, $stack, true)) { 1029 | $out .= '=> *RECURSION*' . PHP_EOL; 1030 | } 1031 | else { 1032 | $out .= '=> ' . self::dump($value, $hide_values, $level + 1, $stack) . PHP_EOL; 1033 | } 1034 | } 1035 | 1036 | array_pop($stack); 1037 | 1038 | $out .= str_repeat(' ', $level * 2) . '}'; 1039 | return $out; 1040 | default: 1041 | return gettype($var); 1042 | } 1043 | } 1044 | 1045 | /** 1046 | * Upload a report to a remote errbit-compatible API 1047 | * @see https://airbrake.io/docs/api/#create-notice-v3 1048 | */ 1049 | static public function sendReport(\stdClass $report, $url) 1050 | { 1051 | $data = json_encode($report); 1052 | 1053 | $headers = [ 1054 | 'Content-Type: application/json', 1055 | 'Content-Lenth: ' . strlen($data), 1056 | ]; 1057 | 1058 | if (function_exists('curl_init')) 1059 | { 1060 | $ch = curl_init($url); 1061 | 1062 | curl_setopt_array($ch, [ 1063 | CURLOPT_HTTPHEADER => $headers, 1064 | CURLOPT_RETURNTRANSFER => true, 1065 | CURLOPT_FOLLOWLOCATION => false, 1066 | CURLOPT_MAXREDIRS => 3, 1067 | CURLOPT_CUSTOMREQUEST => 'POST', 1068 | CURLOPT_TIMEOUT => 10, 1069 | CURLOPT_POSTFIELDS => $data, 1070 | ]); 1071 | 1072 | $body = curl_exec($ch); 1073 | $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); 1074 | curl_close($ch); 1075 | } 1076 | else 1077 | { 1078 | $opts = ['http' => [ 1079 | 'method' => 'POST', 1080 | 'header' => $headers, 1081 | 'content' => $data, 1082 | 'max_redirects' => 3, 1083 | 'timeout' => 10, 1084 | 'ignore_errors' => true, 1085 | ]]; 1086 | 1087 | $body = file_get_contents($url, false, stream_context_create($opts)); 1088 | $code = null; 1089 | 1090 | if (function_exists('http_get_last_response_headers')) { 1091 | $http_response_header = http_get_last_response_headers(); 1092 | } 1093 | 1094 | foreach ($http_response_header as $header) { 1095 | $a = substr($header, 0, 7); 1096 | 1097 | if ($a == 'HTTP/1.') 1098 | { 1099 | $code = substr($header, 11, 3); 1100 | } 1101 | } 1102 | 1103 | unset($http_response_header); 1104 | } 1105 | 1106 | return [ 1107 | 'code' => (int) $code, 1108 | 'body' => $body, 1109 | 'data' => json_decode($body), 1110 | ]; 1111 | } 1112 | 1113 | /** 1114 | * Returns list of reports from error log 1115 | * 1116 | * @param string|null $log_file Log file to use, if NULL then the log file set in error_log will be used 1117 | * @param string|null $filter_id Only return errors matching with this ID 1118 | */ 1119 | static public function getReportsFromLog($log_file = null, $filter_id = null) 1120 | { 1121 | if (!$log_file) 1122 | { 1123 | $log_file = ini_get('error_log'); 1124 | } 1125 | 1126 | if (!file_exists($log_file)) 1127 | { 1128 | return []; 1129 | } 1130 | 1131 | $reports = []; 1132 | $report = null; 1133 | 1134 | foreach (file($log_file) as $line) 1135 | { 1136 | $line = trim($line); 1137 | 1138 | if ($line == '') 1139 | { 1140 | $report = ''; 1141 | } 1142 | elseif ($line == '') 1143 | { 1144 | $report = json_decode($report); 1145 | 1146 | if (!is_null($report) && isset($report->context->id) && (!$filter_id || $filter_id == $report->context->id)) 1147 | { 1148 | $reports[] = $report; 1149 | } 1150 | 1151 | $report = null; 1152 | } 1153 | elseif ($report !== null) 1154 | { 1155 | $report .= $line; 1156 | } 1157 | } 1158 | 1159 | unset($line, $report, $log_file); 1160 | 1161 | return $reports; 1162 | } 1163 | } 1164 | -------------------------------------------------------------------------------- /server/lib/KD2/Smartyer.php: -------------------------------------------------------------------------------- 1 | 4 | 5 | Copyright (c) 2001-2019 BohwaZ 6 | All rights reserved. 7 | 8 | KD2FW is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU Affero General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | Foobar is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU Affero General Public License for more details. 17 | 18 | You should have received a copy of the GNU Affero General Public License 19 | along with Foobar. If not, see . 20 | */ 21 | 22 | /** 23 | * Smartyer: a lightweight Smarty template engine 24 | * 25 | * Smartyer is not really smarter, in fact it is dumber, it is merely replacing 26 | * some Smarty tags to PHP code. This may lead to hard to debug bugs as the 27 | * compiled PHP code may contain invalid syntax. 28 | * 29 | * Differences: 30 | * - UNSAFE! this is directly executing PHP code from the template, 31 | * you MUST NOT allow end users to edit templates. Consider Smartyer templates 32 | * as the same as PHP files. 33 | * - Auto HTML escaping of variables: {$name} will be escaped, 34 | * {$name|rot13} too. 35 | * Use {$name|raw} to disable auto-escaping, or {$name|escape:...} to specify 36 | * a custom escape method. 37 | * - Embedding variables in strings is not supported: "Hello $world" will 38 | * display as is, same for "Hello `$world`"", use |args (= sprintf) 39 | * - Unsupported features: config files, $smarty. variables, cache, switch/case, 40 | * section, insert, {php} 41 | * - Much less default modifiers and functions 42 | * 43 | * @author bohwaz http://bohwaz.net/ 44 | * @license BSD 45 | * @version 0.2 46 | */ 47 | 48 | namespace KD2; 49 | 50 | use Exception; 51 | 52 | class Smartyer 53 | { 54 | /** 55 | * Start delimiter, usually is { 56 | */ 57 | protected string $delimiter_start = '{'; 58 | 59 | /** 60 | * End delimiter, usually } 61 | */ 62 | protected string $delimiter_end = '}'; 63 | 64 | /** 65 | * Current template file name / path 66 | */ 67 | protected ?string $template = null; 68 | 69 | /** 70 | * Current template complete path 71 | */ 72 | protected ?string $template_path = null; 73 | 74 | /** 75 | * Current compiled template path 76 | */ 77 | protected string $compiled_template_path; 78 | 79 | /** 80 | * Content of the template source while compiling 81 | */ 82 | protected ?string $source = null; 83 | 84 | /** 85 | * Parent template (if any) 86 | */ 87 | public ?Smartyer $parent = null; 88 | 89 | /** 90 | * Variables assigned to the template 91 | */ 92 | protected array $variables = []; 93 | 94 | /** 95 | * Functions registered to the template 96 | */ 97 | protected array $functions = []; 98 | 99 | /** 100 | * Block functions registered to the template 101 | */ 102 | protected array $blocks = []; 103 | 104 | /** 105 | * Modifier functions registered to the template 106 | */ 107 | protected array $modifiers = [ 108 | 'nl2br' => 'nl2br', 109 | 'strip_tags' => 'strip_tags', 110 | 'count' => 'count', 111 | 'args' => 'sprintf', 112 | 'const' => 'constant', 113 | 'trim' => 'trim', 114 | 'rtrim' => 'rtrim', 115 | 'ltrim' => 'ltrim', 116 | 'cat' => [__CLASS__, 'concatenate'], 117 | 'escape' => [__CLASS__, 'escape'], 118 | 'truncate' => [__CLASS__, 'truncate'], 119 | 'replace' => [__CLASS__, 'replace'], 120 | 'regex_replace' => [__CLASS__, 'replaceRegExp'], 121 | 'date_format' => [__CLASS__, 'dateFormat'], 122 | ]; 123 | 124 | /** 125 | * Compile function for unknown blocks 126 | */ 127 | protected array $compile_functions = [ 128 | 'assign' => [__CLASS__, 'templateAssign'], 129 | ]; 130 | 131 | /** 132 | * Auto-escaping type (any type accepted by self::escape()) 133 | */ 134 | protected ?string $escape_type = 'html'; 135 | 136 | /** 137 | * List of native PHP tags that don't require any argument 138 | * 139 | * Note: switch/case is not supported because any white space 140 | * between switch and the first case will produce and error 141 | * see https://secure.php.net/manual/en/control-structures.alternative-syntax.php 142 | */ 143 | protected array $raw_php_blocks = ['elseif', 'if', 'else', 'for', 'while']; 144 | 145 | /** 146 | * Internal {foreachelse} stack to know when to use 'endif' instead of 'endforeach' 147 | * for {/foreach} tags 148 | */ 149 | protected array $foreachelse_stack = []; 150 | 151 | /** 152 | * Throws a parse error if an invalid block is encountered 153 | * if set to FALSE, makes life easier for javascript, but this is a bit unreliable 154 | * as some JS code might look like smarty code and produce errors, 155 | * eg. variables: function () { $('.class').forEach(... 156 | * some functions: if (true) { if (ok) } 157 | * one solution is to append a comment line after opening brackets, or use {literal} blocks! 158 | */ 159 | public bool $error_on_invalid_block = true; 160 | 161 | /** 162 | * Default namespace used in templates 163 | */ 164 | protected ?string $namespace = null; 165 | 166 | /** 167 | * Directory used to store the compiled code 168 | */ 169 | protected ?string $compiled_dir = null; 170 | 171 | /** 172 | * Root directory to child templates 173 | */ 174 | protected ?string $templates_dir = null; 175 | 176 | /** 177 | * Sets the path where compiled templates will be stored 178 | */ 179 | public function setCompiledDir(string $path): void 180 | { 181 | if (!file_exists($path)) { 182 | mkdir($path, 0777, true); 183 | } 184 | 185 | if (!is_dir($path)) 186 | { 187 | throw new \RuntimeException($path . ' is not a directory.'); 188 | } 189 | 190 | if (!is_writable($path)) 191 | { 192 | throw new \RuntimeException($path . ' is not writeable by ' . __CLASS__); 193 | } 194 | 195 | $this->compiled_dir = $path; 196 | } 197 | 198 | /** 199 | * Sets the default path containing all templates 200 | */ 201 | public function setTemplatesDir(string $path): void 202 | { 203 | if (!is_dir($path)) 204 | { 205 | throw new \RuntimeException($path . ' is not a directory.'); 206 | } 207 | 208 | if (!is_readable($path)) 209 | { 210 | throw new \RuntimeException($path . ' is not readable by ' . __CLASS__); 211 | } 212 | 213 | $this->templates_dir = $path; 214 | } 215 | 216 | /** 217 | * Sets the namespace used by the template code 218 | */ 219 | public function setNamespace(string $namespace): void 220 | { 221 | $this->namespace = $namespace; 222 | } 223 | 224 | /** 225 | * Creates a new template object 226 | * @param string $template Template filename or full path 227 | * @param Smartyer|null $parent Parent template object, useful to have a global 228 | * template object with lots of assigns that will be used with all templates 229 | */ 230 | public function __construct(?string $template = null, ?Smartyer $parent = null) 231 | { 232 | $this->template = $template; 233 | 234 | // Register parent functions and variables locally 235 | if ($parent instanceof Smartyer) 236 | { 237 | $copy = ['modifiers', 'blocks', 'functions', 'escape_type', 'compile_functions', 'namespace', 'compiled_dir', 'templates_dir']; 238 | 239 | foreach ($copy as $key) { 240 | $this->{$key} = &$parent->{$key}; 241 | } 242 | 243 | // Do not reference variables, we want their scope to stay inside the other template 244 | $this->variables= $parent->variables; 245 | $this->parent = $parent; 246 | } 247 | } 248 | 249 | /** 250 | * Returns Smartyer object built from a template string instead of a file path 251 | * @param string $string Template contents 252 | * @return Smartyer 253 | */ 254 | static public function fromString(string $string, ?Smartyer &$parent = null) 255 | { 256 | $s = new Smartyer(null, $parent); 257 | $s->source = $string; 258 | return $s; 259 | } 260 | 261 | /** 262 | * Display the current template or a new one if $template is supplied 263 | * @param string $template Template file name or full path 264 | * @return Smartyer 265 | */ 266 | public function display(?string $template = null): self 267 | { 268 | echo $this->fetch($template); 269 | return $this; 270 | } 271 | 272 | protected function _isPathRelative(string $path): bool 273 | { 274 | if (substr($this->template, 0, 1) === '/') { 275 | return false; 276 | } 277 | 278 | if (substr($this->template, 0, 7) === 'phar://') { 279 | return false; 280 | } 281 | 282 | if (PHP_OS_FAMILY === 'Windows' && ctype_alpha(substr($path, 0, 1)) && substr($path, 1, 2) === ':\\') { 283 | return false; 284 | } 285 | 286 | return true; 287 | } 288 | 289 | /** 290 | * Fetch the current template and returns the result it as a string, 291 | * or fetch a new template if $template is supplied 292 | * (for Smarty compatibility) 293 | * @param string $template Template file name or full path 294 | * @return string 295 | */ 296 | public function fetch(?string $template = null): string 297 | { 298 | // Compatibility with legacy Smarty calls 299 | if (isset($template)) { 300 | return (new Smartyer($template, $this))->fetch(); 301 | } 302 | 303 | if (!isset($this->compiled_dir)) { 304 | throw new \LogicException('Compile dir not set: call ' . __CLASS__ . '->setCompiledDir() first'); 305 | } 306 | 307 | if (isset($this->template)) { 308 | // Don't prepend templates_dir for phar and absolute paths 309 | if (!$this->_isPathRelative($this->template)) { 310 | $this->template_path = $this->template; 311 | } 312 | else { 313 | $this->template_path = $this->templates_dir . DIRECTORY_SEPARATOR . $this->template; 314 | } 315 | } 316 | 317 | if (isset($this->template_path) && (!is_file($this->template_path) || !is_readable($this->template_path))) { 318 | throw new \RuntimeException('Template file doesn\'t exist or is not readable: ' . $this->template_path); 319 | } 320 | 321 | if (!isset($this->compiled_template_path)) { 322 | if (!isset($this->template_path)) { 323 | // Anonymous templates 324 | $hash = sha1($this->source . $this->namespace); 325 | } 326 | else { 327 | $hash = sha1($this->template_path . $this->namespace); 328 | } 329 | 330 | $this->compiled_template_path = $this->compiled_dir . DIRECTORY_SEPARATOR . $hash . '.tpl.php'; 331 | 332 | $time = @filemtime($this->compiled_template_path); 333 | } 334 | else { 335 | $time = null; 336 | } 337 | 338 | if (!$time || (!is_null($this->template_path) && filemtime($this->template_path) > $time)) { 339 | return $this->compile(); 340 | } 341 | 342 | extract($this->variables, EXTR_REFS); 343 | 344 | ob_start(); 345 | 346 | include $this->compiled_template_path; 347 | 348 | return ob_get_clean(); 349 | } 350 | 351 | /** 352 | * Precompiles all templates, without any execution (so no error, unless invalid template syntax) 353 | */ 354 | static public function precompileAll(string $templates_dir): void 355 | { 356 | if (!is_dir($templates_dir)) 357 | { 358 | throw new \RuntimeException('The template directory specified is not a directory: ' . $templates_dir); 359 | } 360 | 361 | $dir = dir($templates_dir); 362 | 363 | // Compile all templates 364 | while ($file = $dir->read()) 365 | { 366 | if ($file[0] === '.') 367 | { 368 | continue; 369 | } 370 | 371 | $file_path = $templates_dir . DIRECTORY_SEPARATOR . $file; 372 | 373 | if (is_dir($file_path)) 374 | { 375 | self::precompileAll($file_path); 376 | } 377 | 378 | $tpl = new Smartyer(substr($file_path, strpos($file_path, $templates_dir))); 379 | $tpl->compile(); 380 | } 381 | } 382 | 383 | /** 384 | * Sets the auto-escaping type for the current template 385 | * @param string|null $type Escape type supported by self::escape(), set to NULL to disable escaping 386 | */ 387 | public function setEscapeType(?string $type): self 388 | { 389 | $this->escape_type = $type; 390 | return $this; 391 | } 392 | 393 | public function getEscapeType(): ?string 394 | { 395 | return $this->escape_type; 396 | } 397 | 398 | /** 399 | * Assign a variable to the template 400 | * @param mixed $name Variable name or associative array of multiple variables 401 | * @param mixed $value Variable value if variable name is a string 402 | * @return Smartyer 403 | */ 404 | public function assign($name, $value = null): self 405 | { 406 | if (is_array($name)) 407 | { 408 | foreach ($name as $k=>&$v) 409 | { 410 | $this->assign($k, $v); 411 | } 412 | 413 | return $this; 414 | } 415 | 416 | $this->variables[$name] = $value; 417 | return $this; 418 | } 419 | 420 | /** 421 | * Assign a variable by reference to the template 422 | * @param mixed $name Variable name or associative array of multiple variables 423 | * @param mixed &$value Reference 424 | * @return Smartyer 425 | */ 426 | public function assign_by_ref(string $name, &$value): self 427 | { 428 | $this->variables[$name] = $value; 429 | return $this; 430 | } 431 | 432 | /** 433 | * Return assigned variables 434 | * @param string|null $name name of the variable, if NULL then all variables are returned 435 | */ 436 | public function getTemplateVars(?string $name = null) 437 | { 438 | if (!is_null($name)) 439 | { 440 | if (array_key_exists($name, $this->variables)) 441 | { 442 | return $this->variables[$name]; 443 | } 444 | else 445 | { 446 | return null; 447 | } 448 | } 449 | 450 | return $this->variables; 451 | } 452 | 453 | /** 454 | * Register a modifier function to the current template 455 | * @param string|array $name Modifier name or associative array of multiple modifiers 456 | * @param callable|null $callback Valid callback if $name is a string 457 | */ 458 | public function register_modifier($name, ?callable $callback = null): self 459 | { 460 | if (is_array($name)) 461 | { 462 | foreach ($name as $k=>&$v) 463 | { 464 | $this->register_modifier($k, $v); 465 | } 466 | 467 | return $this; 468 | } 469 | 470 | $this->modifiers[$name] = $callback; 471 | return $this; 472 | } 473 | 474 | /** 475 | * Register a function to the current template 476 | * @param string|array $name Function name or associative array of multiple functions 477 | * @param Callable|null $callback Valid callback if $name is a string 478 | */ 479 | public function register_function($name, ?callable $callback = null): self 480 | { 481 | if (is_array($name)) 482 | { 483 | foreach ($name as $k=>&$v) 484 | { 485 | $this->register_function($k, $v); 486 | } 487 | 488 | return $this; 489 | } 490 | 491 | $this->functions[$name] = $callback; 492 | return $this; 493 | } 494 | 495 | /** 496 | * Register a block function to the current template 497 | * @param string|array $name Function name or associative array of multiple functions 498 | * @param Callable|null $callback Valid callback if $name is a string 499 | */ 500 | public function register_block($name, ?callable $callback = null): self 501 | { 502 | if (is_array($name)) 503 | { 504 | foreach ($name as $k=>&$v) 505 | { 506 | $this->register_block($k, $v); 507 | } 508 | 509 | return $this; 510 | } 511 | 512 | $this->blocks[$name] = $callback; 513 | return $this; 514 | } 515 | 516 | /** 517 | * Register a compile function that will be called for unknown blocks 518 | * 519 | * This offers a good way to extend the template language 520 | * 521 | * @param string $name Function name 522 | * @param Callable|null $callback Valid callback 523 | */ 524 | public function register_compile_function(string $name, ?callable $callback): self 525 | { 526 | $this->compile_functions[$name] = $callback; 527 | return $this; 528 | } 529 | 530 | /** 531 | * Compiles the current template to PHP code 532 | */ 533 | protected function compile(): string 534 | { 535 | $code = $this->source; 536 | 537 | if (!isset($code)) { 538 | $code = file_get_contents($this->template_path) ?: null; 539 | } 540 | 541 | if (!isset($code)) { 542 | throw new \LogicException('No source code found'); 543 | } 544 | 545 | $code = str_replace("\r", "", $code); 546 | 547 | $compiled = $this->parse($code); 548 | 549 | // Force new lines (this is to avoid PHP eating new lines after its closing tag) 550 | $compiled = preg_replace("/\?>\n/", "$0\n", $compiled); 551 | 552 | // Keep a trace of the source for debug purposes 553 | $prefix = 'template_path . ' - ' . gmdate('Y-m-d H:i:s') . ' UTC */ '; 554 | 555 | // Apply namespace 556 | if ($this->namespace) { 557 | $prefix .= sprintf("\nnamespace %s;\n", $this->namespace); 558 | } 559 | 560 | // Stop execution if not in the context of Smartyer 561 | // this is to avoid potential execution of template code outside of Smartyer 562 | $prefix .= 'if (!isset($this) || !is_object($this) || (!($this instanceof \KD2\Smartyer) && !is_subclass_of($this, \'\KD2\Smartyer\', true))) { die("Wrong call context."); } '; 563 | 564 | // Initialize useful variables 565 | $prefix .= 'if (!isset($_i)) { $_i = []; } if (!isset($_blocks)) { $_blocks = []; } ?>'; 566 | 567 | $compiled = $prefix . $compiled; 568 | 569 | // Write to temporary file 570 | file_put_contents($this->compiled_template_path . '.tmp', $compiled); 571 | 572 | $out = false; 573 | 574 | // We can catch most errors in the first run 575 | try { 576 | extract($this->variables, EXTR_REFS); 577 | 578 | ob_start(); 579 | 580 | include $this->compiled_template_path . '.tmp'; 581 | 582 | $out = ob_get_clean(); 583 | } 584 | catch (Exception $e) { 585 | ob_end_clean(); 586 | 587 | if ($e instanceof Smartyer_Exception 588 | || $e->getFile() !== $this->compiled_template_path . '.tmp') { 589 | throw $e; 590 | } 591 | 592 | // Finding the original template line number 593 | $compiled = explode("\n", $compiled); 594 | $compiled = array_slice($compiled, $e->getLine()-1); 595 | $compiled = implode("\n", $compiled); 596 | 597 | if (preg_match('!//#(\d+)\?>!', $compiled, $match)) { 598 | $this->parseError($match[1], $e->getMessage(), $e); 599 | } 600 | else { 601 | throw $e; 602 | } 603 | } 604 | 605 | // Atomic update if everything worked, destination will be overwritten 606 | rename($this->compiled_template_path . '.tmp', $this->compiled_template_path); 607 | 608 | unset($compiled, $code); 609 | 610 | return $out; 611 | } 612 | 613 | /** 614 | * Parse the template and all tags 615 | */ 616 | protected function parse(string $source): string 617 | { 618 | $literals = []; 619 | 620 | $pattern = sprintf('/%s\*.*?\*%2$s|<\?(?:php|=).*?\?>|%1$sliteral%2$s.*?%1$s\/literal%2$s/s', 621 | preg_quote($this->delimiter_start), preg_quote($this->delimiter_end)); 622 | 623 | // Remove literal blocks, PHP blocks and comments, to avoid interference with block parsing 624 | $source = preg_replace_callback($pattern, function ($match) use (&$literals) { 625 | $nb = count($literals); 626 | $literals[$nb] = $match[0]; 627 | $lines = substr_count($match[0], "\n"); 628 | return ''; 629 | }, $source); 630 | 631 | // Create block matching pattern 632 | $anti = preg_quote($this->delimiter_start . $this->delimiter_end, '#'); 633 | $pattern = '#' . preg_quote($this->delimiter_start, '#') . '((?:[^' . $anti . ']|(?R))*?)' . preg_quote($this->delimiter_end, '#') . '#i'; 634 | 635 | $blocks = preg_split($pattern, $source, 0, PREG_SPLIT_OFFSET_CAPTURE | PREG_SPLIT_DELIM_CAPTURE); 636 | 637 | unset($anti, $pattern); 638 | 639 | $compiled = ''; 640 | $prev_pos = 0; 641 | $line = 1; 642 | 643 | foreach ($blocks as $i => $block) { 644 | $pos = $block[1]; 645 | $line += $pos && $pos < strlen($source) ? substr_count($source, "\n", $prev_pos, $pos - $prev_pos) : 0; 646 | $prev_pos = $pos; 647 | 648 | $block = $block[0]; 649 | $tblock = trim($block); 650 | 651 | if ($i % 2 === 0) { 652 | $compiled .= $block; 653 | continue; 654 | } 655 | 656 | // Avoid matching JS blocks and others 657 | if ($tblock === 'ldelim') { 658 | $compiled .= $this->delimiter_start; 659 | } 660 | elseif ($tblock === 'rdelim') { 661 | $compiled .= $this->delimiter_end; 662 | } 663 | // Closing blocks 664 | elseif ($tblock[0] === '/') { 665 | $compiled .= $this->parseClosing($line, $tblock); 666 | } 667 | // Variables and strings 668 | elseif ($tblock[0] === '$' || $tblock[0] === '"' || $tblock[0] === "'") { 669 | $compiled .= $this->parseVariable($line, $tblock); 670 | } 671 | elseif ($code = $this->parseBlock($line, $tblock)) { 672 | $compiled .= $code; 673 | } 674 | else { 675 | // Literal javascript / unknown block 676 | $compiled .= $this->delimiter_start . $block . $this->delimiter_end; 677 | } 678 | } 679 | 680 | unset($source, $i, $block, $tblock, $pos, $prev_pos, $line); 681 | 682 | // Include removed literals, PHP blocks etc. 683 | foreach ($literals as $i => $literal) { 684 | // Not PHP code: specific treatment 685 | if ($literal[0] !== '<') { 686 | // Comments 687 | if (strpos($literal, $this->delimiter_start . '*') === 0) { 688 | // Remove 689 | $literal = ''; 690 | } 691 | // literals 692 | else { 693 | $start_tag = $this->delimiter_start . 'literal' . $this->delimiter_end; 694 | $end_tag = $this->delimiter_start . '/literal' . $this->delimiter_end; 695 | $literal = substr($literal, strlen($start_tag), -(strlen($end_tag))); 696 | unset($start_tag, $end_tag); 697 | } 698 | } 699 | else { 700 | // PHP code, leave as is 701 | } 702 | 703 | // We need to match the number of lines in literals 704 | $lines = substr_count($literal, "\n"); 705 | $match = sprintf('', $i, str_repeat("\n", $lines)); 706 | 707 | // replace strings 708 | $pos = strpos($compiled, $match); 709 | if ($pos !== false) { 710 | $compiled = substr_replace($compiled, $literal, $pos, strlen($match)); 711 | } 712 | } 713 | 714 | return $compiled; 715 | } 716 | 717 | /** 718 | * Parse smarty blocks and functions and returns PHP code 719 | */ 720 | protected function parseBlock(int $line, string $block): string 721 | { 722 | // This is not a valid Smarty block, just assume it is PHP and reject any problem on the user 723 | if (!preg_match('/^(else if|.*?)(?:\s+(.+?))?$/s', $block, $match)) { 724 | return ''; 725 | } 726 | 727 | $name = trim(strtolower($match[1])); 728 | $raw_args = !empty($match[2]) ? trim($match[2]) : ''; 729 | $code = ''; 730 | 731 | unset($match); 732 | 733 | // alias 734 | if ($name === 'else if') { 735 | $name = 'elseif'; 736 | } 737 | 738 | // Start counter 739 | if ($name === 'foreach' 740 | || $name === 'for' 741 | || $name === 'while') { 742 | $code = '$_i[] = 0; '; 743 | } 744 | 745 | // This is just PHP, this is easy 746 | if (substr($raw_args, 0, 1) === '(' 747 | && substr($raw_args, -1) === ')') { 748 | $raw_args = $this->parseMagicVariables($raw_args); 749 | 750 | // Make sure the arguments for if/elseif are wrapped in parenthesis 751 | // as it could be a false positive 752 | // eg. "if ($a == 1) || ($b == 1)" would create an error 753 | // this is not valid for other blocks though (foreach/for/while) 754 | 755 | if ($name === 'if' || $name === 'elseif') { 756 | $code .= sprintf('%s (%s):', $name, $raw_args); 757 | } 758 | else { 759 | $code .= sprintf('%s %s:', $name, $raw_args); 760 | } 761 | } 762 | // Raw PHP tags with no enclosing bracket: enclose it in brackets if needed 763 | elseif (in_array($name, $this->raw_php_blocks)) { 764 | if ($name === 'else') { 765 | $code = $name . ':'; 766 | } 767 | elseif ($raw_args === '') { 768 | $this->parseError($line, 'Invalid block {' . $name . '}: no arguments supplied'); 769 | } 770 | else { 771 | $raw_args = $this->parseMagicVariables($raw_args); 772 | $code .= $name . '(' . $raw_args . '):'; 773 | } 774 | } 775 | // Foreach with arguments 776 | elseif ($name === 'foreach') { 777 | array_push($this->foreachelse_stack, false); 778 | $args = $this->parseArguments($raw_args, $line); 779 | 780 | $args['key'] = isset($args['key']) ? $this->getValueFromArgument($args['key']) : null; 781 | $args['item'] = isset($args['item']) ? $this->getValueFromArgument($args['item']) : null; 782 | $args['from'] = isset($args['from']) ? $this->getValueFromArgument($args['from']) : null; 783 | 784 | if (empty($args['item'])) { 785 | $this->parseError($line, 'Invalid foreach call: item parameter required.'); 786 | } 787 | 788 | if (empty($args['from'])) { 789 | $this->parseError($line, 'Invalid foreach call: from parameter required.'); 790 | } 791 | 792 | $key = $args['key'] ? '$' . $args['key'] . ' => ' : ''; 793 | 794 | $code .= $name . ' (' . $args['from'] . ' as ' . $key . '$' . $args['item'] . '):'; 795 | } 796 | // Special case for foreachelse (should be closed with {/if} instead of {/foreach}) 797 | elseif ($name === 'foreachelse') { 798 | array_push($this->foreachelse_stack, true); 799 | $code = 'endforeach; $_i_count = array_pop($_i); '; 800 | $code .= 'if ($_i_count === 0):'; 801 | } 802 | elseif ($name === 'include') { 803 | $args = $this->parseArguments($raw_args, $line); 804 | 805 | if (empty($args['file'])) { 806 | $this->parseError($line, '{include} function requires file parameter.'); 807 | } 808 | 809 | $root = $this->templates_dir; 810 | 811 | if (substr($args['file'], 0, 1) === '"' || substr($args['file'], 0, 1) === '\'') { 812 | $file = $this->getValueFromArgument($args['file']); 813 | 814 | if (substr($file, 0, 2) === './') { 815 | $file = dirname($this->template_path) . substr($file, 1); 816 | } 817 | elseif (substr($file, 0, 3) === '../') { 818 | $file = dirname(dirname($this->template_path)) . substr($file, 2); 819 | } 820 | else { 821 | $file = $root . '/' . ltrim($file, '/'); 822 | } 823 | 824 | $file = realpath($file); 825 | 826 | if (!$file) { 827 | $this->parseError($line, sprintf('Invalid template path for {include} function: %s', $args['file'])); 828 | } 829 | 830 | $args['file'] = var_export($file, true); 831 | } 832 | 833 | $file = $this->exportArgument($args['file']); 834 | unset($args['file']); 835 | 836 | if (count($args) > 0) { 837 | $assign = '$_s->assign(array_merge(get_defined_vars(), ' . $this->exportArguments($args) . '));'; 838 | } 839 | else { 840 | $assign = '$_s->assign(get_defined_vars());'; 841 | } 842 | 843 | $code = '$_s = get_class($this); $_s = new $_s(' . $file . ', $this); ' . $assign . ' $_s->display(); unset($_s);'; 844 | } 845 | else { 846 | if (array_key_exists($name, $this->blocks)) { 847 | $args = $this->parseArguments($raw_args); 848 | $code = sprintf('$_blocks[] = [%s, %s]; echo $this->blocks[%1$s](%2$s, null, $this); ob_start();', 849 | var_export($name, true), $this->exportArguments($args)); 850 | } 851 | elseif (array_key_exists($name, $this->functions)) { 852 | $args = $this->parseArguments($raw_args); 853 | $code = 'echo $this->functions[' . var_export($name, true) . '](' . $this->exportArguments($args) . ', $this);'; 854 | } 855 | else { 856 | // Let's try the user-defined compile callbacks 857 | // and if none of them return something, we are out 858 | 859 | foreach ($this->compile_functions as $function) { 860 | $code = call_user_func_array($function, [&$this, $line, $block, $name, $raw_args]); 861 | 862 | if ($code) { 863 | break; 864 | } 865 | } 866 | 867 | if (!$code) { 868 | if ($this->error_on_invalid_block) { 869 | $this->parseError($line, 'Unknown function or block: ' . $name); 870 | } 871 | else { 872 | // Return raw source block, this is probably javascript 873 | return false; 874 | } 875 | } 876 | } 877 | } 878 | 879 | if ($name === 'foreach' 880 | || $name === 'for' 881 | || $name === 'while') { 882 | // Iteration counter 883 | $code .= ' $iteration =& $_i[count($_i)-1]; $iteration++;'; 884 | } 885 | 886 | $code = ''; 887 | 888 | unset($args, $name, $line, $raw_args, $args, $block, $file); 889 | 890 | return $code; 891 | } 892 | 893 | /** 894 | * Parse closing blocks and returns PHP code 895 | */ 896 | protected function parseClosing(int $line, string $block): string 897 | { 898 | $code = ''; 899 | $name = trim(substr($block, 1)); 900 | 901 | switch ($name) { 902 | case 'foreach': 903 | case 'for': 904 | case 'while': 905 | // Close foreachelse 906 | if ($name === 'foreach' && array_pop($this->foreachelse_stack)) 907 | { 908 | $name = 'if'; 909 | } 910 | 911 | $code .= ' array_pop($_i); unset($iteration);'; 912 | case 'if': 913 | $code = 'end' . $name . ';' . $code; 914 | break; 915 | default: 916 | { 917 | if (array_key_exists($name, $this->blocks)) 918 | { 919 | $code = '$_b_content = ob_get_contents(); ob_end_clean(); $_b = array_pop($_blocks); echo $this->blocks[$_b[0]]($_b[1], $_b_content, $this); unset($_b, $_b_content);'; 920 | } 921 | elseif (array_key_exists($name, $this->compile_functions)) 922 | { 923 | $code = call_user_func_array($this->compile_functions[$name], [&$this, $line, $block, $name]); 924 | } 925 | else 926 | { 927 | $this->parseError($line, 'Unknown closing block: ' . $name); 928 | } 929 | break; 930 | } 931 | } 932 | 933 | $code = ''; 934 | 935 | unset($name, $line, $block); 936 | 937 | return $code; 938 | } 939 | 940 | /** 941 | * Parse a Smarty variable and returns a PHP code 942 | */ 943 | protected function parseVariable(int $line, string $block): string 944 | { 945 | $code = 'echo ' . $this->parseSingleVariable($block, $line) . ';'; 946 | $code = ''; 947 | 948 | return $code; 949 | } 950 | 951 | /** 952 | * Replaces $object.key and $array.key by method call to find the right value from $object and $array 953 | */ 954 | protected function parseMagicVariables(string $str): string 955 | { 956 | return preg_replace_callback('!(isset\s*\(\s*)?(\$[\w\d_]+)((?:\.[\w\d_]+)+)(\s*\))?!', function ($match) { 957 | $find = explode('.', $match[3]); 958 | $out = '$this->_magicVar(' . $match[2] . ', ' . var_export(array_slice($find, 1), true) . ')' . ($match[1] ? '' : @$match[4]); 959 | 960 | // You cannot isset a method, but the method returns NULL if nothing was found, 961 | // so we can test against this 962 | if (!empty($match[1])) { 963 | $out = 'null !== ' . $out; 964 | } 965 | 966 | return $out; 967 | }, $str); 968 | } 969 | 970 | /** 971 | * Throws an exception for the current template and hopefully giving the right line 972 | * @throws Smartyer_Exception 973 | */ 974 | public function parseError(?int $line, string $message, ?Exception $previous = null) 975 | { 976 | throw new Smartyer_Exception($message, $this->template_path, $line, $previous); 977 | } 978 | 979 | /** 980 | * Parse block arguments, this is similar to parsing HTML arguments 981 | * @param string $str List of arguments 982 | * @param integer $line Source code line 983 | * @return array 984 | */ 985 | public function parseArguments(string $str, ?int $line = null): array 986 | { 987 | if ($str === '') { 988 | return []; 989 | } 990 | 991 | $args = []; 992 | $state = 0; 993 | $name = null; 994 | $last_value = ''; 995 | 996 | preg_match_all('/(?:"(?:\\\\"|[^"])*?"|\'(?:\\\\\'|[^\'])*?\'|(?>[^"\'=\s]+))+|[=]/i', $str, $match); 997 | 998 | foreach ($match[0] as $value) 999 | { 1000 | if ($state === 0) { 1001 | $name = $value; 1002 | } 1003 | elseif ($state === 1) 1004 | { 1005 | if ($value !== '=') { 1006 | $this->parseError($line, 'Expecting \'=\' after \'' . $last_value . '\''); 1007 | } 1008 | } 1009 | elseif ($state === 2) 1010 | { 1011 | if ($value === '=') { 1012 | $this->parseError($line, 'Unexpected \'=\' after \'' . $last_value . '\''); 1013 | } 1014 | 1015 | $args[$name] = $this->parseSingleVariable($value, $line, false); 1016 | $name = null; 1017 | $state = -1; 1018 | } 1019 | 1020 | $last_value = $value; 1021 | $state++; 1022 | } 1023 | 1024 | unset($state, $last_value, $name, $str, $match); 1025 | 1026 | return $args; 1027 | } 1028 | 1029 | /** 1030 | * Returns string value from a quoted or unquoted block argument 1031 | * @param string $arg Extracted argument ({foreach from=$loop item="value"} => [from => "$loop", item => "\"value\""]) 1032 | * @return string Raw string 1033 | */ 1034 | public function getValueFromArgument(string $arg): string 1035 | { 1036 | static $replace = [ 1037 | '\\"' => '"', 1038 | '\\\'' => '\'', 1039 | '\\n' => "\n", 1040 | '\\t' => "\t", 1041 | '\\\\' => '\\', 1042 | ]; 1043 | 1044 | if (strlen($arg) && ($arg[0] === '"' || $arg[0] === "'")){ 1045 | return strtr(substr($arg, 1, -1), $replace); 1046 | } 1047 | 1048 | return $arg; 1049 | } 1050 | 1051 | /** 1052 | * Parse a variable, either from a {$block} or from an argument: {block arg=$bla|rot13} 1053 | * @param string $str Variable string 1054 | * @param integer $line Line position in the source 1055 | * @param boolean $escape Auto-escape the variable output? 1056 | * @return string PHP code to return the variable 1057 | */ 1058 | public function parseSingleVariable(string $str, ?int $line = null, bool $escape = true): string 1059 | { 1060 | // Split by pipe (|) except if enclosed in quotes 1061 | $modifiers = preg_split('/\|(?=(([^\'"]*["\']){2})*[^\'"]*$)/', $str); 1062 | $var = array_shift($modifiers); 1063 | 1064 | // No modifiers: easy! 1065 | if (count($modifiers) === 0) { 1066 | $str = $this->exportArgument($str); 1067 | 1068 | if ($escape) { 1069 | return 'self::escape(' . $str . ', $this->escape_type)'; 1070 | } 1071 | else { 1072 | return $str; 1073 | } 1074 | } 1075 | 1076 | $modifiers = array_reverse($modifiers); 1077 | 1078 | $pre = $post = ''; 1079 | 1080 | foreach ($modifiers as &$modifier) { 1081 | $_post = ''; 1082 | 1083 | $pos = strpos($modifier, ':'); 1084 | 1085 | // Arguments 1086 | if ($pos !== false) { 1087 | $mod_name = trim(substr($modifier, 0, $pos)); 1088 | $raw_args = substr($modifier, $pos+1); 1089 | 1090 | // Split by two points (:), or comma (,) except if enclosed in quotes 1091 | $arguments = preg_split('/\s*[:,]\s*|("(?:\\\\.|[^"])*?"|\'(?:\\\\.|[^\'])*?\'|[^:,\'"\s]+)/', trim($raw_args), 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); 1092 | $arguments = array_map([$this, 'exportArgument'], $arguments); 1093 | 1094 | $_post .= ', ' . implode(', ', $arguments); 1095 | } 1096 | else { 1097 | $mod_name = trim($modifier); 1098 | } 1099 | 1100 | // Disable autoescaping 1101 | if ($mod_name === 'raw') { 1102 | $escape = false; 1103 | continue; 1104 | } 1105 | 1106 | if ($mod_name === 'escape') { 1107 | $escape = false; 1108 | } 1109 | 1110 | // Modifiers MUST be registered at compile time 1111 | if (!array_key_exists($mod_name, $this->modifiers)) { 1112 | $this->parseError($line, 'Unknown modifier name: ' . $mod_name); 1113 | } 1114 | 1115 | $post = $_post . ')' . $post; 1116 | $pre .= '$this->modifiers[' . var_export($mod_name, true) . ']('; 1117 | } 1118 | 1119 | $var = $pre . $this->parseMagicVariables($var) . $post; 1120 | 1121 | unset($pre, $post, $arguments, $mod_name, $modifier, $modifiers, $pos, $_post); 1122 | 1123 | // auto escape 1124 | if ($escape) { 1125 | $var = 'self::escape(' . $var . ', $this->escape_type)'; 1126 | } 1127 | 1128 | return $var; 1129 | } 1130 | 1131 | /** 1132 | * Export a string to a PHP value, depending of its type 1133 | * 1134 | * Quoted strings will be escaped, variables and true/false/null left as is, 1135 | * but unquoted strings containing [\w\d_-] will be quoted and escaped 1136 | * 1137 | * @param string $str String to export 1138 | * @return string PHP escaped string 1139 | */ 1140 | public function exportArgument(string $str): string 1141 | { 1142 | $raw_values = ['true', 'false', 'null']; 1143 | 1144 | if ($str[0] === '$') { 1145 | $str = $this->parseMagicVariables($str); 1146 | } 1147 | elseif ($str[0] === '"' 1148 | || $str[0] === "'") { 1149 | $str = var_export($this->getValueFromArgument($str), true); 1150 | } 1151 | elseif (!in_array(strtolower($str), $raw_values) 1152 | && preg_match('/^[\w\d_-]+$/i', $str)) { 1153 | $str = var_export($str, true); 1154 | } 1155 | 1156 | return $str; 1157 | } 1158 | 1159 | /** 1160 | * Export an array to a string, like var_export but without escaping of strings 1161 | * 1162 | * This is used to reference variables and code in arrays 1163 | */ 1164 | public function exportArguments(array $args): string 1165 | { 1166 | $out = '['; 1167 | 1168 | foreach ($args as $key => $value) { 1169 | $out .= var_export($key, true) . ' => ' . trim($value) . ', '; 1170 | } 1171 | 1172 | $out .= ']'; 1173 | 1174 | return $out; 1175 | } 1176 | 1177 | /** 1178 | * Retrieve a magic variable like $object.key or $array.key.subkey 1179 | * @param mixed $var Variable to look into (object or array) 1180 | * @param array $keys List of keys to look for 1181 | * @return mixed NULL if the key doesn't exists, or the value associated to the key 1182 | */ 1183 | protected function _magicVar($var, array $keys) 1184 | { 1185 | $i = 0; 1186 | 1187 | while ($key = array_shift($keys)) { 1188 | if ($i++ > 20) { 1189 | // Limit the amount of recusivity we can go through 1190 | return null; 1191 | } 1192 | 1193 | if (is_object($var)) { 1194 | // Test for constants 1195 | if (defined(get_class($var) . '::' . $key)) { 1196 | return constant(get_class($var) . '::' . $key); 1197 | } 1198 | 1199 | if (!isset($var->$key)) { 1200 | return null; 1201 | } 1202 | 1203 | $var = $var->$key; 1204 | } 1205 | elseif (is_array($var)) { 1206 | if (!array_key_exists($key, $var)) { 1207 | return null; 1208 | } 1209 | 1210 | $var = $var[$key]; 1211 | } 1212 | } 1213 | 1214 | return $var; 1215 | } 1216 | 1217 | /** 1218 | * Native default escape modifier 1219 | */ 1220 | static protected function escape($str, ?string $type = 'html'): string 1221 | { 1222 | if ($type === 'json') { 1223 | $str = json_encode($str); 1224 | } 1225 | 1226 | if (is_array($str) || (is_object($str) && !method_exists($str, '__toString'))) { 1227 | throw new \InvalidArgumentException('Invalid parameter type for "escape" modifier: ' . gettype($str)); 1228 | } 1229 | 1230 | $str = (string) $str; 1231 | 1232 | switch ($type) 1233 | { 1234 | case 'html': 1235 | return htmlspecialchars($str, ENT_QUOTES, 'UTF-8'); 1236 | case 'xml': 1237 | return htmlspecialchars($str, ENT_QUOTES | ENT_XML1, 'UTF-8'); 1238 | case 'htmlall': 1239 | case 'entities': 1240 | return htmlentities($str, ENT_QUOTES, 'UTF-8'); 1241 | case 'url': 1242 | return rawurlencode($str); 1243 | case 'quotes': 1244 | return addslashes($str); 1245 | case 'hex': 1246 | return preg_replace_callback('/./', function ($match) { 1247 | return '%' . ord($match[0]); 1248 | }, $str); 1249 | case 'hexentity': 1250 | return preg_replace_callback('/./', function ($match) { 1251 | return '&#' . ord($match[0]) . ';'; 1252 | }, $str); 1253 | case 'mail': 1254 | return str_replace('.', '[dot]', $str); 1255 | case 'json': 1256 | return $str; 1257 | case 'js': 1258 | case 'javascript': 1259 | return strtr($str, [ 1260 | "\x08" => '\\b', "\x09" => '\\t', "\x0a" => '\\n', 1261 | "\x0b" => '\\v', "\x0c" => '\\f', "\x0d" => '\\r', 1262 | "\x22" => '\\"', "\x27" => '\\\'', "\x5c" => '\\' 1263 | ]); 1264 | default: 1265 | return $str; 1266 | } 1267 | } 1268 | 1269 | /** 1270 | * Simple wrapper for str_replace as modifier 1271 | */ 1272 | static public function replace(string $str, string $a, string $b): string 1273 | { 1274 | return str_replace($a, $b, $str); 1275 | } 1276 | 1277 | static public function replaceRegExp(string $str, string $a, string $b): string 1278 | { 1279 | return preg_replace($a, $b, $str); 1280 | } 1281 | 1282 | /** 1283 | * UTF-8 aware intelligent substr 1284 | * @param string $str UTF-8 string 1285 | * @param integer $length Maximum string length 1286 | * @param string $placeholder Placeholder text to append at the string if it has been cut 1287 | * @param boolean $strict_cut If true then will cut in the middle of words 1288 | * @return string String cut to $length or shorter 1289 | * @example |truncate:10:" (click to read more)":true 1290 | */ 1291 | static public function truncate(string $str, int $length = 80, string $placeholder = '…', bool $strict_cut = false): string 1292 | { 1293 | // Don't try to use unicode if the string is not valid UTF-8 1294 | $u = preg_match('//u', $str) ? 'u' : ''; 1295 | 1296 | // Shorter than $length + 1 1297 | if (!preg_match('/^.{' . ((int)$length + 1) . '}/s' . $u, $str)) { 1298 | return $str; 1299 | } 1300 | 1301 | // Cut at 80 characters 1302 | $str = preg_replace('/^(.{0,' . (int)$length . '}).*$/s' . $u, '$1', $str); 1303 | 1304 | if (!$strict_cut) { 1305 | $cut = preg_replace('/[^\s.,:;!?]*?$/s' . $u, '', $str); 1306 | 1307 | if (trim($cut) === '') { 1308 | $cut = $str; 1309 | } 1310 | } 1311 | 1312 | return trim($str) . $placeholder; 1313 | } 1314 | 1315 | /** 1316 | * Simple strftime wrapper 1317 | * @example |date_format:"%F %Y" 1318 | */ 1319 | static public function dateFormat($date, string $format = '%b, %e %Y'): string 1320 | { 1321 | if (is_null($date) || is_array($date)) { 1322 | return ''; 1323 | } 1324 | elseif (is_object($date)) { 1325 | $date = $date->getTimestamp(); 1326 | } 1327 | elseif (!is_numeric($date)) { 1328 | $date = strtotime($date); 1329 | } 1330 | 1331 | if (strpos('DATE_', $format) === 0 && defined($format)) { 1332 | return date(constant($format), $date); 1333 | } 1334 | 1335 | if (method_exists('\KD2\Translate', 'strftime')) { 1336 | return \KD2\Translate::strftime($format, $date); 1337 | } 1338 | elseif (function_exists('strftime')) { 1339 | return @strftime($format, $date); 1340 | } 1341 | } 1342 | 1343 | /** 1344 | * Concatenate strings (use |args instead!) 1345 | * @example $var|cat:$b:"ok" 1346 | */ 1347 | static public function concatenate(): string 1348 | { 1349 | return implode('', func_get_args()); 1350 | } 1351 | 1352 | /** 1353 | * {assign} compile function 1354 | */ 1355 | static public function templateAssign(Smartyer &$tpl, int $line, string $block, string $name, string $raw_args): ?string 1356 | { 1357 | if (rtrim(substr($block, 0, 6)) !== 'assign') { 1358 | return null; 1359 | } 1360 | 1361 | $args = $tpl->parseArguments($raw_args, $line); 1362 | 1363 | // Value can be NULL! 1364 | if (!isset($args['var']) || !array_key_exists('value', $args)) 1365 | { 1366 | throw new \BadFunctionCallException('Missing argument "var" or "value" to function {assign}'); 1367 | } 1368 | 1369 | $var = $tpl->getValueFromArgument($args['var']); 1370 | 1371 | if (!preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $var) 1372 | || $var === 'this' 1373 | || substr($var, 0, 1) === '_') { 1374 | throw new \BadFunctionCallException('Invalid variable name in "var" argument to function {assign}'); 1375 | } 1376 | 1377 | // Assign variable to _variables array, even for parent templates 1378 | $code = '$_t = $this->parent; while ($_t) { $_t->assign(%s, %s); $_t = $_t->parent; } unset($_t); '; 1379 | 1380 | // Assign variable locally 1381 | $code .= '${%1$s} = %2$s;'; 1382 | 1383 | $code = sprintf($code, 1384 | var_export($var, true), 1385 | $tpl->exportArgument($args['value']) 1386 | ); 1387 | 1388 | return $code; 1389 | } 1390 | } 1391 | 1392 | /** 1393 | * Templates exceptions 1394 | */ 1395 | class Smartyer_Exception extends Exception 1396 | { 1397 | public function __construct($message, $file, $line, $previous) 1398 | { 1399 | parent::__construct($message, 0, $previous); 1400 | $this->file = is_null($file) ? '::fromString() template' : $file; 1401 | $this->line = (int) $line; 1402 | } 1403 | } 1404 | --------------------------------------------------------------------------------