├── .editorconfig ├── .env.example ├── .gitignore ├── CHANGELOG.md ├── README.md ├── app ├── Controller │ ├── AuthController.php │ ├── Controller.php │ ├── IbcController.php │ ├── PageController.php │ └── UsersController.php ├── Helper │ └── Utils.php ├── Middleware │ └── AuthorizationMiddleware.php ├── Model │ ├── Book.php │ ├── Entry.php │ └── User.php ├── config.php ├── dependencies.php ├── middleware.php ├── routes.php └── settings.php ├── cache └── .gitignore ├── composer.json ├── composer.lock ├── deploy.php ├── lib └── Mwhite │ └── PhpIsbn │ ├── Isbn.php │ └── LICENSE ├── logs └── .gitignore ├── public ├── css │ └── style.css ├── htaccess.txt ├── images │ ├── book.png │ ├── book.svg │ └── no-photo.png ├── index.php └── js │ └── main.js ├── schema ├── 0.0.3 │ └── migration.sql ├── 0.1.0 │ └── migration.sql └── schema.sql ├── templates ├── boilerplate │ ├── layouts │ │ └── base-html-layout.twig │ └── partials │ │ └── head-meta.twig ├── layouts │ ├── default-layout.twig │ ├── generic-page-layout.twig │ ├── global-variables.twig │ └── partials │ │ ├── page-footer.twig │ │ ├── page-header.twig │ │ └── user-bar.twig ├── pages │ ├── 400.twig │ ├── 404.twig │ ├── 500.twig │ ├── about.twig │ ├── auth │ │ ├── re-authorize.twig │ │ └── start.twig │ ├── delete.twig │ ├── documentation.twig │ ├── entry.twig │ ├── export.twig │ ├── home.twig │ ├── isbn.twig │ ├── maintenance.twig │ ├── new-post.twig │ ├── profile.twig │ ├── review.twig │ ├── settings.twig │ └── updates.twig └── partials │ ├── entries │ └── read-status.twig │ ├── entry.twig │ └── interactive-messages.twig └── tests └── IbcTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 4 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | charset = utf-8 8 | 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_ENV="dev" 2 | IBC_EMAIL="" 3 | IBC_HOSTNAME="" 4 | IBC_BASE_URL="" 5 | IBC_DB_HOST="" 6 | IBC_DB_NAME="" 7 | IBC_DB_USERNAME="" 8 | IBC_DB_PASSWORD="" 9 | 10 | LOG_DIR="" 11 | LOG_NAME="indiebookclub" 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.env 2 | *.htaccess 3 | app/env.php 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.1.3] 2024-06-15 10 | ### Changed 11 | - Added client metadata JSON endpoint and updated client_id in IndieAuth requests 12 | - See https://github.com/indieweb/indieauth/issues/133 13 | 14 | ## [0.1.2] 2024-04-07 15 | ### Added 16 | - Add `draft` scope and `post-status` support [#21](https://github.com/gRegorLove/indiebookclub/issues/21) 17 | - Show an error if a Micropub user did not grant `create` scope [#21] 18 | 19 | ### Changed 20 | - Persist /new query parameters [#22](https://github.com/gRegorLove/indiebookclub/issues/22) 21 | 22 | ## [0.1.1] - 2023-12-02 23 | ### Added 24 | - Year in Review page for indiebookclub as a whole, e.g. `/review/2023` 25 | - Page is set to start being available November 30 each year. 26 | - Stats are updated and cached daily through the new year. 27 | - Stats are only for public posts. Private and unlisted posts are not included. 28 | 29 | ## [0.1.0] - 2022-11-14 30 | ### Added 31 | - Micropub re-try for posts that failed or were not published previously [#13](https://github.com/gRegorLove/indiebookclub/issues/13) 32 | - "Add to my list" shortcut link on posts [#3](https://github.com/gRegorLove/indiebookclub/issues/3) 33 | - Published Date and Time field to allow backdating [#12](https://github.com/gRegorLove/indiebookclub/issues/12) 34 | - Add profile name and photo [#19](https://github.com/gRegorLove/indiebookclub/issues/19) 35 | 36 | ### Changed 37 | - Updated IndieAuth\Client, now supports IndieAuth Server Metadata 38 | - Migrated templates to Twig 39 | - Refactored and modernized programming 40 | - Removed jQuery https://youmightnotneedjquery.com/ 41 | 42 | ## [0.0.3] - 2021-12-03 43 | ### Changed 44 | - Updated IndieAuth\Client usage, now supports PKCE 45 | - Updated page header to indicate domain you are signed in as, profile link, sign out link, and new post button 46 | 47 | ### Added 48 | - Support for micropub `visibility` property [#4](https://github.com/gRegorLove/indiebookclub/issues/4) 49 | - Support for micropub delete after re-authorizing and granting `delete` scope [#13](https://github.com/gRegorLove/indiebookclub/issues/13) 50 | 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # indiebookclub 2 | indiebookclub is a simple app for tracking books you are reading https://indiebookclub.biz 3 | 4 | # Installation 5 | indiebookclub requires PHP, MySQL, and Composer. It is intended to be installed at the root of a domain or sub-domain. 6 | 7 | * Set the domain’s document root to the `/public` directory 8 | * Configure the server to route requests through `/public/index.php` if they don’t match a file 9 | * If you are running Apache, do this by renaming `/public/htaccess.txt` to `/public/.htaccess` 10 | * Set up MySQL tables using `/schema/schema.sql` 11 | * Run `composer install` 12 | * Rename `/.env.example` to `/.env` and fill in your email, hostname, base URL, and MySQL connection information 13 | * Optionally specify the LOG_DIR if you want logs stored somewhere other than `/logs` 14 | 15 | ## Environment Variables 16 | In development, environment variables are loaded from the `.env` file on each request. In production, it is recommended to set/cache them in the environment to avoid that overhead. 17 | 18 | To cache environment variables, run at the command line: 19 | `php ./deploy.php` 20 | 21 | This will cache the environment variables in the file `/app/env.php`. 22 | 23 | Once cached, if you make updates to `.env`, you will need to run this command again to update the cache. You can also delete this cache file and the application will revert to development mode, loading directly from the `.env` file on each request. 24 | 25 | Advanced: If your hosting lets you set up environment variables another way, you can look in `/app/config.php` to change the code that checks for that cached environment file. 26 | 27 | ## Maintenance Mode 28 | Maintenance mode displays a maintenance message in place of all pages on the app. You can specify an IP address that is able to browse the site normally. 29 | 30 | To enable maintenance mode, update `/app/settings.php`. Set `offline` to `true` and `developer_ip` to your IP address. 31 | 32 | ## Development Mode 33 | Development mode displays all error messages and restricts login to a single domain. Unlike maintenance mode, public pages remain visible and do not display a maintenance message. 34 | 35 | To enable development mode, set the environment variable `APP_ENV` to `dev` (or anything other than `production`). Then update `/app/settings.php` and update `developer_domains` with the domain(s) you want to enable logins for. 36 | 37 | # Changelog 38 | * [Changelog](CHANGELOG.md) 39 | 40 | # Credits 41 | * Inspired by and using open source code from [Aaron Parecki’s](https://aaronparecki.com) [Teacup](https://teacup.p3k.io/) and [Quill](https://quill.p3k.io/). 42 | * “[Book](https://thenounproject.com/icon/1727889/)” icon by Beth Bolton from [the Noun Project](http://thenounproject.com/). 43 | * Naming inspiration: [Marty McGuire](https://martymcgui.re/) 44 | 45 | # License 46 | Copyright 2018 by gRegor Morrill. Licensed under the MIT license https://opensource.org/licenses/MIT 47 | 48 | This project also uses code with the following copyright and licenses: 49 | * [PhpIsbn library](https://github.com/mwhite/php-isbn) Copyright 2012 by Michael White. Licensed under the MIT license. 50 | * [Teacup](https://teacup.p3k.io/) Copyright 2014 by Aaron Parecki. Licensed under the Apache License, Version 2.0. 51 | -------------------------------------------------------------------------------- /app/Controller/AuthController.php: -------------------------------------------------------------------------------- 1 | router->pathFor('auth_callback'); 35 | 36 | // Previously: The client ID should be the home page of your app. 37 | // Client::$clientID = sprintf('https://%s/', $_ENV['IBC_HOSTNAME']); 38 | } 39 | 40 | public function client_metadata( 41 | ServerRequestInterface $request, 42 | ResponseInterface $response, 43 | array $args 44 | ) { 45 | return $response->withJson([ 46 | 'client_id' => $_ENV['IBC_BASE_URL'] . '/id', 47 | 'client_name' => 'indiebookclub', 48 | 'client_uri' => $_ENV['IBC_BASE_URL'] . '/', 49 | 'logo_uri' => $_ENV['IBC_BASE_URL'] . '/images/book.svg', 50 | 'redirect_uris' => [ 51 | $_ENV['IBC_BASE_URL'] . $this->router->pathFor('auth_callback'), 52 | ], 53 | ]); 54 | } 55 | 56 | /** 57 | * Start the authentication process 58 | */ 59 | public function start( 60 | ServerRequestInterface $request, 61 | ResponseInterface $response, 62 | array $args 63 | ) { 64 | // Attempt to normalize the 'me' parameter or display an error 65 | $me = $request->getQueryParam('me', ''); 66 | $me = Client::normalizeMeURL(trim($me)); 67 | if (false === $me) { 68 | return $this->httpErrorResponse( 69 | $response, 70 | 'The URL you entered is not valid.' 71 | ); 72 | } 73 | 74 | if ($request->getQueryParam('debug-me')) { 75 | return $this->httpErrorResponse( 76 | $response, 77 | sprintf('Normalized URL: %s', $me), 78 | 'Debugging' 79 | ); 80 | } 81 | 82 | $hostname = strtolower($this->utils->hostname($me)); 83 | 84 | // Prevent logging in with this domain. 85 | if ($hostname == $_ENV['IBC_HOSTNAME']) { 86 | return $this->httpErrorResponse( 87 | $response, 88 | 'No, we cannot go deeper. Please log in with your domain name. :]', 89 | 'Inception error' 90 | ); 91 | } 92 | 93 | // Prevent logging in with URL paths 94 | if (!in_array(parse_url($me, PHP_URL_PATH), ['/', '//'])) { 95 | return $this->httpErrorResponse( 96 | $response, 97 | 'URL paths like example.com/username are not currently supported. Please log in with only a domain name.' 98 | ); 99 | } 100 | 101 | // Restrict the domains that can log in to development environment. 102 | if ($_ENV['APP_ENV'] !== 'production' && !in_array($hostname, $this->settings['developer_domains'])) { 103 | return $this->httpErrorResponse( 104 | $response, 105 | 'This development instance does not permit logging in with that domain.' 106 | ); 107 | } 108 | 109 | $this->initClient(); 110 | 111 | $metadata_endpoint = Client::discoverMetadataEndpoint($me); 112 | $authorization_endpoint = Client::discoverAuthorizationEndpoint($me); 113 | $token_endpoint = Client::discoverTokenEndpoint($me); 114 | $revocation_endpoint = Client::discoverRevocationEndpoint($me); 115 | $micropub_endpoint = Client::discoverMicropubEndpoint($me); 116 | $is_micropub_user = ($authorization_endpoint && $token_endpoint && $micropub_endpoint); 117 | 118 | if ($is_micropub_user) { 119 | list($authorization_url, $error) = Client::begin($me, 'create draft profile'); 120 | } else { 121 | if (!$authorization_endpoint) { 122 | $authorization_endpoint = 'https://indielogin.com/auth'; 123 | } 124 | 125 | list($authorization_url, $error) = Client::begin($me, false, $authorization_endpoint); 126 | } 127 | 128 | if ($error) { 129 | return $this->httpErrorResponse( 130 | $response, 131 | sprintf('%s (%s)', $error['error_description'], $error['error']) 132 | ); 133 | } 134 | 135 | // Store endpoints in session. Used after authorization to add/update the user. 136 | $_SESSION['authorization_endpoint'] = $authorization_endpoint; 137 | $_SESSION['micropub_endpoint'] = $micropub_endpoint; 138 | $_SESSION['token_endpoint'] = $token_endpoint; 139 | $_SESSION['revocation_endpoint'] = $revocation_endpoint; 140 | 141 | $user = $this->User->findBySlug($hostname); 142 | if ($user) { 143 | // User has logged in before and isn't restarting; can redirect directly to $authorization_url 144 | if ($user['last_login'] && !$request->getQueryParam('restart')) { 145 | return $response->withRedirect($authorization_url, 302); 146 | } 147 | } 148 | 149 | return $this->view->render( 150 | $response, 151 | 'pages/auth/start.twig', 152 | compact( 153 | 'me', 154 | 'is_micropub_user', 155 | 'metadata_endpoint', 156 | 'authorization_endpoint', 157 | 'micropub_endpoint', 158 | 'token_endpoint', 159 | 'revocation_endpoint', 160 | 'authorization_url' 161 | ) 162 | ); 163 | } 164 | 165 | /** 166 | * Handle authentication callback 167 | */ 168 | public function callback( 169 | ServerRequestInterface $request, 170 | ResponseInterface $response, 171 | array $args 172 | ) { 173 | $this->initClient(); 174 | 175 | $params = $request->getQueryParams(); 176 | list($indieauth_response, $error) = Client::complete($params); 177 | 178 | if ($error) { 179 | return $this->httpErrorResponse( 180 | $response, 181 | sprintf('%s (%s)', $error['error_description'], $error['error']) 182 | ); 183 | } 184 | 185 | # get the profile name and photo, preferably from the IndieAuth profile response 186 | $me = $indieauth_response['me']; 187 | $profile = $this->utils->getProfileFromIndieAuth($indieauth_response['response']); 188 | 189 | if (!($profile['name'] && $profile['photo'])) { 190 | # fallback to the representative h-card for missing fields 191 | if ($h_card = Client::representativeHCard($me)) { 192 | $profile = $this->utils->getProfileFromHCard($profile, $h_card); 193 | } 194 | } 195 | 196 | $granted_scopes = $indieauth_response['response']['scope'] ?? ''; 197 | 198 | $supported_visibility = ''; 199 | if ($micropub_endpoint = $this->utils->session('micropub_endpoint')) { 200 | 201 | if (!$this->utils->hasScope($granted_scopes, 'create')) { 202 | return $this->httpErrorResponse( 203 | $response, 204 | 'You must grant the “create” permission in order to publish to your site. Please sign in again.' 205 | ); 206 | } 207 | 208 | # get config from the Micropub endpoint 209 | $config_response = $this->utils->micropub_get( 210 | $micropub_endpoint, 211 | $this->utils->getAccessToken(), 212 | ['q' => 'config'], 213 | ); 214 | 215 | if (array_key_exists('visibility', $config_response['data'])) { 216 | $supported_visibility = json_encode($config_response['data']['visibility']); 217 | } 218 | } 219 | 220 | $hostname = strtolower($this->utils->hostname($me)); 221 | $user = $this->User->findBySlug($hostname); 222 | 223 | $user_data = [ 224 | 'name' => $profile['name'], 225 | 'photo_url' => $profile['photo'], 226 | 'authorization_endpoint' => $this->utils->session('authorization_endpoint'), 227 | 'token_endpoint' => $this->utils->session('token_endpoint'), 228 | 'revocation_endpoint' => $this->utils->session('revocation_endpoint'), 229 | 'micropub_endpoint' => $this->utils->session('micropub_endpoint'), 230 | 'supported_visibility' => $supported_visibility, 231 | 'token_scope' => $granted_scopes, 232 | 'last_login' => true, 233 | ]; 234 | 235 | if ($user) { 236 | # update existing user 237 | $user = $this->User->update((int) $user['id'], $user_data); 238 | } else { 239 | # add new user 240 | $user_data['url'] = $me; 241 | $user_data['profile_slug'] = $hostname; 242 | $user = $this->User->add($user_data); 243 | } 244 | 245 | if (!$user) { 246 | $response = $response->withStatus(500); 247 | return $this->view->render($response, 'pages/500.twig'); 248 | } 249 | 250 | $this->utils->setAccessToken($indieauth_response); 251 | 252 | $_SESSION['me'] = $me; 253 | $_SESSION['hostname'] = $hostname; 254 | $_SESSION['user_id'] = (int) $user['id']; 255 | $_SESSION['display_name'] = $user['display_name']; 256 | $_SESSION['display_photo'] = $user['display_photo']; 257 | 258 | unset($_SESSION['authorization_endpoint']); 259 | unset($_SESSION['micropub_endpoint']); 260 | unset($_SESSION['token_endpoint']); 261 | 262 | # default redirect 263 | $redirect_url = $this->router->pathFor('new'); 264 | 265 | if ($signin_redirect = $this->utils->session('signin_redirect')) { 266 | # override with redirect that was previously sanitized and verified 267 | $redirect_url = $signin_redirect; 268 | unset($_SESSION['signin_redirect']); 269 | } 270 | 271 | return $response->withRedirect($redirect_url, 302); 272 | } 273 | 274 | /** 275 | * Route that handles re-authorizing 276 | */ 277 | public function re_authorize( 278 | ServerRequestInterface $request, 279 | ResponseInterface $response, 280 | array $args 281 | ) { 282 | $user = $this->User->get($this->utils->session('user_id')); 283 | 284 | if ($request->isPost()) { 285 | $data = $request->getParsedBody(); 286 | 287 | $this->initClient(); 288 | 289 | $me = Client::normalizeMeURL($user['url']); 290 | $scopes = implode(' ', $data['scopes']); 291 | 292 | $metadata_endpoint = Client::discoverMetadataEndpoint($me); 293 | $authorization_endpoint = Client::discoverAuthorizationEndpoint($me); 294 | $token_endpoint = Client::discoverTokenEndpoint($me); 295 | $micropub_endpoint = Client::discoverMicropubEndpoint($me); 296 | list($authorization_url, $error) = Client::begin($me, $scopes); 297 | 298 | if ($error) { 299 | return $this->httpErrorResponse( 300 | $response, 301 | sprintf('%s (%s)', $error['error_description'], $error['error']) 302 | ); 303 | } 304 | 305 | // Store endpoints in session. Used after authorization to add/update the user. 306 | $_SESSION['authorization_endpoint'] = $authorization_endpoint; 307 | $_SESSION['micropub_endpoint'] = $micropub_endpoint; 308 | $_SESSION['token_endpoint'] = $token_endpoint; 309 | 310 | return $response->withRedirect($authorization_url, 302); 311 | } 312 | 313 | $message = '

indiebookclub is requesting permission to delete posts from your site.

Click the button below to re-authorize the app.

