├── .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 |
7 |
8 |
--------------------------------------------------------------------------------
/templates/layouts/partials/user-bar.twig:
--------------------------------------------------------------------------------
1 |
2 |
3 | {%- if session('me') ~%}
4 | Signed in as {% if session('display_photo') %}
{% endif %}
{{ session('display_name') }} •
My Profile •
Sign 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 |
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 |
30 | IndieAuth metadata endpoint: {{ metadata_endpoint ?: 'none' }}
31 | Authorization endpoint: {{ authorization_endpoint ?: 'none' }}
32 | Token endpoint: {{ token_endpoint ?: 'none' }}
33 | Micropub endpoint: {{ micropub_endpoint ?: 'none' }}
34 | Revocation endpoint: {{ revocation_endpoint ?: 'none' }}
35 |
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 |
11 | {% include('partials/entry.twig') %}
12 |
13 |
14 |
18 |
19 | {% if is_micropub_post and not has_micropub_delete %}
20 |
23 | {% endif %}
24 |
25 |
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 |
46 | read-status
: Values “to-read”, “reading”, or “finished” (not case-sensitive)
47 | title
48 | authors
49 | isbn
50 | doi
51 | tags
52 | post-status
: Values “published” or “draft” (not case-sensitive)
53 |
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 |
13 | {%~ if cached_entry -%}
14 | {{ cached_entry|raw }}
15 | {%- else -%}
16 | {% include('partials/entry.twig') %}
17 | {%- endif -%}
18 |
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 |
18 | {%- for entry in entries -%}
19 | {% include('partials/entry.twig') %}
20 | {%- endfor -%}
21 |
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 |
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') %} {% endif %} {{ session('display_name') }} • My Profile • Sign 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 |
11 | {%- for entry in entries -%}
12 | {% include('partials/entry.twig') %}
13 | {%- endfor -%}
14 |
15 |
16 |
17 |
18 | {% if older_id %}
19 |
← Older
20 | {% endif %}
21 |
22 |
23 |
24 | {% if before %}
25 | {%- set url_newer = path_for('isbn', {'isbn': isbn}) -%}
26 | {%- if newer_id -%}
27 | {%- set url_newer = path_for('isbn', {'isbn': isbn}, {'before': newer_id}) -%}
28 | {%- endif -%}
29 |
Newer →
30 | {% endif %}
31 |
32 |
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 |
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 |
23 | {% if entries|length == 0 %}
24 | Nothing to see here yet. {% if session('hostname') == profile.profile_slug %}Make your first post!{% endif %}
25 | {% endif %}
26 |
27 | {%- for entry in entries -%}
28 | {% include('partials/entry.twig') %}
29 | {%- endfor -%}
30 |
31 |
32 |
33 |
34 | {% if older_id %}
35 |
← Older
36 | {% endif %}
37 |
38 |
39 |
40 | {% if before %}
41 | {%- set url_newer = path_for('profile', {'domain': profile.profile_slug}) -%}
42 | {%- if newer_id -%}
43 | {%- set url_newer = path_for('profile', {'domain': profile.profile_slug}, {'before': newer_id}) -%}
44 | {%- endif -%}
45 |
Newer →
46 | {% endif %}
47 |
48 |
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 |
23 | {% for item in distinct_entries %}
24 | {%- set title = '' ~ item.title ~ ' ' -%}
25 | {%- set uid = '' -%}
26 | {% if item.isbn %}
27 | {% set title = ''
29 | ~ item.title ~ ' ' %}
30 | {% set uid = 'ISBN: ' ~ item.isbn ~ ' ' %}
31 | {% elseif item.doi %}
32 | {% set uid = 'doi: ' ~ item.doi ~ ' ' %}
33 | {% endif %}
34 |
35 |
36 | {{ title|raw }}{% if item.authors %} by {{ item.authors }} {% endif %}
37 | {{ uid|raw }}
38 |
39 | {% endfor %}
40 |
41 |
42 |
This page last cached: {{ dt|date('Y-m-d H:i:sO') }}
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 |
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 |
84 |
85 |
86 | {% endif %}
87 |
88 |
89 |
Export Posts
90 |
Click this button to download an HTML export of all your posts.
91 |
92 |
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 |
April 7, 2024
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 |
December 2, 2023
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 |
November 14, 2022
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 |
December 3, 2021
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 |
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 |
Tags:
54 | {%~ for tag in tags -%}
55 | {{ tag }} {% if loop.index != loop.length %}, {% endif %}
56 | {%- endfor -%}
57 |
58 | {%~ endif ~%}
59 |
60 | {%~ if entry.visibility != 'public' ~%}
61 |
Visibility: {{ entry.visibility }} {% if entry.visibility == 'private' %}🔒{% elseif entry.visibility == 'unlisted' %}👻{% endif %}
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 |
8 | {% for message in validation_errors %}
9 | {{ message|raw }}
10 | {% endfor %}
11 |
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 |
--------------------------------------------------------------------------------