'; 314 | 315 | $current_scopes = explode(' ', $user['token_scope']); 316 | 317 | return $this->view->render( 318 | $response, 319 | 'pages/auth/re-authorize.twig', 320 | compact( 321 | 'message', 322 | 'current_scopes' 323 | ) 324 | ); 325 | } 326 | 327 | /** 328 | * Reset endpoints then redirect to signout 329 | */ 330 | public function reset( 331 | ServerRequestInterface $request, 332 | ResponseInterface $response, 333 | array $args 334 | ) { 335 | $this->User->reset($this->utils->session('user_id')); 336 | return $response->withRedirect($this->router->pathFor('signout'), 302); 337 | } 338 | 339 | /** 340 | * Revoke access token, if applicable, and signout 341 | */ 342 | public function signout( 343 | ServerRequestInterface $request, 344 | ResponseInterface $response, 345 | array $args 346 | ) { 347 | if (!$this->utils->session('user_id')) { 348 | # already signed out 349 | return $response->withRedirect('/', 302); 350 | } 351 | 352 | $user = $this->User->get($this->utils->session('user_id')); 353 | 354 | if ($user['revocation_endpoint']) { 355 | $this->utils->send_token_revocation( 356 | $user['revocation_endpoint'], 357 | $this->utils->getAccessToken() 358 | ); 359 | } elseif ($user['token_endpoint']) { 360 | $this->utils->send_legacy_token_revocation( 361 | $user['token_endpoint'], 362 | $this->utils->getAccessToken() 363 | ); 364 | } 365 | 366 | $keys = [ 367 | 'auth', 368 | 'me', 369 | 'hostname', 370 | 'user_id', 371 | 'display_name', 372 | 'display_photo', 373 | ]; 374 | 375 | foreach ($keys as $Key) { 376 | unset($_SESSION[$Key]); 377 | } 378 | 379 | return $response->withRedirect('/', 302); 380 | } 381 | 382 | private function httpErrorResponse( 383 | ResponseInterface $response, 384 | string $message = null, 385 | string $short_title = null, 386 | int $status = 400 387 | ): ResponseInterface { 388 | $response = $response->withStatus($status); 389 | return $this->view->render( 390 | $response, 391 | 'pages/400.twig', 392 | compact('short_title', 'message') 393 | ); 394 | } 395 | } 396 | 397 | -------------------------------------------------------------------------------- /app/Controller/Controller.php: -------------------------------------------------------------------------------- 1 | ci = $container; 29 | } 30 | 31 | /** 32 | * @param $name 33 | */ 34 | public function __get($name) 35 | { 36 | if ($this->ci->has($name)) { 37 | return $this->ci->get($name); 38 | } 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /app/Controller/IbcController.php: -------------------------------------------------------------------------------- 1 | User->get($this->utils->session('user_id')); 40 | $validation_errors = []; 41 | 42 | if ($request->isPost()) { 43 | $data = $request->getParsedBody(); 44 | 45 | if (!$this->validate_post_request($data)) { 46 | $response = $response->withStatus(400); 47 | return $this->view->render($response, 'pages/400.twig'); 48 | } 49 | 50 | $validation_errors = $this->validate_new_post($data); 51 | 52 | if (count($validation_errors) === 0) { 53 | 54 | if ($user['micropub_endpoint']) { 55 | $mp_request = $this->build_micropub_request($data); 56 | 57 | $mp_response = $this->utils->micropub_post( 58 | $user['micropub_endpoint'], 59 | $mp_request, 60 | $this->utils->getAccessToken(), 61 | true 62 | ); 63 | 64 | $response_body = trim($mp_response['response']); 65 | 66 | $canonical_url = null; 67 | if (isset($mp_response['headers']['Location'])) { 68 | $canonical_url = $data['canonical_url'] = $mp_response['headers']['Location'][0]; 69 | $data['micropub_success'] = 1; 70 | } 71 | 72 | # DEBUG 73 | // $response_body = 'debug'; 74 | // $canonical_url = $data['canonical_url'] = 'https://example.com/debug'; 75 | // $data['micropub_success'] = 1; 76 | // $data['post_status'] = 'published'; 77 | 78 | # Update user 79 | $user = $this->User->update( 80 | $this->utils->session('user_id'), 81 | [ 82 | 'last_micropub_response' => $response_body, 83 | ] 84 | ); 85 | 86 | if ('draft' == $data['post_status']) { 87 | # drafts are only sent to Micropub endpoint, not stored on IBC 88 | 89 | # default redirect: IBC profile 90 | $url = $this->router->pathFor('profile', ['domain' => $user['profile_slug']]); 91 | if ($canonical_url) { 92 | # redirect to canonical 93 | $url = $canonical_url; 94 | } 95 | 96 | return $response->withRedirect($url, 302); 97 | } 98 | 99 | # continue to add entry to IBC at this point 100 | 101 | $data['micropub_response'] = $response_body; 102 | } 103 | 104 | $data['user_id'] = $this->utils->session('user_id'); 105 | 106 | if ($data['isbn'] = Isbn::to13($data['isbn'])) { 107 | $this->Book->addOrIncrement($data); 108 | } 109 | 110 | $data['category'] = $this->utils->normalizeSeparatedString($data['category']); 111 | $entry = $this->Entry->add($data); 112 | 113 | if (!$entry) { 114 | $response = $response->withStatus(500); 115 | return $this->view->render($response, 'pages/500.twig'); 116 | } 117 | 118 | $entry_id = (int) $entry['id']; 119 | $this->cache_entry($entry_id); 120 | 121 | # default redirect: IBC permalink 122 | $url = $this->router->pathFor( 123 | 'entry', 124 | [ 125 | 'domain' => $user['profile_slug'], 126 | 'entry' => $entry_id, 127 | ] 128 | ); 129 | 130 | if ($entry['canonical_url']) { 131 | # redirect to canonical 132 | $url = $entry['canonical_url']; 133 | } 134 | 135 | return $response->withRedirect($url, 302); 136 | } 137 | } 138 | 139 | $options_status = $this->utils->get_read_status_options(); 140 | $options_post_status = $this->utils->get_post_status_options(); 141 | $options_visibility = $this->utils->get_visibility_options($user); 142 | 143 | $post_status = null; 144 | if ($this->utils->hasScope($user['token_scope'], 'draft')) { 145 | $post_status = 'draft'; 146 | } 147 | 148 | if ($temp_status = $request->getQueryParam('post-status')) { 149 | $post_status = strtolower($temp_status); 150 | if (!in_array($post_status, $this->utils->get_post_status_options())) { 151 | $post_status = 'published'; 152 | } 153 | } 154 | 155 | if ($read_status = $request->getQueryParam('read-status')) { 156 | $read_status = strtolower($read_status); 157 | } 158 | 159 | if (!in_array($read_status, ['to-read', 'reading', 'finished'])) { 160 | $read_status = 'to-read'; 161 | } 162 | 163 | $read_title = $this->utils->sanitize($request->getQueryParam('title')); 164 | $read_authors = $this->utils->sanitize($request->getQueryParam('authors')); 165 | $read_isbn = $this->utils->sanitize($request->getQueryParam('isbn')); 166 | $read_doi = $this->utils->sanitize($request->getQueryParam('doi')); 167 | $read_tags = $this->utils->sanitize($request->getQueryParam('tags')); 168 | 169 | if ($read_of = $request->getQueryParam('read-of')) { 170 | $parsed = $this->utils->parse_read_of($read_of); 171 | $read_title = $this->utils->sanitize($parsed['title']); 172 | $read_authors = $this->utils->sanitize($parsed['authors']); 173 | $read_isbn = $this->utils->sanitize($parsed['uid']); 174 | } 175 | 176 | return $this->view->render( 177 | $response, 178 | 'pages/new-post.twig', 179 | compact( 180 | 'user', 181 | 'read_status', 182 | 'read_title', 183 | 'read_authors', 184 | 'read_isbn', 185 | 'read_doi', 186 | 'read_tags', 187 | 'post_status', 188 | 'options_status', 189 | 'options_post_status', 190 | 'options_visibility', 191 | 'validation_errors' 192 | ) 193 | ); 194 | } 195 | 196 | /** 197 | * Re-try a Micropub request 198 | * 199 | * Ensures the post is owned by the current user and has not been 200 | * published to their site already before sending the Micropub 201 | * request. 202 | * @see https://github.com/gRegorLove/indiebookclub/issues/13 203 | */ 204 | public function retry( 205 | ServerRequestInterface $request, 206 | ResponseInterface $response, 207 | array $args 208 | ) { 209 | $user = $this->User->get($this->utils->session('user_id')); 210 | 211 | $entry_id = (int) $args['entry_id']; 212 | $entry = $this->Entry->getUserEntry($entry_id, $this->utils->session('user_id')); 213 | if (!$entry) { 214 | return $response->withStatus(404); 215 | } 216 | 217 | if ($entry['canonical_url']) { 218 | return $this->view->render($response, 'pages/400.twig', [ 219 | 'short_title' => 'Error', 220 | 'message' => sprintf('

This post has already been published on your site. View the post.

', 221 | $entry['canonical_url'] 222 | ), 223 | ]); 224 | } 225 | 226 | if (!$user['micropub_endpoint']) { 227 | $response = $response->withStatus(400); 228 | return $this->view->render($response, 'pages/400.twig', [ 229 | 'short_title' => 'Micropub Error', 230 | 'message' => '

Your site does not appear to support Micropub.

', 231 | ]); 232 | } 233 | 234 | $url = $this->router->pathFor( 235 | 'entry', 236 | [ 237 | 'domain' => $user['profile_slug'], 238 | 'entry' => $entry_id, 239 | ] 240 | ); 241 | 242 | // Send to the micropub endpoint and store the result. 243 | $mp_request = $this->build_micropub_request($entry); 244 | 245 | $mp_response = $this->utils->micropub_post( 246 | $user['micropub_endpoint'], 247 | $mp_request, 248 | $this->utils->getAccessToken(), 249 | true 250 | ); 251 | 252 | $response_body = trim($mp_response['response']); 253 | 254 | # Update user 255 | $user = $this->User->update($this->utils->session('user_id'), [ 256 | 'last_micropub_response' => $response_body, 257 | ]); 258 | 259 | # Update entry 260 | $entry_data = [ 261 | 'micropub_response' => $response_body, 262 | ]; 263 | 264 | if (isset($mp_response['headers']['Location'])) { 265 | $entry_data['canonical_url'] = reset($mp_response['headers']['Location']); 266 | $entry_data['micropub_success'] = 1; 267 | } 268 | 269 | $entry = $this->Entry->update($entry_id, $entry_data); 270 | 271 | if ($entry['canonical_url']) { 272 | $url = $entry['canonical_url']; 273 | } 274 | 275 | return $response->withRedirect($url, 302); 276 | } 277 | 278 | public function delete( 279 | ServerRequestInterface $request, 280 | ResponseInterface $response, 281 | array $args 282 | ) { 283 | $profile = $this->User->get($this->utils->session('user_id')); 284 | $errors = []; 285 | 286 | if ($request->isPost()) { 287 | $data = $request->getParsedBody(); 288 | 289 | $allowlist = array_fill_keys([ 290 | 'confirm_delete', 291 | 'mp_delete', 292 | 'id', 293 | ], 0); 294 | 295 | if (!$this->validate_post_request($data, $allowlist)) { 296 | $response = $response->withStatus(400); 297 | return $this->view->render($response, 'pages/400.twig'); 298 | } 299 | 300 | $errors = $this->validate_delete_post($data); 301 | 302 | if (count($errors) === 0) { 303 | # double check: can only delete own posts 304 | $entry_id = (int) $data['id']; 305 | $entry = $this->Entry->getUserEntry($entry_id, $this->utils->session('user_id')); 306 | if (!$entry) { 307 | $response = $response->withStatus(403); 308 | return $this->view->render($response, 'pages/400.twig'); 309 | } 310 | 311 | // Send delete to the micropub endpoint 312 | if ($profile['micropub_endpoint'] && $data['mp_delete'] == 'yes' && $entry['canonical_url']) { 313 | $mp_request = [ 314 | 'action' => 'delete', 315 | 'url' => $entry['canonical_url'], 316 | ]; 317 | 318 | $mp_response = $this->utils->micropub_post( 319 | $profile['micropub_endpoint'], 320 | $mp_request, 321 | $this->utils->getAccessToken() 322 | ); 323 | 324 | $response_body = trim($mp_response['response']); 325 | 326 | # Update user 327 | $user = $this->User->update($this->utils->session('user_id'), [ 328 | 'last_micropub_response' => $response_body, 329 | ]); 330 | } 331 | 332 | $this->uncache_entry($entry_id); 333 | $this->Entry->delete($entry_id); 334 | 335 | $url = $this->router->pathFor('profile', ['domain' => $profile['profile_slug']]); 336 | return $response->withRedirect($url, 302); 337 | } 338 | 339 | } elseif ($request->isGet()) { 340 | $entry = $this->Entry->getUserEntry((int) $args['id'], $this->utils->session('user_id')); 341 | if (!$entry) { 342 | return $response->withStatus(404); 343 | } 344 | } 345 | 346 | $is_micropub_post = ($entry['micropub_success'] && $entry['canonical_url']); 347 | $has_micropub_delete = $this->utils->hasScope($profile['token_scope'], 'delete'); 348 | 349 | $is_caching = true; 350 | return $this->view->render( 351 | $response, 352 | 'pages/delete.twig', 353 | compact( 354 | 'errors', 355 | 'entry', 356 | 'profile', 357 | 'is_micropub_post', 358 | 'has_micropub_delete', 359 | 'is_caching' 360 | ) 361 | ); 362 | } 363 | 364 | /** 365 | * Route that handles the ISBN stream 366 | */ 367 | public function isbn( 368 | ServerRequestInterface $request, 369 | ResponseInterface $response, 370 | array $args 371 | ) { 372 | $limit = 2; # default is 10 373 | $isbn = $args['isbn']; 374 | $before = (int) $request->getQueryParam('before'); 375 | $entries = $this->Entry->findByIsbn($isbn, $before, $limit); 376 | 377 | $last_id = (int) end($entries)['id']; 378 | $first_id = (int) reset($entries)['id']; 379 | 380 | $older_id = $this->Entry->getOlderByIsbn($isbn, $last_id); 381 | $newer_id = $this->Entry->getNewerByIsbn($isbn, $first_id); 382 | 383 | return $this->view->render( 384 | $response, 385 | 'pages/isbn.twig', 386 | compact('isbn', 'entries', 'before', 'older_id', 'newer_id') 387 | ); 388 | } 389 | 390 | public function review( 391 | ServerRequestInterface $request, 392 | ResponseInterface $response, 393 | array $args 394 | ) { 395 | $year = (int) $args['year']; 396 | if ($year < 2023) { 397 | return $response->withStatus(404); 398 | } 399 | 400 | $dt = new DateTime(); 401 | $dt_available = new DateTime(sprintf('%d-11-30', $year)); 402 | 403 | if ($dt < $dt_available) { 404 | $messages = [ 405 | 'Flux capacitor required to view this page.', 406 | 'Isn’t time flying by fast enough already?', 407 | 'Patience is a virtue.', 408 | ]; 409 | $index = array_rand($messages, 1); 410 | 411 | $response = $response->withStatus(404); 412 | return $this->view->render( 413 | $response, 414 | 'pages/400.twig', 415 | [ 416 | 'short_title' => 'Not Yet!', 417 | 'message' => sprintf('

%s

', $messages[$index]) 418 | ] 419 | ); 420 | } 421 | 422 | $file_path = sprintf('%s/cache/%d-review.html', 423 | APP_DIR, 424 | $year 425 | ); 426 | 427 | # after the new year, don't need to refresh the cache 428 | $is_final = ($dt->format('Y') > $year); 429 | $is_cached = ($is_final && file_exists($file_path)); 430 | if ($is_cached) { 431 | echo file_get_contents($file_path); 432 | exit; 433 | } 434 | 435 | # before new year, check if cache is more than 1 day old 436 | if (file_exists($file_path)) { 437 | $cache_time = filemtime($file_path); 438 | if (false !== $cache_time) { 439 | $dt_cache = new DateTime('@' . $cache_time); 440 | $interval = $dt->diff($dt_cache); 441 | $days = (int) $interval->format('%a'); 442 | if ($days < 1) { 443 | $is_cached = true; 444 | } 445 | } 446 | } 447 | 448 | if ($is_cached) { 449 | echo file_get_contents($file_path); 450 | exit; 451 | } 452 | 453 | # refresh the cache 454 | $start_date = sprintf('%d-01-01', $year); 455 | $end_date = sprintf('%d-12-31', $year); 456 | 457 | $number_new_entries = $this->Entry->getNewCount( 458 | $start_date, 459 | $end_date 460 | ); 461 | 462 | $number_new_books = $this->Book->getNewCount( 463 | $start_date, 464 | $end_date 465 | ); 466 | 467 | $distinct_entries = $this->Entry->findDistinct( 468 | $start_date, 469 | $end_date 470 | ); 471 | 472 | $number_new_users = $this->User->getNewCount( 473 | $start_date, 474 | $end_date 475 | ); 476 | 477 | $number_logins = $this->User->getLoginCount( 478 | $start_date, 479 | $end_date 480 | ); 481 | 482 | $html = $this->view->fetch( 483 | 'pages/review.twig', 484 | compact( 485 | 'dt', 486 | 'year', 487 | 'is_final', 488 | 'number_new_entries', 489 | 'number_new_books', 490 | 'distinct_entries', 491 | 'number_new_users', 492 | 'number_logins', 493 | ) 494 | ); 495 | 496 | if (file_put_contents($file_path, trim($html)) === false) { 497 | throw new Exception('Could not write review cache file'); 498 | } 499 | 500 | echo $html; 501 | exit; 502 | } 503 | 504 | protected function validate_post_request(array $data, array $allowlist = []): bool 505 | { 506 | if (!$allowlist) { 507 | $allowlist = array_fill_keys([ 508 | 'read_status', 509 | 'title', 510 | 'authors', 511 | 'switch-uid', 512 | 'doi', 513 | 'isbn', 514 | 'category', 515 | 'post_status', 516 | 'visibility', 517 | 'published', 518 | 'tz_offset', 519 | ], 0); 520 | } 521 | 522 | if (count(array_diff_key($data, $allowlist)) > 0) { 523 | return false; 524 | } 525 | 526 | return true; 527 | } 528 | 529 | /** 530 | * Validate new post fields 531 | * 532 | * Returns an array of error messages 533 | */ 534 | protected function validate_new_post(array $data): array 535 | { 536 | $errors = []; 537 | 538 | if (!$data['read_status']) { 539 | $errors[] = 'Please select the Read Status'; 540 | } 541 | 542 | if (!$data['title']) { 543 | $errors[] = 'Please enter the Title'; 544 | } 545 | 546 | if ($data['isbn'] && Isbn::to13($data['isbn'], true) === false) { 547 | $errors[] = 'The ISBN entered appears to be invalid'; 548 | } 549 | 550 | if ($data['published']) { 551 | try { 552 | $dt = new DateTime($data['published']); 553 | $temp_errors = DateTime::getLastErrors(); 554 | 555 | if (!empty($temp_errors['warning_count'])) { 556 | throw new Exception(); 557 | } 558 | } catch (Exception $e) { 559 | $errors[] = 'The Published datetime appears to be invalid'; 560 | } 561 | } 562 | 563 | return $errors; 564 | } 565 | 566 | /** 567 | * Validate delete post fields 568 | * 569 | * Returns an array of error messages 570 | */ 571 | protected function validate_delete_post(array $data): array 572 | { 573 | $errors = []; 574 | 575 | if ($data['confirm_delete'] == 'no') { 576 | $errors[] = 'Please check the box to confirm deletion'; 577 | } 578 | 579 | return $errors; 580 | } 581 | 582 | /** 583 | * Cache read post 584 | */ 585 | protected function cache_entry(int $id): bool 586 | { 587 | try { 588 | $entry = $this->Entry->get($id); 589 | if (!$entry) { 590 | throw new Exception('Could not load entry'); 591 | } 592 | 593 | $profile = $this->User->get((int) $entry['user_id']); 594 | if (!$profile) { 595 | throw new Exception('Could not load user'); 596 | } 597 | 598 | $is_caching = true; 599 | $src = $this->view->fetch( 600 | 'partials/entry.twig', 601 | compact('entry', 'profile', 'is_caching') 602 | ); 603 | 604 | $file_path = sprintf('%s/cache/%s-%d.html', 605 | APP_DIR, 606 | $profile['profile_slug'], 607 | $id 608 | ); 609 | 610 | if (file_put_contents($file_path, trim($src)) === false) { 611 | throw new Exception('Could not write file'); 612 | } 613 | 614 | return true; 615 | } catch (Exception $e) { 616 | $this->logger->error( 617 | 'Error caching entry: ' . $e->getMessage(), 618 | compact('id') 619 | ); 620 | return false; 621 | } 622 | } 623 | 624 | /** 625 | * Un-cache read post 626 | */ 627 | protected function uncache_entry(int $id): bool 628 | { 629 | try { 630 | $entry = $this->Entry->get($id); 631 | if (!$entry) { 632 | throw new Exception('Could not load entry'); 633 | } 634 | 635 | $user = $this->User->get((int) $entry['user_id']); 636 | if (!$user) { 637 | throw new Exception('Could not load user'); 638 | } 639 | 640 | $file_path = sprintf('%s/cache/%s-%d.html', 641 | APP_DIR, 642 | $user['profile_slug'], 643 | $id 644 | ); 645 | 646 | if (file_exists($file_path)) { 647 | unlink($file_path); 648 | } 649 | 650 | return true; 651 | } catch (Exception $e) { 652 | $this->logger->error( 653 | 'Error un-caching entry: ' . $e->getMessage(), 654 | compact('id') 655 | ); 656 | return false; 657 | } 658 | } 659 | 660 | /** 661 | * Build Micropub request from the entry 662 | */ 663 | protected function build_micropub_request(array $data): array 664 | { 665 | $summary = sprintf('%s: %s', 666 | $this->utils->get_read_status_for_humans($data['read_status']), 667 | $data['title'] 668 | ); 669 | 670 | $cite = [ 671 | 'type' => ['h-cite'], 672 | 'properties' => [ 673 | 'name' => [$data['title']], 674 | ] 675 | ]; 676 | 677 | if ($data['authors']) { 678 | $cite['properties']['author'] = [$data['authors']]; 679 | $summary .= sprintf(' by %s', $data['authors']); 680 | } 681 | 682 | if ($doi = $data['doi']) { 683 | if (stripos($doi, 'doi:') !== 0) { 684 | $doi = 'doi:' . $doi; 685 | } 686 | 687 | $cite['properties']['uid'] = [$doi]; 688 | $summary .= sprintf(', %s', $doi); 689 | } elseif ($data['isbn']) { 690 | $cite['properties']['uid'] = ['isbn:' . $data['isbn']]; 691 | $summary .= sprintf(', ISBN: %s', $data['isbn']); 692 | } 693 | 694 | $properties = [ 695 | 'summary' => [$summary], 696 | 'read-status' => [$data['read_status']], 697 | 'read-of' => [$cite], 698 | 'post-status' => [$data['post_status']], 699 | 'visibility' => [$data['visibility']], 700 | ]; 701 | 702 | if (array_key_exists('published', $data) && $data['published']) { 703 | $properties['published'] = [ 704 | $this->Entry->get_datetime_with_offset( 705 | $data['published'], 706 | (int) $data['tz_offset'] 707 | ), 708 | ]; 709 | } 710 | 711 | if ($data['category']) { 712 | $properties['category'] = $this->utils->get_category_array($data['category']); 713 | } 714 | 715 | return [ 716 | 'type' => ['h-entry'], 717 | 'properties' => $properties, 718 | ]; 719 | } 720 | } 721 | 722 | -------------------------------------------------------------------------------- /app/Controller/PageController.php: -------------------------------------------------------------------------------- 1 | utils->session('signin_prompt')) { 31 | $show_signin_prompt = true; 32 | unset($_SESSION['signin_prompt']); 33 | } 34 | 35 | return $this->view->render( 36 | $response, 37 | 'pages/home.twig', 38 | compact('show_signin_prompt') 39 | ); 40 | } 41 | 42 | /** 43 | * Route that handles the about page 44 | */ 45 | public function about( 46 | ServerRequestInterface $request, 47 | ResponseInterface $response, 48 | array $args 49 | ) { 50 | return $this->view->render($response, 'pages/about.twig'); 51 | } 52 | 53 | /** 54 | * Route that handles the documentation page 55 | */ 56 | public function documentation( 57 | ServerRequestInterface $request, 58 | ResponseInterface $response, 59 | array $args 60 | ) { 61 | return $this->view->render($response, 'pages/documentation.twig'); 62 | } 63 | 64 | /** 65 | * Route that handles the updates page 66 | */ 67 | public function updates( 68 | ServerRequestInterface $request, 69 | ResponseInterface $response, 70 | array $args 71 | ) { 72 | return $this->view->render($response, 'pages/updates.twig'); 73 | } 74 | } 75 | 76 | -------------------------------------------------------------------------------- /app/Controller/UsersController.php: -------------------------------------------------------------------------------- 1 | User->findBySlug($args['domain']); 31 | if (!$profile) { 32 | return $response->withStatus(404); 33 | } 34 | 35 | // $limit = 10; # default is 10 36 | $user_id = (int) $profile['id']; 37 | $before = (int) $request->getQueryParam('before'); 38 | $entries = $this->Entry->findByUser($user_id, $before); 39 | 40 | $older_id = $newer_id = null; 41 | if ($entries) { 42 | $last_id = (int) end($entries)['id']; 43 | $first_id = (int) reset($entries)['id']; 44 | 45 | $older_id = $this->Entry->getOlderId($user_id, $last_id); 46 | $newer_id = $this->Entry->getNewerId($user_id, $first_id); 47 | } 48 | 49 | return $this->view->render( 50 | $response, 51 | 'pages/profile.twig', 52 | compact( 53 | 'profile', 54 | 'entries', 55 | 'before', 56 | 'older_id', 57 | 'newer_id' 58 | ) 59 | ); 60 | } 61 | 62 | /** 63 | * Route that handles an individual entry 64 | */ 65 | public function entry(ServerRequestInterface $request, ResponseInterface $response, array $args) 66 | { 67 | $profile = $this->User->findBySlug($args['domain']); 68 | if (!$profile) { 69 | return $response->withStatus(404); 70 | } 71 | 72 | $id = (int) $args['entry']; 73 | $user_id = (int) $profile['id']; 74 | 75 | $entry = $this->Entry->getUserEntry($id, $user_id); 76 | if (!$entry) { 77 | return $response->withStatus(404); 78 | } 79 | 80 | if ($entry['visibility'] == 'private' && $this->utils->session('user_id') !== $entry['user_id']) { 81 | return $response->withStatus(404); 82 | } 83 | 84 | $can_retry = false; 85 | $is_own_post = ($entry['user_id'] == $this->utils->session('user_id')); 86 | $micropub_failed = ( 87 | ($profile['type'] == 'micropub') 88 | && ($entry['micropub_success'] == 0) 89 | && empty($entry['canonical_url']) 90 | ); 91 | 92 | if ($is_own_post && $micropub_failed) { 93 | $can_retry = true; 94 | } 95 | 96 | $cached_entry = null; 97 | if ($entry['user_id'] != $this->utils->session('user_id')) { 98 | # not the post author, check for cached entry 99 | $file_path = sprintf('%s/cache/%s-%d.html', 100 | APP_DIR, 101 | $profile['profile_slug'], 102 | $id 103 | ); 104 | 105 | if (file_exists($file_path)) { 106 | $cached_entry = '' . PHP_EOL . file_get_contents($file_path); 107 | } 108 | } 109 | 110 | return $this->view->render( 111 | $response, 112 | 'pages/entry.twig', 113 | compact( 114 | 'profile', 115 | 'entry', 116 | 'cached_entry', 117 | 'can_retry' 118 | ) 119 | ); 120 | } 121 | 122 | /** 123 | * Route that handles the settings page 124 | */ 125 | public function settings(ServerRequestInterface $request, ResponseInterface $response, array $args) 126 | { 127 | $validation_errors = []; 128 | $user = $this->User->get($this->utils->session('user_id')); 129 | $options_visibility = $this->utils->get_visibility_options($user); 130 | 131 | $supported_visibility = $user['supported_visibility'] ?? null; 132 | if ($supported_visibility) { 133 | if ($options = json_decode($supported_visibility)) { 134 | $supported_visibility = implode(', ', $options); 135 | } 136 | } 137 | 138 | $access_token = $this->utils->getAccessToken(); 139 | $token_ending = null; 140 | if ($token_length = strlen($access_token)) { 141 | $token_ending = substr($access_token, -7); 142 | } 143 | 144 | $version = $this->settings['version']; 145 | 146 | if ($this->utils->session('validation_errors')) { 147 | $validation_errors = $this->utils->session('validation_errors'); 148 | unset($_SESSION['validation_errors']); 149 | } 150 | 151 | return $this->view->render( 152 | $response, 153 | 'pages/settings.twig', 154 | compact( 155 | 'validation_errors', 156 | 'user', 157 | 'supported_visibility', 158 | 'options_visibility', 159 | 'token_length', 160 | 'token_ending', 161 | 'version' 162 | ) 163 | ); 164 | } 165 | 166 | /** 167 | * Route that handles the settings/update POST request 168 | */ 169 | public function settings_update(ServerRequestInterface $request, ResponseInterface $response, array $args) 170 | { 171 | $data = $request->getParsedBody(); 172 | 173 | if (!$this->validate_post_request($data)) { 174 | $response = $response->withStatus(400); 175 | return $this->view->render($response, 'pages/400.twig'); 176 | } 177 | 178 | $errors = $this->validate_settings($data); 179 | 180 | if (count($errors) === 0) { 181 | $user = $this->User->update($this->utils->session('user_id'), $data); 182 | if (!$user) { 183 | $response = $response->withStatus(500); 184 | return $this->view->render($response, 'pages/500.twig'); 185 | } 186 | 187 | $redirect = $this->router->pathFor('settings', [], ['updated' => 1]); 188 | return $response->withRedirect($redirect, 302); 189 | } 190 | 191 | $_SESSION['validation_errors'] = $errors; 192 | return $response->withRedirect($this->router->pathFor('settings'), 302); 193 | } 194 | 195 | /** 196 | * Route that handles the export page 197 | */ 198 | public function export(ServerRequestInterface $request, ResponseInterface $response, array $args) 199 | { 200 | $profile = $this->User->get($this->utils->session('user_id')); 201 | if (!$profile) { 202 | $response = $response->withStatus(500); 203 | return $this->view->render($response, 'pages/500.twig'); 204 | } 205 | 206 | $is_cached = false; 207 | $latest_entry = $this->Entry->getUserLatestEntry($this->utils->session('user_id')); 208 | 209 | $file_path = sprintf('%s/cache/%s-all.html', 210 | APP_DIR, 211 | $profile['profile_slug'] 212 | ); 213 | 214 | if (file_exists($file_path)) { 215 | $latest = new DateTime($latest_entry['published']); 216 | $cached = new DateTime('@' . filemtime($file_path)); 217 | 218 | if ($cached > $latest) { 219 | $is_cached = true; 220 | } else { 221 | unlink($file_path); 222 | } 223 | } 224 | 225 | $dt = new DateTime(); 226 | 227 | if ($is_cached) { 228 | $src = file_get_contents($file_path); 229 | } else { 230 | $is_caching = true; 231 | $inline_css = trim(file_get_contents(APP_DIR . '/public/css/style.css')); 232 | $entries = $this->Entry->findByUserExport($this->utils->session('user_id')); 233 | $export_timestamp = $dt->format('Y-m-d H:i:sO'); 234 | 235 | $src = trim($this->view->fetch( 236 | 'pages/export.twig', 237 | compact( 238 | 'is_caching', 239 | 'inline_css', 240 | 'profile', 241 | 'entries', 242 | 'export_timestamp' 243 | ) 244 | )); 245 | 246 | if (file_put_contents($file_path, $src) === false) { 247 | $this->logger->error( 248 | 'Error caching profile.', 249 | compact('args') 250 | ); 251 | 252 | $response = $response->withStatus(500); 253 | return $this->view->render($response, 'pages/500.twig'); 254 | } 255 | } 256 | 257 | $header_disposition = sprintf('Content-Disposition: attachment; filename="indiebookclub-%s-%s.html"', 258 | $profile['profile_slug'], 259 | $dt->format('Y-m-d-Hi') 260 | ); 261 | $header_length = sprintf('Content-Length: %s', mb_strlen($src)); 262 | 263 | header('Content-Type: text/html; charset=utf8'); 264 | header($header_disposition); 265 | header('Cache-Control: max-age=0'); 266 | header('Expires: 0'); 267 | header('Accept-Ranges: bytes'); 268 | header($header_length); 269 | header('Connection: close'); 270 | echo $src; 271 | exit; 272 | } 273 | 274 | private function validate_post_request(array $data): bool 275 | { 276 | $allowlist = array_fill_keys([ 277 | 'default_visibility', 278 | ], 0); 279 | 280 | if (count(array_diff_key($data, $allowlist)) > 0) { 281 | return false; 282 | } 283 | 284 | return true; 285 | } 286 | 287 | private function validate_settings(array $data): array 288 | { 289 | $errors = []; 290 | 291 | if (array_key_exists('default_visibility', $data)) { 292 | if (!in_array($data['default_visibility'], ['public', 'private', 'unlisted'])) { 293 | $errors[] = 'Invalid selection for Default Visibility'; 294 | } 295 | } 296 | 297 | return $errors; 298 | } 299 | } 300 | 301 | -------------------------------------------------------------------------------- /app/Helper/Utils.php: -------------------------------------------------------------------------------- 1 | 'Want to read', 82 | 'reading' => 'Currently reading', 83 | 'finished' => 'Finished reading', 84 | ]; 85 | } 86 | 87 | /** 88 | * Get a human-friendly read status 89 | */ 90 | public function get_read_status_for_humans(string $read_status): string 91 | { 92 | switch ($read_status) { 93 | case 'finished': 94 | $text = 'Finished reading'; 95 | break; 96 | 97 | case 'reading': 98 | $text = 'Currently reading'; 99 | break; 100 | 101 | case 'to-read': 102 | default: 103 | $text = 'Want to read'; 104 | break; 105 | } 106 | 107 | return $text; 108 | } 109 | 110 | /** 111 | * Normalize a string of text with provided separator 112 | * 113 | * Removes extra whitespace between parts of the input string. 114 | */ 115 | public function normalizeSeparatedString(string $input, string $separator = ','): string 116 | { 117 | $parts = explode($separator, $input); 118 | $parts = array_map('trim', $parts); 119 | $parts = array_filter($parts); 120 | return implode($separator, $parts); 121 | } 122 | 123 | /** 124 | * Convert a comma-separated string of categories to an array 125 | */ 126 | public function get_category_array(string $category): array 127 | { 128 | return explode(',', $this->normalizeSeparatedString($category)); 129 | } 130 | 131 | /** 132 | * Get list of post_status options 133 | */ 134 | public function get_post_status_options(): array 135 | { 136 | return [ 137 | 'published', 138 | 'draft', 139 | ]; 140 | } 141 | 142 | /** 143 | * Get list of visibility options 144 | * 145 | * This depends on what the Micropub endpoint indicates 146 | * it supports. 147 | */ 148 | public function get_visibility_options(array $user): array 149 | { 150 | $supported = $user['supported_visibility'] ?? null; 151 | if (!$supported) { 152 | return ['Public']; 153 | } 154 | 155 | $supported = json_decode($supported); 156 | $options = []; 157 | foreach (['public', 'private', 'unlisted'] as $value) { 158 | if (in_array($value, $supported)) { 159 | $options[] = ucfirst($value); 160 | } 161 | } 162 | 163 | if ($options) { 164 | return $options; 165 | } 166 | 167 | # IBC does not support any of the listed options 168 | return ['Public']; 169 | } 170 | 171 | /** 172 | * Set access_token in the $_SESSION 173 | */ 174 | public function setAccessToken(array $indieauth_response): void 175 | { 176 | $access_token = $indieauth_response['response']['access_token'] ?? null; 177 | if ($access_token) { 178 | $_SESSION['auth']['access_token'] = $access_token; 179 | } 180 | } 181 | 182 | /** 183 | * Get access_token from $_SESSION 184 | */ 185 | public function getAccessToken() 186 | { 187 | if (isset($_SESSION['auth']['access_token'])) { 188 | return $_SESSION['auth']['access_token']; 189 | } 190 | 191 | return ''; 192 | } 193 | 194 | /** 195 | * Attempt to get the user's profile from the IndieAuth response 196 | */ 197 | public function getProfileFromIndieAuth(array $indieauth_response): array 198 | { 199 | $profile = array_fill_keys(['name', 'photo'], ''); 200 | 201 | if (array_key_exists('profile', $indieauth_response)) { 202 | $name = $indieauth_response['profile']['name'] ?? null; 203 | $photo = $indieauth_response['profile']['photo'] ?? null; 204 | 205 | if ($name) { 206 | $profile['name'] = $name; 207 | } 208 | 209 | if ($photo) { 210 | $profile['photo'] = $photo; 211 | } 212 | } 213 | 214 | return $profile; 215 | } 216 | 217 | /** 218 | * Attempt to get the user's profile from an h-card 219 | */ 220 | public function getProfileFromHCard(array $profile, array $h_card): array 221 | { 222 | if (!$profile['name'] && Mf2helper\hasProp($h_card, 'name')) { 223 | $profile['name'] = Mf2helper\getPlaintext($h_card, 'name'); 224 | } 225 | 226 | if (!$profile['photo'] && Mf2helper\hasProp($h_card, 'photo')) { 227 | $profile['photo'] = Mf2helper\getPlaintext($h_card, 'photo'); 228 | } 229 | 230 | return $profile; 231 | } 232 | 233 | public function is_url_allowed(string $url): bool 234 | { 235 | $allowlist_hosts = [ 236 | 'indiebookclub.biz', 237 | 'dev.indiebookclub.biz', 238 | ]; 239 | 240 | $url = filter_var($url, FILTER_SANITIZE_URL); 241 | $info = parse_url($url); 242 | $scheme = (string) ($info['scheme'] ?? ''); 243 | $hostname = $info['host'] ?? null; 244 | 245 | # invalid scheme 246 | if ($scheme && !in_array($scheme, ['https'])) { 247 | return false; 248 | } 249 | 250 | # hostname is not in allowlist 251 | if ($hostname && !in_array($hostname, $allowlist_hosts)) { 252 | return false; 253 | } 254 | 255 | return true; 256 | } 257 | 258 | public function get_redirect( 259 | string $url, 260 | string $default_destination = '/' 261 | ): string { 262 | if ($this->is_url_allowed($url)) { 263 | return $url; 264 | } 265 | 266 | return $default_destination; 267 | } 268 | 269 | /** 270 | * Append query params to a URL 271 | */ 272 | public function build_url($url, $params = []) 273 | { 274 | if (!$params) { 275 | return $url; 276 | } 277 | 278 | $join_char = '?'; 279 | if (parse_url($url, PHP_URL_QUERY)) { 280 | $join_char = '&'; 281 | } 282 | 283 | return $url . $join_char . http_build_query($params); 284 | } 285 | 286 | public function hasScope(string $scopes, string $expected): bool 287 | { 288 | $scopes = explode(' ', $this->normalizeSeparatedString($scopes, ' ')); 289 | return in_array($expected, $scopes); 290 | } 291 | 292 | /** 293 | * Send a Micropub POST request 294 | * @param string $endpoint 295 | * @param array $params 296 | * @param string $access_token 297 | * @param bool $json 298 | * @author Aaron Parecki, https://aaronparecki.com 299 | * @copyright 2014 Aaron Parecki 300 | * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 301 | */ 302 | public function micropub_post($endpoint, $params, $access_token, $json = false) 303 | { 304 | $ch = curl_init(); 305 | curl_setopt($ch, CURLOPT_URL, $endpoint); 306 | curl_setopt($ch, CURLOPT_POST, true); 307 | 308 | // Send the access token in both the header and post body to support more clients 309 | // https://github.com/aaronpk/Quill/issues/4 310 | // http://indiewebcamp.com/irc/2015-02-14#t1423955287064 311 | $httpheaders = ['Authorization: Bearer ' . $access_token]; 312 | 313 | if (!$json) { 314 | $params['access_token'] = $access_token; 315 | 316 | if (!array_key_exists('action', $params)) { 317 | $params['h'] = 'entry'; 318 | } 319 | } 320 | 321 | if ($json) { 322 | $httpheaders[] = 'Accept: application/json'; 323 | $httpheaders[] = 'Content-type: application/json'; 324 | $post = json_encode($params); 325 | } else { 326 | $post = http_build_query($params); 327 | $post = preg_replace('/%5B[0-9]+%5D/', '%5B%5D', $post); // change [0] to [] 328 | } 329 | 330 | curl_setopt($ch, CURLOPT_HTTPHEADER, $httpheaders); 331 | curl_setopt($ch, CURLOPT_POSTFIELDS, $post); 332 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 333 | curl_setopt($ch, CURLOPT_HEADER, true); 334 | curl_setopt($ch, CURLINFO_HEADER_OUT, true); 335 | 336 | $response = curl_exec($ch); 337 | $sent_headers = curl_getinfo($ch, CURLINFO_HEADER_OUT); 338 | $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE); 339 | $header_str = trim(substr($response, 0, $header_size)); 340 | $request = $sent_headers . (is_string($post) ? $post : http_build_query($post)); 341 | 342 | return [ 343 | 'request' => $request, 344 | 'response' => $response, 345 | 'code' => curl_getinfo($ch, CURLINFO_HTTP_CODE), 346 | 'headers' => $this->parse_headers($header_str), 347 | 'error' => curl_error($ch), 348 | 'curlinfo' => curl_getinfo($ch) 349 | ]; 350 | } 351 | 352 | /** 353 | * @author Aaron Parecki, https://aaronparecki.com 354 | * @copyright 2014 Aaron Parecki 355 | * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 356 | */ 357 | public function micropub_get( 358 | string $endpoint, 359 | string $access_token, 360 | array $params = [] 361 | ) { 362 | $endpoint = $this->build_url($endpoint, $params); 363 | 364 | $ch = curl_init(); 365 | curl_setopt($ch, CURLOPT_URL, $endpoint); 366 | curl_setopt($ch, CURLOPT_HTTPHEADER, [ 367 | 'Authorization: Bearer ' . $access_token, 368 | 'Accept: application/json', 369 | ]); 370 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 371 | 372 | $response = curl_exec($ch); 373 | $data = []; 374 | 375 | if ($response) { 376 | $data = @json_decode($response, true) ?? []; 377 | } 378 | 379 | $error = curl_error($ch); 380 | 381 | return [ 382 | 'response' => $response, 383 | 'data' => $data, 384 | 'error' => $error, 385 | 'curlinfo' => curl_getinfo($ch) 386 | ]; 387 | } 388 | 389 | /** 390 | * Parse HTTP headers 391 | * @param string $headers 392 | * @author Aaron Parecki, https://aaronparecki.com 393 | * @copyright 2014 Aaron Parecki 394 | * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 395 | */ 396 | public function parse_headers($headers) 397 | { 398 | $retVal = []; 399 | $fields = explode("\r\n", preg_replace('/\x0D\x0A[\x09\x20]+/', ' ', $headers)); 400 | foreach ($fields as $field) { 401 | if (preg_match('/([^:]+): (.+)/m', $field, $match)) { 402 | $match[1] = preg_replace_callback('/(?<=^|[\x09\x20\x2D])./', function($m) { 403 | return strtoupper($m[0]); 404 | }, strtolower(trim($match[1]))); 405 | // If there's already a value set for the header name being returned, turn it into an array and add the new value 406 | if (isset($retVal[$match[1]])) { 407 | $retVal[$match[1]][] = trim($match[2]); 408 | } else { 409 | $retVal[$match[1]] = [trim($match[2])]; 410 | } 411 | } 412 | } 413 | return $retVal; 414 | } 415 | 416 | /** 417 | * Revoke an access token 418 | * 419 | * Note: authentication is not yet supported for revocation 420 | * endpoint 421 | * 422 | * @see https://indieauth.spec.indieweb.org/#token-revocation-request 423 | * @see https://github.com/aaronpk/Quill/commit/bb0752a72692d03b61f1719dca2a7cdc2b3052cc 424 | */ 425 | public function send_token_revocation( 426 | string $endpoint, 427 | string $token 428 | ) { 429 | $fields = compact('token'); 430 | 431 | $ch = curl_init(); 432 | curl_setopt($ch, CURLOPT_URL, $endpoint); 433 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 434 | curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields)); 435 | curl_exec($ch); 436 | } 437 | 438 | /** 439 | * LEGACY 440 | * Revoke an access token 441 | * 442 | * Earlier versions of the IndieAuth specification 443 | * sent revocation requests to the token endpoint with 444 | * action=revoke. 445 | * 446 | * Should only use this method if site specifies a token 447 | * endpoint but no revocation endpoint. 448 | * 449 | * @see https://indieauth.spec.indieweb.org/#token-revocation-request 450 | * @see https://github.com/aaronpk/Quill/commit/bb0752a72692d03b61f1719dca2a7cdc2b3052cc 451 | */ 452 | public function send_legacy_token_revocation( 453 | string $endpoint, 454 | string $token 455 | ) { 456 | $action = 'revoke'; 457 | $fields = compact('action', 'token'); 458 | 459 | $ch = curl_init(); 460 | curl_setopt($ch, CURLOPT_URL, $endpoint); 461 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 462 | curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields)); 463 | curl_exec($ch); 464 | } 465 | 466 | /** 467 | * Parse a URL for read-of microformats 468 | */ 469 | public function parse_read_of(string $url): array 470 | { 471 | $result = array_fill_keys(['title', 'authors', 'uid'], ''); 472 | $url = filter_var($url, FILTER_SANITIZE_URL); 473 | 474 | if (!$url) { 475 | return $result; 476 | } 477 | 478 | $mf = Mf2\fetch($url); 479 | 480 | if (!Mf2helper\isMicroformatCollection($mf)) { 481 | return $result; 482 | } 483 | 484 | $entries = Mf2helper\findMicroformatsByType($mf, 'h-entry'); 485 | 486 | if ($entries) { 487 | $entry = reset($entries); 488 | 489 | if (Mf2helper\hasProp($entry, 'read-of')) { 490 | $read_of = reset($entry['properties']['read-of']); 491 | 492 | if (Mf2helper\isMicroformat($read_of)) { 493 | $result['title'] = (Mf2helper\hasProp($read_of, 'name')) ? Mf2helper\getPlaintext($read_of, 'name') : Mf2helper\toPlaintext($read_of); 494 | $result['authors'] = Mf2helper\getPlaintext($read_of, 'author'); 495 | $result['uid'] = Mf2helper\getPlaintext($read_of, 'uid'); 496 | } elseif (is_string($read_of)) { 497 | $result['title'] = $read_of; 498 | } 499 | 500 | return $result; 501 | } 502 | } 503 | 504 | // at this point, either no h-entry was found, or was found and did not have read-of property 505 | // parse for h-cite 506 | 507 | if ($citations = Mf2helper\findMicroformatsByType($mf, 'h-cite')) { 508 | $cite = reset($citations); 509 | $result['title'] = Mf2helper\getPlaintext($cite, 'name'); 510 | $result['authors'] = Mf2helper\getPlaintext($cite, 'author'); 511 | $result['uid'] = Mf2helper\getPlaintext($cite, 'uid'); 512 | } 513 | 514 | return $result; 515 | } 516 | 517 | public function notify_admin( 518 | string $message, 519 | string $subject = 'indiebookclub admin notification' 520 | ): bool { 521 | $from = 'no-reply@' . $_ENV['IBC_HOSTNAME']; 522 | $mailer = new PHPMailer(); 523 | $mailer->CharSet = 'UTF-8'; 524 | $mailer->isSendmail(); 525 | $mailer->setFrom($from, 'indiebookclub admin'); 526 | $mailer->addAddress($_ENV['IBC_EMAIL']); 527 | $mailer->Subject = $subject; 528 | $mailer->Body = $message; 529 | return $mailer->send(); 530 | } 531 | } 532 | 533 | -------------------------------------------------------------------------------- /app/Middleware/AuthorizationMiddleware.php: -------------------------------------------------------------------------------- 1 | container = $container; 30 | } 31 | 32 | public function __invoke( 33 | ServerRequestInterface $request, 34 | ResponseInterface $response, 35 | $next 36 | ): ResponseInterface { 37 | $route = $request->getAttribute('route'); 38 | if (!$route) { 39 | return $next($request, $response); 40 | } 41 | 42 | if (strpos($request->getUri()->getPath(), '//') === 0) { 43 | return $response->withStatus(404); 44 | } 45 | 46 | if ($this->is_authentication_required($route)) { 47 | if ($request->isPost()) { 48 | $response = $response->withStatus(401); 49 | return $this->container->view->render( 50 | $response, 51 | 'pages/400.twig', [ 52 | 'short_title' => 'Unauthorized', 53 | 'message' => '

Please log in

', 54 | ] 55 | ); 56 | } 57 | 58 | $route_name = $route->getName() ?? 'index'; 59 | $params = $this->get_allowlist_params( 60 | $request->getQueryParams(), 61 | $route_name 62 | ); 63 | 64 | try { 65 | # attempt to build the redirect URL 66 | $redirect = $this->container->router->pathFor( 67 | $route_name, 68 | $route->getArguments(), 69 | $params 70 | ); 71 | } catch (Exception $e) { 72 | # otherwise, default path 73 | $redirect = $this->container->router->pathFor('index'); 74 | } 75 | 76 | $redirect = $this->container->utils 77 | ->get_redirect($redirect); 78 | 79 | $_SESSION['signin_prompt'] = true; 80 | $_SESSION['signin_redirect'] = $redirect; 81 | 82 | # http redirect status 83 | $status = 302; 84 | if ($request->isPost()) { 85 | $status = 303; 86 | } 87 | 88 | return $response->withRedirect( 89 | $this->container->router->pathFor('index'), 90 | $status 91 | ); 92 | } 93 | 94 | return $next($request, $response); 95 | } 96 | 97 | protected function is_authenticated(): bool 98 | { 99 | return (array_key_exists('user_id', $_SESSION) && !is_null($_SESSION['user_id'])); 100 | } 101 | 102 | protected function is_authentication_required(Route $route): bool 103 | { 104 | # already authenticated 105 | if ($this->is_authenticated()) { 106 | return false; 107 | } 108 | 109 | $route_name = $route->getName(); 110 | if (in_array($route_name, $this->authenticated_route_names)) { 111 | return true; 112 | } 113 | 114 | return false; 115 | } 116 | 117 | protected function get_allowlist_params( 118 | array $params, 119 | string $route_name = 'new' 120 | ): array { 121 | 122 | if ($route_name == 'new') { 123 | $allowlist = array_fill_keys([ 124 | 'read-status', 125 | 'title', 126 | 'authors', 127 | 'isbn', 128 | 'doi', 129 | 'tags', 130 | ], ''); 131 | 132 | return array_intersect_key( 133 | $params, 134 | $allowlist 135 | ); 136 | } 137 | 138 | return []; 139 | } 140 | } 141 | 142 | -------------------------------------------------------------------------------- /app/Model/Book.php: -------------------------------------------------------------------------------- 1 | table_name)->create(); 25 | $record->isbn = $data['isbn'] ?? ''; 26 | $record->entry_count = 1; 27 | $record->first_user_id = $data['user_id'] ?? 0; 28 | $record->set_expr('created', 'NOW()'); 29 | $record->set_expr('modified', 'NOW()'); 30 | 31 | if ($record->save()) { 32 | return $this->get((int) $record->id); 33 | } 34 | 35 | return null; 36 | } 37 | 38 | public function update(int $id, array $data): ?array 39 | { 40 | $record = ORM::for_table($this->table_name) 41 | ->where('id', $id) 42 | ->find_one(); 43 | 44 | if (!$record) { 45 | return null; 46 | } 47 | 48 | if (array_key_exists('entry_count', $data)) { 49 | $record->entry_count = $data['entry_count'] ?? 0; 50 | } 51 | 52 | if ($record->save()) { 53 | return $this->get($id); 54 | } 55 | 56 | return null; 57 | } 58 | 59 | /** 60 | * Add an ISBN if it's new to IBC, otherwise 61 | * increment the entry count. 62 | */ 63 | public function addOrIncrement(array $data): ?array 64 | { 65 | $isbn = $data['isbn'] ?? ''; 66 | $record = ORM::for_table($this->table_name) 67 | ->where('isbn', $isbn) 68 | ->find_one(); 69 | 70 | if (!$record) { 71 | return $this->add($data); 72 | } 73 | 74 | $data['entry_count'] = $record->entry_count + 1; 75 | 76 | return $this->update((int) $record->id, $data); 77 | } 78 | 79 | public function get(int $id): ?array 80 | { 81 | $record = ORM::for_table($this->table_name) 82 | ->where('id', $id) 83 | ->find_one(); 84 | 85 | if ($record) { 86 | return $record->as_array(); 87 | } 88 | 89 | return null; 90 | } 91 | 92 | /** 93 | * Get number of books created during the specified timeframe 94 | * 95 | * Note: books may have been part of a private or unlisted 96 | * post, so be careful when querying book information. 97 | * That's why current usage is only to get the total number 98 | * of new books. 99 | */ 100 | public function getNewCount( 101 | string $start_date, 102 | string $end_date 103 | ): int { 104 | $dt_start = new DateTime($start_date); 105 | $dt_end = new DateTime($end_date); 106 | $dt_end->setTime(23, 59, 59); 107 | 108 | return ORM::for_table($this->table_name) 109 | ->where_gte('created', $dt_start->format('Y-m-d')) 110 | ->where_lte('created', $dt_end->format('Y-m-d')) 111 | ->where_null('deleted') 112 | ->order_by_desc('created') 113 | ->count(); 114 | } 115 | } 116 | 117 | -------------------------------------------------------------------------------- /app/Model/Entry.php: -------------------------------------------------------------------------------- 1 | table_name)->create(); 31 | $record->isbn = $data['isbn'] ?? ''; 32 | $record->doi = $data['doi'] ?? ''; 33 | $record->user_id = $data['user_id']; 34 | $record->published = $dt_published->format('Y-m-d H:i:s'); 35 | $record->tz_offset = $this->tz_offset_to_seconds($data['tz_offset']); 36 | $record->read_status = $data['read_status'] ?? ''; 37 | $record->title = $data['title'] ?? ''; 38 | $record->authors = $data['authors'] ?? ''; 39 | $record->category = $data['category'] ?? ''; 40 | $record->visibility = $data['visibility'] ?? ''; 41 | $record->canonical_url = $data['canonical_url'] ?? ''; 42 | $record->micropub_success = (int) ($data['micropub_success'] ?? 0); 43 | $record->micropub_response = $data['micropub_response'] ?? ''; 44 | 45 | if ($record->save()) { 46 | return $this->get((int) $record->id); 47 | } 48 | 49 | return null; 50 | } 51 | 52 | public function update(int $id, array $data): ?array 53 | { 54 | $record = ORM::for_table($this->table_name) 55 | ->where('id', $id) 56 | ->find_one(); 57 | 58 | if (!$record) { 59 | return null; 60 | } 61 | 62 | if (array_key_exists('canonical_url', $data)) { 63 | $record->canonical_url = $data['canonical_url'] ?? ''; 64 | } 65 | 66 | if (array_key_exists('micropub_success', $data)) { 67 | $record->micropub_success = $data['micropub_success'] ?? 0; 68 | } 69 | 70 | if (array_key_exists('micropub_response', $data)) { 71 | $record->micropub_response = $data['micropub_response'] ?? ''; 72 | } 73 | 74 | if ($record->save()) { 75 | return $this->get($id); 76 | } 77 | 78 | return null; 79 | } 80 | 81 | public function get(int $id): ?array 82 | { 83 | $record = ORM::for_table($this->table_name) 84 | ->where('id', $id) 85 | ->find_one(); 86 | 87 | if ($record) { 88 | return $record->as_array(); 89 | } 90 | 91 | return null; 92 | } 93 | 94 | /** 95 | * Get an entry by id + user_id 96 | */ 97 | public function getUserEntry(int $id, int $user_id): ?array 98 | { 99 | $record = ORM::for_table($this->table_name) 100 | ->table_alias('e') 101 | ->select_many_expr([ 102 | 'e.id', 103 | 'e.read_status', 104 | 'e.title', 105 | 'e.authors', 106 | 'e.isbn', 107 | 'e.doi', 108 | 'e.url', 109 | 'e.category', 110 | 'e.published', 111 | 'e.tz_offset', 112 | 'e.visibility', 113 | 'e.content', 114 | 'e.canonical_url', 115 | 'e.micropub_success', 116 | 'e.user_id', 117 | 'profile_name' => 'u.name', 118 | 'profile_url' => 'u.url', 119 | 'profile_photo_url' => 'u.photo_url', 120 | 'u.profile_slug', 121 | 'user_type' => 'u.type', 122 | ]) 123 | ->join('users', ['e.user_id', '=', 'u.id'], 'u') 124 | ->where('e.id', $id) 125 | ->where('e.user_id', $user_id) 126 | ->find_one(); 127 | 128 | if ($record) { 129 | return $record->as_array(); 130 | } 131 | 132 | return null; 133 | } 134 | 135 | public function getUserLatestEntry(int $user_id): ?array 136 | { 137 | $record = ORM::for_table($this->table_name) 138 | ->where('user_id', $user_id) 139 | ->order_by_desc('published') 140 | ->limit(1) 141 | ->find_one(); 142 | 143 | if ($record) { 144 | return $record->as_array(); 145 | } 146 | 147 | return null; 148 | } 149 | 150 | public function findByUser( 151 | int $user_id, 152 | ?int $before = null, 153 | int $limit = 10 154 | ): array { 155 | $records = ORM::for_table($this->table_name) 156 | ->table_alias('e') 157 | ->select_many_expr([ 158 | 'e.id', 159 | 'e.read_status', 160 | 'e.title', 161 | 'e.authors', 162 | 'e.isbn', 163 | 'e.doi', 164 | 'e.url', 165 | 'e.category', 166 | 'e.published', 167 | 'e.tz_offset', 168 | 'e.visibility', 169 | 'e.content', 170 | 'e.canonical_url', 171 | 'e.micropub_success', 172 | 'e.user_id', 173 | 'profile_name' => 'u.name', 174 | 'profile_url' => 'u.url', 175 | 'profile_photo_url' => 'u.photo_url', 176 | 'u.profile_slug', 177 | 'user_type' => 'u.type', 178 | ]) 179 | ->join('users', ['e.user_id', '=', 'u.id'], 'u') 180 | ->where('e.user_id', $user_id) 181 | ->where_not_equal('e.visibility', 'unlisted'); 182 | 183 | if ($before) { 184 | $records->where_lt('id', $before); 185 | } 186 | 187 | $records = $records->order_by_desc('published') 188 | ->limit($limit); 189 | 190 | return $records->find_array(); 191 | } 192 | 193 | public function findByIsbn( 194 | string $isbn, 195 | ?int $before = null, 196 | int $limit = 10 197 | ): array { 198 | $records = ORM::for_table($this->table_name) 199 | ->table_alias('e') 200 | ->select_many_expr([ 201 | 'e.id', 202 | 'e.read_status', 203 | 'e.title', 204 | 'e.authors', 205 | 'e.isbn', 206 | 'e.doi', 207 | 'e.url', 208 | 'e.category', 209 | 'e.published', 210 | 'e.tz_offset', 211 | 'e.visibility', 212 | 'e.content', 213 | 'e.canonical_url', 214 | 'e.micropub_success', 215 | 'e.user_id', 216 | 'profile_name' => 'u.name', 217 | 'profile_url' => 'u.url', 218 | 'profile_photo_url' => 'u.photo_url', 219 | 'u.profile_slug', 220 | 'user_type' => 'u.type', 221 | ]) 222 | ->join('users', ['e.user_id', '=', 'u.id'], 'u') 223 | ->where('e.isbn', $isbn) 224 | ->where_not_equal('e.visibility', 'unlisted'); 225 | 226 | if ($before) { 227 | $records->where_lt('id', $before); 228 | } 229 | 230 | $records = $records->order_by_desc('published') 231 | ->limit($limit); 232 | 233 | return $records->find_array(); 234 | } 235 | 236 | public function findByUserExport(int $user_id): array 237 | { 238 | return ORM::for_table($this->table_name) 239 | ->table_alias('e') 240 | ->select_many_expr([ 241 | 'e.id', 242 | 'e.read_status', 243 | 'e.title', 244 | 'e.authors', 245 | 'e.isbn', 246 | 'e.doi', 247 | 'e.url', 248 | 'e.category', 249 | 'e.published', 250 | 'e.tz_offset', 251 | 'e.visibility', 252 | 'e.content', 253 | 'e.canonical_url', 254 | 'e.micropub_success', 255 | 'e.user_id', 256 | 'profile_name' => 'u.name', 257 | 'profile_url' => 'u.url', 258 | 'profile_photo_url' => 'u.photo_url', 259 | 'u.profile_slug', 260 | 'user_type' => 'u.type', 261 | ]) 262 | ->join('users', ['e.user_id', '=', 'u.id'], 'u') 263 | ->where('e.user_id', $user_id) 264 | ->order_by_desc('published') 265 | ->find_array(); 266 | } 267 | 268 | /** 269 | * Get number of public posts created during the specified timeframe 270 | */ 271 | public function getNewCount( 272 | string $start_date, 273 | string $end_date 274 | ): int { 275 | $dt_start = new DateTime($start_date); 276 | $dt_end = new DateTime($end_date); 277 | $dt_end->setTime(23, 59, 59); 278 | 279 | return ORM::for_table($this->table_name) 280 | ->where('visibility', 'public') 281 | ->where_gte('published', $dt_start->format('Y-m-d')) 282 | ->where_lte('published', $dt_end->format('Y-m-d')) 283 | ->count(); 284 | } 285 | 286 | /** 287 | * Public entries created during the specified timeframe 288 | */ 289 | public function findNew( 290 | string $start_date, 291 | string $end_date, 292 | ?int $user_id = null 293 | ): array { 294 | $dt_start = new DateTime($start_date); 295 | $dt_end = new DateTime($end_date); 296 | $dt_end->setTime(23, 59, 59); 297 | 298 | $records = ORM::for_table($this->table_name) 299 | ->select_many([ 300 | 'id', 301 | 'user_id', 302 | 'title', 303 | 'isbn', 304 | 'doi', 305 | 'canonical_url', 306 | ]) 307 | ->where('visibility', 'public') 308 | ->where_gte('published', $dt_start->format('Y-m-d')) 309 | ->where_lte('published', $dt_end->format('Y-m-d')) 310 | ->order_by_desc('published'); 311 | 312 | if ($user_id) { 313 | $records = $records->where('user_id', $user_id); 314 | } 315 | 316 | return $records->find_array(); 317 | } 318 | 319 | /** 320 | * Find a list of distinct titles from public posts during 321 | * the specified timeframe 322 | * 323 | * ISBN and DOI are used to determine distinct posts. 324 | * If neither identifier, the title is added as distinct. 325 | */ 326 | public function findDistinct( 327 | string $start_date, 328 | string $end_date 329 | ): array { 330 | $dt_start = new DateTime($start_date); 331 | $dt_end = new DateTime($end_date); 332 | $dt_end->setTime(23, 59, 59); 333 | 334 | $results = []; 335 | 336 | $records = ORM::for_table($this->table_name) 337 | ->select_many([ 338 | 'id', 339 | 'title', 340 | 'authors', 341 | 'isbn', 342 | 'doi', 343 | // 'user_id', 344 | // 'canonical_url', 345 | ]) 346 | ->where('visibility', 'public') 347 | ->where_gte('published', $dt_start->format('Y-m-d')) 348 | ->where_lte('published', $dt_end->format('Y-m-d')) 349 | ->order_by_asc('published') 350 | ->find_array(); 351 | 352 | foreach ($records as $entry) { 353 | if ($entry['isbn'] && ($isbn_ten = Isbn::to10($entry['isbn'], true))) { 354 | $entry['isbn'] = $isbn_ten; 355 | } 356 | 357 | $index = $entry['isbn']; 358 | if (!$index) { 359 | # fallback to doi or no_id for results index 360 | if ($entry['doi']) { 361 | $index = $entry['doi']; 362 | } else { 363 | $index = 'no_id' . $entry['id']; 364 | } 365 | } 366 | 367 | if (array_key_exists($index, $results)) { 368 | $results[$index]['count'] += 1; 369 | } else { 370 | $results[$index] = array_merge( 371 | $entry, 372 | ['count' => 1] 373 | ); 374 | } 375 | } 376 | 377 | return $results; 378 | } 379 | 380 | /** 381 | * If there are older entries, return the ID to use in the 382 | * query paramaeter `before` 383 | * Returns null if there are no older entries 384 | */ 385 | public function getOlderId(int $user_id, int $id): ?int 386 | { 387 | $count = ORM::for_table($this->table_name) 388 | ->where('user_id', $user_id) 389 | ->where_lt('id', $id) 390 | ->order_by_desc('published') 391 | ->count(); 392 | 393 | if ($count > 0) { 394 | return $id; 395 | } 396 | 397 | return null; 398 | } 399 | 400 | /** 401 | * If there are newer entries, return the ID to use in the 402 | * query paramaeter `before` 403 | * Returns null if newer entries are on the first page 404 | */ 405 | public function getNewerId(int $user_id, int $id, int $limit = 10): ?int 406 | { 407 | $record = ORM::for_table($this->table_name) 408 | ->where('user_id', $user_id) 409 | ->where_gt('id', $id) 410 | ->order_by_asc('published') 411 | ->offset($limit) 412 | ->find_one(); 413 | 414 | if ($record) { 415 | return (int) $record->id; 416 | } 417 | 418 | return null; 419 | } 420 | 421 | /** 422 | * If there are newer entries by ISBN, return the ID to use in the 423 | * query paramaeter `before` 424 | * Returns null if newer entries are on the first page 425 | */ 426 | public function getNewerByIsbn(string $isbn, int $id, int $limit = 10): ?int 427 | { 428 | $record = ORM::for_table($this->table_name) 429 | ->where('isbn', $isbn); 430 | 431 | return $this->getNewer($record, $id, $limit); 432 | } 433 | 434 | /** 435 | * If there are older entries by ISBN, return the ID to use in the 436 | * query paramaeter `before` 437 | * Returns null if there are no older entries 438 | */ 439 | public function getOlderByIsbn(string $isbn, int $id): ?int 440 | { 441 | $record = ORM::for_table($this->table_name) 442 | ->where('isbn', $isbn); 443 | 444 | return $this->getOlder($record, $id); 445 | } 446 | 447 | /** 448 | * Helper method that takes a base ORM query and 449 | * adds the conditions common to all getNewer* queries. 450 | */ 451 | private function getNewer(ORM $record, int $id, int $limit): ?int 452 | { 453 | $record->where_gt('id', $id) 454 | ->order_by_asc('published') 455 | ->offset($limit) 456 | ->find_one(); 457 | 458 | if ($record) { 459 | return (int) $record->id; 460 | } 461 | 462 | return null; 463 | } 464 | 465 | /** 466 | * Helper method that takes a base ORM query and 467 | * adds the conditions common to all getOlder* queries. 468 | */ 469 | private function getOlder(ORM $record, int $id): ?int 470 | { 471 | $count = $record->where_lt('id', $id) 472 | ->order_by_desc('published') 473 | ->count(); 474 | 475 | if ($count > 0) { 476 | return $id; 477 | } 478 | 479 | return null; 480 | } 481 | 482 | public function delete(int $id): bool 483 | { 484 | $record = ORM::for_table($this->table_name) 485 | ->where('id', $id) 486 | ->find_one(); 487 | 488 | if ($record) { 489 | $record->delete(); 490 | return true; 491 | } 492 | 493 | return false; 494 | } 495 | 496 | /** 497 | * @author Aaron Parecki, https://aaronparecki.com 498 | * @copyright 2014 Aaron Parecki 499 | * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 500 | */ 501 | public function tz_seconds_to_offset(int $seconds) 502 | { 503 | return ($seconds < 0 ? '-' : '+') . sprintf('%02d%02d', abs($seconds/60/60), ($seconds/60)%60); 504 | } 505 | 506 | /** 507 | * @author Aaron Parecki, https://aaronparecki.com 508 | * @copyright 2014 Aaron Parecki 509 | * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 510 | */ 511 | public function tz_offset_to_seconds(string $offset) 512 | { 513 | if (preg_match('/([+-])(\d{2}):?(\d{2})/', $offset, $match)) { 514 | $sign = ($match[1] == '-' ? -1 : 1); 515 | return (($match[2] * 60 * 60) + ($match[3] * 60)) * $sign; 516 | } 517 | 518 | return 0; 519 | } 520 | 521 | public function get_datetime_with_offset(string $date, int $seconds): string 522 | { 523 | $offset = $this->tz_seconds_to_offset($seconds); 524 | $dt = new Datetime($date); 525 | return $dt->format('Y-m-d H:i:s') . $offset; 526 | } 527 | } 528 | 529 | -------------------------------------------------------------------------------- /app/Model/User.php: -------------------------------------------------------------------------------- 1 | table_name)->create(); 25 | $record->url = $data['url'] ?? ''; 26 | $record->profile_slug = $data['profile_slug'] ?? ''; 27 | $record->name = $data['name'] ?? ''; 28 | $record->photo_url = $data['photo_url'] ?? ''; 29 | $record->authorization_endpoint = $data['authorization_endpoint'] ?? ''; 30 | $record->token_endpoint = $data['token_endpoint'] ?? ''; 31 | $record->revocation_endpoint = $data['revocation_endpoint'] ?? ''; 32 | $record->micropub_endpoint = $data['micropub_endpoint'] ?? ''; 33 | $record->supported_visibility = $data['supported_visibility'] ?? null; 34 | $record->token_scope = $data['token_scope'] ?? ''; 35 | $record->default_visibility = $data['default_visibility'] ?? 'public'; 36 | $record->set_expr('date_created', 'NOW()'); 37 | $record->set_expr('last_login', 'NOW()'); 38 | $record->type = ($record->micropub_endpoint) ? 'micropub' : 'local'; 39 | 40 | if ($record->save()) { 41 | return $this->get((int) $record->id); 42 | } 43 | 44 | return null; 45 | } 46 | 47 | public function update(int $id, array $data): ?array 48 | { 49 | $record = ORM::for_table($this->table_name) 50 | ->where('id', $id) 51 | ->find_one(); 52 | 53 | if (!$record) { 54 | return null; 55 | } 56 | 57 | if (array_key_exists('name', $data)) { 58 | $record->name = $data['name'] ?? ''; 59 | } 60 | 61 | if (array_key_exists('photo_url', $data)) { 62 | $record->photo_url = $data['photo_url'] ?? ''; 63 | } 64 | 65 | if (array_key_exists('authorization_endpoint', $data)) { 66 | $record->authorization_endpoint = $data['authorization_endpoint'] ?? ''; 67 | } 68 | 69 | if (array_key_exists('token_endpoint', $data)) { 70 | $record->token_endpoint = $data['token_endpoint'] ?? ''; 71 | } 72 | 73 | if (array_key_exists('revocation_endpoint', $data)) { 74 | $record->revocation_endpoint = $data['revocation_endpoint'] ?? ''; 75 | } 76 | 77 | if (array_key_exists('micropub_endpoint', $data)) { 78 | $record->micropub_endpoint = $data['micropub_endpoint'] ?? ''; 79 | } 80 | 81 | if (array_key_exists('supported_visibility', $data)) { 82 | $record->supported_visibility = $data['supported_visibility'] ?? ''; 83 | } 84 | 85 | if (array_key_exists('token_scope', $data)) { 86 | $record->token_scope = $data['token_scope'] ?? ''; 87 | } 88 | 89 | if (array_key_exists('last_micropub_response', $data)) { 90 | $record->last_micropub_response = $data['last_micropub_response'] ?? ''; 91 | } 92 | 93 | if (array_key_exists('default_visibility', $data)) { 94 | $record->default_visibility = $data['default_visibility'] ?? ''; 95 | } 96 | 97 | if (array_key_exists('last_login', $data)) { 98 | $record->set_expr('last_login', 'NOW()'); 99 | if (is_string($data['last_login'])) { 100 | $record->last_login = $data['last_login']; 101 | } elseif (is_null($data['last_login'])) { 102 | $record->last_login = null; 103 | } 104 | } 105 | 106 | $record->type = ($record->micropub_endpoint) ? 'micropub' : 'local'; 107 | 108 | if ($record->save()) { 109 | return $this->get($id); 110 | } 111 | 112 | return null; 113 | } 114 | 115 | /** 116 | * Reset endpoints and last login 117 | */ 118 | public function reset(int $user_id): ?array 119 | { 120 | $data = array_fill_keys([ 121 | 'authorization_endpoint', 122 | 'token_endpoint', 123 | 'micropub_endpoint', 124 | 'micropub_media_endpoint', 125 | 'token_scope', 126 | ], ''); 127 | $data['last_login'] = null; 128 | 129 | return $this->update($user_id, $data); 130 | } 131 | 132 | public function get(int $id): ?array 133 | { 134 | $record = ORM::for_table($this->table_name) 135 | ->select_many_expr([ 136 | '*', 137 | 'display_name' => 'IF (name = "", profile_slug, name)', 138 | 'display_photo' => 'IF (photo_url = "", "", photo_url)', 139 | ]) 140 | ->where('id', $id) 141 | ->find_one(); 142 | 143 | if ($record) { 144 | return $record->as_array(); 145 | } 146 | 147 | return null; 148 | } 149 | 150 | public function findBySlug(string $slug): ?array 151 | { 152 | $record = ORM::for_table($this->table_name) 153 | ->select_many([ 154 | 'id', 155 | 'type', 156 | 'url', 157 | 'profile_slug', 158 | 'name', 159 | 'photo_url', 160 | 'last_login', 161 | ]) 162 | ->where('profile_slug', $slug) 163 | ->find_one(); 164 | 165 | if ($record) { 166 | return $record->as_array(); 167 | } 168 | 169 | return null; 170 | } 171 | 172 | /** 173 | * Get number of users created during the specified timeframe 174 | */ 175 | public function getNewCount( 176 | string $start_date, 177 | string $end_date 178 | ) { 179 | $dt_start = new DateTime($start_date); 180 | $dt_end = new DateTime($end_date); 181 | $dt_end->setTime(23, 59, 59); 182 | 183 | return ORM::for_table($this->table_name) 184 | ->where_gte('date_created', $dt_start->format('Y-m-d')) 185 | ->where_lte('date_created', $dt_end->format('Y-m-d')) 186 | ->count(); 187 | } 188 | 189 | /** 190 | * Get number of users that signed in during the specified timeframe 191 | */ 192 | public function getLoginCount( 193 | string $start_date, 194 | string $end_date 195 | ) { 196 | $dt_start = new DateTime($start_date); 197 | $dt_end = new DateTime($end_date); 198 | $dt_end->setTime(23, 59, 59); 199 | 200 | return ORM::for_table($this->table_name) 201 | ->where_gte('last_login', $dt_start->format('Y-m-d')) 202 | // ->where_lte('last_login', $dt_end->format('Y-m-d')) 203 | ->count(); 204 | } 205 | 206 | } 207 | 208 | -------------------------------------------------------------------------------- /app/config.php: -------------------------------------------------------------------------------- 1 | load(); 18 | error_reporting(E_ALL); 19 | ini_set('display_errors', '1'); 20 | 21 | ## require these environment variables 22 | $dotenv->required([ 23 | 'APP_ENV', 24 | 'IBC_EMAIL', 25 | 'IBC_HOSTNAME', 26 | 'IBC_BASE_URL', 27 | 'IBC_DB_HOST', 28 | 'IBC_DB_NAME', 29 | 'IBC_DB_USERNAME', 30 | 'IBC_DB_PASSWORD', 31 | 'LOG_DIR', 32 | 'LOG_NAME', 33 | ]); 34 | } 35 | } catch (RunTimeException $e) { 36 | echo $e->getMessage(); exit; 37 | } 38 | 39 | define('APP_DIR', dirname(__DIR__)); 40 | date_default_timezone_set('UTC'); 41 | 42 | $session_name = 'indiebookclub'; 43 | $app_env = $_ENV['APP_ENV'] ?? 'dev'; 44 | if ($app_env !== 'production') { 45 | $session_name = $app_env . '_' . $session_name; 46 | } 47 | ini_set('session.name', $session_name); 48 | ini_set('session.auto_start', '0'); 49 | ini_set('session.use_trans_sid', '0'); 50 | ini_set('session.cookie_domain', $_ENV['IBC_HOSTNAME']); 51 | ini_set('session.cookie_path', '/'); 52 | ini_set('session.use_strict_mode', '1'); 53 | ini_set('session.use_cookies', '1'); 54 | ini_set('session.use_only_cookies', '1'); 55 | ini_set('session.cookie_lifetime', '0'); 56 | ini_set('session.cookie_secure', '1'); 57 | ini_set('session.cookie_httponly', '1'); 58 | ini_set('session.cache_expire', '30'); 59 | ini_set('session.sid_length', '48'); 60 | ini_set('session.sid_bits_per_character', '6'); 61 | ini_set('session.cache_limiter', 'nocache'); 62 | session_start(); 63 | 64 | // Make sure session canary is set. 65 | if (!isset($_SESSION['canary'])) { 66 | session_regenerate_id(true); 67 | $_SESSION['canary'] = time(); 68 | } 69 | 70 | // Regenerate session ID every five minutes. 71 | if ($_SESSION['canary'] < time() - 300) { 72 | session_regenerate_id(true); 73 | $_SESSION['canary'] = time(); 74 | } 75 | 76 | -------------------------------------------------------------------------------- /app/dependencies.php: -------------------------------------------------------------------------------- 1 | 'SET NAMES utf8mb4']); 42 | 43 | $container = $app->getContainer(); 44 | 45 | # Logging 46 | $log_dir = dirname(__DIR__) . '/logs/'; # default log location 47 | if ($_ENV['LOG_DIR']) { 48 | # custom log location 49 | $log_dir = $_ENV['LOG_DIR']; 50 | } 51 | 52 | $container['logger'] = function($c) use ($log_dir) { 53 | $logger = new Logger($_ENV['LOG_NAME']); 54 | $logger->pushHandler( 55 | new StreamHandler( 56 | $log_dir . sprintf('%s.log', $_ENV['LOG_NAME']), 57 | Logger::DEBUG, 58 | $bubble = true, 59 | 0666 60 | ) 61 | ); 62 | 63 | return $logger; 64 | }; 65 | 66 | $container['php_logger'] = function($c) use ($log_dir) { 67 | $logger = new Logger('php-' . $_ENV['LOG_NAME']); 68 | $logger->pushHandler( 69 | new RotatingFileHandler( 70 | $log_dir . sprintf('php-%s.log', $_ENV['LOG_NAME']), 71 | 14, 72 | Logger::DEBUG, 73 | $bubble = true, 74 | 0666 75 | ) 76 | ); 77 | 78 | $handler = new ErrorHandler($logger); 79 | $handler->registerErrorHandler([], true); 80 | $handler->registerExceptionHandler(); 81 | $handler->registerFatalHandler(); 82 | 83 | return $logger; 84 | }; 85 | 86 | $container['utils'] = function ($c) { 87 | return new Utils(); 88 | }; 89 | 90 | # Views 91 | $container['view'] = function ($c) { 92 | $settings = $c->get('settings'); 93 | 94 | $cache = $settings['theme']['twig_cache_path'] ?? false; 95 | $auto_reload = true; 96 | 97 | $twig = new Twig( 98 | $settings['theme']['twig_path'], 99 | compact('cache', 'auto_reload') 100 | ); 101 | 102 | # Instantiate and add Slim-specific extension 103 | $router = $c->get('router'); 104 | $uri = Uri::createFromEnvironment(new Environment($_SERVER)); 105 | $twig->addExtension(new TwigExtension($router, $uri)); 106 | 107 | $utils = $c->get('utils'); 108 | 109 | $environment = $twig->getEnvironment(); 110 | 111 | $environment->addFunction( 112 | new TwigFunction('getenv', function ($key) { 113 | return $_ENV[$key] ?? ''; 114 | }) 115 | ); 116 | 117 | $environment->addFunction( 118 | new TwigFunction('session', function ($key) use ($utils) { 119 | return $utils->session($key); 120 | }) 121 | ); 122 | 123 | $environment->addFunction( 124 | new TwigFunction('debug', function($debug = null) { 125 | if (!is_null($debug)) { 126 | echo sprintf('
Debugging
%s
', 127 | print_r($debug, true) 128 | ); 129 | } 130 | }, ['is_safe' => ['html']]) 131 | ); 132 | 133 | $environment->addFunction( 134 | /** 135 | * @author Aaron Parecki, https://aaronparecki.com 136 | * @copyright 2014 Aaron Parecki 137 | * @license http://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0 138 | */ 139 | new TwigFunction('get_entry_date', function(string $date, int $tz_offset = 0) { 140 | $dt = new DateTime($date); 141 | 142 | if ($tz_offset > 0) { 143 | $dt->add(new DateInterval('PT' . $tz_offset . 'S')); 144 | } elseif ($tz_offset < 0) { 145 | $dt->sub(new DateInterval('PT' . abs($tz_offset) . 'S')); 146 | } 147 | 148 | $tz = ($tz_offset < 0 ? '-' : '+') . sprintf('%02d%02d', abs($tz_offset/60/60), ($tz_offset/60)%60); 149 | return new DateTime($dt->format('Y-m-d H:i:s') . $tz); 150 | }) 151 | ); 152 | 153 | return $twig; 154 | }; 155 | 156 | # Not Found handler 157 | $container['notFoundHandler'] = function ($c) { 158 | return function (ServerRequestInterface $request, ResponseInterface $response) use ($c) { 159 | $response = $response->withStatus(404); 160 | return $c->view->render($response, 'pages/404.twig'); 161 | }; 162 | }; 163 | 164 | # Default error handler 165 | $container['errorHandler'] = function ($c) { 166 | return function (ServerRequestInterface $request, ResponseInterface $response, Exception $exception) use ($c) { 167 | $c->logger->error($exception->getMessage()); 168 | 169 | $message = 'ENVIRONMENT:' . PHP_EOL . ($_ENV['APP_ENV'] ?? 'unspecified'); 170 | $message .= PHP_EOL . PHP_EOL . 'TYPE:' . PHP_EOL . get_class($exception); 171 | $message .= PHP_EOL . PHP_EOL . 'MESSAGE:' . PHP_EOL . $exception->getMessage(); 172 | $message .= PHP_EOL . PHP_EOL . 'URI:' . PHP_EOL . print_r($request->getUri(), true); 173 | $message .= PHP_EOL . PHP_EOL . 'METHOD:' . PHP_EOL . $request->getMethod(); 174 | $message .= PHP_EOL . PHP_EOL . 'REQUEST BODY:' . PHP_EOL . print_r($request->getParsedBody(), true); 175 | $message .= PHP_EOL . PHP_EOL . 'STACK TRACE:' . PHP_EOL . $exception->getTraceAsString(); 176 | $c->utils->notify_admin($message, 'indiebookclub internal server error'); 177 | unset($message); 178 | 179 | $response = $response->withStatus(500); 180 | return $c->view->render($response, 'pages/500.twig'); 181 | }; 182 | }; 183 | 184 | # PHP error handler 185 | $container['phpErrorHandler'] = function ($c) { 186 | return function (ServerRequestInterface $request, ResponseInterface $response, $exception) use ($c) { 187 | $c->php_logger->error($exception->getMessage()); 188 | 189 | $message = 'ENVIRONMENT:' . PHP_EOL . ($_ENV['APP_ENV'] ?? 'unspecified'); 190 | $message .= PHP_EOL . PHP_EOL . 'TYPE:' . PHP_EOL . get_class($exception); 191 | $message .= PHP_EOL . PHP_EOL . 'MESSAGE:' . PHP_EOL . $exception->getMessage(); 192 | $message .= PHP_EOL . PHP_EOL . 'URI:' . PHP_EOL . print_r($request->getUri(), true); 193 | $message .= PHP_EOL . PHP_EOL . 'METHOD:' . PHP_EOL . $request->getMethod(); 194 | $message .= PHP_EOL . PHP_EOL . 'REQUEST BODY:' . PHP_EOL . print_r($request->getParsedBody(), true); 195 | $message .= PHP_EOL . PHP_EOL . 'STACK TRACE:' . PHP_EOL . $exception->getTraceAsString(); 196 | $c->utils->notify_admin($message, 'indiebookclub PHP error'); 197 | unset($message); 198 | 199 | $response = $response->withStatus(500); 200 | return $c->view->render($response, 'pages/500.twig'); 201 | }; 202 | }; 203 | 204 | # Controllers 205 | $container['AuthController'] = function ($c) { 206 | return new AuthController($c); 207 | }; 208 | 209 | $container['PageController'] = function ($c) { 210 | return new PageController($c); 211 | }; 212 | 213 | $container['IbcController'] = function ($c) { 214 | return new IbcController($c); 215 | }; 216 | 217 | $container['UsersController'] = function ($c) { 218 | return new UsersController($c); 219 | }; 220 | 221 | # Models 222 | $container['Book'] = function ($c) { 223 | return new Book(); 224 | }; 225 | 226 | $container['Entry'] = function ($c) { 227 | return new Entry(); 228 | }; 229 | 230 | $container['User'] = function ($c) { 231 | return new User(); 232 | }; 233 | 234 | -------------------------------------------------------------------------------- /app/middleware.php: -------------------------------------------------------------------------------- 1 | add(function (ServerRequestInterface $request, ResponseInterface $response, $next) use ($container) { 13 | if ($this->get('settings')['offline'] && $_SERVER['REMOTE_ADDR'] != $this->settings['developer_ip']) { 14 | $response = $response->withStatus(503)->withHeader('Retry-After', 3600); 15 | return $this->view->render($response, 'pages/maintenance.twig'); 16 | } 17 | return $next($request, $response); 18 | }); 19 | 20 | ## Authorization middleware 21 | $app->add( new AuthorizationMiddleware($container) ); 22 | 23 | // Security headers 24 | $app->add(function (ServerRequestInterface $request, ResponseInterface $response, $next) { 25 | $response = $next($request, $response); 26 | return $response 27 | ->withHeader('Strict-Transport-Security', 'max-age=10368000; includeSubDomains') 28 | ->withHeader('X-Frame-Options', 'SAMEORIGIN') 29 | ->withHeader('X-XSS-Protection', '1; mode=block') 30 | ->withHeader('X-Content-Type-Options', 'nosniff'); 31 | }); 32 | 33 | // 404 Handler 34 | $app->add(function (ServerRequestInterface $request, ResponseInterface $response, $next) use ($container) { 35 | $response = $next($request, $response); 36 | 37 | if (404 === $response->getStatusCode() && 0 === $response->getBody()->getSize()) { 38 | $handler = $container['notFoundHandler']; 39 | return $handler($request, $response); 40 | } 41 | 42 | return $response; 43 | }); 44 | 45 | 46 | // No trailing slash on URLs 47 | $app->add(function (ServerRequestInterface $request, ResponseInterface $response, $next) { 48 | $uri = $request->getUri(); 49 | $path = $uri->getPath(); 50 | 51 | if ($path != '/' && substr($path, -1) == '/') { 52 | $uri = $uri->withPath(substr($path, 0, -1)); 53 | return $response->withRedirect((string)$uri, 301); 54 | } 55 | 56 | return $next($request, $response); 57 | }); 58 | 59 | -------------------------------------------------------------------------------- /app/routes.php: -------------------------------------------------------------------------------- 1 | group('/auth', function() { 6 | $prefix = 'auth'; 7 | 8 | $this->redirect('', '/auth/start', 302); 9 | $this->get('/start', 'AuthController:start') 10 | ->setName($prefix . '_start'); 11 | $this->get('/callback', 'AuthController:callback') 12 | ->setName($prefix . '_callback'); 13 | $this->get('/reset', 'AuthController:reset') 14 | ->setName($prefix . '_reset'); 15 | $this->map(['GET', 'POST'], '/re-authorize', 'AuthController:re_authorize') 16 | ->setName($prefix . '_re_authorize'); 17 | }); 18 | 19 | $app->get('/id', 'AuthController:client_metadata') 20 | ->setName('client_metadata'); 21 | $app->get('/', 'PageController:index') 22 | ->setName('index'); 23 | $app->get('/about', 'PageController:about') 24 | ->setName('about'); 25 | $app->get('/documentation', 'PageController:documentation') 26 | ->setName('documentation'); 27 | $app->get('/updates', 'PageController:updates') 28 | ->setName('updates'); 29 | 30 | $app->map(['GET', 'POST'], '/new', 'IbcController:new') 31 | ->setName('new'); 32 | $app->map(['GET', 'POST'], '/retry[/{entry_id}]', 'IbcController:retry') 33 | ->setName('retry'); 34 | $app->map(['GET', 'POST'], '/delete[/{id}]', 'IbcController:delete') 35 | ->setName('delete'); 36 | $app->get('/export', 'UsersController:export') 37 | ->setName('export'); 38 | $app->get('/isbn/{isbn:\d+}', 'IbcController:isbn') 39 | ->setName('isbn'); 40 | 41 | $app->group('/settings', function() { 42 | $prefix = 'settings'; 43 | 44 | $this->get('', 'UsersController:settings') 45 | ->setName($prefix); 46 | $this->post('/update', 'UsersController:settings_update') 47 | ->setName($prefix . '_update'); 48 | }); 49 | 50 | $app->get('/signout', 'AuthController:signout') 51 | ->setName('signout'); 52 | 53 | $app->group('/users', function() { 54 | $prefix = 'users'; 55 | 56 | $this->get('/{domain:[a-zA-Z0-9\.-]+\.[a-z]+}', 'UsersController:profile') 57 | ->setName('profile'); 58 | $this->get('/{domain:[a-zA-Z0-9\.-]+\.[a-z]+}/{entry:\d+}', 'UsersController:entry') 59 | ->setName('entry'); 60 | }); 61 | 62 | $app->get('/review/{year}', 'IbcController:review') 63 | ->setName('review'); 64 | 65 | -------------------------------------------------------------------------------- /app/settings.php: -------------------------------------------------------------------------------- 1 | [ 7 | 'version' => '0.1.3', 8 | 'offline' => false, 9 | 'developer_ip' => '127.0.0.1', 10 | 'developer_domains' => [ 11 | 'example.com', 12 | ], 13 | 'displayErrorDetails' => ($_ENV['APP_ENV'] !== 'production'), 14 | 'determineRouteBeforeAppMiddleware' => true, 15 | 'theme' => [ 16 | 'public_path' => dirname(__DIR__) . '/public/', 17 | 'twig_path' => dirname(__DIR__) . '/templates/', 18 | 'twig_cache_path' => dirname(__DIR__) . '/cache/twig/', 19 | 'enable_twig_cache' => false, 20 | ] 21 | ], 22 | ]; 23 | 24 | -------------------------------------------------------------------------------- /cache/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | 6 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "slim/slim": "^3.0", 4 | "slim/twig-view": "^2.5", 5 | "j4mie/idiorm": "^1.5", 6 | "mf2/mf2": "^0.5", 7 | "barnabywalters/mf-cleaner": "^0.2", 8 | "indieauth/client": "^1.1", 9 | "vlucas/phpdotenv": "^5.5", 10 | "monolog/monolog": "^1.23", 11 | "phpmailer/phpmailer": "^6.6", 12 | "phpunit/phpunit": "^8.4" 13 | }, 14 | "autoload": { 15 | "psr-4": { 16 | "App\\": "app/", 17 | "Mwhite\\": "lib/Mwhite" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /deploy.php: -------------------------------------------------------------------------------- 1 | load(); 10 | 11 | # construct env.php contents 12 | $output = ' $value) { 16 | $output .= sprintf('$_ENV[\'%s\'] = "%s";', 17 | $key, 18 | $value 19 | ) . PHP_EOL; 20 | } 21 | 22 | $output .= sprintf('$_ENV[\'ENV_GENERATED\'] = "%s";', 23 | date('Y-m-d H:i:s') 24 | ) . PHP_EOL; 25 | 26 | # write env.php 27 | $filename = __DIR__ . '/app/env.php'; 28 | if (!file_exists($filename) || false === file_put_contents($filename, $output)) { 29 | echo 'Error deploying environments file' . PHP_EOL; 30 | exit; 31 | } 32 | 33 | echo 'Deployed successfully' . PHP_EOL; 34 | 35 | -------------------------------------------------------------------------------- /lib/Mwhite/PhpIsbn/Isbn.php: -------------------------------------------------------------------------------- 1 | a:first-child { 340 | flex: 0 0 4.688rem; /* effective 75px */ 341 | margin-right: 1rem; 342 | } 343 | 344 | header img { 345 | max-width: 7.5rem; 346 | max-height: 7.5rem; 347 | } 348 | 349 | footer { 350 | border-top: 2px solid #2C8166; 351 | margin-top: 3rem; 352 | padding: 1rem 1rem 3rem; 353 | background: #FAFAFA; 354 | text-align: center; 355 | } 356 | 357 | footer ul { 358 | list-style-type: none; 359 | margin: 0; 360 | padding: 0; 361 | text-align: center 362 | } 363 | 364 | footer li { 365 | display: inline-block; 366 | } 367 | 368 | footer li a:link, footer li a:link,footer li a:hover { 369 | display: inline-block; 370 | padding: 0.5rem 1rem; 371 | text-decoration: none; 372 | } 373 | 374 | footer li a:hover { 375 | background-color: #2C8166; 376 | color: #eee; 377 | } 378 | 379 | .user-bar { 380 | padding-block: 0.5rem; 381 | text-align: right; 382 | } 383 | 384 | .one-line-form, 385 | .sign-in { 386 | display: flex; 387 | } 388 | 389 | .one-line-form > select:first-child, 390 | .one-line-form > input:first-child, 391 | .sign-in > input:first-child { 392 | margin-right: 1rem; 393 | } 394 | 395 | .entries-navigation { 396 | display: grid; 397 | grid-template-columns: 1fr 1fr; 398 | } 399 | 400 | .older {} 401 | 402 | .newer { 403 | text-align: right; 404 | } 405 | 406 | .logo-text-book { 407 | color: #2C8166; 408 | } 409 | 410 | .callout { 411 | padding: 2rem; 412 | margin: 2rem 0; 413 | border: 1px solid #c1d7e1; 414 | border-left-width: 0.5rem; 415 | border-radius: 0.3rem; 416 | } 417 | 418 | .alert { 419 | padding: 15px; 420 | margin-bottom: 20px; 421 | border: 1px solid transparent; 422 | border-radius: 4px; 423 | } 424 | 425 | .alert-success { 426 | color: #155724; 427 | background-color: #d4edda; 428 | border-color: #c3e6cb; 429 | } 430 | 431 | .alert-warning { 432 | color: #8a6d3b; 433 | background-color: #fcf8e3; 434 | border-color: #faebcc; 435 | } 436 | 437 | .util-full-width { 438 | width: 100%; 439 | } 440 | 441 | .mb-1 { 442 | margin-bottom: 1em; 443 | } 444 | 445 | .attention { 446 | color: #c00; 447 | } 448 | 449 | .help-block { 450 | color: #555; 451 | display: block; 452 | font-size: 0.875em; /* effective 14px */ 453 | } 454 | 455 | .entries { 456 | list-style-type: none; 457 | padding-left: 0; 458 | } 459 | 460 | .entries li { 461 | box-shadow: 0px 0px 8px 0px rgba(0,0,0,0.5); 462 | margin-bottom: 2.5rem; 463 | } 464 | 465 | .author { 466 | display: flex; 467 | background-color: #c1d7e1; 468 | border-bottom: 1px #B0C4CD solid; 469 | padding: 1rem; 470 | } 471 | 472 | .author > a { 473 | flex: 0 0 5rem; 474 | max-width: 5rem; 475 | margin-right: 1rem; 476 | } 477 | 478 | .author a:link, .author a:visited, .author a:hover { 479 | color: #444; 480 | } 481 | 482 | .photo { 483 | max-width: 100%; 484 | } 485 | 486 | .author-details { 487 | line-height: 1.2; 488 | } 489 | 490 | .author-details .name { 491 | font-weight: bold; 492 | } 493 | 494 | .content { 495 | padding: 1.3rem 1rem; 496 | } 497 | 498 | .summary { 499 | font-size: 1.375em; /* effective 22px */ 500 | } 501 | 502 | .isbn { 503 | display: block; 504 | } 505 | 506 | .post-meta, 507 | .tags { 508 | color: #767676; 509 | font-size: 0.875em; /* effective 14px */ 510 | text-align: right; 511 | } 512 | 513 | .date a:link, .date a:visited, .date a:hover { 514 | color: #999; 515 | font-size: 0.875em; /* effective 14px */ 516 | } 517 | 518 | .scroll-container { 519 | width: 100%; 520 | overflow-x: scroll; 521 | } 522 | 523 | .scroll-container .break { 524 | word-break: break-all; 525 | } 526 | 527 | @supports(display: grid) { 528 | body { 529 | display: grid; 530 | grid-template-rows: auto 1fr auto; 531 | grid-template-columns: 100%; 532 | } 533 | 534 | .masthead { 535 | display: grid; 536 | grid-template-columns: 4.688rem auto; /* effective 75px */ 537 | grid-column-gap: .625rem; /* effective 10px */ 538 | } 539 | 540 | .masthead img { 541 | max-width: 100%; 542 | margin-right: auto; 543 | } 544 | 545 | header { 546 | display: grid; 547 | grid-template-columns: 4.688rem auto; /* effective 75px */ 548 | grid-column-gap: .625rem; /* effective 10px */ 549 | } 550 | 551 | header > a:first-child { 552 | margin-right: 0; 553 | } 554 | 555 | header img { 556 | max-width: 100%; 557 | max-height: auto; 558 | } 559 | 560 | .author { 561 | display: grid; 562 | grid-template-columns: 5rem auto; 563 | grid-column-gap: 1rem; 564 | } 565 | 566 | .author > a { 567 | margin-right: auto; 568 | } 569 | 570 | } 571 | 572 | .toggle-label { 573 | font-size: 0.875em; /* effective 14px */ 574 | background: #eee; 575 | padding: 0.5em; 576 | border-radius: 0.5rem; 577 | } 578 | 579 | input[name="switch-uid"], 580 | .doi-fieldset 581 | { 582 | display: none; 583 | } 584 | 585 | #show-doi:checked ~ .doi-fieldset, 586 | #show-isbn:checked ~ .isbn-fieldset 587 | { 588 | display: block; 589 | } 590 | 591 | #show-doi:checked ~ .isbn-fieldset, 592 | #show-isbn:checked ~ .doi-fieldset 593 | { 594 | display: none; 595 | } 596 | 597 | .btn-primary, 598 | #show-doi:checked ~ label[for="show-doi"], 599 | #show-isbn:checked ~ label[for="show-isbn"] 600 | { 601 | background: #2C8166; 602 | color: #fff; 603 | border: 0; 604 | } 605 | 606 | -------------------------------------------------------------------------------- /public/htaccess.txt: -------------------------------------------------------------------------------- 1 | Options -Indexes 2 | 3 | RewriteEngine On 4 | RewriteBase / 5 | 6 | #Force https 7 | #RewriteCond %{HTTPS} off 8 | #RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [L,R=302] 9 | 10 | RewriteCond %{REQUEST_FILENAME} !-f 11 | RewriteCond %{REQUEST_FILENAME} !-d 12 | RewriteRule ^ index.php [QSA,L] 13 | 14 | -------------------------------------------------------------------------------- /public/images/book.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gRegorLove/indiebookclub/4b685740e9a11ef406fef6cebbed2337dfc11f6f/public/images/book.png -------------------------------------------------------------------------------- /public/images/book.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/no-photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gRegorLove/indiebookclub/4b685740e9a11ef406fef6cebbed2337dfc11f6f/public/images/no-photo.png -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | run(); 25 | 26 | -------------------------------------------------------------------------------- /public/js/main.js: -------------------------------------------------------------------------------- 1 | function ready(fn) { 2 | if (document.readyState !== 'loading') { 3 | fn(); 4 | } else { 5 | document.addEventListener('DOMContentLoaded', fn); 6 | } 7 | } 8 | 9 | ready(function() { 10 | var d = new Date(); 11 | var el = document.querySelector('#i_tz_offset'); 12 | var offset = tz_seconds_to_offset(d.getTimezoneOffset() * 60 * -1); 13 | el.value = offset; 14 | }); 15 | 16 | function tz_seconds_to_offset(seconds) { 17 | var tz_offset = ''; 18 | var hours = zero_pad(Math.floor(Math.abs(seconds / 60 / 60))); 19 | var minutes = zero_pad(Math.floor(seconds / 60) % 60); 20 | return (seconds < 0 ? '-' : '+') + hours + ":" + minutes; 21 | } 22 | 23 | function zero_pad(num) { 24 | num = "" + num; 25 | if (num.length == 1) { 26 | num = "0" + num; 27 | } 28 | return num; 29 | } 30 | 31 | -------------------------------------------------------------------------------- /schema/0.0.3/migration.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `entries` ADD COLUMN `visibility` enum('public','private','unlisted') NOT NULL DEFAULT 'public' AFTER `category`; 2 | 3 | ALTER TABLE `users` ADD COLUMN `supported_visibility` text NOT NULL AFTER `micropub_media_endpoint`; 4 | ALTER TABLE `users` ADD COLUMN `default_visibility` enum('public','private','unlisted') NOT NULL DEFAULT 'public' AFTER `last_micropub_response`; 5 | 6 | -------------------------------------------------------------------------------- /schema/0.1.0/migration.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `entries` CHANGE COLUMN `canonical_url` `canonical_url` VARCHAR(255) NOT NULL DEFAULT ''; 2 | 3 | ALTER TABLE `users` ADD COLUMN `revocation_endpoint` VARCHAR(255) NOT NULL DEFAULT '' AFTER `token_endpoint`; 4 | 5 | -------------------------------------------------------------------------------- /schema/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `users` ( 2 | `id` int unsigned NOT NULL AUTO_INCREMENT, 3 | `type` enum('micropub','local') NOT NULL, 4 | `url` varchar(255) NOT NULL, 5 | `profile_slug` varchar(255) NOT NULL DEFAULT '', 6 | `name` varchar(255) NOT NULL DEFAULT '', 7 | `photo_url` varchar(255) NOT NULL DEFAULT '', 8 | `authorization_endpoint` varchar(255) NOT NULL DEFAULT '', 9 | `token_endpoint` varchar(255) NOT NULL DEFAULT '', 10 | `revocation_endpoint` varchar(255) NOT NULL DEFAULT '', 11 | `micropub_endpoint` varchar(255) NOT NULL DEFAULT '', 12 | `micropub_media_endpoint` varchar(255) NOT NULL DEFAULT '', 13 | `supported_visibility` text NOT NULL, 14 | `token_scope` varchar(255) NOT NULL DEFAULT '', 15 | `micropub_success` tinyint unsigned NOT NULL DEFAULT '0', 16 | `last_micropub_response` text, 17 | `default_visibility` enum('public','private','unlisted') NOT NULL DEFAULT 'public', 18 | `date_created` datetime DEFAULT NULL, 19 | `last_login` datetime DEFAULT NULL, 20 | PRIMARY KEY (`id`), 21 | KEY `PROFILESLUG` (`profile_slug`(191)) 22 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 23 | 24 | CREATE TABLE `entries` ( 25 | `id` int unsigned NOT NULL AUTO_INCREMENT, 26 | `user_id` int unsigned DEFAULT '0', 27 | `published` datetime DEFAULT NULL, 28 | `tz_offset` int DEFAULT '0', 29 | `read_status` varchar(255) NOT NULL DEFAULT '', 30 | `title` varchar(255) NOT NULL DEFAULT '', 31 | `authors` varchar(255) NOT NULL DEFAULT '', 32 | `isbn` varchar(255) NOT NULL DEFAULT '', 33 | `doi` varchar(255) NOT NULL DEFAULT '', 34 | `url` varchar(255) NOT NULL DEFAULT '', 35 | `category` varchar(255) NOT NULL DEFAULT '', 36 | `visibility` enum('public','private','unlisted') NOT NULL DEFAULT 'public', 37 | `content` text, 38 | `canonical_url` varchar(255) NOT NULL DEFAULT '', 39 | `micropub_success` tinyint unsigned NOT NULL DEFAULT '0', 40 | `micropub_response` text, 41 | PRIMARY KEY (`id`), 42 | KEY `ISBN` (`isbn`) 43 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 44 | 45 | CREATE TABLE `books` ( 46 | `id` int unsigned NOT NULL AUTO_INCREMENT, 47 | `isbn` varchar(20) NOT NULL DEFAULT '', 48 | `entry_count` int unsigned NOT NULL DEFAULT '0', 49 | `first_user_id` int unsigned NOT NULL DEFAULT '0', 50 | `created` datetime NOT NULL, 51 | `modified` datetime NOT NULL, 52 | `deleted` datetime DEFAULT NULL, 53 | PRIMARY KEY (`id`), 54 | UNIQUE KEY `ISBN` (`isbn`) 55 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 56 | 57 | -------------------------------------------------------------------------------- /templates/boilerplate/layouts/base-html-layout.twig: -------------------------------------------------------------------------------- 1 | {# -- Base HTML layout template that all HTML requests inherit from -- #} 2 | {# -- https://nystudio107.com/blog/an-effective-twig-base-templating-setup #} 3 | {% extends 'layouts/global-variables.twig' %} 4 | 5 | {%- block htmlPage -%} 6 | 7 | {% block htmlTag %} 8 | 9 | {% endblock htmlTag %} 10 | 11 | {% include('boilerplate/partials/head-meta.twig') %} 12 | 13 | {# -- Any tags that should be included in the #} 14 | {% block headMeta %} 15 | {% endblock headMeta %} 16 | {{ title }} 17 | 18 | {# -- Any tags that should be included in the #} 19 | {% block headLinks %} 20 | {% endblock headLinks %} 21 | 22 | {# -- Any JavaScript that should be included before -- #} 23 | {% block headJs %} 24 | {% endblock headJs %} 25 | 26 | 27 | 28 | {% block bodyBlock %} 29 | 30 | {# -- Page content that should be included in the -- #} 31 | {% block bodyHtml %} 32 | {% endblock bodyHtml %} 33 | 34 | {# -- Any JavaScript that should be included before -- #} 35 | {%~ block bodyJs %} 36 | {% endblock bodyJs ~%} 37 | {% endblock bodyBlock %} 38 | 39 | 40 | {%- endblock htmlPage -%} 41 | 42 | -------------------------------------------------------------------------------- /templates/boilerplate/partials/head-meta.twig: -------------------------------------------------------------------------------- 1 | {# -- Basic meta tags -- #} 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /templates/layouts/default-layout.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/generic-page-layout.twig' %} 2 | 3 | {% if show_user_bar is not defined %} 4 | {% set show_user_bar = true %} 5 | {% endif %} 6 | 7 | {% block bodyContent %} 8 |
9 | {% if show_user_bar %}{% include('layouts/partials/user-bar.twig') %}{% endif %} 10 | 11 | {% block content %} 12 | {% endblock content %} 13 | 14 |
15 | {% endblock bodyContent %} 16 | 17 | -------------------------------------------------------------------------------- /templates/layouts/generic-page-layout.twig: -------------------------------------------------------------------------------- 1 | {% extends 'boilerplate/layouts/base-html-layout.twig' %} 2 | 3 | {% if show_header is not defined %} 4 | {% set show_header = true %} 5 | {% endif %} 6 | 7 | {# -- Any additional tags that should be included in the #} 8 | {% block headMeta %} 9 | {% endblock headMeta %} 10 | 11 | 12 | {# -- Any additional tags that should be included in the #} 13 | {% block headLinks %} 14 | 15 | 16 | 17 | 18 | {% endblock headLinks %} 19 | 20 | 21 | {# -- Any JavaScript that should be included before -- #} 22 | {% block headJs %} 23 | 24 | {% endblock headJs %} 25 | 26 | 27 | {# -- Page body -- #} 28 | {% block bodyHtml %} 29 | {% if show_header %}{% include('layouts/partials/page-header.twig') %}{% endif %} 30 | 31 | {% block bodyContent %} 32 |

This is placeholder body content.

33 |

To modify this page's contents in the Twig template, use block bodyContent

34 |

Twig documentation

35 | {% endblock bodyContent %} 36 | 37 | {% include('layouts/partials/page-footer.twig') %} 38 | {% endblock bodyHtml %} 39 | 40 | 41 | {# -- Any JavaScript that should be included before -- #} 42 | {%~ block bodyJs %} 43 | {% endblock bodyJs ~%} 44 | 45 | -------------------------------------------------------------------------------- /templates/layouts/global-variables.twig: -------------------------------------------------------------------------------- 1 | {# -- Root global variables that all templates inherit from -- #} 2 | {# -- This allows for defining site-wide Twig variables as needed -- #} 3 | {% apply spaceless %} 4 | 5 | {# -- General global variables -- #} 6 | {% set meta_description = meta_description|default('indiebookclub is a simple app for tracking books you are reading.') %} 7 | 8 | {% if short_title %} 9 | {% set title = short_title ~ ' – indiebookclub' %} 10 | {% elseif title is empty %} 11 | {% set title = 'indiebookclub' %} 12 | {% endif %} 13 | 14 | {# -- Twig output from the render; this must be in a block -- #} 15 | {% endapply %}{% block htmlPage %} 16 | {% endblock %} 17 | 18 | -------------------------------------------------------------------------------- /templates/layouts/partials/page-footer.twig: -------------------------------------------------------------------------------- 1 | 2 | 24 | 25 | -------------------------------------------------------------------------------- /templates/layouts/partials/page-header.twig: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | indiebookclub 6 |
7 | 8 | -------------------------------------------------------------------------------- /templates/layouts/partials/user-bar.twig: -------------------------------------------------------------------------------- 1 | 2 |
3 | {%- if session('me') ~%} 4 | Signed in as {% if session('display_photo') %}profile photo for{% endif %} {{ session('display_name') }}My ProfileSign Out 5 | {%- else ~%} 6 | Sign In 7 | {%- endif ~%} 8 |
9 | 10 | -------------------------------------------------------------------------------- /templates/pages/400.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = short_title|default('Invalid Request') %} 4 | {% set message = message|default('

The server was unable to process that request. Please check the syntax of your request and try again.

') %} 5 | 6 | {% block content %} 7 | 8 |

{{ short_title|raw }}

9 | 10 | {{ message|raw }} 11 | 12 | {% endblock %} 13 | 14 | -------------------------------------------------------------------------------- /templates/pages/404.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = short_title|default('Page Not Found') %} 4 | {% set message = message|default('

Sorry, that page could not be found.

') %} 5 | 6 | {% block content %} 7 | 8 |

{{ short_title }}

9 | 10 | {{ message|raw }} 11 | 12 | {% endblock %} 13 | 14 | -------------------------------------------------------------------------------- /templates/pages/500.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = short_title|default('Internal Server Error') %} 4 | {% set message = message|default('

Sorry, an unexpected error has occurred.

The website administrators have been notified.

') %} 5 | 6 | {% block content %} 7 | 8 |

{{ short_title }}

9 | 10 | {{ message|raw }} 11 | 12 | {% endblock %} 13 | 14 | -------------------------------------------------------------------------------- /templates/pages/about.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = 'About indiebookclub' %} 4 | 5 | {% block content %} 6 | 7 |
8 |

{{ short_title }}

9 |
10 |

indiebookclub is an app for keeping track of the books you are reading or want to read. It is primarily intended to help you own your data by posting directly to your own site with Micropub. If your site does not support Micropub yet, you can still post to your indiebookclub profile.

11 | 12 |

If you are using Micropub, your posts will be linked to the canonical copy on your site. There are no plans to make indiebookclub a complex social network, but I might add features like a stream of newly-added books.

13 | 14 |

The Name

15 | 16 |

During IndieWebCamp Baltimore 2018, Marty McGuire made a comment that he will be more interested in indieweb read posts when there’s “some aggregator like indiebookclub dot biz.” That domain name was obviously a joke, but that has rarely stopped me before.

17 | 18 |

Credits

19 | 20 |

Inspired by and using open source code from Aaron Parecki’s Teacup and Quill.

21 | 22 |

Book” icon by Beth Bolton from the Noun Project.

23 | 24 |

Maintained by gRegor Morrill.

25 |
26 |
27 | 28 | {% endblock %} 29 | 30 | -------------------------------------------------------------------------------- /templates/pages/auth/re-authorize.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = short_title|default('Additional Permission Requested') %} 4 | {% set message = message|default('

indiebookclub is requesting additional permissions.

') %} 5 | 6 | {% block content %} 7 | 8 |

{{ short_title|raw }}

9 | 10 | {{ message|raw }} 11 | 12 | Permissions that indiebookclub will request: 13 | 14 |
15 |
16 | {% for scope in current_scopes %} 17 | 18 | ✔ {{ scope }}
19 | {% endfor %} 20 | 21 |
22 | 23 | 24 |
25 | 26 | {% endblock %} 27 | 28 | -------------------------------------------------------------------------------- /templates/pages/auth/start.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% block content %} 4 | 5 | {% if is_micropub_user %} 6 | 7 |

Sign In

8 |

indiebookclub is able to post to your website!

9 |

Click the button below to sign in and allow this app to post to your site.

10 | 11 | {% else %} 12 | 13 |

Would you like to use a hosted account?

14 |

It looks like your site doesn’t support posting with Micropub. You can still use indiebookclub to track what you are reading and the posts will be here instead of on your own site. You can export your posts in HTML at any point.

15 | 16 | {% endif %} 17 | 18 |

Sign In

19 | 20 | {% if is_micropub_user and metadata_endpoint is empty %} 21 |
22 | Notice: Your site does not appear to support IndieAuth Server Metadata, which is recommended by the IndieAuth specification. You can continue to log in and use indiebookclub normally, but you might want to update your IndieAuth software or reach out to the developer to request an update. 23 |
24 | {% endif %} 25 | 26 |
27 | Debugging Information: 28 |

indiebookclub found the following endpoints on your site:

29 | 36 |
37 | 38 | {% endblock content %} 39 | 40 | -------------------------------------------------------------------------------- /templates/pages/delete.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = 'Delete Post' %} 4 | 5 | {% block content %} 6 | 7 |

{{ short_title }}

8 |

Are you sure you want to delete this post?

9 | 10 | 13 | 14 |
15 | Check the box below to confirm you want to delete this post.
16 | If you don’t want to delete, you can cancel and return to your profile. 17 |
18 | 19 | {% if is_micropub_post and not has_micropub_delete %} 20 |
21 | Optional: If you would like to also delete this post from your site, indiebookclub will need additional permission: Re-authorize to add delete permission 22 |
23 | {% endif %} 24 | 25 |
26 | 27 |

28 | 29 | 30 | 31 | {% if is_micropub_post and has_micropub_delete %} 32 |

33 | 34 | This will send a Micropub delete request to your site 35 |

36 | {% endif %} 37 | 38 | 39 | 40 |
41 | 42 | {% endblock content %} 43 | 44 | -------------------------------------------------------------------------------- /templates/pages/documentation.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = 'Documentation' %} 4 | 5 | {% block content %} 6 | 7 |
8 |

{{ short_title }}

9 | 10 |
11 |

Micropub Requests

12 |

The Micropub JSON syntax is used when indiebookclub posts to your endpoint.

13 | 14 |

Example:

15 |
{
16 |   "type": ["h-entry"],
17 |   "properties": {
18 |     "summary": ["Want to read: Title by Author, ISBN: ISBN"],
19 |     "read-status": ["to-read"],
20 |     "read-of": [
21 |       {
22 |         "type": ["h-cite"],
23 |         "properties": {
24 |           "name": ["Title"],
25 |           "author": ["Author"],
26 |           "uid": ["isbn:ISBN"]
27 |         }
28 |       }
29 |     ],
30 |     "visibility": ["public"],
31 |     "post-status": ["published"]
32 |   }
33 | }
34 | 35 |

read-status will be “to-read”, “reading”, or “finished” based on your selection.

36 |

uid will have a scheme of either isbn: or doi: based on your selection

37 |

author and uid properties will only be included if you enter those fields.

38 |

visibility will be “public”, “private”, or “unlisted” based on your selection. The private and unlisted options are only available if your Micropub endpoint indicates it supports them.

39 |

post-status will be “published” or “draft” based on your selection. Note that drafts are only sent to your Micropub endpoint and are not stored on your indiebookclub profile.

40 | 41 |

Query Parameters

42 |

The new post form accepts URL query parameters to pre-populate fields. This can be used with bookmarklets to make adding new posts easier.

43 |

Parameters

44 |

Any combination of these parameters can be used.

45 | 54 | 55 |

Consuming read posts

56 |

You can provide a URL in the read-of parameter. If indiebookclub finds a read-of microformat at that URL, it will pre-populate the form with that information. If no read-of property is found, it will check for an h-cite microformat.

57 |

Note that if the read-of query parameter is provided, it takes precedence over the other parameters listed above.

58 |
59 | 60 |

Export Posts

61 |

Your settings page has a button to download an HTML+microformats export of your posts.

62 |
63 | 64 | {% endblock %} 65 | 66 | -------------------------------------------------------------------------------- /templates/pages/entry.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% block headLinks %} 4 | {{ parent() }} 5 | {%- if entry.canonical_url -%} 6 | 7 | {%- endif -%} 8 | {% endblock headLinks %} 9 | 10 | {% block content %} 11 | 12 | 19 | 20 | {% endblock content %} 21 | -------------------------------------------------------------------------------- /templates/pages/export.twig: -------------------------------------------------------------------------------- 1 | {%- set short_title = 'Entries by ' ~ profile.url -%} 2 | {%- if profile.name -%} 3 | {%- set short_title = 'Entries by ' ~ profile.name -%} 4 | {%- endif -%} 5 | 6 | 7 | 8 | 9 | <?=$feed_name;?> 10 | 11 | 12 | 13 | 14 | 15 |
16 |

{{ short_title }}

17 | 22 |
23 | 24 |

This export was generated at {{ export_timestamp }}.

25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /templates/pages/home.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set show_header = false %} 4 | {% set show_user_bar = false %} 5 | 6 | {% block content %} 7 |
8 |
9 | 10 |

indiebookclub

11 |
12 | 13 |
14 |

indiebookclub is a simple app for tracking books you are reading.

15 | 16 | {% if session('me') %} 17 |

You are signed in as {% if session('display_photo') %}profile photo for{% endif %} {{ session('display_name') }}My ProfileSign Out

18 | 19 |
20 | {% else %} 21 |

To use indiebookclub, sign in with your domain. If your website supports Micropub, it will post directly to your site. Otherwise, it will post to your profile on this website.

22 | 23 | {% if show_signin_prompt %} 24 |
Please sign in again
25 | {% endif %} 26 | 27 | 31 | {% endif %} 32 |
33 |
34 | 35 | {% endblock %} 36 | 37 | -------------------------------------------------------------------------------- /templates/pages/isbn.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = 'Entries for ISBN ' ~ isbn %} 4 | 5 | {% block content %} 6 | 7 |
8 |

{{ short_title }}

9 | 10 | 15 | 16 | 33 |
34 | 35 | {% endblock content %} 36 | 37 | -------------------------------------------------------------------------------- /templates/pages/maintenance.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = 'Under Maintenance' %} 4 | 5 | {% block content %} 6 | 7 |

{{ short_title }}

8 | 9 |

indiebookclub is temporarily unavailable due to maintenance. Please check back soon.

10 | 11 | {% endblock %} 12 | 13 | -------------------------------------------------------------------------------- /templates/pages/new-post.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = 'New Post' %} 4 | 5 | {% block content %} 6 | 7 |

{{ short_title }}

8 | 9 | {% include('partials/interactive-messages.twig') %} 10 | 11 |
12 |

13 | 14 | 19 |

20 | 21 |

22 | 23 | 24 |

25 | 26 |

27 | (optional) 28 | 29 |

30 | 31 | 32 |   33 | 34 |

35 | (optional) 36 | 37 |

38 | 39 |

40 | (optional) 41 | ISBN-13 preferred — ISBN-10 will be converted to ISBN-13. 42 | 43 |

44 | 45 |

46 | (optional) 47 | Separate tags with commas 48 | 49 |

50 | 51 | {% if user.micropub_endpoint %} 52 |

53 | 54 | 59 |

60 | {% else %} 61 | 62 | {% endif %} 63 | 64 |

65 | 66 | 71 |

72 | 73 |

74 | (optional) 75 | Leave blank to use current time 76 | 77 |

78 | 79 |

80 |

81 | Advanced 82 | 83 | 84 |
85 |

86 | 87 | 88 | 89 |
90 | 91 | {% if user.micropub_endpoint %} 92 |
93 | 94 |

Clicking Submit will post a read post to your Micropub endpoint: {{ user.micropub_endpoint }}

95 | 96 |

See the documentation for more information about the request that will be sent.

97 | 98 |

If you are experiencing problems with posts not showing up on your site, check the settings page for more information, including the last response from your Micropub endpoint.

99 | 100 |
101 | {% endif %} 102 | 103 |
104 | 105 | {% endblock content %} 106 | 107 | -------------------------------------------------------------------------------- /templates/pages/profile.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = 'Entries by ' ~ profile.url %} 4 | {% if profile.name %} 5 | {% set short_title = 'Entries by ' ~ profile.name %} 6 | {% endif %} 7 | 8 | {% block headLinks %} 9 | {{ parent() }} 10 | 11 | {% endblock headLinks %} 12 | 13 | {% block content %} 14 | 15 |
16 | {%~ if session('me') -%} 17 |
18 | {%- endif ~%} 19 | 20 |

{{ short_title }}

21 | 22 | 31 | 32 | 49 |
50 | 51 | {% endblock content %} 52 | 53 | -------------------------------------------------------------------------------- /templates/pages/review.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = year ~ ' Year in Review' %} 4 | {% set word_domain = number_logins == 1 ? 'domain' : 'domains' %} 5 | 6 | {% block content %} 7 |
8 |

{{ short_title }}

9 | 10 |
11 |

{% if is_final == false %}So far: {% endif %}In {{ year }} indiebookclub had {{ number_new_entries }} public posts and at least {{ number_new_books }} new books* added. {{ number_logins }} {{ word_domain }} signed in and {{ number_new_users }} of them were new to indiebookclub.

12 | 13 | {% if is_final == false %}

This page will update daily until {{ '2024-01-01'|date('F j, Y') }}.

{% endif %} 14 | 15 | * New to indiebookclub as measured by unique ISBN in posts. Since the ISBN is optional when posting, the actual number of new books may be higher. 16 |
17 | 18 |

What People Read This Year (Or wanted to!)

19 | 20 |

From public indiebookclub posts. Links below are to Open Library. If any information is incorrect or missing, Open Library welcomes contributions.

21 | 22 | 41 | 42 | This page last cached: 43 |
44 | {% endblock %} 45 | 46 | -------------------------------------------------------------------------------- /templates/pages/settings.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = 'Settings' %} 4 | 5 | {% block content %} 6 | 7 |

{{ short_title }}

8 | 9 | {% include('partials/interactive-messages.twig') %} 10 | 11 |
12 | You are signed in as {{ user.url }}. Sign Out? indiebookclub v{{ version }} 13 |
14 | 15 |
16 | Account
17 | {% if session('display_photo') %}{% endif %} {{ session('display_name') }} 18 | 19 | Name and photo come from your site's IndieAuth response or the representative h-card 20 |
21 | 22 | {% if user.micropub_endpoint %} 23 | 24 |

Micropub

25 | 26 |
27 |
28 | 29 | The new post form will default to this setting 30 |
31 | 36 | 37 |
38 |
39 |
40 | 41 |
42 | visibility
43 | The visibility options your site supports 44 | {% if supported_visibility %} 45 | {{ supported_visibility }} 46 | {% else %} 47 | None indicated. Defaults to: public 48 | {% endif %} 49 |
50 | 51 |
52 | scope
53 | Should be a space-separated list of permissions including “create” or “post” 54 | {{ user.token_scope }} 55 |
56 | 57 |
58 | micropub endpoint
59 | Should be a URL 60 | {{ user.micropub_endpoint }} 61 |
62 | 63 |
64 | access token
65 | Should be greater than length 0 66 | String of length {{ token_length }} {% if token_ending %} ending in {{ token_ending }}{% endif %} 67 |
68 | 69 | {% if user.last_micropub_response %} 70 |
71 | Last response from your Micropub endpoint
72 | 73 |
74 | {% endif %} 75 | 76 |
77 |

Reset Login

78 | 79 |

Clicking this button will tell your token endpoint to revoke the token. indiebookclub will forget the access token stored, forget all cached endpoints, and sign you out. If you sign back in, you will start over and see the authorization screen for your endpoints.

80 | 81 |
82 | 83 |
84 |
85 | 86 | {% endif %} 87 | 88 |
89 |

Export Posts

90 |

Click this button to download an HTML export of all your posts.

91 | 92 |
93 | 94 |
95 |
96 | 97 | {% endblock content %} 98 | 99 | -------------------------------------------------------------------------------- /templates/pages/updates.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/default-layout.twig' %} 2 | 3 | {% set short_title = 'indiebookclub updates' %} 4 | 5 | {% block content %} 6 | 7 |
8 |

{{ short_title }}

9 | 10 |
11 |

indiebookclub version 0.1.2 launched

12 | 13 |
14 |

Draft posts: If you are using Micropub, the new post form lets you select post status “published” or “draft.” If you grant the “draft” permission during sign-in, the post status will default to “draft.” Your Micropub server must understand the post-status property in order to handle draft posts. Draft posts are only sent to your Micropub endpoint and are not stored on your indiebookclub profile.

15 | 16 |

The new post form supports query parameter post-status as well. The documentation sections for Micropub requests and query parameters have been updated.

17 | 18 |

Improved query parameter support: If you are using a bookmarklet for the new post form and are not currently signed in, your initial query parameters will be preserved through the sign-in process.

19 | 20 |

Improved sign-in error handling: When using Micropub, ensure that “create” permission has been granted.

21 |
22 |
23 | 24 |
25 |

indiebookclub Year in Review

26 | 27 |
28 |

Recent IndieWeb discussion of year in review features inspired me to set up the indiebookclub 2023 Year in Review. This is an overview for all of indiebookclub’s public posts and includes a list of what people have been reading (or wanted to).

29 | 30 |

2023 is still ongoing, of course, so this review page will continue to update daily until the new year.

31 | 32 |

Happy holidays and happy reading!

33 |
34 |
35 | 36 |
37 |

indiebookclub version 0.1.0 launched

38 | 39 |
40 |

Re-try publishing: If you experience an error publishing to your site with Micropub — or if you added Micropub support after you started using the app — you can now re-try publishing individual posts to your site. Browse to the post on your indiebookclub profile, click the timestamp to view the post, then you should see the link to re-try publishing. You can only re-try posts that have not been published on your site already.

41 | 42 |

Published Date and Time: optionally set the date and time of a post to backdate it.

43 | 44 |

Add to my list: When browsing posts on someone’s profile, there is a shortcut link at the bottom right. Click ➕ Add to my list to open the new post form with the information pre-populated.

45 | 46 |

Updated IndieAuth: Now uses the latest IndieAuth specification (2022-02-12), which adds IndieAuth Server Metadata support. The app will still work normally if your site doesn’t support this yet, but it will warn you that you might want to update your software.

47 | 48 |

Behind the scenes, a lot of the code has been refactored and modernized.

49 |
50 |
51 | 52 |
53 | 54 |
55 |

indiebookclub version 0.0.3 launched

56 | 57 |
58 |

Post visibility: If your Micropub server supports visibility options (public, unlisted, or private), you can select the visibility when making a new post. You can also select a default visibility for new posts on your settings page.

59 | 60 |

If you make a private post, it will only appear on your profile page when you are signed in. It will not be visible by other indiebookclub users or the public.

61 | 62 |

If you make an unlisted post, the post will not appear on your profile page or the ISBN page. However, the permalink for the unlisted post is not protected, so if it is shared publicly, others will be able to view it.

63 | 64 |

Delete posts: You can now delete posts. If you are using Micropub, you also have the option to grant indiebookclub permission to delete the post from your site.

65 | 66 |

Deleting from indiebookclub is permanent; posts cannot be un-deleted.

67 | 68 |

Layout updates: After signing in, the home page and the page header indicate the domain you are signed in as. They also include a link to your profile, sign out link, and a button to create a new post.

69 | 70 |

Updated IndieAuth: The sign-in process now uses the latest IndieAuth specification (2020-11-26), which adds PKCE support to protect against authorization code injection and CSRF attacks.

71 | 72 |
73 |
74 |
75 | 76 | {% endblock %} 77 | 78 | -------------------------------------------------------------------------------- /templates/partials/entries/read-status.twig: -------------------------------------------------------------------------------- 1 | {%- set status_for_humans = 'Want to read' -%} 2 | {%- if entry.read_status == 'finished' -%} 3 | {%- set status_for_humans = 'Finished reading' -%} 4 | {%- elseif entry.read_status == 'reading' -%} 5 | {%- set status_for_humans = 'Currently reading' -%} 6 | {%- endif -%} 7 | {{ status_for_humans }}: 8 | -------------------------------------------------------------------------------- /templates/partials/entry.twig: -------------------------------------------------------------------------------- 1 | {%- set url_profile = path_for('profile', {'domain': entry.profile_slug}) -%} 2 | 3 | {%- set permalink = entry.canonical_url -%} 4 | {%- if entry.canonical_url is empty -%} 5 | {%- set permalink = path_for('entry', {'domain': entry.profile_slug, 'entry': entry.id }) -%} 6 | {%- endif -%} 7 | 8 | {%- set url_add = path_for('new', {}, { 9 | 'title': entry.title, 10 | 'authors': entry.authors, 11 | 'isbn': entry.isbn, 12 | 'doi': entry.doi 13 | }) -%} 14 | 15 | {%- set is_own_post = (session('user_id') == entry.user_id) -%} 16 | 17 | {% if session('me') and can_retry %} 18 |
19 | This post was not published to your site. This may be because your site did not support Micropub at the time, or there was an error publishing. Click this link if you would like to try again:
Try publishing again 20 |
21 |
22 | If you continue to experience problems publishing, check your settings page for more information, including the last response from your Micropub endpoint. 23 |
24 | {% endif %} 25 | 26 |
  • 27 | 28 |
    29 | 30 | {%- if entry.profile_photo_url -%} 31 | photo of 32 | {%- else -%} 33 | placeholder photo 34 | {%- endif -%} 35 | 36 | 40 |
    41 | 42 |
    43 | 44 |
    45 | {%- include('partials/entries/read-status.twig') -%} {{ entry.title }}{% if entry.authors %} by {{ entry.authors}}{% endif %} {% if entry.doi %}doi:{{ entry.doi }}{% elseif entry.isbn %}ISBN: {{ entry.isbn }}{% endif %} 46 |
    47 | 50 | 51 | {%~ if entry.category ~%} 52 | {%- set tags = entry.category|split(',') -%} 53 | 58 | {%~ endif ~%} 59 | 60 | {%~ if entry.visibility != 'public' ~%} 61 | 62 | {%~ endif ~%} 63 | 64 | {%~ if not is_caching ~%} 65 | {%- if session('me') -%} 66 | 67 | {%- endif -%} 68 | 69 | {%- if is_own_post -%} 70 | 71 | {%- endif -%} 72 | {%~ endif ~%} 73 |
    74 |
  • 75 | 76 | -------------------------------------------------------------------------------- /templates/partials/interactive-messages.twig: -------------------------------------------------------------------------------- 1 | {# TODO: migrate to Aura.Session and flash messages, e.g. flash.get('errors') #} 2 | {# {%- set validation_errors = flash.get('validation-errors') -%} #} 3 | 4 | {% if validation_errors|length > 0 %} 5 |
    6 |

    Please fix the following {{ (validation_errors|length == 1) ? 'error' : 'errors' }} and re-submit the form:

    7 | 12 |
    13 | {% endif %} 14 | 15 | -------------------------------------------------------------------------------- /tests/IbcTest.php: -------------------------------------------------------------------------------- 1 | app = $app; 22 | 23 | $this->container = $app->getContainer(); 24 | } 25 | 26 | public function testBuildUrlNoQueryString(): void 27 | { 28 | $url = $this->container['utils']->build_url('https://example.com'); 29 | $query = parse_url($url, PHP_URL_QUERY); 30 | $this->assertNull($query, 'build_url should not append query string in this instance'); 31 | } 32 | 33 | public function testBuildUrlQueryString(): void 34 | { 35 | $url = $this->container['utils']->build_url('https://example.com', ['foo' => 'bar']); 36 | $query = parse_url($url, PHP_URL_QUERY); 37 | $this->assertNotNull($query, 'build_url should append a query string in this instance'); 38 | } 39 | 40 | public function testBuildUrlOnlyOneQueryString(): void 41 | { 42 | $url = $this->container['utils']->build_url('https://example.com?endpoint=micropub', ['foo' => 'bar']); 43 | $query = parse_url($url, PHP_URL_QUERY); 44 | 45 | $this->assertFalse(strpos($query, '?'), 'URL already has query parameters. build_url should append query params with &'); 46 | } 47 | 48 | public function testUtilsIsUrlAllowed(): void 49 | { 50 | $url = 'https://indiebookclub.biz/redirect1'; 51 | $result = $this->container['utils']->is_url_allowed($url); 52 | $this->assertTrue($result, sprintf('%s should be an allowed url', $url)); 53 | 54 | $url = 'https://dev.indiebookclub.biz/redirect2'; 55 | $result = $this->container['utils']->is_url_allowed($url); 56 | $this->assertTrue($result, sprintf('%s should be an allowed url', $url)); 57 | 58 | $url = '/redirect3'; 59 | $result = $this->container['utils']->is_url_allowed($url); 60 | $this->assertTrue($result, sprintf('%s should be an allowed url', $url)); 61 | 62 | $url = 'https://subdomain.indiebookclub.biz/'; 63 | $result = $this->container['utils']->is_url_allowed($url); 64 | $this->assertFalse($result, sprintf('%s should not be an allowed url', $url)); 65 | 66 | $url = 'https://example.com/'; 67 | $result = $this->container['utils']->is_url_allowed($url); 68 | $this->assertFalse($result, sprintf('%s should not be an allowed url', $url)); 69 | } 70 | 71 | public function testUtilsGetRedirect(): void 72 | { 73 | $url = '/redirect1'; 74 | $result = $this->container['utils']->get_redirect($url); 75 | $this->assertEquals($url, $result); 76 | 77 | $url = 'https://indiebookclub.biz/redirect2'; 78 | $result = $this->container['utils']->get_redirect($url); 79 | $this->assertEquals($url, $result); 80 | 81 | $url = 'https://dev.indiebookclub.biz/redirect3'; 82 | $result = $this->container['utils']->get_redirect($url); 83 | $this->assertEquals($url, $result); 84 | 85 | $url = 'https://example.com/redirect4'; 86 | $result = $this->container['utils']->get_redirect($url); 87 | $this->assertEquals('/', $result); 88 | 89 | $default = '/profile'; 90 | $url = 'https://example.com/redirect4'; 91 | $result = $this->container['utils']->get_redirect($url, $default); 92 | $this->assertEquals($default, $result); 93 | } 94 | 95 | public function testNormalizeSeparatedString(): void 96 | { 97 | $input = 'create, draft, delete'; 98 | $result = $this->container['utils']->normalizeSeparatedString($input); 99 | $this->assertEquals('create,draft,delete', $result); 100 | 101 | $input = ' create delete profile '; 102 | $result = $this->container['utils']->normalizeSeparatedString($input, ' '); 103 | $this->assertEquals('create delete profile', $result); 104 | } 105 | 106 | public function testHasScope(): void 107 | { 108 | $scopes = 'create profile'; 109 | $result = $this->container['utils']->hasScope($scopes, 'profile'); 110 | $this->assertTrue($result); 111 | 112 | $result = $this->container['utils']->hasScope($scopes, 'delete'); 113 | $this->assertFalse($result); 114 | } 115 | } 116 | 117 | --------------------------------------------------------------------------------