From f93f3d63ba2f7ff2957bde9ea1657f5f65fb0551 Mon Sep 17 00:00:00 2001 From: Jordan <37647414+linuxgoose@users.noreply.github.com> Date: Sun, 14 Sep 2025 10:27:04 +0100 Subject: [PATCH] Initial commit from GitHub --- .build.yml | 21 + .envrc.example | 24 + .github/dependabot.yml | 11 + .github/workflows/check.yml | 49 + .github/workflows/docs.yml | 50 + .github/workflows/image.yml | 49 + .gitignore | 24 + CHANGELOG.md | 83 + Dockerfile | 28 + LICENSE | 661 ++++++++ README.md | 327 ++++ SECURITY.md | 6 + ansible/.envrc.example | 42 + ansible/Caddyfile.j2 | 43 + ansible/ansible.cfg | 3 + ansible/backup-database.sh | 33 + ansible/caddy.service.j2 | 22 + ansible/inventory.yaml | 5 + ansible/mataroa-backup.service.j2 | 12 + ansible/mataroa-backup.timer.j2 | 8 + ansible/mataroa-dailysummary.service.j2 | 12 + ansible/mataroa-dailysummary.timer.j2 | 8 + ansible/mataroa-exports.service.j2 | 12 + ansible/mataroa-exports.timer.j2 | 8 + ansible/mataroa-notifications.service.j2 | 12 + ansible/mataroa-notifications.timer.j2 | 8 + ansible/mataroa.env.j2 | 12 + ansible/mataroa.service.j2 | 17 + ansible/playbook.yaml | 201 +++ ansible/vars.yaml | 20 + docker-compose.yml | 32 + docs/book.toml | 6 + docs/src/SUMMARY.md | 13 + docs/src/coding-conventions.md | 4 + docs/src/commit-messages.md | 15 + docs/src/cronjobs.md | 31 + docs/src/database-backup.md | 45 + docs/src/dependencies.md | 47 + docs/src/deployment.md | 66 + docs/src/file-structure-walkthrough.md | 191 +++ docs/src/introduction.md | 29 + docs/src/main-repository-readme.md | 1 + docs/src/runbook.md | 114 ++ docs/src/server-migration.md | 36 + export_base_epub/container.xml | 6 + export_base_epub/content.opf | 29 + export_base_epub/mimetype | 1 + export_base_epub/toc.ncx | 24 + export_base_epub/toc.xhtml | 15 + export_base_hugo/404.html | 19 + export_base_hugo/baseof.html | 25 + export_base_hugo/config.toml | 12 + export_base_hugo/index.html | 33 + export_base_hugo/list.html | 14 + export_base_hugo/single.html | 32 + export_base_hugo/style.css | 151 ++ export_base_hugo/theme.toml | 7 + export_base_zola/404.html | 15 + export_base_zola/_index.md | 3 + export_base_zola/config.toml | 22 + export_base_zola/index.html | 38 + export_base_zola/post.html | 26 + export_base_zola/style.css | 157 ++ main/__init__.py | 0 main/admin.py | 236 +++ main/apps.py | 5 + main/denylist.py | 370 ++++ main/feeds.py | 161 ++ main/fixtures/dev-data.json | 147 ++ main/forms.py | 75 + main/management/commands/checkstripe.py | 39 + main/management/commands/mailexports.py | 116 ++ main/management/commands/mailsummary.py | 141 ++ .../commands/processnotifications.py | 169 ++ main/management/commands/testbulkmail.py | 74 + main/middleware.py | 119 ++ main/migrations/0001_initial.py | 130 ++ main/migrations/0002_user_blog_title.py | 18 + main/migrations/0003_post.py | 41 + main/migrations/0004_auto_20200530_0046.py | 24 + main/migrations/0005_auto_20200530_1206.py | 16 + main/migrations/0006_auto_20200531_1619.py | 16 + main/migrations/0007_user_blog_byline.py | 17 + main/migrations/0008_auto_20200601_2326.py | 25 + main/migrations/0009_auto_20200604_2327.py | 17 + main/migrations/0010_user_cname.py | 17 + main/migrations/0011_auto_20200606_1903.py | 17 + main/migrations/0012_auto_20200606_1903.py | 17 + main/migrations/0013_auto_20200606_2048.py | 25 + main/migrations/0014_auto_20200607_0017.py | 35 + main/migrations/0015_auto_20200607_0023.py | 24 + main/migrations/0016_auto_20200607_0024.py | 25 + main/migrations/0017_post_published_at.py | 23 + main/migrations/0018_auto_20200607_2049.py | 23 + main/migrations/0019_auto_20200607_2131.py | 16 + main/migrations/0020_auto_20200608_1915.py | 40 + main/migrations/0021_image_slug.py | 18 + main/migrations/0022_auto_20200608_2117.py | 17 + main/migrations/0023_auto_20200608_2141.py | 16 + main/migrations/0024_auto_20200608_2304.py | 23 + main/migrations/0025_page.py | 42 + main/migrations/0026_auto_20200610_1854.py | 23 + main/migrations/0027_auto_20200610_1904.py | 23 + main/migrations/0028_auto_20200610_2248.py | 20 + main/migrations/0029_user_footer_note.py | 17 + main/migrations/0030_auto_20200613_1726.py | 17 + main/migrations/0031_auto_20200620_1344.py | 47 + main/migrations/0032_auto_20200620_1431.py | 32 + main/migrations/0033_auto_20200626_1947.py | 27 + main/migrations/0034_analytic.py | 34 + main/migrations/0035_auto_20200812_2020.py | 47 + main/migrations/0036_auto_20200812_2102.py | 22 + main/migrations/0037_auto_20200812_2126.py | 24 + main/migrations/0038_auto_20200812_2152.py | 16 + main/migrations/0039_auto_20200816_1543.py | 24 + ...postnotification_postnotificationrecord.py | 66 + main/migrations/0041_auto_20200820_2107.py | 16 + main/migrations/0042_auto_20200821_1342.py | 19 + main/migrations/0043_user_notifications_on.py | 20 + .../0044_postnotificationrecord_post.py | 20 + main/migrations/0045_auto_20200821_1659.py | 16 + main/migrations/0046_auto_20200821_1820.py | 18 + main/migrations/0047_auto_20200830_1057.py | 20 + main/migrations/0048_auto_20201218_1351.py | 20 + main/migrations/0049_user_redirect_domain.py | 25 + main/migrations/0050_auto_20210101_1509.py | 25 + main/migrations/0051_auto_20210111_2111.py | 29 + main/migrations/0052_auto_20210124_1932.py | 30 + .../migrations/0053_notification_is_active.py | 17 + main/migrations/0054_auto_20210312_1643.py | 25 + main/migrations/0055_user_theme_zialucia.py | 21 + main/migrations/0056_auto_20210317_2313.py | 25 + main/migrations/0057_auto_20210317_2321.py | 23 + .../0058_remove_analytic_referer.py | 16 + main/migrations/0059_auto_20210409_1320.py | 68 + main/migrations/0060_auto_20210429_1506.py | 35 + main/migrations/0061_auto_20210503_0035.py | 27 + main/migrations/0062_auto_20210516_2310.py | 39 + main/migrations/0063_exportrecord.py | 40 + .../0064_user_export_unsubscribe_key.py | 19 + ...065_remove_uuid_null_export_unsubscribe.py | 22 + .../0066_add_uuid_field_export_unsubscribe.py | 19 + .../0067_rename_analytic_analyticpost.py | 16 + main/migrations/0068_analyticpage.py | 40 + .../0069_alter_analyticpage_path.py | 17 + .../0070_notificationrecord_is_canceled.py | 17 + main/migrations/0071_user_monero_address.py | 17 + main/migrations/0072_alter_user_username.py | 28 + main/migrations/0073_user_api_key.py | 21 + .../0074_populate_api_key_values.py | 22 + main/migrations/0075_remove_api_key_null.py | 21 + .../migrations/0076_alter_user_footer_note.py | 23 + main/migrations/0077_comment_is_approved.py | 17 + .../0078_alter_user_theme_zialucia.py | 21 + ...eme_sansserif_alter_user_theme_zialucia.py | 30 + .../0080_alter_user_theme_sansserif.py | 21 + ...ent_is_author_alter_comment_is_approved.py | 24 + main/migrations/0082_snapshot.py | 41 + main/migrations/0083_user_post_backups_on.py | 21 + .../0084_alter_user_post_backups_on.py | 21 + .../migrations/0085_alter_user_footer_note.py | 22 + .../migrations/0086_alter_user_blog_byline.py | 19 + main/migrations/0087_user_is_approved.py | 17 + main/migrations/0088_alter_page_is_hidden.py | 20 + .../0089_user_stripe_subscription_id.py | 17 + main/migrations/0090_onboard.py | 37 + main/migrations/0091_onboard_created_at.py | 21 + main/migrations/0092_alter_onboard_user.py | 24 + ..._onboard_problems_alter_onboard_quality.py | 22 + main/migrations/0094_onboard_code.py | 19 + main/migrations/0095_auto_20231109_2111.py | 22 + main/migrations/0096_auto_20231109_2111.py | 19 + main/migrations/0097_user_subscribe_note.py | 22 + .../0098_alter_user_notifications_on.py | 21 + .../0099_alter_user_subscribe_note.py | 23 + main/migrations/0100_onboard_seo.py | 18 + main/migrations/0101_post_broadcasted_at.py | 17 + ...2_remove_notificationrecord_is_canceled.py | 16 + .../0103_alter_post_broadcasted_at.py | 17 + ...problems_alter_onboard_quality_and_more.py | 27 + ...content_alter_user_footer_note_and_more.py | 29 + ...av_user_show_posts_on_homepage_and_more.py | 29 + .../0107_alter_user_show_posts_on_homepage.py | 18 + main/migrations/0108_user_noindex_on.py | 18 + main/migrations/0109_user_posts_page_title.py | 18 + main/migrations/0110_user_robots_txt.py | 18 + ...r_reading_time_on_alter_user_robots_txt.py | 23 + main/migrations/0112_rsl_settings.py | 27 + ...name_rsl_settings_reallysimplelicensing.py | 17 + ...14_alter_reallysimplelicensing_show_rss.py | 18 + ...115_alter_reallysimplelicensing_license.py | 18 + .../0116_alter_reallysimplelicensing_user.py | 20 + ..._reallysimplelicensing_license_and_more.py | 38 + main/migrations/__init__.py | 0 main/models.py | 510 ++++++ main/sitemaps.py | 56 + main/static/favicon.png | Bin 0 -> 411 bytes main/static/logo.svg | 16 + main/templates/400.html | 13 + main/templates/403.html | 13 + main/templates/404.html | 12 + main/templates/500.html | 13 + main/templates/assets/drag-and-drop-upload.js | 82 + main/templates/assets/make-draft-button.js | 35 + main/templates/assets/save-snapshot.js | 58 + main/templates/assets/style.css | 949 +++++++++++ main/templates/main/analytic_detail.html | 60 + main/templates/main/analytic_list.html | 33 + main/templates/main/api_docs.html | 203 +++ main/templates/main/api_key_reset.html | 21 + main/templates/main/billing_card.html | 103 ++ .../main/billing_card_confirm_delete.html | 24 + main/templates/main/billing_index.html | 163 ++ main/templates/main/billing_subscribe.html | 119 ++ .../main/billing_subscription_cancel.html | 14 + main/templates/main/blog_import.html | 29 + main/templates/main/blog_index.html | 97 ++ main/templates/main/blog_posts.html | 85 + main/templates/main/comment_approve.html | 23 + .../main/comment_confirm_delete.html | 33 + main/templates/main/comment_form.html | 64 + main/templates/main/comment_list.html | 35 + main/templates/main/comparisons.html | 164 ++ main/templates/main/dashboard.html | 75 + main/templates/main/export_index.html | 137 ++ main/templates/main/export_print.html | 53 + .../main/export_unsubscribe_success.html | 26 + main/templates/main/guides_comments.html | 50 + main/templates/main/guides_customdomain.html | 42 + main/templates/main/guides_images.html | 46 + main/templates/main/guides_markdown.html | 106 ++ main/templates/main/image_confirm_delete.html | 13 + main/templates/main/image_detail.html | 43 + main/templates/main/image_form.html | 21 + main/templates/main/image_list.html | 37 + main/templates/main/landing.html | 241 +++ main/templates/main/layout.html | 88 + main/templates/main/methodology.html | 346 ++++ main/templates/main/moderation_activity.html | 183 ++ main/templates/main/moderation_cohorts.html | 28 + main/templates/main/moderation_images.html | 46 + main/templates/main/moderation_index.html | 22 + main/templates/main/moderation_posts.html | 58 + main/templates/main/moderation_stats.html | 66 + main/templates/main/moderation_summary.html | 107 ++ main/templates/main/moderation_user_list.html | 265 +++ .../main/moderation_user_single.html | 189 +++ main/templates/main/notification.html | 25 + main/templates/main/notification_list.html | 28 + .../main/notification_unsubscribe.html | 25 + .../notification_unsubscribe_success.html | 27 + .../notificationrecord_confirm_delete.html | 18 + .../main/notificationrecord_list.html | 49 + main/templates/main/page_confirm_delete.html | 13 + main/templates/main/page_detail.html | 51 + main/templates/main/page_form.html | 70 + main/templates/main/page_list.html | 26 + main/templates/main/post_confirm_delete.html | 13 + main/templates/main/post_detail.html | 196 +++ main/templates/main/post_form.html | 90 + main/templates/main/post_list.html | 36 + main/templates/main/rsl_update.html | 29 + .../main/snapshot_confirm_delete.html | 13 + main/templates/main/snapshot_detail.html | 37 + main/templates/main/snapshot_list.html | 25 + main/templates/main/transparency.html | 117 ++ main/templates/main/user_confirm_delete.html | 16 + main/templates/main/user_create_step_one.html | 54 + main/templates/main/user_create_step_two.html | 71 + main/templates/main/user_update.html | 20 + main/templates/main/webring.html | 34 + main/templates/partials/footer.html | 5 + main/templates/partials/footer_blog.html | 7 + main/templates/partials/rsl_license_head.html | 7 + main/templates/partials/webring.html | 20 + main/templates/registration/logged_out.html | 10 + main/templates/registration/login.html | 56 + .../registration/password_change_done.html | 10 + .../registration/password_change_form.html | 15 + .../registration/password_reset_complete.html | 10 + .../registration/password_reset_confirm.html | 14 + .../registration/password_reset_done.html | 10 + .../registration/password_reset_email.html | 14 + .../registration/password_reset_form.html | 15 + main/tests/__init__.py | 0 main/tests/test_analytics.py | 296 ++++ main/tests/test_api.py | 745 ++++++++ main/tests/test_billing.py | 294 ++++ main/tests/test_blog.py | 420 +++++ main/tests/test_comments.py | 563 +++++++ main/tests/test_feeds.py | 129 ++ main/tests/test_images.py | 244 +++ main/tests/test_management.py | 200 +++ main/tests/test_pages.py | 297 ++++ main/tests/test_posts.py | 432 +++++ main/tests/test_sitemap.py | 169 ++ main/tests/test_snapshots.py | 109 ++ main/tests/test_static.py | 30 + main/tests/test_users.py | 291 ++++ main/tests/test_webring.py | 117 ++ main/tests/testdata/lorem.md | 5 + main/tests/testdata/vulf.jpeg | Bin 0 -> 43845 bytes main/urls.py | 282 ++++ main/util.py | 220 +++ main/validators.py | 20 + main/views/__init__.py | 0 main/views/api.py | 187 ++ main/views/billing.py | 534 ++++++ main/views/export.py | 457 +++++ main/views/general.py | 1501 +++++++++++++++++ main/views/moderation.py | 781 +++++++++ manage.py | 23 + mataroa/__init__.py | 0 mataroa/asgi.py | 16 + mataroa/settings.py | 248 +++ mataroa/urls.py | 23 + mataroa/wsgi.py | 16 + postgresql/Makefile | 18 + pyproject.toml | 41 + uv.lock | 573 +++++++ 320 files changed, 23498 insertions(+) create mode 100644 .build.yml create mode 100644 .envrc.example create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/image.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 ansible/.envrc.example create mode 100644 ansible/Caddyfile.j2 create mode 100644 ansible/ansible.cfg create mode 100755 ansible/backup-database.sh create mode 100644 ansible/caddy.service.j2 create mode 100644 ansible/inventory.yaml create mode 100644 ansible/mataroa-backup.service.j2 create mode 100644 ansible/mataroa-backup.timer.j2 create mode 100644 ansible/mataroa-dailysummary.service.j2 create mode 100644 ansible/mataroa-dailysummary.timer.j2 create mode 100644 ansible/mataroa-exports.service.j2 create mode 100644 ansible/mataroa-exports.timer.j2 create mode 100644 ansible/mataroa-notifications.service.j2 create mode 100644 ansible/mataroa-notifications.timer.j2 create mode 100644 ansible/mataroa.env.j2 create mode 100644 ansible/mataroa.service.j2 create mode 100644 ansible/playbook.yaml create mode 100644 ansible/vars.yaml create mode 100644 docker-compose.yml create mode 100644 docs/book.toml create mode 100644 docs/src/SUMMARY.md create mode 100644 docs/src/coding-conventions.md create mode 100644 docs/src/commit-messages.md create mode 100644 docs/src/cronjobs.md create mode 100644 docs/src/database-backup.md create mode 100644 docs/src/dependencies.md create mode 100644 docs/src/deployment.md create mode 100644 docs/src/file-structure-walkthrough.md create mode 100644 docs/src/introduction.md create mode 120000 docs/src/main-repository-readme.md create mode 100644 docs/src/runbook.md create mode 100644 docs/src/server-migration.md create mode 100644 export_base_epub/container.xml create mode 100644 export_base_epub/content.opf create mode 100644 export_base_epub/mimetype create mode 100644 export_base_epub/toc.ncx create mode 100644 export_base_epub/toc.xhtml create mode 100644 export_base_hugo/404.html create mode 100644 export_base_hugo/baseof.html create mode 100644 export_base_hugo/config.toml create mode 100644 export_base_hugo/index.html create mode 100644 export_base_hugo/list.html create mode 100644 export_base_hugo/single.html create mode 100644 export_base_hugo/style.css create mode 100644 export_base_hugo/theme.toml create mode 100644 export_base_zola/404.html create mode 100644 export_base_zola/_index.md create mode 100644 export_base_zola/config.toml create mode 100644 export_base_zola/index.html create mode 100644 export_base_zola/post.html create mode 100644 export_base_zola/style.css create mode 100644 main/__init__.py create mode 100644 main/admin.py create mode 100644 main/apps.py create mode 100644 main/denylist.py create mode 100644 main/feeds.py create mode 100644 main/fixtures/dev-data.json create mode 100644 main/forms.py create mode 100644 main/management/commands/checkstripe.py create mode 100644 main/management/commands/mailexports.py create mode 100644 main/management/commands/mailsummary.py create mode 100644 main/management/commands/processnotifications.py create mode 100644 main/management/commands/testbulkmail.py create mode 100644 main/middleware.py create mode 100644 main/migrations/0001_initial.py create mode 100644 main/migrations/0002_user_blog_title.py create mode 100644 main/migrations/0003_post.py create mode 100644 main/migrations/0004_auto_20200530_0046.py create mode 100644 main/migrations/0005_auto_20200530_1206.py create mode 100644 main/migrations/0006_auto_20200531_1619.py create mode 100644 main/migrations/0007_user_blog_byline.py create mode 100644 main/migrations/0008_auto_20200601_2326.py create mode 100644 main/migrations/0009_auto_20200604_2327.py create mode 100644 main/migrations/0010_user_cname.py create mode 100644 main/migrations/0011_auto_20200606_1903.py create mode 100644 main/migrations/0012_auto_20200606_1903.py create mode 100644 main/migrations/0013_auto_20200606_2048.py create mode 100644 main/migrations/0014_auto_20200607_0017.py create mode 100644 main/migrations/0015_auto_20200607_0023.py create mode 100644 main/migrations/0016_auto_20200607_0024.py create mode 100644 main/migrations/0017_post_published_at.py create mode 100644 main/migrations/0018_auto_20200607_2049.py create mode 100644 main/migrations/0019_auto_20200607_2131.py create mode 100644 main/migrations/0020_auto_20200608_1915.py create mode 100644 main/migrations/0021_image_slug.py create mode 100644 main/migrations/0022_auto_20200608_2117.py create mode 100644 main/migrations/0023_auto_20200608_2141.py create mode 100644 main/migrations/0024_auto_20200608_2304.py create mode 100644 main/migrations/0025_page.py create mode 100644 main/migrations/0026_auto_20200610_1854.py create mode 100644 main/migrations/0027_auto_20200610_1904.py create mode 100644 main/migrations/0028_auto_20200610_2248.py create mode 100644 main/migrations/0029_user_footer_note.py create mode 100644 main/migrations/0030_auto_20200613_1726.py create mode 100644 main/migrations/0031_auto_20200620_1344.py create mode 100644 main/migrations/0032_auto_20200620_1431.py create mode 100644 main/migrations/0033_auto_20200626_1947.py create mode 100644 main/migrations/0034_analytic.py create mode 100644 main/migrations/0035_auto_20200812_2020.py create mode 100644 main/migrations/0036_auto_20200812_2102.py create mode 100644 main/migrations/0037_auto_20200812_2126.py create mode 100644 main/migrations/0038_auto_20200812_2152.py create mode 100644 main/migrations/0039_auto_20200816_1543.py create mode 100644 main/migrations/0040_postnotification_postnotificationrecord.py create mode 100644 main/migrations/0041_auto_20200820_2107.py create mode 100644 main/migrations/0042_auto_20200821_1342.py create mode 100644 main/migrations/0043_user_notifications_on.py create mode 100644 main/migrations/0044_postnotificationrecord_post.py create mode 100644 main/migrations/0045_auto_20200821_1659.py create mode 100644 main/migrations/0046_auto_20200821_1820.py create mode 100644 main/migrations/0047_auto_20200830_1057.py create mode 100644 main/migrations/0048_auto_20201218_1351.py create mode 100644 main/migrations/0049_user_redirect_domain.py create mode 100644 main/migrations/0050_auto_20210101_1509.py create mode 100644 main/migrations/0051_auto_20210111_2111.py create mode 100644 main/migrations/0052_auto_20210124_1932.py create mode 100644 main/migrations/0053_notification_is_active.py create mode 100644 main/migrations/0054_auto_20210312_1643.py create mode 100644 main/migrations/0055_user_theme_zialucia.py create mode 100644 main/migrations/0056_auto_20210317_2313.py create mode 100644 main/migrations/0057_auto_20210317_2321.py create mode 100644 main/migrations/0058_remove_analytic_referer.py create mode 100644 main/migrations/0059_auto_20210409_1320.py create mode 100644 main/migrations/0060_auto_20210429_1506.py create mode 100644 main/migrations/0061_auto_20210503_0035.py create mode 100644 main/migrations/0062_auto_20210516_2310.py create mode 100644 main/migrations/0063_exportrecord.py create mode 100644 main/migrations/0064_user_export_unsubscribe_key.py create mode 100644 main/migrations/0065_remove_uuid_null_export_unsubscribe.py create mode 100644 main/migrations/0066_add_uuid_field_export_unsubscribe.py create mode 100644 main/migrations/0067_rename_analytic_analyticpost.py create mode 100644 main/migrations/0068_analyticpage.py create mode 100644 main/migrations/0069_alter_analyticpage_path.py create mode 100644 main/migrations/0070_notificationrecord_is_canceled.py create mode 100644 main/migrations/0071_user_monero_address.py create mode 100644 main/migrations/0072_alter_user_username.py create mode 100644 main/migrations/0073_user_api_key.py create mode 100644 main/migrations/0074_populate_api_key_values.py create mode 100644 main/migrations/0075_remove_api_key_null.py create mode 100644 main/migrations/0076_alter_user_footer_note.py create mode 100644 main/migrations/0077_comment_is_approved.py create mode 100644 main/migrations/0078_alter_user_theme_zialucia.py create mode 100644 main/migrations/0079_user_theme_sansserif_alter_user_theme_zialucia.py create mode 100644 main/migrations/0080_alter_user_theme_sansserif.py create mode 100644 main/migrations/0081_comment_is_author_alter_comment_is_approved.py create mode 100644 main/migrations/0082_snapshot.py create mode 100644 main/migrations/0083_user_post_backups_on.py create mode 100644 main/migrations/0084_alter_user_post_backups_on.py create mode 100644 main/migrations/0085_alter_user_footer_note.py create mode 100644 main/migrations/0086_alter_user_blog_byline.py create mode 100644 main/migrations/0087_user_is_approved.py create mode 100644 main/migrations/0088_alter_page_is_hidden.py create mode 100644 main/migrations/0089_user_stripe_subscription_id.py create mode 100644 main/migrations/0090_onboard.py create mode 100644 main/migrations/0091_onboard_created_at.py create mode 100644 main/migrations/0092_alter_onboard_user.py create mode 100644 main/migrations/0093_alter_onboard_problems_alter_onboard_quality.py create mode 100644 main/migrations/0094_onboard_code.py create mode 100644 main/migrations/0095_auto_20231109_2111.py create mode 100644 main/migrations/0096_auto_20231109_2111.py create mode 100644 main/migrations/0097_user_subscribe_note.py create mode 100644 main/migrations/0098_alter_user_notifications_on.py create mode 100644 main/migrations/0099_alter_user_subscribe_note.py create mode 100644 main/migrations/0100_onboard_seo.py create mode 100644 main/migrations/0101_post_broadcasted_at.py create mode 100644 main/migrations/0102_remove_notificationrecord_is_canceled.py create mode 100644 main/migrations/0103_alter_post_broadcasted_at.py create mode 100644 main/migrations/0104_alter_onboard_problems_alter_onboard_quality_and_more.py create mode 100644 main/migrations/0105_user_blog_index_content_alter_user_footer_note_and_more.py create mode 100644 main/migrations/0106_user_show_posts_in_nav_user_show_posts_on_homepage_and_more.py create mode 100644 main/migrations/0107_alter_user_show_posts_on_homepage.py create mode 100644 main/migrations/0108_user_noindex_on.py create mode 100644 main/migrations/0109_user_posts_page_title.py create mode 100644 main/migrations/0110_user_robots_txt.py create mode 100644 main/migrations/0111_user_reading_time_on_alter_user_robots_txt.py create mode 100644 main/migrations/0112_rsl_settings.py create mode 100644 main/migrations/0113_rename_rsl_settings_reallysimplelicensing.py create mode 100644 main/migrations/0114_alter_reallysimplelicensing_show_rss.py create mode 100644 main/migrations/0115_alter_reallysimplelicensing_license.py create mode 100644 main/migrations/0116_alter_reallysimplelicensing_user.py create mode 100644 main/migrations/0117_alter_reallysimplelicensing_license_and_more.py create mode 100644 main/migrations/__init__.py create mode 100644 main/models.py create mode 100644 main/sitemaps.py create mode 100644 main/static/favicon.png create mode 100644 main/static/logo.svg create mode 100644 main/templates/400.html create mode 100644 main/templates/403.html create mode 100644 main/templates/404.html create mode 100644 main/templates/500.html create mode 100644 main/templates/assets/drag-and-drop-upload.js create mode 100644 main/templates/assets/make-draft-button.js create mode 100644 main/templates/assets/save-snapshot.js create mode 100644 main/templates/assets/style.css create mode 100644 main/templates/main/analytic_detail.html create mode 100644 main/templates/main/analytic_list.html create mode 100644 main/templates/main/api_docs.html create mode 100644 main/templates/main/api_key_reset.html create mode 100644 main/templates/main/billing_card.html create mode 100644 main/templates/main/billing_card_confirm_delete.html create mode 100644 main/templates/main/billing_index.html create mode 100644 main/templates/main/billing_subscribe.html create mode 100644 main/templates/main/billing_subscription_cancel.html create mode 100644 main/templates/main/blog_import.html create mode 100644 main/templates/main/blog_index.html create mode 100644 main/templates/main/blog_posts.html create mode 100644 main/templates/main/comment_approve.html create mode 100644 main/templates/main/comment_confirm_delete.html create mode 100644 main/templates/main/comment_form.html create mode 100644 main/templates/main/comment_list.html create mode 100644 main/templates/main/comparisons.html create mode 100644 main/templates/main/dashboard.html create mode 100644 main/templates/main/export_index.html create mode 100644 main/templates/main/export_print.html create mode 100644 main/templates/main/export_unsubscribe_success.html create mode 100644 main/templates/main/guides_comments.html create mode 100644 main/templates/main/guides_customdomain.html create mode 100644 main/templates/main/guides_images.html create mode 100644 main/templates/main/guides_markdown.html create mode 100644 main/templates/main/image_confirm_delete.html create mode 100644 main/templates/main/image_detail.html create mode 100644 main/templates/main/image_form.html create mode 100644 main/templates/main/image_list.html create mode 100644 main/templates/main/landing.html create mode 100644 main/templates/main/layout.html create mode 100644 main/templates/main/methodology.html create mode 100644 main/templates/main/moderation_activity.html create mode 100644 main/templates/main/moderation_cohorts.html create mode 100644 main/templates/main/moderation_images.html create mode 100644 main/templates/main/moderation_index.html create mode 100644 main/templates/main/moderation_posts.html create mode 100644 main/templates/main/moderation_stats.html create mode 100644 main/templates/main/moderation_summary.html create mode 100644 main/templates/main/moderation_user_list.html create mode 100644 main/templates/main/moderation_user_single.html create mode 100644 main/templates/main/notification.html create mode 100644 main/templates/main/notification_list.html create mode 100644 main/templates/main/notification_unsubscribe.html create mode 100644 main/templates/main/notification_unsubscribe_success.html create mode 100644 main/templates/main/notificationrecord_confirm_delete.html create mode 100644 main/templates/main/notificationrecord_list.html create mode 100644 main/templates/main/page_confirm_delete.html create mode 100644 main/templates/main/page_detail.html create mode 100644 main/templates/main/page_form.html create mode 100644 main/templates/main/page_list.html create mode 100644 main/templates/main/post_confirm_delete.html create mode 100644 main/templates/main/post_detail.html create mode 100644 main/templates/main/post_form.html create mode 100644 main/templates/main/post_list.html create mode 100644 main/templates/main/rsl_update.html create mode 100644 main/templates/main/snapshot_confirm_delete.html create mode 100644 main/templates/main/snapshot_detail.html create mode 100644 main/templates/main/snapshot_list.html create mode 100644 main/templates/main/transparency.html create mode 100644 main/templates/main/user_confirm_delete.html create mode 100644 main/templates/main/user_create_step_one.html create mode 100644 main/templates/main/user_create_step_two.html create mode 100644 main/templates/main/user_update.html create mode 100644 main/templates/main/webring.html create mode 100644 main/templates/partials/footer.html create mode 100644 main/templates/partials/footer_blog.html create mode 100644 main/templates/partials/rsl_license_head.html create mode 100644 main/templates/partials/webring.html create mode 100644 main/templates/registration/logged_out.html create mode 100644 main/templates/registration/login.html create mode 100644 main/templates/registration/password_change_done.html create mode 100644 main/templates/registration/password_change_form.html create mode 100644 main/templates/registration/password_reset_complete.html create mode 100644 main/templates/registration/password_reset_confirm.html create mode 100644 main/templates/registration/password_reset_done.html create mode 100644 main/templates/registration/password_reset_email.html create mode 100644 main/templates/registration/password_reset_form.html create mode 100644 main/tests/__init__.py create mode 100644 main/tests/test_analytics.py create mode 100644 main/tests/test_api.py create mode 100644 main/tests/test_billing.py create mode 100644 main/tests/test_blog.py create mode 100644 main/tests/test_comments.py create mode 100644 main/tests/test_feeds.py create mode 100644 main/tests/test_images.py create mode 100644 main/tests/test_management.py create mode 100644 main/tests/test_pages.py create mode 100644 main/tests/test_posts.py create mode 100644 main/tests/test_sitemap.py create mode 100644 main/tests/test_snapshots.py create mode 100644 main/tests/test_static.py create mode 100644 main/tests/test_users.py create mode 100644 main/tests/test_webring.py create mode 100644 main/tests/testdata/lorem.md create mode 100644 main/tests/testdata/vulf.jpeg create mode 100644 main/urls.py create mode 100644 main/util.py create mode 100644 main/validators.py create mode 100644 main/views/__init__.py create mode 100644 main/views/api.py create mode 100644 main/views/billing.py create mode 100644 main/views/export.py create mode 100644 main/views/general.py create mode 100644 main/views/moderation.py create mode 100755 manage.py create mode 100644 mataroa/__init__.py create mode 100644 mataroa/asgi.py create mode 100644 mataroa/settings.py create mode 100644 mataroa/urls.py create mode 100644 mataroa/wsgi.py create mode 100644 postgresql/Makefile create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.build.yml b/.build.yml new file mode 100644 index 0000000000000000000000000000000000000000..fe6e108087f114ae6270b50a585d5bb171522333 --- /dev/null +++ b/.build.yml @@ -0,0 +1,21 @@ +image: ubuntu/24.04 +packages: + - curl + - postgresql +tasks: + - test: | + curl -LsSf https://astral.sh/uv/0.8.8/install.sh | sh + ~/.local/bin/uv run python -V + sudo -u postgres psql -U postgres -d postgres -c "ALTER USER postgres WITH PASSWORD 'postgres';" + cd mataroa/ + export DEBUG=1 + export SECRET_KEY='thisisthesecretkey' + export DATABASE_URL='postgres://postgres:postgres@localhost:5432/postgres' + ~/.local/bin/uv run manage.py collectstatic --noinput + ~/.local/bin/uv run manage.py test + - lint: | + curl -LsSf https://astral.sh/uv/0.8.8/install.sh | sh + ~/.local/bin/uv run python -V + cd mataroa/ + ~/.local/bin/uv run ruff check + ~/.local/bin/uv run djade main/templates/**/*.html diff --git a/.envrc.example b/.envrc.example new file mode 100644 index 0000000000000000000000000000000000000000..d3b937f2cd9b9e7100b60a6fe488c3b371dc3dd8 --- /dev/null +++ b/.envrc.example @@ -0,0 +1,24 @@ +# Exceptions and tracebacks on errors +# 1: show +# 0: don't show +export DEBUG=1 + +# Stop real emails and turn https off +# 1: stop and off +# 0: do not stop and on +export LOCALDEV=1 + +# Session cookies secret +export SECRET_KEY=some-secret-key + +# Database connection +export DATABASE_URL=postgres://mataroa:xxx@localhost:5432/mataroa + +# SMTP credentials +export EMAIL_HOST_USER= +export EMAIL_HOST_PASSWORD= + +# Stripe payments details +export STRIPE_API_KEY= +export STRIPE_PUBLIC_KEY= +export STRIPE_PRICE_ID= diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000000000000000000000000000000..5990d9c64c141eb290490adc52f4cc3da248232b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000000000000000000000000000000000000..e9ffc3e1aaddf3618b995228ee28e4c9ff64704b --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,49 @@ +name: Check + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-24.04 + + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install uv + run: curl -LsSf https://astral.sh/uv/0.8.8/install.sh | sh + + - name: Run Python linting + run: uv run ruff check + + - name: Run HTML linting + run: uv run djade main/templates/**/*.html + + - name: Run Tests + run: | + uv run manage.py collectstatic --no-input + uv run manage.py test + env: + DEBUG: '1' + SECRET_KEY: 'thisisthesecretkey' + DATABASE_URL: 'postgres://postgres:postgres@localhost:5432/postgres' diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000000000000000000000000000000000..a62c66b7c3b25d8432de9b9d5859a3cf56ed0d48 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,50 @@ +name: Docs + +on: + push: + branches: [ main ] + workflow_dispatch: + +# Set permissions to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-24.04 + env: + MDBOOK_VERSION: 0.4.40 + steps: + - uses: actions/checkout@v4 + - name: Install mdBook + run: | + # https://github.com/rust-lang/mdBook + curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf -y | sh + rustup update + cargo install --version ${MDBOOK_VERSION} mdbook + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + - name: Build with mdBook + run: cd docs && mdbook build + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./docs/book + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-24.04 + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000000000000000000000000000000000000..5352b4a02578b116f8f580f0665ddb56b0bdfa15 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,49 @@ +name: Image + +on: + release: + types: [ published ] + workflow_dispatch: + +# Set permissions to allow deployment to ghcr.io +permissions: + packages: write + contents: read + +# Prevent concurrent builds +concurrency: + group: image-${{ github.ref }} + cancel-in-progress: false + +jobs: + push: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ab9177c1fea654f57df493c7fae2c6b6b779e73c --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# python +*.pyc + +# environment +.envrc +.venv/ +postgres-data/ + +# generated static +/static/ + +# testing +.coverage +htmlcov/ + +# docker +docker-postgres-data/ +docker-compose.override.yml + +# mdbook docs +/docs/book/ +.env +db.sqlite3 +debug.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..896895cd4303ad5e200e95e64fcbe94009c5a1ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,83 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.3.2](https://github.com/linuxgoose/mataroa/releases/tag/1.3.2) +* add blog index content by @linuxgoose in https://github.com/linuxgoose/mataroa/pull/23 +* [add custom robots.txt blog setting](https://github.com/linuxgoose/mataroa/commit/47bd06b3a71d75ddf73a91958b2e3101c675633e) by @linuxgoose +* [add custom posts page title](https://github.com/linuxgoose/mataroa/commit/3ac28fdb08b59310c6fcc313a6af511320a66f50) +* [add more feed urls](https://github.com/linuxgoose/mataroa/commit/b57c9d0b06642b4983258ffd2bdf57cd18d49613) by @linuxgoose +* [add noindex support - meta tag](https://github.com/linuxgoose/mataroa/commit/dcbd6e2360068953bcd72910c76574b4a63a35a0) by @linuxgoose +* [add ability to set post_backups_on in blog settings](https://github.com/linuxgoose/mataroa/commit/ac590b7092b9786b1d7e4d1cb6aa6a7c88937237) by @linuxgoose +* [add blog index content](https://github.com/linuxgoose/mataroa/commit/f480264f048edbf0b9164388e3a77de95c48fc55) by @linuxgoose + +## [1.3.1](https://github.com/linuxgoose/mataroa/releases/tag/1.3.1) +* Add LaTeX Support with l2m4m dependency & mathml + +## [1.3.0](https://github.com/mataroablog/mataroa/compare/v1.2...v1.3) + +### Important changes + +* Rebuild content moderation dashboard with: + * pagination + * filters + * sort by + * day summary + * images overview + * global stats + * daily admin summary email +* Switch to astral uv +* Add hard check for image uploading limit +* Change sign up text +* Remove robot checks from sign up form +* Upgrade to Django 5.2 +* Add docker image auto-push to ghcr.io + +### Bug fixes + +* Improve dark mode colours for better readability +* Fix ansible not auto-enabling systemd timers + +## [1.2.0](https://github.com/mataroablog/mataroa/compare/v1.1...v1.2) - 2025-02-06 + +### Important changes + +* Change project license from MIT to AGPL-3.0-only +* Enable customisation of subscribe note on footer +* Introduce ansible configuration for deployment +* Switch jobs from cron to systemd timers +* Replace uWSGI with Gunicorn +* Replace black/flake8/isort with ruff +* Refactor newsletter processing into more robust and simpler workflow +* Setup docs using mdbook +* Improve docker local development setup +* Add guide for custom domains +* Simplify Zola and Hugo base CSS styles +* Add themed error pages +* Upgrade to Django 5.1 +* Limit RSS to last 10 posts + +### Bugfixes + +* Fix Zola v0.19 RSS feed configuration + +## [1.1.0](https://github.com/mataroablog/mataroa/compare/v1.0...v1.1) - 2023-12-05 + +### Important changes + +* Rewrite moderation dashboard +* Rewrite Stripe integration with latest APIs +* Create new signup workflow +* Lower image size limit to 1MB +* Upgrade to Django 5.0 + +## [1.0.0](https://github.com/mataroablog/mataroa/compare/5ff277da71fb653631ea38407cd6154e831be540...v1.0) - 2023-09-06 + +This is an initial numbered release after 3+ years of development. + +* Core blogging functionalities +* Export functionalities +* Email newsletter +* Custom domains +* Backend-based analytics +* API diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..659dfcb7b7ad53290188f124ad05627178d3081e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.13-slim-bookworm + +ENV PYTHONUNBUFFERED=1 \ + PYTHONFAULTHANDLER=1 \ + VIRTUAL_ENV=/opt/venv \ + PATH="/opt/venv/bin:$PATH" + +RUN pip install uv + +# Create the virtual environment directory +RUN python -m venv $VIRTUAL_ENV + +WORKDIR /code + +COPY pyproject.toml uv.lock /code/ + +RUN uv sync --all-groups --project . + +RUN rm -rf /code/pyproject.toml /code/uv.lock + +# mount local code over /code, but /opt/venv remains untouched +COPY . /code/ + +# Expose port 8000 for the Django development server +EXPOSE 8000 + +# Command to run the Django development server (can be overridden by docker-compose.yml) +CMD ["uv", "run", "python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..be3f7b28e564e7dd05eaf59d64adba1a4065ac0e --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f6ef6f574da6cd225763984dc18961427725ff40 --- /dev/null +++ b/README.md @@ -0,0 +1,327 @@ +# mataroa + +Naked blogging platform. + +## Community + +We have a mailing list at +[~sirodoht/mataroa-community@lists.sr.ht](mailto:~sirodoht/mataroa-community@lists.sr.ht) +for the mataroa community to introduce themselves, their blogs, and discuss +anything that’s on their mind! + +Archives at +[lists.sr.ht/~sirodoht/mataroa-community](https://lists.sr.ht/~sirodoht/mataroa-community) + +### Tools + +* [mataroa-cli](https://github.com/mataroablog/mataroa-cli) +* [Mataroa Telegram Bot](https://github.com/Unknowing9428/Mataroa-Telegram-Bot) + +## Contributing + +Open a PR on [GitHub](https://github.com/mataroablog/mataroa). + +Send an email patch to +[~sirodoht/public-inbox@lists.sr.ht](mailto:~sirodoht/public-inbox@lists.sr.ht). +See how to contribute using email patches here: +[git-send-email.io](https://git-send-email.io/). + +Read our docs at [docs.mataroa.blog](https://docs.mataroa.blog/) + +## Development + +This is a [Django](https://www.djangoproject.com/) codebase. Check out the +[Django docs](https://docs.djangoproject.com/) for general technical +documentation. + +### Structure + +The Django project is [`mataroa`](mataroa). There is one Django app, +[`main`](main), with all business logic. Application CLI commands are generally +divided into two categories, those under `python manage.py` and those under +`make`. + +### Set up subdomains + +Because mataroa works primarily with subdomain, one cannot access the basic web app +using the standard `http://127.0.0.1:8000` or `http://localhost:8000` URLs. What we do +for local development is adding a few custom entries on our `/etc/hosts` system file. + +Important note: there needs to be an entry of each user account created in the local +development environment, so that the web server can respond to it. + +The first line is the main needed: `mataroalocal.blog`. The rest are included as +examples of other users one can create in their local environment. The +easiest way to create them is to go through the sign up page +(`http://mataroalocal.blog:8000/accounts/create/` using default values). + +``` +# /etc/hosts + +127.0.0.1 mataroalocal.blog + +127.0.0.1 paul.mataroalocal.blog +127.0.0.1 random.mataroalocal.blog +127.0.0.1 anyusername.mataroalocal.blog +``` + +This will enable us to access mataroa locally (once we start the web server) at +[http://mataroalocal.blog:8000/](http://mataroalocal.blog:8000/) +and if we make a user account with username `paul`, then we will be able to access it at +[http://paul.mataroalocal.blog:8000/](http://paul.mataroalocal.blog:8000/) + +### Docker + +> [!NOTE] +> This is the last step for initial Docker setup. See the "Environment variables" +> section below, for further configuration details. + +To set up a development environment with Docker and Docker Compose, run the following +to start the web server and database: + +``` +docker compose up +``` + +If you have also configured hosts as described above in the "Set up subdomains" +section, mataroa should now be locally accessible at +[http://mataroalocal.blog:8000/](http://mataroalocal.blog:8000/) + +Note: The database data are saved in the git-ignored `docker-postgres-data` docker +volume, located in the root of the project. + +### Dependencies + +We use `uv` for dependency management and virtual environments. + +``` +uv sync --all-groups +``` + +### Environment variables + +A file named `.envrc` is used to define the environment variables required for +this project to function. One can either export it directly or use +[direnv](https://github.com/direnv/direnv). There is an example environment +file one can copy as base: + +```sh +cp .envrc.example .envrc +``` + +`.envrc` should contain the following variables: + +```sh +# .envrc + +export DEBUG=1 +export SECRET_KEY=some-secret-key +export DATABASE_URL=postgres://mataroa:db-password@db:5432/mataroa +export EMAIL_HOST_USER=smtp-user +export EMAIL_HOST_PASSWORD=smtp-password +``` + +When on production, also include/update the following variables (see +[Deployment](#Deployment) and [Backup](#Backup)): + +```sh +# .envrc + +export DEBUG=0 +export PGPASSWORD=db-password +``` + +When on Docker, to change or populate environment variables, edit the `environment` +key of the `web` service either directly on `docker-compose.yml` or by overriding it +using the standard named git-ignored `docker-compose.override.yml`. + +```sh +# docker-compose.override.yml + +version: "3.8" + +services: + web: + environment: + EMAIL_HOST_USER=smtp-user + EMAIL_HOST_PASSWORD=smtp-password +``` + +Finally, stop and start `docker compose up` again. It should pick up the override file +as it has the default name `docker-compose.override.yml`. + +### Database + +This project is using one PostreSQL database for persistence. + +One can use the `make pginit` command to initialise a database in the +`postgres-data/` directory. + +After setting the `DATABASE_URL` ([see above](#environment-variables)), create +the database schema with: + +```sh +uv python manage.py migrate +``` + +Initialising the database with some sample development data is possible with: + +```sh +uv python manage.py loaddata dev-data +``` + +* `dev-data` is defined in [`main/fixtures/dev-data.json`](main/fixtures/dev-data.json) +* Credentials of the fixtured user are `admin` / `admin`. + +### Serve + +To run the Django development server: + +```sh +uv python manage.py runserver +``` + +If you have also configured hosts as described above in the "Set up subdomains" +section, mataroa should now be locally accessible at +[http://mataroalocal.blog:8000/](http://mataroalocal.blog:8000/) + +## Testing + +Using the Django test runner: + +```sh +uv run python manage.py test +``` + +For coverage, run: + +```sh +uv run coverage run --source='.' --omit '.venv/*' manage.py test +uv run coverage report -m +``` + +## Code linting & formatting + +We use [ruff](https://github.com/astral-sh/ruff) for Python code formatting and linting. + +To format: + +```sh +uv run ruff format +``` + +To lint: + +```sh +uv run ruff check +uv run ruff check --fix +``` + +## Python dependencies + +We use `uv` to manage dependencies declared in `pyproject.toml` (see `[project]` and `[dependency-groups]`). + +Common commands: + +```sh +# Add or remove dependencies +uv add +uv remove + +# Update locked versions and install +uv lock -U +uv sync --all-groups +``` + +## Deployment + +See the [Deployment](./docs/deployment.md) document for an overview on steps +required to deploy a mataroa instance. + +### Useful Commands + +To reload the gunicorn process: + +```sh +sudo systemctl reload mataroa +``` + +To reload Caddy: + +```sh +systemctl restart caddy # root only +``` + +gunicorn logs: + +```sh +journalctl -fb -u mataroa +``` + +Caddy logs: + +```sh +journalctl -fb -u caddy +``` + +Get an overview with systemd status: + +```sh +systemctl status caddy +systemctl status mataroa +``` + +## Backup + +See [Database Backup](docs/database-backup.md) for details. In summary: + +To create a database dump: + +```sh +pg_dump -Fc --no-acl mataroa -h localhost -U mataroa -f /home/deploy/mataroa.dump -w +``` + +To restore a database dump: + +```sh +pg_restore -v -h localhost -cO --if-exists -d mataroa -U mataroa -W mataroa.dump +``` + +## Management + +In addition to the standard Django management commands, there are also: + +* `processnotifications`: sends notification emails for new blog posts of existing records. +* `mailexports`: emails users of their blog exports. + +They are triggered using the standard `manage.py` Django way; eg: + +```sh +python manage.py processnotifications +``` + +## Billing + +One can deploy mataroa without setting up billing functionalities. This is +the default case. To handle payments and subscriptions this project uses +[Stripe](https://stripe.com/). To enable Stripe and payments, one needs to have +a Stripe account with a single +[Product](https://stripe.com/docs/billing/prices-guide) (eg. "Mataroa Premium +Plan"). + +To configure, add the following variables from your Stripe account to your +`.envrc`: + +```sh +export STRIPE_API_KEY="sk_test_XXX" +export STRIPE_PUBLIC_KEY="pk_test_XXX" +export STRIPE_PRICE_ID="price_XXX" +``` + +## License + +Copyright Mataroa Contributors + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License as published by the Free +Software Foundation, version 3. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000000000000000000000000000000000..c7b8ce681a11af490c48320e617c17cb94bc1230 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,6 @@ +# Security Policy + +## Reporting a Vulnerability + +To report a security vulnerability please send an email +to [admin+security@bocpress.co.uk](mailto:admin+security@bocpress.co.uk). diff --git a/ansible/.envrc.example b/ansible/.envrc.example new file mode 100644 index 0000000000000000000000000000000000000000..d6f64436c8b735e4864afc3c454f7d97eb3509c4 --- /dev/null +++ b/ansible/.envrc.example @@ -0,0 +1,42 @@ +# inventory.yaml + +# Server IP and user with ssh access +export ANSIBLE_HOST= +export ANSIBLE_USER=root + + +# vars.yaml + +# Domain name and email for Caddy +export DOMAIN=mataroa.blog +export EMAIL=admin@mataroa.blog + +# Show exceptions and tracebacks on errors +# 1: show +# 0: don't show +export DEBUG=1 + +# Stop real emails and turn https off +# 1: stop and off +# 0: do not stop and on +export LOCALDEV=1 + +# Session cookies secret +export SECRET_KEY=some-secret-key + +# Database connection +export DATABASE_URL=postgres://mataroa:xxx@localhost:5432/mataroa +export POSTGRES_USERNAME=mataroa +export POSTGRES_PASSWORD=xxx + +# SMTP credentials +export EMAIL_HOST_USER= +export EMAIL_HOST_PASSWORD= + +# Receivers for email tests (comma separated) +export EMAIL_TEST_RECEIVE_LIST= + +# Stripe payments details +export STRIPE_API_KEY= +export STRIPE_PUBLIC_KEY= +export STRIPE_PRICE_ID= diff --git a/ansible/Caddyfile.j2 b/ansible/Caddyfile.j2 new file mode 100644 index 0000000000000000000000000000000000000000..a8c37782ab236e6be27ecb1babda6029d4fe63c0 --- /dev/null +++ b/ansible/Caddyfile.j2 @@ -0,0 +1,43 @@ +{ + on_demand_tls { + ask https://{{ domain }}/accounts/domain/ + } +} + +*.{{ domain }}, {{ domain }} { + route { + file_server /static/* { + root /var/www/mataroa + } + reverse_proxy 127.0.0.1:5000 + } + + tls /etc/caddy/mataroa-blog-cert.pem /etc/caddy/mataroa-blog-key.pem + + encode zstd gzip + + log { + output stdout + format console + } +} + +:443 { + route { + file_server /static/* { + root /var/www/mataroa + } + reverse_proxy 127.0.0.1:5000 + } + + tls {{ email }} { + on_demand + } + + encode zstd gzip + + log { + output stdout + format console + } +} diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000000000000000000000000000000000..f9e6eec0c73148340e98b47d48dff7e3e69ffd3c --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,3 @@ +[defaults] +inventory = inventory.yaml +pipelining = True diff --git a/ansible/backup-database.sh b/ansible/backup-database.sh new file mode 100755 index 0000000000000000000000000000000000000000..69cd524367e54cf020a6dddf14d98b871051243c --- /dev/null +++ b/ansible/backup-database.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail +if [[ "${TRACE-0}" == "1" ]]; then + set -o xtrace +fi + +if [[ "${1-}" =~ ^-*h(elp)?$ ]]; then + echo 'Usage: ./backup-database.sh + +This script dumps the mataroa postgres database and uploads it into an S3-compatible server.' + exit +fi + +cd "$(dirname "$0")" + +main() { + # source for PGPASSWORD variable if present + if [ -f /var/www/mataroa/.envrc ]; then + # shellcheck disable=SC1091 + source /var/www/mataroa/.envrc + fi + + # dump database in the home folder + pg_dump -Fc --no-acl mataroa -h localhost -U mataroa -f /home/deploy/mataroa.dump -w + + # upload using aws cli + /usr/bin/rclone copy --progress /home/deploy/mataroa.dump scaleway:bucket/mataroa-backups/postgres-mataroa-"$(date --utc +%Y%m%d-%H%M%S)"/ +} + +main "$@" diff --git a/ansible/caddy.service.j2 b/ansible/caddy.service.j2 new file mode 100644 index 0000000000000000000000000000000000000000..b67030ea0b08502d273982a75028cd7dbe25bd88 --- /dev/null +++ b/ansible/caddy.service.j2 @@ -0,0 +1,22 @@ +[Unit] +Description=Caddy +Documentation=https://caddyserver.com/docs/ +After=network.target network-online.target +Requires=network-online.target + +[Service] +Type=notify +User=caddy +Group=caddy +ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile +ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --force +Restart=on-failure +RestartSec=5 +TimeoutStopSec=5s +LimitNOFILE=1048576 +PrivateTmp=true +ProtectSystem=full +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE + +[Install] +WantedBy=multi-user.target diff --git a/ansible/inventory.yaml b/ansible/inventory.yaml new file mode 100644 index 0000000000000000000000000000000000000000..17a3ccc307e34372629e569fba2d026396ecfdce --- /dev/null +++ b/ansible/inventory.yaml @@ -0,0 +1,5 @@ +virtualmachines: + hosts: + main: + ansible_host: "{{ lookup('env', 'ANSIBLE_HOST') }}" + ansible_user: "{{ lookup('env', 'ANSIBLE_USER') }}" diff --git a/ansible/mataroa-backup.service.j2 b/ansible/mataroa-backup.service.j2 new file mode 100644 index 0000000000000000000000000000000000000000..50fa84cac24432a39107d2feecbf6b6dc5fe29bd --- /dev/null +++ b/ansible/mataroa-backup.service.j2 @@ -0,0 +1,12 @@ +[Unit] +Description=Backup mataroa database + +[Service] +Type=oneshot +User=deploy +WorkingDirectory=/home/deploy +Environment="PGPASSWORD={{ postgres_password }}" +ExecStart=/home/deploy/backup-database.sh + +[Install] +WantedBy=multi-user.target diff --git a/ansible/mataroa-backup.timer.j2 b/ansible/mataroa-backup.timer.j2 new file mode 100644 index 0000000000000000000000000000000000000000..dec812be0164083070345e7298a0a632834f686f --- /dev/null +++ b/ansible/mataroa-backup.timer.j2 @@ -0,0 +1,8 @@ +[Unit] +Description=Run mataroa-backup every 6 hours + +[Timer] +OnCalendar=*-*-* 00/6:00:00 + +[Install] +WantedBy=timers.target diff --git a/ansible/mataroa-dailysummary.service.j2 b/ansible/mataroa-dailysummary.service.j2 new file mode 100644 index 0000000000000000000000000000000000000000..5f7d4135b44b28e751c254a743662de72eeb8627 --- /dev/null +++ b/ansible/mataroa-dailysummary.service.j2 @@ -0,0 +1,12 @@ +[Unit] +Description=Send mataroa daily moderation summary + +[Service] +Type=oneshot +User=deploy +WorkingDirectory=/var/www/mataroa +EnvironmentFile=/etc/systemd/system/mataroa.env +ExecStart=/home/deploy/.local/bin/uv run manage.py mailsummary + +[Install] +WantedBy=multi-user.target diff --git a/ansible/mataroa-dailysummary.timer.j2 b/ansible/mataroa-dailysummary.timer.j2 new file mode 100644 index 0000000000000000000000000000000000000000..2921a8e94f52261c661d7b3bab0f05ee0d8401a6 --- /dev/null +++ b/ansible/mataroa-dailysummary.timer.j2 @@ -0,0 +1,8 @@ +[Unit] +Description=Run mataroa-dailysummary every day at 00:15 UTC + +[Timer] +OnCalendar=*-*-* 00:15:00 + +[Install] +WantedBy=timers.target diff --git a/ansible/mataroa-exports.service.j2 b/ansible/mataroa-exports.service.j2 new file mode 100644 index 0000000000000000000000000000000000000000..d1ad4e56f1c9e7997a684220625ac7688a53711b --- /dev/null +++ b/ansible/mataroa-exports.service.j2 @@ -0,0 +1,12 @@ +[Unit] +Description=Send mataroa exports + +[Service] +Type=oneshot +User=deploy +WorkingDirectory=/var/www/mataroa +EnvironmentFile=/etc/systemd/system/mataroa.env +ExecStart=/home/deploy/.local/bin/uv run manage.py mailexports + +[Install] +WantedBy=multi-user.target diff --git a/ansible/mataroa-exports.timer.j2 b/ansible/mataroa-exports.timer.j2 new file mode 100644 index 0000000000000000000000000000000000000000..a51d153ebef22df9160f64600bd64731fedbb884 --- /dev/null +++ b/ansible/mataroa-exports.timer.j2 @@ -0,0 +1,8 @@ +[Unit] +Description=Run mataroa-exports every first of the month + +[Timer] +OnCalendar=*-*-01 06:00:00 + +[Install] +WantedBy=timers.target diff --git a/ansible/mataroa-notifications.service.j2 b/ansible/mataroa-notifications.service.j2 new file mode 100644 index 0000000000000000000000000000000000000000..d1648b5a085d77e0ddee2c619518d2eda760eb29 --- /dev/null +++ b/ansible/mataroa-notifications.service.j2 @@ -0,0 +1,12 @@ +[Unit] +Description=Process mataroa notifications + +[Service] +Type=oneshot +User=deploy +WorkingDirectory=/var/www/mataroa +EnvironmentFile=/etc/systemd/system/mataroa.env +ExecStart=/home/deploy/.local/bin/uv run manage.py processnotifications --no-dryrun + +[Install] +WantedBy=multi-user.target diff --git a/ansible/mataroa-notifications.timer.j2 b/ansible/mataroa-notifications.timer.j2 new file mode 100644 index 0000000000000000000000000000000000000000..ab166a24195cc0014c52175bd6948165f58f5cce --- /dev/null +++ b/ansible/mataroa-notifications.timer.j2 @@ -0,0 +1,8 @@ +[Unit] +Description=Run mataroa-notifications every day + +[Timer] +OnCalendar=*-*-* 10:00:00 + +[Install] +WantedBy=timers.target diff --git a/ansible/mataroa.env.j2 b/ansible/mataroa.env.j2 new file mode 100644 index 0000000000000000000000000000000000000000..5d002c25d087e8d8c0c0126215918f374ef13747 --- /dev/null +++ b/ansible/mataroa.env.j2 @@ -0,0 +1,12 @@ +DOMAIN={{ domain }} +EMAIL={{ email }} +DEBUG={{ debug }} +LOCALDEV={{ localdev }} +SECRET_KEY={{ secret_key }} +DATABASE_URL={{ database_url }} +EMAIL_HOST_USER={{ email_host_user }} +EMAIL_HOST_PASSWORD={{ email_host_password }} +EMAIL_TEST_RECEIVE_LIST={{ email_test_receive_list }} +STRIPE_API_KEY={{ stripe_api_key }} +STRIPE_PUBLIC_KEY={{ stripe_public_key }} +STRIPE_PRICE_ID={{ stripe_price_id }} diff --git a/ansible/mataroa.service.j2 b/ansible/mataroa.service.j2 new file mode 100644 index 0000000000000000000000000000000000000000..3b2f56d80f158ac26c823ab414c155eaec1bd95b --- /dev/null +++ b/ansible/mataroa.service.j2 @@ -0,0 +1,17 @@ +[Unit] +Description=mataroa +After=network.target + +[Service] +Type=simple +User=deploy +Group=www-data +WorkingDirectory=/var/www/mataroa +ExecStart=/home/deploy/.local/bin/uv run gunicorn -b 127.0.0.1:5000 -w 4 --access-logfile - mataroa.wsgi +ExecReload=/bin/kill -HUP $MAINPID +EnvironmentFile=/etc/systemd/system/mataroa.env +TimeoutSec=15 +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/ansible/playbook.yaml b/ansible/playbook.yaml new file mode 100644 index 0000000000000000000000000000000000000000..84cca796595945982f915ba14d293d9a53eefd41 --- /dev/null +++ b/ansible/playbook.yaml @@ -0,0 +1,201 @@ +--- +- hosts: virtualmachines + vars_files: + - vars.yaml + vars: + app_dir: /var/www/mataroa + django_manage: /home/deploy/.local/bin/uv run python manage.py + systemd_unit_templates: + - mataroa-notifications.timer.j2 + - mataroa-notifications.service.j2 + - mataroa-exports.timer.j2 + - mataroa-exports.service.j2 + - mataroa-backup.timer.j2 + - mataroa-backup.service.j2 + - mataroa-dailysummary.timer.j2 + - mataroa-dailysummary.service.j2 + become: yes + tasks: + # smoke test and essential dependencies + - name: ping + ansible.builtin.ping: + - name: essentials + ansible.builtin.apt: + update_cache: yes + name: + - gcc + - git + - rclone + - vim + state: present + + # caddy + - name: add caddy key + ansible.builtin.apt_key: + id: 65760C51EDEA2017CEA2CA15155B6D79CA56EA34 + url: https://dl.cloudsmith.io/public/caddy/stable/gpg.key + keyring: /etc/apt/trusted.gpg.d/caddy-stable.gpg + state: present + - name: add caddy repositories + ansible.builtin.apt_repository: + repo: "{{ item }}" + loop: + - "deb [signed-by=/etc/apt/trusted.gpg.d/caddy-stable.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main" + - "deb-src [signed-by=/etc/apt/trusted.gpg.d/caddy-stable.gpg] https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main" + - name: install caddy + ansible.builtin.apt: + update_cache: yes + name: caddy + - name: caddyfile + ansible.builtin.template: + src: Caddyfile.j2 + dest: /etc/caddy/Caddyfile + owner: root + group: root + mode: '0644' + notify: restart caddy + - name: systemd environment file for mataroa + ansible.builtin.template: + src: mataroa.env.j2 + dest: /etc/systemd/system/mataroa.env + owner: root + group: root + mode: '0640' + + # deploy user and directory + - name: create user + ansible.builtin.user: + name: deploy + password: "" + shell: /bin/bash + groups: + - sudo + - www-data + append: yes + createhome: yes + skeleton: '/etc/skel' + generate_ssh_key: yes + ssh_key_type: 'ed25519' + - name: www directory + ansible.builtin.file: + path: /var/www + state: directory + mode: '0755' + owner: deploy + group: www-data + + # postgresql setup + - name: pg user + community.general.postgresql_user: + name: "{{ postgres_username }}" + password: "{{ postgres_password }}" + expires: infinity + state: present + become_user: postgres + - name: pg database + community.general.postgresql_db: + name: mataroa + owner: "{{ postgres_username }}" + state: present + become_user: postgres + - name: pg permissions + community.postgresql.postgresql_privs: + db: mataroa + privs: ALL + objs: ALL_IN_SCHEMA + role: "{{ postgres_username }}" + grant_option: true + become_user: postgres + + # repo and tooling (as deploy user) + - name: setup repo and tooling + become_user: deploy + block: + - name: uv + ansible.builtin.shell: + cmd: curl -LsSf https://astral.sh/uv/0.8.8/install.sh | sh + - name: clone + ansible.builtin.git: + repo: https://github.com/mataroablog/mataroa + dest: /var/www/mataroa + version: main + accept_hostkey: true + + # systemd + - name: systemd main service + ansible.builtin.template: + src: mataroa.service.j2 + dest: /etc/systemd/system/mataroa.service + owner: root + group: root + mode: '0644' + notify: + - reload systemd + - restart mataroa + - name: systemd timers and helper services + ansible.builtin.template: + src: "{{ item }}" + dest: "/etc/systemd/system/{{ item | regex_replace('\\.j2$', '') }}" + owner: root + group: root + mode: '0644' + loop: "{{ systemd_unit_templates }}" + notify: reload systemd + - name: install backup script + ansible.builtin.copy: + src: backup-database.sh + dest: /home/deploy/backup-database.sh + owner: deploy + group: deploy + mode: '0755' + - name: flush handlers before enabling timers + ansible.builtin.meta: flush_handlers + - name: systemd enable and start timers + ansible.builtin.systemd: + name: "{{ item }}" + enabled: yes + state: started + loop: + - mataroa-notifications.timer + - mataroa-exports.timer + - mataroa-backup.timer + - mataroa-dailysummary.timer + - name: systemd enable + ansible.builtin.systemd: + name: mataroa + enabled: yes + + # deployment specific + - name: collectstatic + ansible.builtin.shell: + cmd: "{{ django_manage }} collectstatic --no-input" + chdir: "{{ app_dir }}" + args: + executable: /bin/bash + become_user: deploy + - name: migrations + ansible.builtin.shell: + cmd: "{{ django_manage }} migrate --no-input" + chdir: "{{ app_dir }}" + args: + executable: /bin/bash + environment: + DATABASE_URL: "{{ database_url }}" + become_user: deploy + - name: caddy enable + ansible.builtin.systemd: + name: caddy + enabled: yes + + handlers: + - name: reload systemd + ansible.builtin.systemd: + daemon_reload: true + - name: restart mataroa + ansible.builtin.systemd: + name: mataroa + state: restarted + - name: restart caddy + ansible.builtin.systemd: + name: caddy + state: restarted diff --git a/ansible/vars.yaml b/ansible/vars.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0717c5ba735bdc0a580d1b65b0802fab5b3535f3 --- /dev/null +++ b/ansible/vars.yaml @@ -0,0 +1,20 @@ +--- +domain: "{{ lookup('env', 'DOMAIN') }}" +email: "{{ lookup('env', 'EMAIL') }}" + +debug: "{{ lookup('env', 'DEBUG') }}" +localdev: "{{ lookup('env', 'LOCALDEV') }}" + +secret_key: "{{ lookup('env', 'SECRET_KEY') }}" + +database_url: "{{ lookup('env', 'DATABASE_URL') }}" +postgres_username: "{{ lookup('env', 'POSTGRES_USERNAME') }}" +postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') }}" + +email_host_user: "{{ lookup('env', 'EMAIL_HOST_USER') }}" +email_host_password: "{{ lookup('env', 'EMAIL_HOST_PASSWORD') }}" +email_test_receive_list: "{{ lookup('env', 'EMAIL_TEST_RECEIVE_LIST') }}" + +stripe_api_key: "{{ lookup('env', 'STRIPE_API_KEY') }}" +stripe_public_key: "{{ lookup('env', 'STRIPE_PUBLIC_KEY') }}" +stripe_price_id: "{{ lookup('env', 'STRIPE_PRICE_ID') }}" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..971e76adb0dd84d70df28790428b39f43e970681 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + db: + image: postgres:17 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + environment: + POSTGRES_PASSWORD: postgres + volumes: + - ./docker-postgres-data:/var/lib/postgresql/data + ports: + - "5432:5432" + web: + command: > + bash -c "uv run --active python manage.py migrate && \ + uv run --active python manage.py collectstatic --noinput && \ + uv run --active python manage.py runserver 0.0.0.0:8000" + build: . + image: mataroa + volumes: + - .:/code + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + environment: + DEBUG: 1 + LOCALDEV: 1 + DATABASE_URL: postgres://postgres:postgres@db:5432/postgres diff --git a/docs/book.toml b/docs/book.toml new file mode 100644 index 0000000000000000000000000000000000000000..c5707bcb7eca8f41473b9a06c09bb68f4ac7714e --- /dev/null +++ b/docs/book.toml @@ -0,0 +1,6 @@ +[book] +authors = ["Theodore Keloglou"] +language = "en" +multilingual = false +src = "src" +title = "Mataroa Documentation" diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md new file mode 100644 index 0000000000000000000000000000000000000000..f93d1b632988f63d79a3c97358517bb6d033c456 --- /dev/null +++ b/docs/src/SUMMARY.md @@ -0,0 +1,13 @@ +# Summary + +- [Introduction](./introduction.md) +- [Main Repository README](./main-repository-readme.md) +- [Coding Conventions](./coding-conventions.md) +- [Git Commit Message Guidelines](./commit-messages.md) +- [File Structure Walkthrough](./file-structure-walkthrough.md) +- [Dependencies](./dependencies.md) +- [Deployment](./deployment.md) +- [Cronjobs](./cronjobs.md) +- [Database Backup](./database-backup.md) +- [Server Migration](./server-migration.md) +- [Runbook](./runbook.md) diff --git a/docs/src/coding-conventions.md b/docs/src/coding-conventions.md new file mode 100644 index 0000000000000000000000000000000000000000..0a8d5617650de50166564038272c7cb903b7f251 --- /dev/null +++ b/docs/src/coding-conventions.md @@ -0,0 +1,4 @@ +# Coding Conventions + +1. All files should end with a new line character. +1. Python code should be formatted with [ruff](https://github.com/astral-sh/ruff). diff --git a/docs/src/commit-messages.md b/docs/src/commit-messages.md new file mode 100644 index 0000000000000000000000000000000000000000..6b9267dde634b70231c52958240cb688d4f850f4 --- /dev/null +++ b/docs/src/commit-messages.md @@ -0,0 +1,15 @@ +# Git Commit Message Guidelines + +We follow some simple non-austere git commit message guidelines. + +* Start with a verb + * `add` + * `change` + * `delete` + * `fix` + * `refactor` + * `tweak` + * et al. +* Start with a lowercase letter + * eg. `change analytic page path to the same of page slug` +* Do not end with a fullstop diff --git a/docs/src/cronjobs.md b/docs/src/cronjobs.md new file mode 100644 index 0000000000000000000000000000000000000000..9a5f641deea6c1430a38852de4dddbe1c29cbce1 --- /dev/null +++ b/docs/src/cronjobs.md @@ -0,0 +1,31 @@ +# Cronjobs + +We don't use cron but systemd timers for jobs that need to run recurringly. + +## Process email notifications + +```sh +python manage.py processnotifications +``` + +Sends notification emails for new blog posts. + +Triggers daily at 10AM server time. + +## Email blog exports + +```sh +python manage.py mailexports +``` + +Emails users their blog exports. + +Triggers monthly, first day of the month, 6AM server time. + +## Database backup + +``` +./backup-database.sh +``` + +Triggers every 6 hours. diff --git a/docs/src/database-backup.md b/docs/src/database-backup.md new file mode 100644 index 0000000000000000000000000000000000000000..ade2e57f209b4be9f6b77fbd6b372367f9183ba7 --- /dev/null +++ b/docs/src/database-backup.md @@ -0,0 +1,45 @@ +# Database Backup + +## Shell Script + +We use the script [`backup-database.sh`](backup-database.sh) to dump the +database and upload it into an S3-compatible object storage cloud using +[rclone](https://rclone.org/). This script needs the database password +as an environment variable. The key must be `PGPASSWORD`. The variable can live +in `.envrc` as such: + +```sh +export PGPASSWORD=db-password +``` + +## Commands + +To create a database dump run: + +```sh +pg_dump -Fc --no-acl mataroa -h localhost -U mataroa -f /home/deploy/mataroa.dump -W +``` + +To restore a database dump run: + +```sh +pg_restore --disable-triggers -j 4 -v -h localhost -cO --if-exists -d mataroa -U mataroa -W mataroa.dump +``` + +## Initialise and configure backup script + +```sh +cp /var/www/mataroa/backup-database.sh /home/deploy/ +``` + +## Setup rclone + +1. Create bucket on Scaleway or any other S3-compatible object storage. +1. Find bucket URL. + * On Scaleway: it's on Bucket Settings. +1. Acquire IAM Access Key ID and Secret Key. + * On Scaleway: IAM -> Applications -> Project default -> API Keys + +```sh +rclone config +``` diff --git a/docs/src/dependencies.md b/docs/src/dependencies.md new file mode 100644 index 0000000000000000000000000000000000000000..b12420c9adeb21b44564b896964725daeacb39a9 --- /dev/null +++ b/docs/src/dependencies.md @@ -0,0 +1,47 @@ +# Dependencies + +## Dependency Policy + +The mataroa project has an unusually strict yet usually unclear dependency policy. + +Vague rules include: + +* No third-party Django apps. +* All Python / PyPI packages should be individually vetted. + * Packages should be published from community-trusted organisations or developers. + * Packages should be actively maintained (though not necessarily actively developed). + * Packages should hold a high quality of coding practices. +* No JavaScript libraries / dependencies. + +Current list of top-level PyPI dependencies (source at [`pyproject.toml`](/pyproject.toml)): + +* [Django](https://pypi.org/project/Django/) +* [psycopg](https://pypi.org/project/psycopg/) +* [gunicorn](https://pypi.org/project/gunicorn/) +* [Markdown](https://pypi.org/project/Markdown/) +* [Pygments](https://pypi.org/project/Pygments/) +* [bleach](https://pypi.org/project/bleach/) +* [stripe](https://pypi.org/project/stripe/) + +## Adding a new dependency + +After approving a dependency, add it using `uv`: + +1. Ensure `uv` is installed and a virtualenv exists (managed by `uv`). +1. Add the dependency to `pyproject.toml` and lockfile with: + - Runtime: `uv add PACKAGE` + - Dev-only: `uv add --dev PACKAGE` +1. Install/sync dependencies: `uv sync` + +## Upgrading dependencies + +When a new Django version is out it’s a good idea to upgrade everything. + +Steps: + +1. Update the lockfile: `uv lock --upgrade` +1. Review changes: `git diff uv.lock` and spot non-patch level version bumps. +1. Examine release notes of each one. +1. Install updated deps: `uv sync` +1. Unless something comes up, make sure tests and smoke tests pass. +1. Deploy new dependency versions. diff --git a/docs/src/deployment.md b/docs/src/deployment.md new file mode 100644 index 0000000000000000000000000000000000000000..228363c1bed7b78f28a9428a40677faad64cd0a0 --- /dev/null +++ b/docs/src/deployment.md @@ -0,0 +1,66 @@ +# Deployment + +## Step 1: Ansible + +We use ansible to provision a Debian 12 Linux server. + +(1a) First, set up configuration files: + +```sh +cd ansible/ +# Make a copy of the example file +cp .envrc.example .envrc + +# Edit parameters as required +vim .envrc + +# Load variables into environment +source .envrc +``` + +(1b) Then, provision: + +```sh +ansible-playbook playbook.yaml -v +``` + +## Step 2: Wildcard certificates + +We use Automatic DNS API integration with DNSimple: + +* https://github.com/acmesh-official/acme.sh?tab=readme-ov-file#1-how-to-install +* https://github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_dnsimple + +Note: acme.sh's default SSL provider is ZeroSSL which does not accept email with +plus-subaddressing. It will not error gracefully, just fail with a cryptic +message (tested with acmesh v3.0.7). + +```sh +curl https://get.acme.sh | sh -s email=person@example.com +# Note: Installation inserts a cronjob for auto-renewal + +# Setup DNSimple API +echo 'export DNSimple_OAUTH_TOKEN="token-here"' >> /root/.acme.sh/acme.sh.env + +# Issue cert +acme.sh --issue --dns dns_dnsimple -d mataroa.blog -d *.mataroa.blog + +# We "install" (copy) the cert because we should not use the cert from acme.sh's internal store +acme.sh --install-cert -d mataroa.blog -d *.mataroa.blog --key-file /etc/caddy/mataroa-blog-key.pem --fullchain-file /etc/caddy/mataroa-blog-cert.pem --reloadcmd "chown caddy:www-data /etc/caddy/mataroa-blog-{cert,key}.pem && systemctl restart caddy" +``` + +## Step 3: Cronjobs and Automated backups + +There are a few cronjobs that need setting up and, of course, backups are essential: + +* (3a) [Cronjobs](./cronjobs.md) +* (3b) [Database Backup](./database-backup.md) + +## Step 4: Deploy changes + +```sh +git push origin main +source .venv/bin/activate +cd ansible/ +ansible-playbook -v deploy.yaml +``` diff --git a/docs/src/file-structure-walkthrough.md b/docs/src/file-structure-walkthrough.md new file mode 100644 index 0000000000000000000000000000000000000000..fd5717bbf581ab6f9c4bc1dead2619e5d8d0c51b --- /dev/null +++ b/docs/src/file-structure-walkthrough.md @@ -0,0 +1,191 @@ +# File Structure Walkthrough + +Here, an overview of the project's code sources is presented. The purpose is +for the reader to understand what kind of functionality is located where in +the sources. + +All business logic of the application is in one Django app: [`main`](/main). + +Condensed and commented sources file tree: + +``` +. +├── .build.yml # SourceHut CI build config +├── .envrc.example # example direnv file +├── .github/ # GitHub Actions config files +├── Caddyfile # configuration for Caddy webserver +├── Dockerfile +├── LICENSE +├── Makefile # make-defined tasks +├── README.md +├── backup-database.sh +├── default.nix # nix profile +├── deploy.sh +├── docker-compose.yml +├── docs/ +├── export_base_epub/ # base sources for epub export functionality +├── export_base_hugo/ # base sources for hugo export functionality +├── export_base_zola/ # base sources for zola export functionality +├── main/ +│   ├── admin.py +│   ├── apps.py +│   ├── denylist.py # list of various keywords allowed and denied +│   ├── feeds.py # django rss functionality +│   ├── fixtures/ +│   │   └── dev-data.json # sample development data +│   ├── forms.py +│   ├── management/ # commands under `python manage.py` +│   │   └── commands/ +│   │   └── processnotifications.py +│   │   └── mailexports.py +│   ├── middleware.py # mostly subdomain routing +│   ├── migrations/ +│   ├── models.py +│   ├── static/ +│   ├── templates +│   │   ├── main/ # HTML templates for most pages +│   │   ├── assets/ +│   │   │   ├── drag-and-drop-upload.js +│   │   │   └── style.css +│   │   ├── partials/ +│   │   │   ├── footer.html +│   │   │   ├── footer_blog.html +│   │   │   └── webring.html +│   │   └── registration/ +│   ├── tests/ +│   │   ├── test_billing.py +│   │   ├── test_blog.py +│   │   ├── test_comments.py +│   │   ├── test_images.py +│   │   ├── test_management.py +│   │   ├── test_pages.py +│   │   ├── test_posts.py +│   │   ├── test_users.py +│   │   └── testdata/ +│   ├── urls.py +│   ├── util.py +│   ├── validators.py # custom form and field validators +│   ├── views.py +│   ├── views_api.py +│   ├── views_billing.py +│   └── views_export.py +├── manage.py +└── mataroa +    ├── asgi.py +    ├── settings.py # django configuration file +    ├── urls.py +    └── wsgi.py +``` + +## [`main/urls.py`](/main/urls.py) + +All urls are in this module. They are visually divided into several sections: + +* general, includes index, dashboard, static pages +* user system, includes signup, settings, logout +* blog posts, the CRUD opertions of +* blog extras, includes rss and newsletter features +* comments, related to the blog post comments +* billing, subscription and card related +* blog import, export, webring +* images CRUD +* analytics list and details +* pages CRUD + +## [`main/views.py`](/main/views.py) + +The majority of business logic is in the `views.py` module. + +It includes: + +* indexes, dashboard, static pages +* user CRUD and login/logout +* posts CRUD +* comments CRUD +* images CRUD +* pages CRUD +* webring +* analytics +* notifications subscribe/unsubscribe +* moderation dashboard +* sitemaps + +Generally, +[Django class-based generic views](https://docs.djangoproject.com/en/3.2/topics/class-based-views/generic-display/) +are used most of the time as they provide useful functionality abstracted away. + +The Django source code [for generic views](https://github.com/django/django/tree/main/django/views/generic) +is also extremely readable: + +* [base.py](https://github.com/django/django/blob/main/django/views/generic/base.py): base `View` and `TemplateView` +* [list.py](https://github.com/django/django/blob/main/django/views/generic/list.py): `ListView` +* [edit.py](https://github.com/django/django/blob/main/django/views/generic/edit.py): `UpdateView`, `DeleteView`, `FormView` +* [detail.py](https://github.com/django/django/blob/main/django/views/generic/detail.py): `DetailView` + +[Function-based views](https://docs.djangoproject.com/en/3.2/intro/tutorial01/#write-your-first-view) +are used in cases where the CRUD/RESTful design pattern is not clear such as +`notification_unsubscribe_key` where we unsubscribe an email via a GET operation. + +## [`main/views_api.py`](/main/views_api.py) + +This module contains all API related views. These views have their own +api key based authentication. + +## [`main/views_export.py`](/main/views_export.py) + +This module contains all views related to the export capabilities of mataroa. + +The way the exports work is by reading the base files from the repository root: +[export_base_hugo](export_base_hugo/), [export_base_zola](export_base_zola/), +[export_base_epub](export_base_epub/) for Hugo, Zola, and epub respectively. +After reading, we replace some strings on the configurations, generate posts +as markdown strings, and zip-archive everything in-memory. Finally, we respond +using the appropriate content type (`application/zip` or `application/epub`) and +[Content-Disposition](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) +`attachment`. + +## [`main/views_billing.py`](/main/views_billing.py) + +This module contains all billing and subscription related views. It’s designed to +support one payment processor, Stripe. + +## [`main/tests/`](/main/tests/) + +All tests are under this directory. They are divided into several modules, +based on the functionality and the views they test. + +Everything uses the built-in Python `unittest` module along with standard +Django testing facilities. + +## [`main/models.py`](/main/models.py) and [`main/migrations/`](/main/migrations/) + +`main/models.py` is where the database schema is defined, translated into +Django ORM-speak. This always displays the latest schema. + +`main/migrations/` includes all incremental migrations required to reach +the schema defined in `main/models.py` starting from an empty database. + +We use the built-in Django commands to generate and execute migrations, namely +`makemigrations` and `migrate`. For example, the steps to make a schema change +would be something like: + +1. Make the change in `main/models.py`. See +[Django Model field reference](https://docs.djangoproject.com/en/3.2/ref/models/fields/). +1. Run `python manage.py makemigrations` to auto-generate the migrations. +1. Potentially refactor the auto-generated migration file (located at `main/migrations/XXXX_auto_XXXXXXXX.py`) +1. Run `python manage.py migrate` to execute migrations. +1. Also `make format` before committing. + +## [`main/forms.py`](/main/forms.py) + +Here a collection of Django-based forms resides, mostly in regards to user creation, +upload functionalities (for post import or image upload), and card details +submission. + +See [Django Form fields reference](https://docs.djangoproject.com/en/3.2/ref/forms/fields/). + +## [`main/templates/assets/style.css`](main/templates/assets/style.css) + +On Mataroa, a user can enable an option, Theme Zia Lucia, and get a higher font +size by default. Because we need to change the body font-size value, we render +the CSS. It is not static. This is why it lives inside the templates directory. diff --git a/docs/src/introduction.md b/docs/src/introduction.md new file mode 100644 index 0000000000000000000000000000000000000000..e05dd07df4b5c463db30ca1853b50d612127ec2e --- /dev/null +++ b/docs/src/introduction.md @@ -0,0 +1,29 @@ +# Introduction + +Welcome to the documentation site of the +[mataroa blog](https://github.com/mataroablog) +project! + +**Main repository on GitHub** +[github.com/mataroablog/mataroa](https://github.com/mataroablog/mataroa) + +**Mirror repository on sr.ht** +[git.sr.ht/~sirodoht/mataroa](https://git.sr.ht/~sirodoht/mataroa) + +**Report bugs on GitHub** +[github.com/mataroablog/mataroa/issues](https://github.com/mataroablog/mataroa/issues) + +**Contribute on GitHub with Pull Requests** +[github.com/mataroablog/mataroa/pulls](https://github.com/mataroablog/mataroa/pulls) + +**Contribute (platform independent) with email patches** +[~sirodoht/public-inbox@lists.sr.ht](mailto:~sirodoht/public-inbox@lists.sr.ht) + +**Community mailing list** +[lists.sr.ht/~sirodoht/mataroa-community](https://lists.sr.ht/~sirodoht/mataroa-community) + +## Start + +Start learning about mataroa with reading the +main repository +README: diff --git a/docs/src/main-repository-readme.md b/docs/src/main-repository-readme.md new file mode 120000 index 0000000000000000000000000000000000000000..fe840054137e2ccda075344f21e728249a60a2fc --- /dev/null +++ b/docs/src/main-repository-readme.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/docs/src/runbook.md b/docs/src/runbook.md new file mode 100644 index 0000000000000000000000000000000000000000..125fce752b6d304573ef9f93eea91749aef5f924 --- /dev/null +++ b/docs/src/runbook.md @@ -0,0 +1,114 @@ +# Runbook + +So, mataroa is down. What do we do? + +Firstly, panic. Run around in circles with your hands up in despair. It's important to +do this, don't think this is a joke! Ok, once that's done: + +## 1. Check Caddy + +Caddy is the first point of contact inside the server from the outside world. + +First ssh into server: + +```sh +ssh root@mataroa.blog +``` + +Caddy runs as a systemd service. Check status with: + +```sh +systemctl status caddy +``` + +Exit with `q`. If the service is not running and is errored restart with: + +```sh +systemctl restart caddy +``` + +If restart does not work, check logs: + +```sh +journalctl -u caddy -r +``` + +`-r` is for reverse. Use `-f` to follow logs real time: + +```sh +journalctl -u caddy -f +``` + +To search within all logs do slash and then the keyword itself, eg: `/keyword-here`, +then hit enter. + +The config for Caddy is: + +```sh +cat /etc/caddy/Caddyfile +``` + +One entry is to serve anything with *.mataroa.blog host, and the second is for anything +not in that domain, which is exclusively all the blogs custom domains. + +The systemd config for Caddy is: + +```sh +cat /etc/systemd/system/multi-user.target.wants/caddy.service +``` + +## 2. Check gunicorn + +After caddy receives the request, it forwards it to gunicorn. Gunicorn is what runs the +mataroa Django instances, so it's named `mataroa`. It also runs as a systemd service. + +To see status: + +```sh +systemctl status mataroa +``` + +To restart: + +```sh +systemctl restart mataroa +``` + +To see logs: + +```sh +journalctl -u mataroa -r +``` + +and to follow them: + +```sh +journalctl -u mataroa -f +``` + +The systemd config for mataroa/gunicorn is: + +```sh +cat /etc/systemd/system/multi-user.target.wants/mataroa.service +``` + +Note that the env variables for production live inside the systemd service file. + +## 3. How to hotfix code + +Here's where the code lives and how to access it: + +```sh +sudo -i -u deploy +cd /var/www/mataroa/ +source .envrc # load env variables for manual runs +source .venv/bin/activate # activate venv +python manage.py +``` + +If you make a change in the source code files (inside `/var/www/mataroa`) you need to +restart the service for the changes to take effect: + +```sh +systemctl restart mataroa +``` diff --git a/docs/src/server-migration.md b/docs/src/server-migration.md new file mode 100644 index 0000000000000000000000000000000000000000..c2501455561a9c5db14fb897f8f596f97ad27e36 --- /dev/null +++ b/docs/src/server-migration.md @@ -0,0 +1,36 @@ +# Server Migration + +Sadly or not, nothing lasts forever. One day you might do a server migration. +Among many, mataroa is doing something naughty. We store everything, images +including, in the Postgres database. Naughty indeed, yet makes it much easier to +backup but also migrate. + +To start with, one a migrator has setup their new server (see +[Deployment](./deployment.md)) we recommend testing everything in another +domain, other than the main (existing) one. + +Once everything works: + +1. Verify all production variables and canonical server names exist in settings et al. +1. Disconnect production server from public IP. This is not a zero-downtime migration — to be clear. +1. Run backup-database.sh one last time. +1. Assign elastic/floating IP to new server. +1. Run TLS certificate (naked and wildcard) generations. +1. `scp` database dump into new server. +1. Restore database dump in new server. +1. Start mataroa and caddy systemd services + +Later: + +1. Setup cronjobs / systemd timers +1. Setup healthcheks for recurring jobs. +1. Verify DEBUG is 0. + +The above assume the migrator has a floating IP that they can move around. If +not, there are two problems. The migrator needs to coordinate DNS but much +more problematically all custom domains stop working :/ For this reason we +should implement CNAME custom domains. However, CNAME custom domains do not +support root domains, so what's the point anyway you ask. Good question. I don't +know. I only hope I never decide to switch away from Hetzner. + +Peace. diff --git a/export_base_epub/container.xml b/export_base_epub/container.xml new file mode 100644 index 0000000000000000000000000000000000000000..8b80cd0770c07481152c8336a6fa5133559b4e25 --- /dev/null +++ b/export_base_epub/container.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/export_base_epub/content.opf b/export_base_epub/content.opf new file mode 100644 index 0000000000000000000000000000000000000000..c87525e67cf8a698b6238a8af383012e0318646d --- /dev/null +++ b/export_base_epub/content.opf @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/export_base_epub/mimetype b/export_base_epub/mimetype new file mode 100644 index 0000000000000000000000000000000000000000..403c4f02dfa7f008d60eee9539a2695157623370 --- /dev/null +++ b/export_base_epub/mimetype @@ -0,0 +1 @@ +application/epub+zip diff --git a/export_base_epub/toc.ncx b/export_base_epub/toc.ncx new file mode 100644 index 0000000000000000000000000000000000000000..68114b119a85c3859a5ca6970c36fb97a802173c --- /dev/null +++ b/export_base_epub/toc.ncx @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + Title page + + + + + Table of Contents + + + + + diff --git a/export_base_epub/toc.xhtml b/export_base_epub/toc.xhtml new file mode 100644 index 0000000000000000000000000000000000000000..6d4e6242ce949764ee4eed755c8b87b0fae8f72a --- /dev/null +++ b/export_base_epub/toc.xhtml @@ -0,0 +1,15 @@ + + + + + Table of Contents + + +

Table of Contents

+ + + diff --git a/export_base_hugo/404.html b/export_base_hugo/404.html new file mode 100644 index 0000000000000000000000000000000000000000..583856c44cd3e605d820b28cf1a1f20011281a80 --- /dev/null +++ b/export_base_hugo/404.html @@ -0,0 +1,19 @@ +{{ define "main" }} +
+

404

+

Page Not Found

+

The URL you requested does not exist on this blog. Got lost?

+

← Go back or return to the homepage.

+
+ +
+ {{- with .Site.GetPage "/" }} + {{- with .OutputFormats.Get "rss" }} +

+ Subscribe via + {{ printf `RSS.` .Permalink site.Title | safeHTML }} +

+ {{- end }} + {{- end }} +
+{{ end }} diff --git a/export_base_hugo/baseof.html b/export_base_hugo/baseof.html new file mode 100644 index 0000000000000000000000000000000000000000..a9e52698e5f556655cff9cadf21082fbc88e69cc --- /dev/null +++ b/export_base_hugo/baseof.html @@ -0,0 +1,25 @@ + + + + + + + {{- if eq .Section "blog" }} + {{ .Title }} - {{ .Site.Title }} + + {{- else }} + {{ .Site.Title }} + + {{- end }} + + + {{ with .OutputFormats.Get "rss" -}} + {{ printf `` .Rel .MediaType.Type .Permalink site.Title | safeHTML }} + {{ end }} + + + + {{- block "main" . }} + {{- end }} + + diff --git a/export_base_hugo/config.toml b/export_base_hugo/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..1cdaac5b553a3ec50140275072971a9b6df73848 --- /dev/null +++ b/export_base_hugo/config.toml @@ -0,0 +1,12 @@ +baseURL = "http://example.com/" +languageCode = "en-us" +title = "Example blog title" +theme = "mataroa" + +[params] + description = "Example blog description" + +[outputFormats] + [outputFormats.RSS] + mediatype = "application/rss" + baseName = "rss" diff --git a/export_base_hugo/index.html b/export_base_hugo/index.html new file mode 100644 index 0000000000000000000000000000000000000000..d8b3684808817b6cafa76dab89b081fe22dc115b --- /dev/null +++ b/export_base_hugo/index.html @@ -0,0 +1,33 @@ +{{ define "main" }} +
+

{{ .Site.Title }}

+ + {{- if .Site.Params.Description }} + + {{- end }} + +
    + {{ range .Pages }} +
  • + {{ .Title }} + + — + +
  • + {{ end }} +
+
+ +
+ {{ with .OutputFormats.Get "rss" -}} +

+ Subscribe via + {{ printf `RSS` .Permalink site.Title | safeHTML }} +

+ {{ end }} +
+{{ end }} diff --git a/export_base_hugo/list.html b/export_base_hugo/list.html new file mode 100644 index 0000000000000000000000000000000000000000..9d2d823f4f0692b442c47edffa475bc737e8659d --- /dev/null +++ b/export_base_hugo/list.html @@ -0,0 +1,14 @@ +{{ define "main" -}} +
+
    + {{ range .Data.Pages -}} +
  • + {{ .Title }} + +
  • + {{- end }} +
+
+{{- end }} diff --git a/export_base_hugo/single.html b/export_base_hugo/single.html new file mode 100644 index 0000000000000000000000000000000000000000..1e91d15dc85dd3fefb375d97fc226e1c67ce14e9 --- /dev/null +++ b/export_base_hugo/single.html @@ -0,0 +1,32 @@ +{{ define "main" }} +
+ {{ .Site.Title }} + +
+

+ {{ .Title }} +

+ + + +
+ {{ .Content }} +
+
+
+
+ {{- with .Site.GetPage "/" }} + {{- with .OutputFormats.Get "rss" }} +

+ Subscribe via + {{ printf `RSS` .Permalink site.Title | safeHTML }} +

+ {{- end }} + {{- end }} +
+{{ end }} diff --git a/export_base_hugo/style.css b/export_base_hugo/style.css new file mode 100644 index 0000000000000000000000000000000000000000..d004b421f2e89cacce0311f5d4832c373c5d1112 --- /dev/null +++ b/export_base_hugo/style.css @@ -0,0 +1,151 @@ +/* general */ +:root { + --dark-grey-color: #a8a8a8; + --light-grey-color: #eff1f5; + --green-color: #26bd60; + --red-color: #ff0000; +} + +html, +body { + margin: 0; + font-family: sans-serif; + font-size: 14px; + line-height: 1.4; +} + +a { + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +p { + max-width: 500px; +} + +img { + width: 100%; +} + +h2, +h3 { + font-weight: 500; +} + +main { + max-width: 500px; + margin-bottom: 16px; + margin-left: auto; + margin-right: auto; +} +@media (max-width: 500px) { + main { + padding-left: 8px; + padding-right: 8px; + } +} + +aside { + max-width: 500px; + padding: 2px 6px; + margin-top: 8px; + margin-left: auto; + margin-right: auto; + box-sizing: border-box; +} +@media (max-width: 500px) { + aside { + margin-left: 8px; + margin-right: 8px; + } +} + +nav { + max-width: 500px; + margin-top: 16px; + margin-left: auto; + margin-right: auto; +} +@media (max-width: 500px) { + nav { + padding-left: 8px; + padding-right: 8px; + } +} + +blockquote { + max-width: 500px; +} + +/* index */ +.byline { + color: var(--dark-grey-color); + margin-top: 24px; + margin-bottom: 24px; +} + +.byline::before { + content: "» "; +} + +.posts { + list-style: none; + padding-left: 0; +} + +.posts li { + font-size: 16px; + margin-bottom: 24px; +} + +.posts small { + white-space: nowrap; + color: var(--dark-grey-color); +} + +.posts time { + white-space: nowrap; +} + +/* post detail */ +.posts-item-title { + margin-bottom: 8px; +} + +.posts-item-byline { + color: var(--dark-grey-color); + margin-bottom: 8px; +} + +.posts-item-brand { + display: block; + margin-top: 16px; + margin-bottom: 16px; + color: var(--dark-grey-color); +} + +.posts-item-brand:hover { + text-decoration: none; +} + +.posts-item-brand::before { + content: "« "; +} + +/* footer */ +footer { + margin-left: auto; + margin-right: auto; + margin-top: 64px; + margin-bottom: 16px; + max-width: 500px; +} +@media (max-width: 500px) { + footer { + padding-left: 8px; + padding-right: 8px; + } +} diff --git a/export_base_hugo/theme.toml b/export_base_hugo/theme.toml new file mode 100644 index 0000000000000000000000000000000000000000..9dacee6bdeff25826c1a8c691d64ed6c1c60c67d --- /dev/null +++ b/export_base_hugo/theme.toml @@ -0,0 +1,7 @@ +name = "mataroa" +description = "Theme from the mataroa blog platform" +min_version = "0.41" + +[author] + name = "mataroa" + homepage = "https://mataroa.blog/" diff --git a/export_base_zola/404.html b/export_base_zola/404.html new file mode 100644 index 0000000000000000000000000000000000000000..afc2fe91d6bea52869473d49593bc0586ee62760 --- /dev/null +++ b/export_base_zola/404.html @@ -0,0 +1,15 @@ +{% extends 'index.html' %} + +{% block title %}404 — Page Not Found{% endblock title %} + +{% block content %} +
+

404

+

Page Not Found

+

The URL you requested does not exist on this blog. Got lost?

+

← Go back or return to the homepage.

+
+
+ Subscribe via RSS. +
+{% endblock content %} diff --git a/export_base_zola/_index.md b/export_base_zola/_index.md new file mode 100644 index 0000000000000000000000000000000000000000..8bc00690ea34d5db98d6c0328ae89be8709eb1eb --- /dev/null +++ b/export_base_zola/_index.md @@ -0,0 +1,3 @@ ++++ +sort_by = "date" ++++ diff --git a/export_base_zola/config.toml b/export_base_zola/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..b8c4bdd4765170ceb9a9dda1796641c3425a9bcf --- /dev/null +++ b/export_base_zola/config.toml @@ -0,0 +1,22 @@ +# The URL the site will be built for +base_url = "https://example.com" + +title = "Example blog title" +description = "Example blog description" + +# Whether to automatically compile all Sass files in the sass directory +compile_sass = false + +generate_feeds = true +feed_filenames = ["rss.xml"] + +# Whether to do syntax highlighting +# Theme can be customised by setting the `highlight_theme` variable to a theme supported by Zola +[markdown] +highlight_code = true + +# Whether to build a search index to be used later on by a JavaScript library +build_search_index = false + +[extra] +# Put all your custom variables here diff --git a/export_base_zola/index.html b/export_base_zola/index.html new file mode 100644 index 0000000000000000000000000000000000000000..e1996a4c439074ef3ea4678e7cf8ced55ea599cc --- /dev/null +++ b/export_base_zola/index.html @@ -0,0 +1,38 @@ + + + + + + + {% block title %}{{ config.title }}{% endblock title %} + + + + {% block content %} +
+

{{ config.title }}

+ + {% if config.description %} + + {% endif %} + +
    + {% set blog = get_section(path='_index.md') %} + {% for post in blog.pages %} +
  • + {{ post.title }} + +
  • + {% endfor %} +
+
+ +
+ Subscribe via RSS. +
+ {% endblock content %} + + + diff --git a/export_base_zola/post.html b/export_base_zola/post.html new file mode 100644 index 0000000000000000000000000000000000000000..2acb51a54d9a421a5c94ad7711513648985d0f40 --- /dev/null +++ b/export_base_zola/post.html @@ -0,0 +1,26 @@ +{% extends 'index.html' %} + +{% block title %}{{ page.title }} - {{ config.title }}{% endblock title %} + +{% block content %} +
+ {{ config.title }} + +
+

+ {{ page.title }} +

+ + + +
+ {{ page.content | safe }} +
+
+
+
+ Subscribe via RSS. +
+{% endblock content %} diff --git a/export_base_zola/style.css b/export_base_zola/style.css new file mode 100644 index 0000000000000000000000000000000000000000..7f21243e2444313c1730f0ec7bef7692154c8bac --- /dev/null +++ b/export_base_zola/style.css @@ -0,0 +1,157 @@ +/* general */ +:root { + --dark-grey-color: #a8a8a8; + --light-grey-color: #eff1f5; + --green-color: #26bd60; + --red-color: #ff0000; +} + +html, +body { + margin: 0; + font-family: sans-serif; + font-size: 14px; + line-height: 1.4; +} + +a { + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +p { + max-width: 500px; +} + +img { + width: 100%; +} + +h1 { + font-weight: 500; + padding-bottom: 4px; + border-bottom: 2px solid var(--light-grey-color); +} + +h2, +h3 { + font-weight: 500; +} + +main { + max-width: 500px; + margin-bottom: 16px; + margin-left: auto; + margin-right: auto; +} +@media (max-width: 500px) { + main { + padding-left: 8px; + padding-right: 8px; + } +} + +aside { + max-width: 500px; + padding: 2px 6px; + margin-top: 8px; + margin-left: auto; + margin-right: auto; + box-sizing: border-box; +} +@media (max-width: 500px) { + aside { + margin-left: 8px; + margin-right: 8px; + } +} + +nav { + max-width: 500px; + margin-top: 16px; + margin-left: auto; + margin-right: auto; +} +@media (max-width: 500px) { + nav { + padding-left: 8px; + padding-right: 8px; + } +} + +blockquote { + max-width: 500px; +} + +/* index */ +.byline { + color: var(--dark-grey-color); + margin-top: 24px; + margin-bottom: 24px; +} + +.byline::before { + content: "» "; +} + +.posts { + list-style: none; + padding-left: 0; +} + +.posts li { + font-size: 16px; + margin-bottom: 24px; +} + +.posts small { + white-space: nowrap; + color: var(--dark-grey-color); +} + +.posts time { + white-space: nowrap; +} + +/* post detail */ +.posts-item-title { + margin-bottom: 8px; +} + +.posts-item-byline { + color: var(--dark-grey-color); + margin-bottom: 8px; +} + +.posts-item-brand { + display: block; + margin-top: 16px; + margin-bottom: 16px; + color: var(--dark-grey-color); +} + +.posts-item-brand:hover { + text-decoration: none; +} + +.posts-item-brand::before { + content: "» "; +} + +/* footer */ +footer { + margin-left: auto; + margin-right: auto; + margin-top: 64px; + margin-bottom: 16px; + max-width: 500px; +} +@media (max-width: 500px) { + footer { + padding-left: 8px; + padding-right: 8px; + } +} diff --git a/main/__init__.py b/main/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/main/admin.py b/main/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..33a89b17d21c6a69039ce0f587f456d2c861841e --- /dev/null +++ b/main/admin.py @@ -0,0 +1,236 @@ +from django.conf import settings +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjUserAdmin +from django.utils.html import format_html + +from main import models, util + + +@admin.action(description="Mark selected users as approved") +def make_approved(modeladmin, request, queryset): + queryset.update(is_approved=True) + +@admin.register(models.User) +class UserAdmin(DjUserAdmin): + list_display = ( + "id", + "username", + "blog_url", + "email", + "stripe_customer_id", + "is_premium", + "mail_export_on", + "post_count", + "is_approved", + "blog_title", + "date_joined", + "last_login", + ) + list_display_links = ("id", "username") + list_filter = ("is_premium", "mail_export_on", "comments_on") + search_fields = ("username", "email", "stripe_customer_id", "blog_title") + actions = [make_approved] + + @admin.display + def blog_url(self, obj): + url = f"{util.get_protocol()}" + if obj.custom_domain: + url += f"//{obj.custom_domain}" + else: + url += f"//{obj.username}.{settings.CANONICAL_HOST}" + return format_html(f'{url}') + + fieldsets = DjUserAdmin.fieldsets + ( + ( + "Blog options", + { + "fields": ( + "about", + "blog_title", + "posts_page_title", + "blog_byline", + "blog_index_content", + "footer_note", + "theme_zialucia", + "redirect_domain", + "custom_domain", + "comments_on", + "notifications_on", + "mail_export_on", + "post_backups_on", + "show_posts_on_homepage", + "show_posts_in_nav", + "noindex_on", + "reading_time_on", + "export_unsubscribe_key", + "webring_name", + "webring_prev_url", + "webring_next_url", + "stripe_customer_id", + "stripe_subscription_id", + "monero_address", + "is_premium", + "is_grandfathered", + "is_approved", + "api_key", + "robots_txt", + ), + }, + ), + ) + ordering = ["-id"] + + +@admin.register(models.Post) +class PostAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "slug", + "post_url", + "owner", + "created_at", + "broadcasted_at", + "published_at", + ) + search_fields = ("title", "slug", "body", "owner__username") + ordering = ["-id"] + + @admin.display + def post_url(self, obj): + url = util.get_protocol() + obj.get_proper_url() + return format_html(f'{url}') + + +@admin.register(models.Page) +class PageAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "slug", + "owner", + "created_at", + "updated_at", + "is_hidden", + ) + ordering = ["-id"] + + +@admin.register(models.Image) +class ImageAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "slug", + "extension", + "owner", + "uploaded_at", + ) + ordering = ["-id"] + + +@admin.register(models.AnalyticPage) +class AnalyticPageAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "path", + "created_at", + ) + ordering = ["-id"] + + +@admin.register(models.AnalyticPost) +class AnalyticPostAdmin(admin.ModelAdmin): + list_display = ( + "id", + "post", + "created_at", + ) + ordering = ["-id"] + + +@admin.register(models.Comment) +class CommentAdmin(admin.ModelAdmin): + list_display = ( + "id", + "post", + "is_approved", + "name", + "email", + "body", + "created_at", + ) + ordering = ["-id"] + + +@admin.register(models.Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ( + "id", + "email", + "blog_user", + "unsubscribe_key", + "is_active", + ) + ordering = ["-id"] + + +@admin.register(models.NotificationRecord) +class NotificationRecordAdmin(admin.ModelAdmin): + list_display = ( + "id", + "sent_at", + "notification", + "post", + ) + ordering = ["-id"] + + +@admin.register(models.ExportRecord) +class ExportRecordAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "sent_at", + "user", + ) + list_display_links = ("id", "name") + ordering = ["-id"] + + +@admin.register(models.Snapshot) +class SnapshotAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "owner", + ) + list_display_links = ("id", "title") + ordering = ["-id"] + + +@admin.register(models.Onboard) +class OnboardAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "created_at", + ) + ordering = ["-id"] + +@admin.register(models.ReallySimpleLicensing) +class RSLAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "license", + "show_http", + "show_rss", + "show_robotstxt", + "show_webpage", + ) + list_display_links = ("id", "user") + list_filter = ("show_http", "show_rss", "show_robotstxt", "show_webpage") + search_fields = ("user__username", "license") + ordering = ["-id"] \ No newline at end of file diff --git a/main/apps.py b/main/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..de0edc1d9cbd20e386a9344b5d3a00c66fdcf871 --- /dev/null +++ b/main/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MainConfig(AppConfig): + name = "main" diff --git a/main/denylist.py b/main/denylist.py new file mode 100644 index 0000000000000000000000000000000000000000..ba7ab397d58cd671f0540e7b30e1cfcfe2d1ebb2 --- /dev/null +++ b/main/denylist.py @@ -0,0 +1,370 @@ +# not allowed to create account with these as username +DISALLOWED_USERNAMES = [ + "about", + "abuse", + "account", + "accounts", + "admin", + "administration", + "administrator", + "api", + "auth", + "authentication", + "billing", + "blog", + "blogs", + "bot", + "cdn", + "collection", + "config", + "contact", + "dash", + "dashboard", + "developer", + "developers", + "docs", + "documentation", + "help", + "helpcenter", + "home", + "image", + "images", + "img", + "index", + "irc", + "knowledgebase", + "legal", + "login", + "logout", + "man", + "manual", + "meta", + "metrics", + "on", + "ops", + "pricing", + "privacy", + "profile", + "random", + "register", + "registration", + "robot", + "search", + "server", + "settings", + "setup", + "signin", + "signup", + "smtp", + "static", + "status", + "support", + "terms", + "test", + "tests", + "tmp", + "up", + "uptime", + "wiki", + "www", +] + +# not allowed to create a blog page with these as slugs +DISALLOWED_PAGE_SLUGS = [ + "analytics", + "billing", + "blog", + "dashboard", + "export", + "halsey", + "images", + "import", + "newsletter", + "notifications", + "pages", + "rss", + "webring", +] + +# elements allowed to exist inside the HTML of a markdown text +ALLOWED_HTML_ELEMENTS = [ + "a", + "abbr", + "acronym", + "article", + "audio", + "b", + "bdi", + "bdo", + "blockquote", + "br", + "cite", + "code", + "colgroup", + "dd", + "del", + "details", + "dfn", + "div", + "dl", + "dt", + "em", + "figcaption", + "figure", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "iframe", + "img", + "kbd", + "li", + "mark", + "ol", + "p", + "pre", + "q", + "rb", + "rt", + "rtc", + "ruby", + "s", + "samp", + "section", + "small", + "span", + "strong", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "th", + "thead", + "time", + "tr", + "u", + "ul", + "var", + "wbr", +] + +# see https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Element +mathml_elements = [ + "math", + # "maction", # depracated + "annotation", + "annotation-xml", + # "memclose", # non-standard + "merror", + # "mfenced", # depracated + "mfrac", + "mi", + "mmultiscripts", + "mn", + "mo", + "mover", + "mpadded", + "mphantom", + "mprescript", + "mroot", + "mrow", + "ms", + "semantics", + "mspace", + "msqrt", + "mstyle", + "msub", + "msup", + "msubsup", + "mtable", + "mtd", + "mtext", + "mtr", + "munder", + "munderover", +] +ALLOWED_HTML_ELEMENTS += mathml_elements + +# attributes allowed to exist inside the elements of the HTML of a markdown text +ALLOWED_HTML_ATTRS = [ + "align", + "allowfullscreen", + "alt", + "class", + "controls", + "frameborder", + "height", + "href", + "id", + "name", + "preload", + "rel", + "seamless", + "span", + "src", + "start", + "style", + "target", + "title", + "width", +] + +# https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Attribute +# unsure if these are required for mathml to work? we could ask the `latex2mathml` project what attributes they use. +mathml_attrs = [ + # ### global attributes -> there are more see https://developer.mozilla.org/en-US/docs/Web/MathML/Reference/Global_attributes + "displaystyle", + "mathbackground", + "mathcolor", + "mathsize", + "scriptlevel", + # ### non-global + "accent", + "accentunder", + # "actiontype", # depracated + "align", + # "background", # depracated + # "close", # depracated + # "color", # depracated + "columnalign", + "columnlines", + "columnspacing", + "columnspan", + # "denomalign", # depracated + "depth", + "dir", + "display", + "displaystyle", + "fence", + # "fontfamily", # depracated + # "fontsize", # depracated + # "fontstyle", # depracated + # "fontweight", # depracated + "frame", + "framespacing", + "height", + "href", + "id", + "linethickness", + "lspace", + "lspace", + # "lquote", # depracated + "mathbackground", + "mathcolor", + "mathsize", + "mathvariant", + "maxsize", + "minsize", + "movablelimits", + "notation", + # "numalign", # depracated + # "open", # depracated + "rowalign", + "rowlines", + "rowspacing", + "rowspan", + "rspace", + # "rspace", # depracated + "scriptlevel", + # "scriptminsize", # depracated + # "scriptsizemultiplier", # depracated + # "selection", # depracated + "separator", + # "separators", # depracated + "stretchy", + # "subscriptshift", # depracated + # "superscriptshift", # depracated + "symmetric", + "voffset", + "width", + "xmlns", +] +ALLOWED_HTML_ATTRS += mathml_attrs + +# css rules allowed to exist as inline styles on HTML elements of a markdown text +ALLOWED_CSS_STYLES = [ + "background", + "border", + "border-radius", + "box-shadow", + "color", + "display", + "font-family", + "font-size", + "height", + "margin", + "padding", + "width", +] + +# control characters of unicode category Cc except for \t, \n, \r +DISALLOWED_CHARACTERS = [ + "\x00", + "\x01", + "\x02", + "\x03", + "\x04", + "\x05", + "\x06", + "\x07", + "\x08", + "\x0b", + "\x0c", + "\x0e", + "\x0f", + "\x10", + "\x11", + "\x12", + "\x13", + "\x14", + "\x15", + "\x16", + "\x17", + "\x18", + "\x19", + "\x1a", + "\x1b", + "\x1c", + "\x1d", + "\x1e", + "\x1f", + "\x7f", + "\x80", + "\x81", + "\x82", + "\x83", + "\x84", + "\x85", + "\x86", + "\x87", + "\x88", + "\x89", + "\x8a", + "\x8b", + "\x8c", + "\x8d", + "\x8e", + "\x8f", + "\x90", + "\x91", + "\x92", + "\x93", + "\x94", + "\x95", + "\x96", + "\x97", + "\x98", + "\x99", + "\x9a", + "\x9b", + "\x9c", + "\x9d", + "\x9e", + "\x9f", +] diff --git a/main/feeds.py b/main/feeds.py new file mode 100644 index 0000000000000000000000000000000000000000..e7085105e4445e81bc029781bc01aee17063bb61 --- /dev/null +++ b/main/feeds.py @@ -0,0 +1,161 @@ +from datetime import datetime +import xml.etree.ElementTree as ET + +from django.contrib.syndication.views import Feed +from django.http import Http404 +from django.utils import timezone + +from main import models +from main.util import reading_time +from django.utils.feedgenerator import Rss201rev2Feed + +class RSLRSSFeed(Rss201rev2Feed): + def root_attributes(self): + """ + Override the root attributes to include the RSL namespace. + """ + attrs = super().root_attributes() + attrs['xmlns:rsl'] = "https://rslstandard.org/rsl" + return attrs + + def add_item_elements(self, handler, item): + super().add_item_elements(handler, item) + + rsl_content = item.get("rsl_content") + if rsl_content: + # + handler.startElement("rsl:content", {"url": rsl_content["url"]}) + + # + handler.startElement("rsl:license", {}) + + # train-ai + permits = rsl_content.get("permits") + if permits: + handler.startElement("rsl:permits", {"type": permits["type"]}) + handler.characters(permits["value"]) + handler.endElement("rsl:permits") + + # + payment = rsl_content.get("payment") + if payment: + handler.startElement("rsl:payment", {"type": payment["type"]}) + + # https://test.org/contact + custom_url = payment.get("custom") + if custom_url: + handler.startElement("rsl:custom", {}) + handler.characters(custom_url) + handler.endElement("rsl:custom") + + # https://rslcollective.org/license + standard_url = payment.get("standard") + if standard_url: + handler.startElement("rsl:standard", {}) + handler.characters(standard_url) + handler.endElement("rsl:standard") + + handler.endElement("rsl:payment") + + handler.endElement("rsl:license") + handler.endElement("rsl:content") + + +class RSSBlogFeed(Feed): + feed_type = RSLRSSFeed + title = "" + link = "" + description = "" + subdomain = "" + + def __call__(self, request, *args, **kwargs): + if not hasattr(request, "subdomain"): + raise Http404() + user = models.User.objects.get(username=request.subdomain) + self.user = user + self.title = user.blog_title + self.description = user.blog_byline_as_text + self.subdomain = request.subdomain + self.link = user.blog_url + + models.AnalyticPage.objects.create(user=user, path="rss") + + return super().__call__(request, *args, **kwargs) + + def items(self): + return models.Post.objects.filter( + owner__username=self.subdomain, + published_at__isnull=False, + published_at__lte=timezone.now().date(), + ).order_by("-published_at")[:10] + + def item_title(self, item): + return item.title + + def item_link(self, item): + return item.get_proper_url() + + def item_description(self, item): + html = item.body_as_html + + if not item.owner.reading_time_on: + return html + + reading_time_text = f"

Estimated reading time: {reading_time(html)} min

" + + return reading_time_text + html + + def item_pubdate(self, item): + # set time to 00:00 because we don't store time for published_at field + return datetime.combine(item.published_at, datetime.min.time()) + + def item_extra_kwargs(self, item): + """ + Parse the user’s RSL XML and return dict for item_extra_elements. + """ + user = self.user + rsl_xml = user.reallysimplelicensing.license + if rsl_xml and user.reallysimplelicensing.show_rss: + rsl_content = parse_rsl_xml(rsl_xml, post_url=f'https:{item.get_proper_url()}') + return {"rsl_content": rsl_content} + return {} + +# Helper to parse RSL XML + +RSL_NS = {"rsl": "https://rslstandard.org/rsl"} + +def parse_rsl_xml(rsl_xml, post_url=None): + """ + Parse an RSL XML string and return a dict describing license info. + If post_url is provided, replace '/' URLs with it. + """ + tree = ET.ElementTree(ET.fromstring(rsl_xml)) + root = tree.getroot() + + content_el = root.find("rsl:content", RSL_NS) + if content_el is None: + return None + + url = content_el.attrib.get("url", "") + if post_url and url == "/": + url = post_url + + license_el = content_el.find("rsl:license", RSL_NS) + + permits = None + payment = None + + if license_el is not None: + permits_el = license_el.find("rsl:permits", RSL_NS) + if permits_el is not None: + permits = {"type": permits_el.attrib.get("type"), "value": permits_el.text} + + payment_el = license_el.find("rsl:payment", RSL_NS) + if payment_el is not None: + payment = {"type": payment_el.attrib.get("type")} + for tag in ["custom", "standard"]: + child = payment_el.find(f"rsl:{tag}", RSL_NS) + if child is not None: + payment[tag] = child.text + + return {"url": url, "permits": permits, "payment": payment} \ No newline at end of file diff --git a/main/fixtures/dev-data.json b/main/fixtures/dev-data.json new file mode 100644 index 0000000000000000000000000000000000000000..7b60bb8ee7c4d45876e3dc4bb7afdef1a990b883 --- /dev/null +++ b/main/fixtures/dev-data.json @@ -0,0 +1,147 @@ +[ + { + "model": "main.user", + "pk": 1, + "fields": { + "password": "pbkdf2_sha256$320000$ENkWsLlFC6V66zsGNR77xv$vmVnKNC8YN1poxLB5JfhhVvFtXeGmWklXq0Xnig0eA4=", + "last_login": "2022-04-02T23:01:17.859", + "is_superuser": true, + "first_name": "", + "last_name": "", + "is_staff": true, + "is_active": true, + "date_joined": "2022-04-02T22:57:11", + "username": "admin", + "email": "", + "about": "", + "blog_title": "admin dev blog", + "blog_byline": "stuff", + "footer_note": "[Public domain](/license). Powered by [mataroa.blog](https://mataroa.blog/).", + "theme_zialucia": false, + "redirect_domain": null, + "custom_domain": null, + "comments_on": false, + "notifications_on": true, + "mail_export_on": true, + "export_unsubscribe_key": "97a97638-a14a-451d-9bca-43a33455ec38", + "webring_name": "hackers webring", + "webring_url": null, + "webring_prev_url": null, + "webring_next_url": null, + "stripe_customer_id": null, + "monero_address": null, + "is_premium": false, + "is_grandfathered": false, + "groups": [], + "user_permissions": [] + } + }, + { + "model": "main.post", + "pk": 1, + "fields": { + "title": "Hello world!", + "slug": "hello-world", + "body": "Hi there!\n\nHow are you?", + "owner": 1, + "created_at": "2022-04-02T22:57:16.104", + "updated_at": "2022-04-02T22:57:16.104", + "published_at": "2022-04-02" + } + }, + { + "model": "main.post", + "pk": 2, + "fields": { + "title": "Hi again", + "slug": "hi-again", + "body": "There are things that are within our power, and things that fall outside our power. Within our power are our own opinions, aims, desires, dislikes—in sum, our own thoughts and actions. Outside our power are our physical characteristics, the class into which we were born, our reputation in the eyes of others, and honors and offices that may be bestowed on us.", + "owner": 1, + "created_at": "2022-04-02T22:57:16.106", + "updated_at": "2022-04-02T22:57:16.106", + "published_at": "2022-04-02" + } + }, + { + "model": "main.post", + "pk": 3, + "fields": { + "title": "I am draft", + "slug": "i-am-draft", + "body": "As in I am Groot.", + "owner": 1, + "created_at": "2022-04-02T22:57:16.106", + "updated_at": "2022-04-02T22:57:16.106", + "published_at": "2022-04-02" + } + }, + { + "model": "main.page", + "pk": 1, + "fields": { + "title": "License", + "slug": "licence", + "body": "MIT", + "owner": 1, + "created_at": "2022-04-02T22:57:16.107", + "updated_at": "2022-04-02T22:57:16.107", + "is_hidden": false + } + }, + { + "model": "main.notification", + "pk": 1, + "fields": { + "blog_user": 1, + "email": "admin@example.com", + "unsubscribe_key": "cd2c35a5-7a37-4f85-852e-3f68572330e2", + "is_active": true + } + }, + { + "model": "main.notification", + "pk": 2, + "fields": { + "blog_user": 1, + "email": "admin+mataroa@example.com", + "unsubscribe_key": "e8ccfa9f-25b0-4c2f-b2c0-11449c1c0cd2", + "is_active": true + } + }, + { + "model": "main.notificationrecord", + "pk": 1, + "fields": { + "notification": 1, + "post": 1, + "sent_at": "2022-04-02T22:57:16.108" + } + }, + { + "model": "main.notificationrecord", + "pk": 2, + "fields": { + "notification": 2, + "post": 1, + "sent_at": "2022-04-02T22:57:16.109" + } + }, + { + "model": "main.notificationrecord", + "pk": 3, + "fields": { + "notification": 1, + "post": 2, + "sent_at": "2022-04-02T22:57:16.109" + } + }, + { + "model": "main.notificationrecord", + "pk": 4, + "fields": { + "notification": 2, + "post": 2, + "sent_at": "2022-04-02T22:57:16.110" + } + } +] diff --git a/main/forms.py b/main/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..0ac4533bef247ea5f65af4db8dbe4f7302a4a426 --- /dev/null +++ b/main/forms.py @@ -0,0 +1,75 @@ +from django import forms +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import UserCreationForm as DjUserCreationForm +from django.core import validators as dj_validators + +from main import models + + +class OnboardForm(forms.ModelForm): + sunflower = forms.BooleanField(required=False) + + class Meta: + model = models.Onboard + fields = ["problems", "quality", "seo"] + + +class UserCreationForm(DjUserCreationForm): + class Meta: + model = get_user_model() + fields = ["username", "email"] + + +class NotificationForm(forms.ModelForm): + class Meta: + model = models.Notification + fields = ["email"] + + +class MultipleFileInput(forms.ClearableFileInput): + allow_multiple_selected = True + + +class MultipleFileField(forms.FileField): + def __init__(self, *args, **kwargs): + kwargs.setdefault("widget", MultipleFileInput()) + super().__init__(*args, **kwargs) + + def clean(self, data, initial=None): + single_file_clean = super().clean + if isinstance(data, list | tuple): + result = [single_file_clean(d, initial) for d in data] + else: + result = single_file_clean(data, initial) + return result + + +class UploadTextFilesForm(forms.Form): + file = MultipleFileField() + + +class UploadImagesForm(forms.Form): + file = MultipleFileField( + validators=[ + dj_validators.FileExtensionValidator( + ["jpeg", "jpg", "png", "svg", "gif", "webp", "tiff", "tif", "bmp"] + ) + ], + ) + + +class StripeForm(forms.Form): + card_token = forms.CharField(max_length=100, widget=forms.HiddenInput()) + + +class ResetAPIKeyForm(forms.Form): + """Reset user's api_key field.""" + + +class APIPost(forms.Form): + """Form for Post resource when accessed from the API.""" + + title = forms.CharField(max_length=300, required=False) + slug = forms.SlugField(max_length=300, required=False) + body = forms.CharField(widget=forms.Textarea, required=False) + published_at = forms.DateField(required=False) diff --git a/main/management/commands/checkstripe.py b/main/management/commands/checkstripe.py new file mode 100644 index 0000000000000000000000000000000000000000..9068d1469080d445005d3108272d6937e29440af --- /dev/null +++ b/main/management/commands/checkstripe.py @@ -0,0 +1,39 @@ +import stripe +from django.conf import settings +from django.core.management.base import BaseCommand + +from main import models + + +class Command(BaseCommand): + help = "Check Stripe data is in sync with database." + + def handle(self, *args, **options): + stripe.api_key = settings.STRIPE_API_KEY + + total_count = 0 + last = None + while True: + if last: + subscription_list = stripe.Subscription.list( + limit=100, starting_after=last.id + ) + else: + subscription_list = stripe.Subscription.list(limit=100) + total_count += len(subscription_list) + print(f"Current total: {total_count}") + + for subscription in subscription_list: + if not models.User.objects.filter( + stripe_customer_id=subscription.customer + ).exists(): + self.stdout.write( + self.style.NOTICE( + "Customer not found in mataroa " + f"database: {subscription.customer}" + ) + ) + + if not subscription_list.has_more: + break + last = list(reversed(subscription_list))[0] diff --git a/main/management/commands/mailexports.py b/main/management/commands/mailexports.py new file mode 100644 index 0000000000000000000000000000000000000000..de5071b91f2b00e947f4aaa19d8a6ccbffa5a3ae --- /dev/null +++ b/main/management/commands/mailexports.py @@ -0,0 +1,116 @@ +import io +import uuid +import zipfile +from datetime import datetime + +from django.conf import settings +from django.core import mail +from django.core.management.base import BaseCommand +from django.utils import timezone + +from main import models, util + + +def get_mail_connection(): + """Returns the default EmailBackend but instantiated with a custom host.""" + return mail.get_connection( + "django.core.mail.backends.smtp.EmailBackend", + host=settings.EMAIL_HOST_BROADCASTS, + ) + + +def get_unsubscribe_url(user): + return util.get_protocol() + user.get_export_unsubscribe_url() + + +def get_email_body(user): + """ + Returns the email body (which contains the post body) for the automated + export email. + """ + today = datetime.now().date().strftime("%B %d, %Y") + body = f"""Greetings, + +This is the {today} edition of your Mataroa blog export. + +Please find your blog’s zip archive in markdown format attached. + +--- + +Stop receiving exports: +{get_unsubscribe_url(user)} +""" + return body + + +class Command(BaseCommand): + help = "Generate zip account exports and email them to users." + + def handle(self, *args, **options): + if timezone.now().day != 1: + msg = "No action. Not the first day of the month." + self.stdout.write(self.style.NOTICE(msg)) + return + + self.stdout.write(self.style.NOTICE("Processing email exports.")) + + # gather all user posts for all users + users = models.User.objects.filter(mail_export_on=True) + for user in users: + self.stdout.write(self.style.NOTICE(f"Processing user {user.username}.")) + + user_posts = models.Post.objects.filter(owner=user) + export_posts = [] + for p in user_posts: + pub_date = p.published_at or p.created_at + title = p.slug + ".md" + body = ( + f"# {p.title}\n\n" + f"> Published on {pub_date.strftime('%b %-d, %Y')}\n\n" + f"{p.body}\n" + ) + export_posts.append((title, io.BytesIO(body.encode()))) + + # write zip archive in /tmp/ + export_name = "export-markdown-" + str(uuid.uuid4())[:8] + container_dir = f"{user.username}-mataroa-blog" + zip_outfile = f"/tmp/{export_name}.zip" + with zipfile.ZipFile( + zip_outfile, "a", zipfile.ZIP_DEFLATED, False + ) as export_archive: + for file_name, data in export_posts: + export_archive.writestr( + export_name + f"/{container_dir}/" + file_name, data.getvalue() + ) + + # reopen zipfile and load in memory + with open(zip_outfile, "rb") as f: + # create emails + today = datetime.now().date().isoformat() + email = mail.EmailMessage( + subject=f"Mataroa export {today} — {user.username}.{settings.CANONICAL_HOST}", + body=get_email_body(user), + from_email=settings.DEFAULT_FROM_EMAIL, + to=[user.email], + headers={ + "X-PM-Message-Stream": "exports", # postmark-specific header + "List-Unsubscribe": get_unsubscribe_url(user), + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + attachments=[(f"{export_name}.zip", f.read(), "application/zip")], + ) + + # sent out messages + connection = get_mail_connection() + connection.send_messages([email]) + self.stdout.write(self.style.SUCCESS(f"Export sent to {user.username}.")) + + # log export record + name = f"{export_name}.zip" + record = models.ExportRecord.objects.create(name=name, user=user) + self.stdout.write( + self.style.SUCCESS(f"Logging export record for '{record.name}'.") + ) + + # log all users mailing is complete + self.stdout.write(self.style.SUCCESS("Emailing all exports complete.")) diff --git a/main/management/commands/mailsummary.py b/main/management/commands/mailsummary.py new file mode 100644 index 0000000000000000000000000000000000000000..bf11508aa1ca89ce038c47f0ccd14cfbc0623a66 --- /dev/null +++ b/main/management/commands/mailsummary.py @@ -0,0 +1,141 @@ +from datetime import datetime, timedelta + +from django.conf import settings +from django.core import mail +from django.core.management.base import BaseCommand +from django.db.models import Count, Q + +from main import models + + +def build_summary_text(target_date: datetime.date) -> str: + prev_date = target_date - timedelta(days=1) + next_date = target_date + timedelta(days=1) + + new_users_qs = models.User.objects.filter(date_joined__date=target_date).order_by( + "-id" + ) + new_posts_qs = ( + models.Post.objects.filter(created_at__date=target_date) + .select_related("owner") + .order_by("-created_at") + ) + new_pages_qs = ( + models.Page.objects.filter(created_at__date=target_date) + .select_related("owner") + .order_by("-created_at") + ) + new_comments_qs = ( + models.Comment.objects.filter(created_at__date=target_date) + .select_related("post", "post__owner") + .order_by("-created_at") + ) + + post_visits_count = models.AnalyticPost.objects.filter( + created_at__date=target_date + ).count() + top_posts_by_visits_qs = ( + models.Post.objects.filter(analyticpost__created_at__date=target_date) + .annotate( + visit_count=Count( + "analyticpost", filter=Q(analyticpost__created_at__date=target_date) + ) + ) + .select_related("owner") + .order_by("-visit_count", "-id") + ) + + lines: list[str] = [] + lines.append(f"Moderation — Summary {target_date.strftime('%Y-%m-%d')}") + lines.append("") + lines.append("Counts") + lines.append(f"- New users: {new_users_qs.count()}") + lines.append(f"- New posts: {new_posts_qs.count()}") + lines.append(f"- New pages: {new_pages_qs.count()}") + lines.append(f"- New comments: {new_comments_qs.count()}") + lines.append(f"- Post visits: {post_visits_count}") + lines.append("") + + lines.append("Top Posts by Visits") + if top_posts_by_visits_qs.exists(): + for post in top_posts_by_visits_qs[:20]: + lines.append( + f"- {post.title} — {post.visit_count} — {post.owner.username} — {post.get_proper_url}" + ) + else: + lines.append("- None.") + lines.append("") + + lines.append("New Posts") + if new_posts_qs.exists(): + for p in new_posts_qs: + lines.append( + f"- {p.title} by {p.owner.username} ({p.created_at.strftime('%H:%M')}) — {p.get_proper_url}" + ) + else: + lines.append("- None.") + lines.append("") + + lines.append("New Users") + if new_users_qs.exists(): + for u in new_users_qs: + lines.append( + f"- {u.username} ({u.date_joined.strftime('%H:%M')}) — {u.blog_url}" + ) + else: + lines.append("- None.") + lines.append("") + + lines.append("New Pages") + if new_pages_qs.exists(): + for pg in new_pages_qs: + lines.append( + f"- {pg.title} by {pg.owner.username} ({pg.created_at.strftime('%H:%M')}) — {pg.get_absolute_url()}" + ) + else: + lines.append("- None.") + lines.append("") + + lines.append("New Comments") + if new_comments_qs.exists(): + for c in new_comments_qs: + pending_note = " pending" if not c.is_approved else "" + lines.append( + f"- on {c.post.title} by {c.post.owner.username} ({c.created_at.strftime('%H:%M')}){pending_note} — {c.post.get_proper_url}#comment-{c.id}" + ) + else: + lines.append("- None.") + + lines.append("") + lines.append(f"Prev day: {prev_date.strftime('%Y-%m-%d')} | Next day: {next_date.strftime('%Y-%m-%d')}") + + return "\n".join(lines) + + +class Command(BaseCommand): + help = "Email daily moderation summary to admins." + + def handle(self, *args, **options): + # Run for the previous day + target_date = (datetime.utcnow().date()) - timedelta(days=1) + + self.stdout.write(self.style.NOTICE(f"Building summary for {target_date}.")) + body = build_summary_text(target_date) + + subject = f"Mataroa moderation summary {target_date.isoformat()} — {settings.CANONICAL_HOST}" + + to_addresses = [email for _name, email in settings.ADMINS] + if not to_addresses: + self.stdout.write(self.style.ERROR("No admin addresses configured (ADMINS).")) + return + + email = mail.EmailMessage( + subject=subject, + body=body, + from_email=settings.DEFAULT_FROM_EMAIL, + to=to_addresses, + ) + + connection = mail.get_connection() + connection.send_messages([email]) + self.stdout.write(self.style.SUCCESS("Daily moderation summary sent.")) diff --git a/main/management/commands/processnotifications.py b/main/management/commands/processnotifications.py new file mode 100644 index 0000000000000000000000000000000000000000..6af1632db1a3477ccb285a615a71a6e000894a35 --- /dev/null +++ b/main/management/commands/processnotifications.py @@ -0,0 +1,169 @@ +from datetime import timedelta + +from django.conf import settings +from django.core import mail +from django.core.management.base import BaseCommand +from django.utils import timezone + +from main import models, util + + +def get_mail_connection(): + if settings.DEBUG: + return mail.get_connection("django.core.mail.backends.console.EmailBackend") + + # SMPT EmailBackend instantiated with the broadcast-specific email host + return mail.get_connection( + "django.core.mail.backends.smtp.EmailBackend", + host=settings.EMAIL_HOST_BROADCASTS, + ) + + +def get_email_body(post, notification): + """Returns the email body (which contains the post body) along with titles and links.""" + post_url = util.get_protocol() + post.get_proper_url() + unsubscribe_url = util.get_protocol() + notification.get_unsubscribe_url() + blog_title = post.owner.blog_title or post.owner.username + + body = f"""{blog_title} has published: + +# {post.title} + +{post_url} + +{post.body} + +--- + +Blog post URL: +{post_url} + +--- + +Unsubscribe: +{unsubscribe_url} +""" + return body + + +def get_email(post, notification): + """Returns the email object, containing all info needed to be sent.""" + + blog_title = post.owner.username + # email sender name cannot contain commas + if post.owner.blog_title and "," not in post.owner.blog_title: + blog_title = post.owner.blog_title + + unsubscribe_url = util.get_protocol() + notification.get_unsubscribe_url() + body = get_email_body(post, notification) + email = mail.EmailMessage( + subject=post.title, + body=body, + from_email=f"{blog_title} <{post.owner.username}@{settings.EMAIL_FROM_HOST}>", + to=[notification.email], + headers={ + "X-PM-Message-Stream": "newsletters", + "List-Unsubscribe": unsubscribe_url, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + ) + return email + + +class Command(BaseCommand): + help = "Process new posts and send email to subscribers" + + def add_arguments(self, parser): + parser.add_argument( + "--no-dryrun", + action="store_false", + dest="dryrun", + help="No dry run. Send actual emails.", + ) + parser.set_defaults(dryrun=True) + + def handle(self, *args, **options): + self.stdout.write(self.style.NOTICE("Processing notifications.")) + + yesterday = timezone.now().date() - timedelta(days=1) + post_list = models.Post.objects.filter( + owner__notifications_on=True, + broadcasted_at__isnull=True, + published_at=yesterday, + ) + self.stdout.write(self.style.NOTICE(f"Post count to process: {len(post_list)}")) + + count_sent = 0 + connection = get_mail_connection() + + # for all posts that were published yesterday + for post in post_list: + # assume no notification will fail + no_send_failures = True + + notification_list = models.Notification.objects.filter( + blog_user=post.owner, + is_active=True, + ) + msg = ( + f"Subscriber count for: '{post.title}' (author: {post.owner.username})" + f" is {len(notification_list)}." + ) + self.stdout.write(self.style.NOTICE(msg)) + # for every email address subcribed to the post's blog owner + for notification in notification_list: + # don't send if dry run mode + if options["dryrun"]: + msg = f"Would otherwise sent: '{post.title}' for '{notification.email}'." + self.stdout.write(self.style.NOTICE(msg)) + continue + + # log record + record, created = models.NotificationRecord.objects.get_or_create( + notification=notification, + post=post, + ) + # check if this post id has already been sent to this email + # could be because the published_at date has been changed + if created: + # keep count of all emails of this run + count_sent += 1 + + # sent out email + email = get_email(post, notification) + try: + connection.send_messages([email]) + except Exception as ex: + no_send_failures = False + msg = f"Failed to send '{post.title}' to {notification.email}." + self.stdout.write(self.style.ERROR(msg)) + record.delete() + self.stdout.write(self.style.ERROR(ex)) + + msg = f"Email sent for '{post.title}' to '{notification.email}'." + self.stdout.write(self.style.SUCCESS(msg)) + else: + msg = ( + f"No email sent for '{post.title}' to '{notification.email}'. " + f"Email was sent {record.sent_at}" + ) + self.stdout.write(self.style.NOTICE(msg)) + + # broadcast for this post done + if not options["dryrun"] and no_send_failures: + post.broadcasted_at = timezone.now() + post.save() + + # broadcast for all posts done + connection.close() + + # return if send mode is off + if options["dryrun"]: + self.stdout.write( + self.style.SUCCESS("Broadcast dry run done. No emails were sent.") + ) + return + + self.stdout.write( + self.style.SUCCESS(f"Broadcast sent. Total {count_sent} emails.") + ) diff --git a/main/management/commands/testbulkmail.py b/main/management/commands/testbulkmail.py new file mode 100644 index 0000000000000000000000000000000000000000..4e73e4f963b4ddca545554f715f90a68fccd83b8 --- /dev/null +++ b/main/management/commands/testbulkmail.py @@ -0,0 +1,74 @@ +import time + +from django.conf import settings +from django.core import mail +from django.core.management.base import BaseCommand + + +def get_mail_connection(): + return mail.get_connection( + "django.core.mail.backends.smtp.EmailBackend", + host=settings.EMAIL_HOST_BROADCASTS, + ) + + +def get_email_body(): + body = """The need for webrings stemmed during the 90s when there was no +Google and search engines were inefficient in helping people +discover web content. + +The need re-arises in 2020, when search engines are influenced by SEO +techniques and content platforms have become silos. These days, an indie +web revival would be incredible. + +A webring has a specific theme, and the links that comprise it are +curated. Manually curating a webring's content means that it has been +agreed that the website's content is relevant to the webring's theme. +The modern web approach would be to add a neural network to figure out +the website's theme, but that would be totally not fly! +""" + + return body + + +def get_email(address): + email = mail.EmailMessage( + subject="Hey, this is a test", + body=get_email_body(), + from_email=f"Mataroa Test Agency ", + to=[address], + ) + return email + + +class Command(BaseCommand): + help = "Sends a few bulk emails to test email provider." + + def handle(self, *args, **options): + self.stdout.write(self.style.NOTICE("Processing test bulk mails.")) + + if not settings.EMAIL_TEST_RECEIVE_LIST: + self.stdout.write( + self.style.NOTICE("Setting EMAIL_TEST_RECEIVE_LIST not set.") + ) + return + + message_list = set() + for address in settings.EMAIL_TEST_RECEIVE_LIST.split(","): + email = get_email(address) + message_list.add(email) + + msg = f"Logging record for '{address}'." + self.stdout.write(self.style.SUCCESS(msg)) + + # sent out messages + connection = get_mail_connection() + for message in message_list: + connection.send_messages([message]) + time.sleep(0.1) + + self.stdout.write( + self.style.SUCCESS( + f"Test broadcast sent. Total {len(message_list)} emails." + ) + ) diff --git a/main/middleware.py b/main/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..4967d85020397abea90fa090c1e038c8631527be --- /dev/null +++ b/main/middleware.py @@ -0,0 +1,119 @@ +from timeit import default_timer as timer + +from django.conf import settings +from django.http import Http404, HttpResponseBadRequest +from django.shortcuts import redirect + +from main import denylist, models, util + + +def host_middleware(get_response): + def middleware(request): + host = request.META.get("HTTP_HOST") + + # no http Host header in testing + if not host: + return get_response(request) + + host_parts = host.split(".") + canonical_parts = settings.CANONICAL_HOST.split(".") + + if host == settings.CANONICAL_HOST: + # this case is for mataroa.blog landing and dashboard pages + # * don't set request.subdomain + # * set theme if logged in + # * return immediately + if request.user.is_authenticated: + request.theme_zialucia = request.user.theme_zialucia + request.theme_sansserif = request.user.theme_sansserif + return get_response(request) + elif ( + len(host_parts) == 4 + and host_parts[1] == canonical_parts[0] # should be "bocpress" + and host_parts[2] == canonical_parts[1] # should be "co" + and host_parts[3] == canonical_parts[2] # should be "uk" + ): + # this case is for .bocpress.co.uk: + # * set subdomain to given subdomain + # * the lists indexes are different because CANONICAL_HOST has no subdomain + # * also validation will happen inside views + request.subdomain = host_parts[0] + + # check if subdomain is disallowed + if request.subdomain in denylist.DISALLOWED_USERNAMES: + return redirect(f"{util.get_protocol()}//{settings.CANONICAL_HOST}") + # check if subdomain exists as blog + elif models.User.objects.filter(username=request.subdomain).exists(): + request.blog_user = models.User.objects.get(username=request.subdomain) + + # set theme + request.theme_zialucia = request.blog_user.theme_zialucia + request.theme_sansserif = request.blog_user.theme_sansserif + + # redirect to custom and/or retired urls for cases: + # * logged out / anon users + # * logged in but on other user's subdomain + if not request.user.is_authenticated or ( + request.user.is_authenticated + and request.user.username != request.subdomain + ): + redir_domain = "" + print(request.blog_user.custom_domain) + if request.blog_user.custom_domain: # user has set custom domain + redir_domain = ( + request.blog_user.custom_domain + request.path_info + ) + + # user has retired their mataroa blog, redirect to new domain + if request.blog_user.redirect_domain: + redir_domain = ( + request.blog_user.redirect_domain + request.path_info[5:] + ) + + # if there is no protocol prefix, + # prepend double slashes to indicate other domain + if redir_domain and "://" not in redir_domain: + redir_domain = "//" + redir_domain + + if redir_domain: + return redirect(redir_domain) + else: + raise Http404() + elif models.User.objects.filter(custom_domain=host).exists(): + # custom domain case + request.blog_user = models.User.objects.get(custom_domain=host) + request.subdomain = request.blog_user.username + request.theme_zialucia = request.blog_user.theme_zialucia + request.theme_sansserif = request.blog_user.theme_sansserif + + # if user has retired their mataroa blog (and keeps the custom domain) + # redirect to new domain + if request.blog_user.redirect_domain: + redir_domain = request.blog_user.redirect_domain + request.path_info[5:] + + # if there is no protocol prefix, + # prepend double slashes to indicate other domain + if "://" not in redir_domain: + redir_domain = "//" + redir_domain + + return redirect(redir_domain) + + else: + return HttpResponseBadRequest() + + return get_response(request) + + return middleware + + +def speed_middleware(get_response): + def middleware(request): + request.start = timer() + + response = get_response(request) + + end = timer() + response["X-Request-Time"] = end - request.start + return response + + return middleware diff --git a/main/migrations/0001_initial.py b/main/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..d01a172802e3b128353fb21b497a05ceb45d01e3 --- /dev/null +++ b/main/migrations/0001_initial.py @@ -0,0 +1,130 @@ +# Generated by Django 3.0.6 on 2020-05-27 20:53 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0011_update_proxy_permissions"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=30, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ("about", models.TextField(blank=True, null=True)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[("objects", django.contrib.auth.models.UserManager())], + ), + ] diff --git a/main/migrations/0002_user_blog_title.py b/main/migrations/0002_user_blog_title.py new file mode 100644 index 0000000000000000000000000000000000000000..121b2173b32e53f0aee5da1c20ff89218a93dd59 --- /dev/null +++ b/main/migrations/0002_user_blog_title.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.6 on 2020-05-27 21:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="blog_title", + field=models.CharField(default="Default Title", max_length=500), + preserve_default=False, + ), + ] diff --git a/main/migrations/0003_post.py b/main/migrations/0003_post.py new file mode 100644 index 0000000000000000000000000000000000000000..10dccb7f2e3ff56fe407e8c92d2a50344914805c --- /dev/null +++ b/main/migrations/0003_post.py @@ -0,0 +1,41 @@ +# Generated by Django 3.0.6 on 2020-05-27 21:54 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0002_user_blog_title"), + ] + + operations = [ + migrations.CreateModel( + name="Post", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=300)), + ("slug", models.CharField(max_length=300)), + ("body", models.TextField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "owner", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/main/migrations/0004_auto_20200530_0046.py b/main/migrations/0004_auto_20200530_0046.py new file mode 100644 index 0000000000000000000000000000000000000000..c802cc65501fd31a31289c1d351a7d2da3d9ff49 --- /dev/null +++ b/main/migrations/0004_auto_20200530_0046.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.6 on 2020-05-30 00:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0003_post"), + ] + + operations = [ + migrations.AlterField( + model_name="post", + name="owner", + field=models.ForeignKey( + default=1, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + preserve_default=False, + ), + ] diff --git a/main/migrations/0005_auto_20200530_1206.py b/main/migrations/0005_auto_20200530_1206.py new file mode 100644 index 0000000000000000000000000000000000000000..8d6fffa5a10001a5e2ae224a5a0af98ef5469637 --- /dev/null +++ b/main/migrations/0005_auto_20200530_1206.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.6 on 2020-05-30 12:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0004_auto_20200530_0046"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="post", + unique_together={("slug", "owner")}, + ), + ] diff --git a/main/migrations/0006_auto_20200531_1619.py b/main/migrations/0006_auto_20200531_1619.py new file mode 100644 index 0000000000000000000000000000000000000000..8ef46ae7fe1a795c0b727dde4a143299f3565210 --- /dev/null +++ b/main/migrations/0006_auto_20200531_1619.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.6 on 2020-05-31 16:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0005_auto_20200530_1206"), + ] + + operations = [ + migrations.AlterModelOptions( + name="post", + options={"ordering": ["-created_at"]}, + ), + ] diff --git a/main/migrations/0007_user_blog_byline.py b/main/migrations/0007_user_blog_byline.py new file mode 100644 index 0000000000000000000000000000000000000000..66c3a1cd98e0232ae77085b645a22e6c47a086e2 --- /dev/null +++ b/main/migrations/0007_user_blog_byline.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.6 on 2020-06-01 19:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0006_auto_20200531_1619"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="blog_byline", + field=models.CharField(blank=True, max_length=500, null=True), + ), + ] diff --git a/main/migrations/0008_auto_20200601_2326.py b/main/migrations/0008_auto_20200601_2326.py new file mode 100644 index 0000000000000000000000000000000000000000..70464d2f44938243b431305528f7973735af975c --- /dev/null +++ b/main/migrations/0008_auto_20200601_2326.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.6 on 2020-06-01 23:26 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0007_user_blog_byline"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="username", + field=models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="This will be your subdomain. Lowercase alphanumeric.", + max_length=150, + unique=True, + validators=[main.validators.AlphanumericHyphenValidator()], + ), + ), + ] diff --git a/main/migrations/0009_auto_20200604_2327.py b/main/migrations/0009_auto_20200604_2327.py new file mode 100644 index 0000000000000000000000000000000000000000..76adc0fe1be8b67eab641086ad77aea80ae5d57e --- /dev/null +++ b/main/migrations/0009_auto_20200604_2327.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.6 on 2020-06-04 23:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0008_auto_20200601_2326"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="blog_title", + field=models.CharField(blank=True, max_length=500, null=True), + ), + ] diff --git a/main/migrations/0010_user_cname.py b/main/migrations/0010_user_cname.py new file mode 100644 index 0000000000000000000000000000000000000000..d6bde547760bfc7c75890ded83377df8d23ace3f --- /dev/null +++ b/main/migrations/0010_user_cname.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-06-06 10:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0009_auto_20200604_2327"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="cname", + field=models.URLField(blank=True, null=True), + ), + ] diff --git a/main/migrations/0011_auto_20200606_1903.py b/main/migrations/0011_auto_20200606_1903.py new file mode 100644 index 0000000000000000000000000000000000000000..ca03c561d9d96988bc7d597d9dbbc0ca01c068db --- /dev/null +++ b/main/migrations/0011_auto_20200606_1903.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-06-06 19:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0010_user_cname"), + ] + + operations = [ + migrations.RenameField( + model_name="user", + old_name="cname", + new_name="custom_domain", + ), + ] diff --git a/main/migrations/0012_auto_20200606_1903.py b/main/migrations/0012_auto_20200606_1903.py new file mode 100644 index 0000000000000000000000000000000000000000..5a07c6ecab626d55652684ab61b33343abbc40b0 --- /dev/null +++ b/main/migrations/0012_auto_20200606_1903.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-06-06 19:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0011_auto_20200606_1903"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="custom_domain", + field=models.CharField(blank=True, max_length=150, null=True), + ), + ] diff --git a/main/migrations/0013_auto_20200606_2048.py b/main/migrations/0013_auto_20200606_2048.py new file mode 100644 index 0000000000000000000000000000000000000000..5294f780e45e33bb750c0acee32dea1a45135afa --- /dev/null +++ b/main/migrations/0013_auto_20200606_2048.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.7 on 2020-06-06 20:48 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0012_auto_20200606_1903"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="custom_domain", + field=models.CharField( + blank=True, + help_text="DNS: CNAME your .mataroa.blog subdomain or A with IP 95.217.176.64", + max_length=150, + null=True, + validators=[main.validators.validate_domain_name], + ), + ), + ] diff --git a/main/migrations/0014_auto_20200607_0017.py b/main/migrations/0014_auto_20200607_0017.py new file mode 100644 index 0000000000000000000000000000000000000000..8b0e596fcf156db8e8e5a13ba7cee9a9da29a2ed --- /dev/null +++ b/main/migrations/0014_auto_20200607_0017.py @@ -0,0 +1,35 @@ +# Generated by Django 3.0.7 on 2020-06-07 00:17 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0013_auto_20200606_2048"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="custom_domain_cert", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="user", + name="custom_domain_key", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="user", + name="custom_domain", + field=models.CharField( + help_text="DNS: CNAME your .mataroa.blog subdomain or A with IP 95.217.176.64", + max_length=150, + null=True, + unique=True, + validators=[main.validators.validate_domain_name], + ), + ), + ] diff --git a/main/migrations/0015_auto_20200607_0023.py b/main/migrations/0015_auto_20200607_0023.py new file mode 100644 index 0000000000000000000000000000000000000000..47c601123b7a185f1878d1415bc426afd8a21cac --- /dev/null +++ b/main/migrations/0015_auto_20200607_0023.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.7 on 2020-06-07 00:23 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0014_auto_20200607_0017"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="custom_domain", + field=models.CharField( + help_text="DNS: CNAME your .mataroa.blog subdomain or A with IP 95.217.176.64", + max_length=150, + null=True, + validators=[main.validators.validate_domain_name], + ), + ), + ] diff --git a/main/migrations/0016_auto_20200607_0024.py b/main/migrations/0016_auto_20200607_0024.py new file mode 100644 index 0000000000000000000000000000000000000000..06b0fec4652bdd328ebeddbc49c32b5d8e3f350d --- /dev/null +++ b/main/migrations/0016_auto_20200607_0024.py @@ -0,0 +1,25 @@ +# Generated by Django 3.0.7 on 2020-06-07 00:24 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0015_auto_20200607_0023"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="custom_domain", + field=models.CharField( + blank=True, + help_text="DNS: CNAME your .mataroa.blog subdomain or A with IP 95.217.176.64", + max_length=150, + null=True, + validators=[main.validators.validate_domain_name], + ), + ), + ] diff --git a/main/migrations/0017_post_published_at.py b/main/migrations/0017_post_published_at.py new file mode 100644 index 0000000000000000000000000000000000000000..602d12eaecaa8a60864936ad95b78ec7b8242532 --- /dev/null +++ b/main/migrations/0017_post_published_at.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2020-06-07 20:48 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0016_auto_20200607_0024"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="published_at", + field=models.DateTimeField( + blank=True, + default=django.utils.timezone.now, + help_text="Leave blank to keep as draft/unpublished", + null=True, + ), + ), + ] diff --git a/main/migrations/0018_auto_20200607_2049.py b/main/migrations/0018_auto_20200607_2049.py new file mode 100644 index 0000000000000000000000000000000000000000..3519e4e70015b15cfbefef62425ef9b2c36c3cf3 --- /dev/null +++ b/main/migrations/0018_auto_20200607_2049.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2020-06-07 20:49 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0017_post_published_at"), + ] + + operations = [ + migrations.AlterField( + model_name="post", + name="published_at", + field=models.DateField( + blank=True, + default=django.utils.timezone.now, + help_text="Leave blank to keep as draft/unpublished", + null=True, + ), + ), + ] diff --git a/main/migrations/0019_auto_20200607_2131.py b/main/migrations/0019_auto_20200607_2131.py new file mode 100644 index 0000000000000000000000000000000000000000..407b05c24b8d72ca0dc1cd30d25c8ca8ca8562f6 --- /dev/null +++ b/main/migrations/0019_auto_20200607_2131.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.7 on 2020-06-07 21:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0018_auto_20200607_2049"), + ] + + operations = [ + migrations.AlterModelOptions( + name="post", + options={"ordering": ["-published_at", "-created_at"]}, + ), + ] diff --git a/main/migrations/0020_auto_20200608_1915.py b/main/migrations/0020_auto_20200608_1915.py new file mode 100644 index 0000000000000000000000000000000000000000..c9f563086c0885a3e182fcabb2deb32647f4e44e --- /dev/null +++ b/main/migrations/0020_auto_20200608_1915.py @@ -0,0 +1,40 @@ +# Generated by Django 3.0.7 on 2020-06-08 19:15 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0019_auto_20200607_2131"), + ] + + operations = [ + migrations.CreateModel( + name="Image", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=300)), + ("data", models.BinaryField()), + ("extension", models.CharField(max_length=10)), + ("uploaded_at", models.DateTimeField(auto_now_add=True)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/main/migrations/0021_image_slug.py b/main/migrations/0021_image_slug.py new file mode 100644 index 0000000000000000000000000000000000000000..85035b9902e0acd4d3a41818e05691d72075c436 --- /dev/null +++ b/main/migrations/0021_image_slug.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.7 on 2020-06-08 21:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0020_auto_20200608_1915"), + ] + + operations = [ + migrations.AddField( + model_name="image", + name="slug", + field=models.CharField(default="default-slug", max_length=300), + preserve_default=False, + ), + ] diff --git a/main/migrations/0022_auto_20200608_2117.py b/main/migrations/0022_auto_20200608_2117.py new file mode 100644 index 0000000000000000000000000000000000000000..3efe4a261986f485d2d635fa741fd632d11143f1 --- /dev/null +++ b/main/migrations/0022_auto_20200608_2117.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-06-08 21:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0021_image_slug"), + ] + + operations = [ + migrations.AlterField( + model_name="image", + name="slug", + field=models.CharField(max_length=300, unique=True), + ), + ] diff --git a/main/migrations/0023_auto_20200608_2141.py b/main/migrations/0023_auto_20200608_2141.py new file mode 100644 index 0000000000000000000000000000000000000000..a0bb4836b9f1ae47074dcec76e91657a59430084 --- /dev/null +++ b/main/migrations/0023_auto_20200608_2141.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.7 on 2020-06-08 21:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0022_auto_20200608_2117"), + ] + + operations = [ + migrations.AlterModelOptions( + name="image", + options={"ordering": ["-uploaded_at"]}, + ), + ] diff --git a/main/migrations/0024_auto_20200608_2304.py b/main/migrations/0024_auto_20200608_2304.py new file mode 100644 index 0000000000000000000000000000000000000000..709bfccad7dfe0683d2280b5d39cd92097d62500 --- /dev/null +++ b/main/migrations/0024_auto_20200608_2304.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2020-06-08 23:04 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0023_auto_20200608_2141"), + ] + + operations = [ + migrations.AlterField( + model_name="post", + name="published_at", + field=models.DateField( + blank=True, + default=django.utils.timezone.now, + help_text="Leave blank to keep as draft/unpublished. Use a future date for auto-posting.", + null=True, + ), + ), + ] diff --git a/main/migrations/0025_page.py b/main/migrations/0025_page.py new file mode 100644 index 0000000000000000000000000000000000000000..a1645479ef3146bc60192eb34a6c53d1f258c378 --- /dev/null +++ b/main/migrations/0025_page.py @@ -0,0 +1,42 @@ +# Generated by Django 3.0.7 on 2020-06-10 16:40 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0024_auto_20200608_2304"), + ] + + operations = [ + migrations.CreateModel( + name="Page", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=300)), + ("slug", models.CharField(max_length=300)), + ("body", models.TextField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("is_hidden", models.BooleanField(default=False)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"ordering": ["slug"], "unique_together": {("slug", "owner")}}, + ), + ] diff --git a/main/migrations/0026_auto_20200610_1854.py b/main/migrations/0026_auto_20200610_1854.py new file mode 100644 index 0000000000000000000000000000000000000000..1696e277ea18896d316e9651c6b03b378809ff48 --- /dev/null +++ b/main/migrations/0026_auto_20200610_1854.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2020-06-10 18:54 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0025_page"), + ] + + operations = [ + migrations.AlterField( + model_name="page", + name="slug", + field=models.CharField( + help_text="Alphanumeric with dashes.", + max_length=300, + validators=[main.validators.AlphanumericHyphenValidator()], + ), + ), + ] diff --git a/main/migrations/0027_auto_20200610_1904.py b/main/migrations/0027_auto_20200610_1904.py new file mode 100644 index 0000000000000000000000000000000000000000..6de33c43a9edd774bd445d6be010bf45c3c72e3b --- /dev/null +++ b/main/migrations/0027_auto_20200610_1904.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.7 on 2020-06-10 19:04 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0026_auto_20200610_1854"), + ] + + operations = [ + migrations.AlterField( + model_name="page", + name="slug", + field=models.CharField( + help_text="Lowercase letters, numbers, and - (hyphen) allowed.", + max_length=300, + validators=[main.validators.AlphanumericHyphenValidator()], + ), + ), + ] diff --git a/main/migrations/0028_auto_20200610_2248.py b/main/migrations/0028_auto_20200610_2248.py new file mode 100644 index 0000000000000000000000000000000000000000..6f8d1989b88b547b142242582cca769a7e8e53b7 --- /dev/null +++ b/main/migrations/0028_auto_20200610_2248.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.7 on 2020-06-10 22:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0027_auto_20200610_1904"), + ] + + operations = [ + migrations.AlterField( + model_name="page", + name="is_hidden", + field=models.BooleanField( + default=False, + help_text="If checked, page link will not appear on index footer.", + ), + ), + ] diff --git a/main/migrations/0029_user_footer_note.py b/main/migrations/0029_user_footer_note.py new file mode 100644 index 0000000000000000000000000000000000000000..370ab5df6fbe3c506bb274832f868c6730be4e03 --- /dev/null +++ b/main/migrations/0029_user_footer_note.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-06-13 17:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0028_auto_20200610_2248"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="footer_note", + field=models.TextField(blank=True, default=None, null=True), + ), + ] diff --git a/main/migrations/0030_auto_20200613_1726.py b/main/migrations/0030_auto_20200613_1726.py new file mode 100644 index 0000000000000000000000000000000000000000..f845343ee4494998e0a8fa0d0379a333722de434 --- /dev/null +++ b/main/migrations/0030_auto_20200613_1726.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-06-13 17:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0029_user_footer_note"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="footer_note", + field=models.CharField(blank=True, default=None, max_length=500, null=True), + ), + ] diff --git a/main/migrations/0031_auto_20200620_1344.py b/main/migrations/0031_auto_20200620_1344.py new file mode 100644 index 0000000000000000000000000000000000000000..c427ec45ccf131d22e4a44e786e45ac6b0412877 --- /dev/null +++ b/main/migrations/0031_auto_20200620_1344.py @@ -0,0 +1,47 @@ +# Generated by Django 3.0.7 on 2020-06-20 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0030_auto_20200613_1726"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="webring_name", + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AddField( + model_name="user", + name="webring_next_url", + field=models.URLField( + blank=True, + help_text="URL to get webring's next website.", + null=True, + verbose_name="Webring next URL", + ), + ), + migrations.AddField( + model_name="user", + name="webring_prev_url", + field=models.URLField( + blank=True, + help_text="URL to get webring's previous website.", + null=True, + verbose_name="Webring previous URL", + ), + ), + migrations.AddField( + model_name="user", + name="webring_url", + field=models.URLField( + blank=True, + help_text="Informational URL.", + null=True, + verbose_name="Webring info URL", + ), + ), + ] diff --git a/main/migrations/0032_auto_20200620_1431.py b/main/migrations/0032_auto_20200620_1431.py new file mode 100644 index 0000000000000000000000000000000000000000..93ad7d8587e037164532922605fc7551136a9bea --- /dev/null +++ b/main/migrations/0032_auto_20200620_1431.py @@ -0,0 +1,32 @@ +# Generated by Django 3.0.7 on 2020-06-20 14:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0031_auto_20200620_1344"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="webring_next_url", + field=models.URLField( + blank=True, + help_text="URL for your webring's next website.", + null=True, + verbose_name="Webring next URL", + ), + ), + migrations.AlterField( + model_name="user", + name="webring_prev_url", + field=models.URLField( + blank=True, + help_text="URL for your webring's previous website.", + null=True, + verbose_name="Webring previous URL", + ), + ), + ] diff --git a/main/migrations/0033_auto_20200626_1947.py b/main/migrations/0033_auto_20200626_1947.py new file mode 100644 index 0000000000000000000000000000000000000000..46ee861c4839ee95ef363d17e83c99a90f2d4a34 --- /dev/null +++ b/main/migrations/0033_auto_20200626_1947.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.7 on 2020-06-26 19:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0032_auto_20200620_1431"), + ] + + operations = [ + migrations.AlterModelOptions( + name="user", + options={"ordering": ["-id"]}, + ), + migrations.AlterField( + model_name="user", + name="footer_note", + field=models.CharField( + blank=True, + default=None, + help_text="Supports markdown", + max_length=500, + null=True, + ), + ), + ] diff --git a/main/migrations/0034_analytic.py b/main/migrations/0034_analytic.py new file mode 100644 index 0000000000000000000000000000000000000000..ac6721a074372b10b3711630496464721dffe10e --- /dev/null +++ b/main/migrations/0034_analytic.py @@ -0,0 +1,34 @@ +# Generated by Django 3.0.8 on 2020-07-19 20:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0033_auto_20200626_1947"), + ] + + operations = [ + migrations.CreateModel( + name="Analytic", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "post", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="main.Post" + ), + ), + ], + ), + ] diff --git a/main/migrations/0035_auto_20200812_2020.py b/main/migrations/0035_auto_20200812_2020.py new file mode 100644 index 0000000000000000000000000000000000000000..2351a145d082c872ae81d119edafdfe16d2a96c8 --- /dev/null +++ b/main/migrations/0035_auto_20200812_2020.py @@ -0,0 +1,47 @@ +# Generated by Django 3.0.8 on 2020-08-12 20:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0034_analytic"), + ] + + operations = [ + migrations.AlterModelOptions( + name="analytic", + options={"ordering": ["-created_at"]}, + ), + migrations.AddField( + model_name="user", + name="comments_on", + field=models.BooleanField( + default=False, help_text="Enable/disable comments for your blog" + ), + ), + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("body", models.TextField()), + ( + "post", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="main.Post" + ), + ), + ], + options={"ordering": ["-created_at"]}, + ), + ] diff --git a/main/migrations/0036_auto_20200812_2102.py b/main/migrations/0036_auto_20200812_2102.py new file mode 100644 index 0000000000000000000000000000000000000000..81da95f467b0ba774abb01be321d0311c2e5c6f6 --- /dev/null +++ b/main/migrations/0036_auto_20200812_2102.py @@ -0,0 +1,22 @@ +# Generated by Django 3.0.8 on 2020-08-12 21:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0035_auto_20200812_2020"), + ] + + operations = [ + migrations.AddField( + model_name="comment", + name="email", + field=models.EmailField(max_length=254, null=True), + ), + migrations.AddField( + model_name="comment", + name="name", + field=models.CharField(default="Anonymous", max_length=150, null=True), + ), + ] diff --git a/main/migrations/0037_auto_20200812_2126.py b/main/migrations/0037_auto_20200812_2126.py new file mode 100644 index 0000000000000000000000000000000000000000..2e6372623848acb6886fedb75730221c56bb1e5f --- /dev/null +++ b/main/migrations/0037_auto_20200812_2126.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.8 on 2020-08-12 21:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0036_auto_20200812_2102"), + ] + + operations = [ + migrations.AlterField( + model_name="comment", + name="email", + field=models.EmailField(blank=True, max_length=254, null=True), + ), + migrations.AlterField( + model_name="comment", + name="name", + field=models.CharField( + blank=True, default="Anonymous", max_length=150, null=True + ), + ), + ] diff --git a/main/migrations/0038_auto_20200812_2152.py b/main/migrations/0038_auto_20200812_2152.py new file mode 100644 index 0000000000000000000000000000000000000000..09e136a1e64fb5f71fb1a56158ae8170b0babfeb --- /dev/null +++ b/main/migrations/0038_auto_20200812_2152.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.8 on 2020-08-12 21:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0037_auto_20200812_2126"), + ] + + operations = [ + migrations.AlterModelOptions( + name="comment", + options={"ordering": ["created_at"]}, + ), + ] diff --git a/main/migrations/0039_auto_20200816_1543.py b/main/migrations/0039_auto_20200816_1543.py new file mode 100644 index 0000000000000000000000000000000000000000..f513a9b86f083618db4777f62ad828b11f78b6ab --- /dev/null +++ b/main/migrations/0039_auto_20200816_1543.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1 on 2020-08-16 15:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0038_auto_20200812_2152"), + ] + + operations = [ + migrations.AddField( + model_name="analytic", + name="referer", + field=models.URLField(blank=True, null=True), + ), + migrations.AlterField( + model_name="user", + name="first_name", + field=models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ] diff --git a/main/migrations/0040_postnotification_postnotificationrecord.py b/main/migrations/0040_postnotification_postnotificationrecord.py new file mode 100644 index 0000000000000000000000000000000000000000..96f0f6114b257bba0a7fee4eea1f4e81a7a40169 --- /dev/null +++ b/main/migrations/0040_postnotification_postnotificationrecord.py @@ -0,0 +1,66 @@ +# Generated by Django 3.1 on 2020-08-20 19:56 + +import uuid + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0039_auto_20200816_1543"), + ] + + operations = [ + migrations.CreateModel( + name="PostNotification", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("email", models.EmailField(max_length=254)), + ("unsubscribe_key", models.UUIDField(default=uuid.uuid4)), + ( + "blog_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"ordering": ["email"]}, + ), + migrations.CreateModel( + name="PostNotificationRecord", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("sent_at", models.DateTimeField(default=django.utils.timezone.now)), + ( + "post_notification", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="main.postnotification", + ), + ), + ], + options={"ordering": ["-sent_at"]}, + ), + ] diff --git a/main/migrations/0041_auto_20200820_2107.py b/main/migrations/0041_auto_20200820_2107.py new file mode 100644 index 0000000000000000000000000000000000000000..f39221f0ffcd23bff420b8e8943bf3137e426020 --- /dev/null +++ b/main/migrations/0041_auto_20200820_2107.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1 on 2020-08-20 21:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0040_postnotification_postnotificationrecord"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="postnotification", + unique_together={("email", "blog_user")}, + ), + ] diff --git a/main/migrations/0042_auto_20200821_1342.py b/main/migrations/0042_auto_20200821_1342.py new file mode 100644 index 0000000000000000000000000000000000000000..ddb5570bb89ebb6b6797af71a5c761371489be89 --- /dev/null +++ b/main/migrations/0042_auto_20200821_1342.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1 on 2020-08-21 13:42 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0041_auto_20200820_2107"), + ] + + operations = [ + migrations.AlterField( + model_name="postnotification", + name="unsubscribe_key", + field=models.UUIDField(default=uuid.uuid4, unique=True), + ), + ] diff --git a/main/migrations/0043_user_notifications_on.py b/main/migrations/0043_user_notifications_on.py new file mode 100644 index 0000000000000000000000000000000000000000..3a6adb1d0acf83528054222bbcd284f9811ee463 --- /dev/null +++ b/main/migrations/0043_user_notifications_on.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2020-08-21 15:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0042_auto_20200821_1342"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="notifications_on", + field=models.BooleanField( + default=False, + help_text="Allow/disallow people subscribing for new posts notifications", + ), + ), + ] diff --git a/main/migrations/0044_postnotificationrecord_post.py b/main/migrations/0044_postnotificationrecord_post.py new file mode 100644 index 0000000000000000000000000000000000000000..9ed501a2d4b98efd35cf5e9ac684ccd6cb3c7229 --- /dev/null +++ b/main/migrations/0044_postnotificationrecord_post.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2020-08-21 16:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0043_user_notifications_on"), + ] + + operations = [ + migrations.AddField( + model_name="postnotificationrecord", + name="post", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to="main.post" + ), + ), + ] diff --git a/main/migrations/0045_auto_20200821_1659.py b/main/migrations/0045_auto_20200821_1659.py new file mode 100644 index 0000000000000000000000000000000000000000..66baaba1c6bce6f1bc145de65b0693245a5930af --- /dev/null +++ b/main/migrations/0045_auto_20200821_1659.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1 on 2020-08-21 16:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0044_postnotificationrecord_post"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="postnotificationrecord", + unique_together={("post", "post_notification")}, + ), + ] diff --git a/main/migrations/0046_auto_20200821_1820.py b/main/migrations/0046_auto_20200821_1820.py new file mode 100644 index 0000000000000000000000000000000000000000..1dfe25a7105e43e2511402ca98595526fb7906ea --- /dev/null +++ b/main/migrations/0046_auto_20200821_1820.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-08-21 18:20 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0045_auto_20200821_1659"), + ] + + operations = [ + migrations.AlterField( + model_name="postnotificationrecord", + name="sent_at", + field=models.DateTimeField(default=django.utils.timezone.now, null=True), + ), + ] diff --git a/main/migrations/0047_auto_20200830_1057.py b/main/migrations/0047_auto_20200830_1057.py new file mode 100644 index 0000000000000000000000000000000000000000..35bd56547b19902ea2edd40b47d305886a98847c --- /dev/null +++ b/main/migrations/0047_auto_20200830_1057.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2020-08-30 10:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0046_auto_20200821_1820"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="custom_domain_cert", + ), + migrations.RemoveField( + model_name="user", + name="custom_domain_key", + ), + ] diff --git a/main/migrations/0048_auto_20201218_1351.py b/main/migrations/0048_auto_20201218_1351.py new file mode 100644 index 0000000000000000000000000000000000000000..59544aa8f1f8d5be91888b14dccf4d95ba85593e --- /dev/null +++ b/main/migrations/0048_auto_20201218_1351.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1 on 2020-12-18 13:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0047_auto_20200830_1057"), + ] + + operations = [ + migrations.AlterField( + model_name="page", + name="is_hidden", + field=models.BooleanField( + default=False, + help_text="If checked, page link will not appear on the blog footer.", + ), + ), + ] diff --git a/main/migrations/0049_user_redirect_domain.py b/main/migrations/0049_user_redirect_domain.py new file mode 100644 index 0000000000000000000000000000000000000000..d19df95dccfbed01993b1dd992da9bdc394c213e --- /dev/null +++ b/main/migrations/0049_user_redirect_domain.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1 on 2020-12-28 15:35 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0048_auto_20201218_1351"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="redirect_domain", + field=models.CharField( + blank=True, + help_text="Retiring your mataroa blog? We can redirect to your domain.", + max_length=150, + null=True, + validators=[main.validators.validate_domain_name], + ), + ), + ] diff --git a/main/migrations/0050_auto_20210101_1509.py b/main/migrations/0050_auto_20210101_1509.py new file mode 100644 index 0000000000000000000000000000000000000000..03f173ebcbcf8182bb5e341f4bdfc93292f72b1e --- /dev/null +++ b/main/migrations/0050_auto_20210101_1509.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1 on 2021-01-01 15:09 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0049_user_redirect_domain"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="redirect_domain", + field=models.CharField( + blank=True, + help_text="Retiring your mataroa blog? We can redirect to your new domain.", + max_length=150, + null=True, + validators=[main.validators.validate_domain_name], + ), + ), + ] diff --git a/main/migrations/0051_auto_20210111_2111.py b/main/migrations/0051_auto_20210111_2111.py new file mode 100644 index 0000000000000000000000000000000000000000..617899eb2cc43f2018757f0a49069bf1a95fc667 --- /dev/null +++ b/main/migrations/0051_auto_20210111_2111.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1 on 2021-01-11 21:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0050_auto_20210101_1509"), + ] + + operations = [ + migrations.RenameModel( + old_name="PostNotification", + new_name="Notification", + ), + migrations.RenameModel( + old_name="PostNotificationRecord", + new_name="NotificationRecord", + ), + migrations.RenameField( + model_name="notificationrecord", + old_name="post_notification", + new_name="notification", + ), + migrations.AlterUniqueTogether( + name="notificationrecord", + unique_together={("post", "notification")}, + ), + ] diff --git a/main/migrations/0052_auto_20210124_1932.py b/main/migrations/0052_auto_20210124_1932.py new file mode 100644 index 0000000000000000000000000000000000000000..ff4f44bc47a6a454e5677c534ee3fff8d100ac94 --- /dev/null +++ b/main/migrations/0052_auto_20210124_1932.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1 on 2021-01-24 19:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0051_auto_20210111_2111"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="comments_on", + field=models.BooleanField( + default=False, + help_text="Enable/disable comments for your blog", + verbose_name="Comments", + ), + ), + migrations.AlterField( + model_name="user", + name="notifications_on", + field=models.BooleanField( + default=False, + help_text="Allow/disallow people subscribing for email newsletter for new posts", + verbose_name="Newsletter", + ), + ), + ] diff --git a/main/migrations/0053_notification_is_active.py b/main/migrations/0053_notification_is_active.py new file mode 100644 index 0000000000000000000000000000000000000000..df0f5640fc843751135708614936c46c13782c94 --- /dev/null +++ b/main/migrations/0053_notification_is_active.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1 on 2021-02-19 14:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0052_auto_20210124_1932"), + ] + + operations = [ + migrations.AddField( + model_name="notification", + name="is_active", + field=models.BooleanField(default=True), + ), + ] diff --git a/main/migrations/0054_auto_20210312_1643.py b/main/migrations/0054_auto_20210312_1643.py new file mode 100644 index 0000000000000000000000000000000000000000..c0f7b08961bf0e352673bde15f2b1358559ddcec --- /dev/null +++ b/main/migrations/0054_auto_20210312_1643.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1 on 2021-03-12 16:43 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0053_notification_is_active"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="custom_domain", + field=models.CharField( + blank=True, + help_text="To setup: Add an A record in your domain's DNS with IP 95.217.177.163", + max_length=150, + null=True, + validators=[main.validators.validate_domain_name], + ), + ), + ] diff --git a/main/migrations/0055_user_theme_zialucia.py b/main/migrations/0055_user_theme_zialucia.py new file mode 100644 index 0000000000000000000000000000000000000000..8a402854bc5141201c97902e3de3506f6e285ee1 --- /dev/null +++ b/main/migrations/0055_user_theme_zialucia.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.7 on 2021-03-16 19:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0054_auto_20210312_1643"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="theme_zialucia", + field=models.BooleanField( + default=False, + help_text="Enable/disable Zia Lucia theme with larger serif font.", + verbose_name="Theme Zia Lucia", + ), + ), + ] diff --git a/main/migrations/0056_auto_20210317_2313.py b/main/migrations/0056_auto_20210317_2313.py new file mode 100644 index 0000000000000000000000000000000000000000..5684435e93befc3767a7aeab2ed2dc002f77079b --- /dev/null +++ b/main/migrations/0056_auto_20210317_2313.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.7 on 2021-03-17 23:13 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0055_user_theme_zialucia"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="custom_domain", + field=models.CharField( + blank=True, + help_text="To setup: Add an A record in your domain's DNS with IP 95.217.30.133", + max_length=150, + null=True, + validators=[main.validators.validate_domain_name], + ), + ), + ] diff --git a/main/migrations/0057_auto_20210317_2321.py b/main/migrations/0057_auto_20210317_2321.py new file mode 100644 index 0000000000000000000000000000000000000000..88aa87e88b9c40019c494419e1ab35ffd0f4572d --- /dev/null +++ b/main/migrations/0057_auto_20210317_2321.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.7 on 2021-03-17 23:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0056_auto_20210317_2313"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="footer_note", + field=models.CharField( + blank=True, + default="Powered by [mataroa.blog](https://mataroa.blog/).", + help_text="Supports markdown", + max_length=500, + null=True, + ), + ), + ] diff --git a/main/migrations/0058_remove_analytic_referer.py b/main/migrations/0058_remove_analytic_referer.py new file mode 100644 index 0000000000000000000000000000000000000000..862665681e6068dba676a41b87d6bdb62188016d --- /dev/null +++ b/main/migrations/0058_remove_analytic_referer.py @@ -0,0 +1,16 @@ +# Generated by Django 3.1.7 on 2021-03-24 00:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0057_auto_20210317_2321"), + ] + + operations = [ + migrations.RemoveField( + model_name="analytic", + name="referer", + ), + ] diff --git a/main/migrations/0059_auto_20210409_1320.py b/main/migrations/0059_auto_20210409_1320.py new file mode 100644 index 0000000000000000000000000000000000000000..0650bc4c618186bb40f4e8ddbf4d2971623cb980 --- /dev/null +++ b/main/migrations/0059_auto_20210409_1320.py @@ -0,0 +1,68 @@ +# Generated by Django 3.2 on 2021-04-09 13:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0058_remove_analytic_referer"), + ] + + operations = [ + migrations.AlterField( + model_name="analytic", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="comment", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="image", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="notification", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="notificationrecord", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="page", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="post", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="user", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ] diff --git a/main/migrations/0060_auto_20210429_1506.py b/main/migrations/0060_auto_20210429_1506.py new file mode 100644 index 0000000000000000000000000000000000000000..afb3d5aa51f1119f67dfd0921c1225e35dac782b --- /dev/null +++ b/main/migrations/0060_auto_20210429_1506.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2 on 2021-04-29 15:06 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0059_auto_20210409_1320"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="email", + field=models.EmailField( + blank=True, + help_text="Optional, but also the only way to recover password if forgotten.", + max_length=254, + null=True, + ), + ), + migrations.AlterField( + model_name="user", + name="username", + field=models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="This is your subdomain. Lowercase alphanumeric.", + max_length=150, + unique=True, + validators=[main.validators.AlphanumericHyphenValidator()], + ), + ), + ] diff --git a/main/migrations/0061_auto_20210503_0035.py b/main/migrations/0061_auto_20210503_0035.py new file mode 100644 index 0000000000000000000000000000000000000000..311853074f8fde6645d12b4fc00c2cf2745189f0 --- /dev/null +++ b/main/migrations/0061_auto_20210503_0035.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2 on 2021-05-03 00:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0060_auto_20210429_1506"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_grandfathered", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="user", + name="is_premium", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="user", + name="stripe_customer_id", + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/main/migrations/0062_auto_20210516_2310.py b/main/migrations/0062_auto_20210516_2310.py new file mode 100644 index 0000000000000000000000000000000000000000..a82b49f0959b30ecba2c046ac6aaa5bd5b65560e --- /dev/null +++ b/main/migrations/0062_auto_20210516_2310.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2 on 2021-05-16 23:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0061_auto_20210503_0035"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="mail_export_on", + field=models.BooleanField( + default=False, + help_text="Enable/disable auto emailing of account exports every month.", + verbose_name="Mail export", + ), + ), + migrations.AlterField( + model_name="user", + name="comments_on", + field=models.BooleanField( + default=False, + help_text="Enable/disable comments for your blog.", + verbose_name="Comments", + ), + ), + migrations.AlterField( + model_name="user", + name="notifications_on", + field=models.BooleanField( + default=False, + help_text="Allow/disallow people subscribing for email newsletter for new posts.", + verbose_name="Newsletter", + ), + ), + ] diff --git a/main/migrations/0063_exportrecord.py b/main/migrations/0063_exportrecord.py new file mode 100644 index 0000000000000000000000000000000000000000..2560ce3ad171359c96ba1ce3d5bc655ff6047e83 --- /dev/null +++ b/main/migrations/0063_exportrecord.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2 on 2021-05-22 01:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0062_auto_20210516_2310"), + ] + + operations = [ + migrations.CreateModel( + name="ExportRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=150)), + ("sent_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-sent_at"], + }, + ), + ] diff --git a/main/migrations/0064_user_export_unsubscribe_key.py b/main/migrations/0064_user_export_unsubscribe_key.py new file mode 100644 index 0000000000000000000000000000000000000000..afd0ef6352a96d87e9718e26a5443e2840017fa0 --- /dev/null +++ b/main/migrations/0064_user_export_unsubscribe_key.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2 on 2021-05-17 00:01 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0063_exportrecord"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="export_unsubscribe_key", + field=models.UUIDField(default=uuid.uuid4, null=True), + ), + ] diff --git a/main/migrations/0065_remove_uuid_null_export_unsubscribe.py b/main/migrations/0065_remove_uuid_null_export_unsubscribe.py new file mode 100644 index 0000000000000000000000000000000000000000..be98a25a158d2482ee92630d6dfb5890482b3233 --- /dev/null +++ b/main/migrations/0065_remove_uuid_null_export_unsubscribe.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2 on 2021-05-17 00:03 + +import uuid + +from django.db import migrations + + +def gen_uuid(apps, schema_editor): + User = apps.get_model("main", "User") + for row in User.objects.all(): + row.export_unsubscribe_key = uuid.uuid4() + row.save(update_fields=["export_unsubscribe_key"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0064_user_export_unsubscribe_key"), + ] + + operations = [ + migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), + ] diff --git a/main/migrations/0066_add_uuid_field_export_unsubscribe.py b/main/migrations/0066_add_uuid_field_export_unsubscribe.py new file mode 100644 index 0000000000000000000000000000000000000000..3424635f4a7fac7fc50e6f6a731fd9ee6721bcfc --- /dev/null +++ b/main/migrations/0066_add_uuid_field_export_unsubscribe.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2 on 2021-05-17 00:03 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0065_remove_uuid_null_export_unsubscribe"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="export_unsubscribe_key", + field=models.UUIDField(default=uuid.uuid4, unique=True), + ), + ] diff --git a/main/migrations/0067_rename_analytic_analyticpost.py b/main/migrations/0067_rename_analytic_analyticpost.py new file mode 100644 index 0000000000000000000000000000000000000000..374457ddc0112992bd2dc6a4f70186d4bda542f2 --- /dev/null +++ b/main/migrations/0067_rename_analytic_analyticpost.py @@ -0,0 +1,16 @@ +# Generated by Django 3.2 on 2021-05-23 11:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0066_add_uuid_field_export_unsubscribe"), + ] + + operations = [ + migrations.RenameModel( + old_name="Analytic", + new_name="AnalyticPost", + ), + ] diff --git a/main/migrations/0068_analyticpage.py b/main/migrations/0068_analyticpage.py new file mode 100644 index 0000000000000000000000000000000000000000..5202558dfbdf9c697c25549d1d1f32e920955c4c --- /dev/null +++ b/main/migrations/0068_analyticpage.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2 on 2021-05-23 20:37 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0067_rename_analytic_analyticpost"), + ] + + operations = [ + migrations.CreateModel( + name="AnalyticPage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("path", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/main/migrations/0069_alter_analyticpage_path.py b/main/migrations/0069_alter_analyticpage_path.py new file mode 100644 index 0000000000000000000000000000000000000000..bf9bec20d0c7bfd632939a8de681f66c54b74cc2 --- /dev/null +++ b/main/migrations/0069_alter_analyticpage_path.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2 on 2021-05-24 23:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0068_analyticpage"), + ] + + operations = [ + migrations.AlterField( + model_name="analyticpage", + name="path", + field=models.CharField(max_length=300), + ), + ] diff --git a/main/migrations/0070_notificationrecord_is_canceled.py b/main/migrations/0070_notificationrecord_is_canceled.py new file mode 100644 index 0000000000000000000000000000000000000000..aec77e3b86014dd8017f5c922739ff7d3b21d1f5 --- /dev/null +++ b/main/migrations/0070_notificationrecord_is_canceled.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2 on 2021-05-25 20:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0069_alter_analyticpage_path"), + ] + + operations = [ + migrations.AddField( + model_name="notificationrecord", + name="is_canceled", + field=models.BooleanField(default=False), + ), + ] diff --git a/main/migrations/0071_user_monero_address.py b/main/migrations/0071_user_monero_address.py new file mode 100644 index 0000000000000000000000000000000000000000..72746f93d6de89db245b178943fd06de841d98ba --- /dev/null +++ b/main/migrations/0071_user_monero_address.py @@ -0,0 +1,17 @@ +# Generated by Django 4.0.2 on 2022-02-19 15:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0070_notificationrecord_is_canceled"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="monero_address", + field=models.CharField(blank=True, max_length=95, null=True), + ), + ] diff --git a/main/migrations/0072_alter_user_username.py b/main/migrations/0072_alter_user_username.py new file mode 100644 index 0000000000000000000000000000000000000000..80c06abec2cdaa715203a00e1db608d27b38fae3 --- /dev/null +++ b/main/migrations/0072_alter_user_username.py @@ -0,0 +1,28 @@ +# Generated by Django 4.0.3 on 2022-04-11 22:53 + +from django.db import migrations, models + +import main.validators + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0071_user_monero_address"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="username", + field=models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="This is your subdomain. Lowercase alphanumeric.", + max_length=150, + unique=True, + validators=[ + main.validators.AlphanumericHyphenValidator(), + main.validators.HyphenOnlyValidator(), + ], + ), + ), + ] diff --git a/main/migrations/0073_user_api_key.py b/main/migrations/0073_user_api_key.py new file mode 100644 index 0000000000000000000000000000000000000000..5da2f7b53477d6076652b0574fc6cfa50dcd73f8 --- /dev/null +++ b/main/migrations/0073_user_api_key.py @@ -0,0 +1,21 @@ +# Generated by Django 4.0.3 on 2022-04-12 22:40 + +from django.db import migrations, models + +import main.models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0072_alter_user_username"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="api_key", + field=models.CharField( + default=main.models._generate_key, max_length=32, null=True + ), + ), + ] diff --git a/main/migrations/0074_populate_api_key_values.py b/main/migrations/0074_populate_api_key_values.py new file mode 100644 index 0000000000000000000000000000000000000000..56d2579e4ddb553473d687c60557dafc6d460b38 --- /dev/null +++ b/main/migrations/0074_populate_api_key_values.py @@ -0,0 +1,22 @@ +# Generated by Django 4.0.3 on 2022-04-12 22:41 + +import binascii +import os + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0073_user_api_key"), + ] + + def gen_api_key(apps, schema_editor): + User = apps.get_model("main", "User") + for row in User.objects.all(): + row.api_key = binascii.b2a_hex(os.urandom(16)).decode("utf-8") + row.save(update_fields=["api_key"]) + + operations = [ + migrations.RunPython(gen_api_key, reverse_code=migrations.RunPython.noop), + ] diff --git a/main/migrations/0075_remove_api_key_null.py b/main/migrations/0075_remove_api_key_null.py new file mode 100644 index 0000000000000000000000000000000000000000..663c93f97219aa115f58ae37375612375c54918b --- /dev/null +++ b/main/migrations/0075_remove_api_key_null.py @@ -0,0 +1,21 @@ +# Generated by Django 4.0.3 on 2022-04-12 22:41 + +from django.db import migrations, models + +import main.models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0074_populate_api_key_values"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="api_key", + field=models.CharField( + default=main.models._generate_key, max_length=32, unique=True + ), + ), + ] diff --git a/main/migrations/0076_alter_user_footer_note.py b/main/migrations/0076_alter_user_footer_note.py new file mode 100644 index 0000000000000000000000000000000000000000..98975d8ea3191175636bf9f17346ad95d8fa9b88 --- /dev/null +++ b/main/migrations/0076_alter_user_footer_note.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.4 on 2022-04-23 15:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0075_remove_api_key_null"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="footer_note", + field=models.CharField( + blank=True, + default="Powered by [mataroa.blog](https://mataroa.blog/).", + help_text="Supports markdown", + max_length=500, + null=True, + ), + ), + ] diff --git a/main/migrations/0077_comment_is_approved.py b/main/migrations/0077_comment_is_approved.py new file mode 100644 index 0000000000000000000000000000000000000000..0dc4cdd81ffed332b0381fc842959453f964c865 --- /dev/null +++ b/main/migrations/0077_comment_is_approved.py @@ -0,0 +1,17 @@ +# Generated by Django 4.0.4 on 2022-05-23 22:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0076_alter_user_footer_note"), + ] + + operations = [ + migrations.AddField( + model_name="comment", + name="is_approved", + field=models.BooleanField(default=True), + ), + ] diff --git a/main/migrations/0078_alter_user_theme_zialucia.py b/main/migrations/0078_alter_user_theme_zialucia.py new file mode 100644 index 0000000000000000000000000000000000000000..c97e46cf348b9cd38d7d89e19abaa974d29f3157 --- /dev/null +++ b/main/migrations/0078_alter_user_theme_zialucia.py @@ -0,0 +1,21 @@ +# Generated by Django 4.0.4 on 2022-05-26 18:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0077_comment_is_approved"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="theme_zialucia", + field=models.BooleanField( + default=False, + help_text="Enable/disable Zia Lucia theme with larger font size.", + verbose_name="Theme Zia Lucia", + ), + ), + ] diff --git a/main/migrations/0079_user_theme_sansserif_alter_user_theme_zialucia.py b/main/migrations/0079_user_theme_sansserif_alter_user_theme_zialucia.py new file mode 100644 index 0000000000000000000000000000000000000000..d74881af2fb58c67b8bf3f1e45332e954139c470 --- /dev/null +++ b/main/migrations/0079_user_theme_sansserif_alter_user_theme_zialucia.py @@ -0,0 +1,30 @@ +# Generated by Django 4.0.4 on 2022-06-01 13:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0078_alter_user_theme_zialucia"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="theme_sansserif", + field=models.BooleanField( + default=False, + help_text="Force sans-serif font in blog content.", + verbose_name="Theme Sans-serif", + ), + ), + migrations.AlterField( + model_name="user", + name="theme_zialucia", + field=models.BooleanField( + default=False, + help_text="Enable/disable Zia Lucia theme with larger default font size.", + verbose_name="Theme Zia Lucia", + ), + ), + ] diff --git a/main/migrations/0080_alter_user_theme_sansserif.py b/main/migrations/0080_alter_user_theme_sansserif.py new file mode 100644 index 0000000000000000000000000000000000000000..39a6ae2a3e742427ef20998ae1452c24d937293b --- /dev/null +++ b/main/migrations/0080_alter_user_theme_sansserif.py @@ -0,0 +1,21 @@ +# Generated by Django 4.0.4 on 2022-06-01 13:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0079_user_theme_sansserif_alter_user_theme_zialucia"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="theme_sansserif", + field=models.BooleanField( + default=False, + help_text="Use sans-serif font in blog content.", + verbose_name="Theme Sans-serif", + ), + ), + ] diff --git a/main/migrations/0081_comment_is_author_alter_comment_is_approved.py b/main/migrations/0081_comment_is_author_alter_comment_is_approved.py new file mode 100644 index 0000000000000000000000000000000000000000..1cc84a7144ec2f741569d20a4fbfcbe9fc4c57c3 --- /dev/null +++ b/main/migrations/0081_comment_is_author_alter_comment_is_approved.py @@ -0,0 +1,24 @@ +# Generated by Django 4.0.4 on 2022-06-04 19:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0080_alter_user_theme_sansserif"), + ] + + operations = [ + migrations.AddField( + model_name="comment", + name="is_author", + field=models.BooleanField( + default=False, help_text="True if logged in author has posted comment." + ), + ), + migrations.AlterField( + model_name="comment", + name="is_approved", + field=models.BooleanField(default=False), + ), + ] diff --git a/main/migrations/0082_snapshot.py b/main/migrations/0082_snapshot.py new file mode 100644 index 0000000000000000000000000000000000000000..20069879e99b001b37fb0dff7f87c6d9bb61c741 --- /dev/null +++ b/main/migrations/0082_snapshot.py @@ -0,0 +1,41 @@ +# Generated by Django 4.0.6 on 2022-07-26 15:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0081_comment_is_author_alter_comment_is_approved"), + ] + + operations = [ + migrations.CreateModel( + name="Snapshot", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=300)), + ("body", models.TextField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/main/migrations/0083_user_post_backups_on.py b/main/migrations/0083_user_post_backups_on.py new file mode 100644 index 0000000000000000000000000000000000000000..f45715933969afbe0dfaaf93c046363af2bcb276 --- /dev/null +++ b/main/migrations/0083_user_post_backups_on.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1 on 2022-08-21 21:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0082_snapshot"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="post_backups_on", + field=models.BooleanField( + default=False, + help_text="Enable/disable automatic post backups.", + verbose_name="Snapshots On", + ), + ), + ] diff --git a/main/migrations/0084_alter_user_post_backups_on.py b/main/migrations/0084_alter_user_post_backups_on.py new file mode 100644 index 0000000000000000000000000000000000000000..0ee36de137f49bbba0fb1d4683dd42b29f98fb87 --- /dev/null +++ b/main/migrations/0084_alter_user_post_backups_on.py @@ -0,0 +1,21 @@ +# Generated by Django 4.1 on 2022-08-21 21:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0083_user_post_backups_on"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="post_backups_on", + field=models.BooleanField( + default=False, + help_text="Enable/disable automatic post backups.", + verbose_name="Post Backups On", + ), + ), + ] diff --git a/main/migrations/0085_alter_user_footer_note.py b/main/migrations/0085_alter_user_footer_note.py new file mode 100644 index 0000000000000000000000000000000000000000..ae3b9786e8e3ac599177fe7ab8c0914f806f8059 --- /dev/null +++ b/main/migrations/0085_alter_user_footer_note.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.3 on 2022-12-24 16:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0084_alter_user_post_backups_on"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="footer_note", + field=models.TextField( + blank=True, + default="Powered by [mataroa.blog](https://mataroa.blog/).", + help_text="Supports markdown", + null=True, + ), + ), + ] diff --git a/main/migrations/0086_alter_user_blog_byline.py b/main/migrations/0086_alter_user_blog_byline.py new file mode 100644 index 0000000000000000000000000000000000000000..06fac5f32efe9931b96ecbc9417c5cddf3bb41a7 --- /dev/null +++ b/main/migrations/0086_alter_user_blog_byline.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.3 on 2022-12-24 16:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0085_alter_user_footer_note"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="blog_byline", + field=models.TextField( + blank=True, help_text="Supports markdown", null=True + ), + ), + ] diff --git a/main/migrations/0087_user_is_approved.py b/main/migrations/0087_user_is_approved.py new file mode 100644 index 0000000000000000000000000000000000000000..b6b0e21736ff81b7d2690739913970812a8efb7a --- /dev/null +++ b/main/migrations/0087_user_is_approved.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.4 on 2023-08-12 16:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0086_alter_user_blog_byline"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_approved", + field=models.BooleanField(default=False), + ), + ] diff --git a/main/migrations/0088_alter_page_is_hidden.py b/main/migrations/0088_alter_page_is_hidden.py new file mode 100644 index 0000000000000000000000000000000000000000..8406d3abc9168a132f2aa72f21e93fafe0ef70db --- /dev/null +++ b/main/migrations/0088_alter_page_is_hidden.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.4 on 2023-08-12 16:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0087_user_is_approved"), + ] + + operations = [ + migrations.AlterField( + model_name="page", + name="is_hidden", + field=models.BooleanField( + default=False, + help_text="If checked, page link will not appear on the blog header.", + ), + ), + ] diff --git a/main/migrations/0089_user_stripe_subscription_id.py b/main/migrations/0089_user_stripe_subscription_id.py new file mode 100644 index 0000000000000000000000000000000000000000..f3a3eccc19a4ff3ea713d5d952c7e90c0370ed04 --- /dev/null +++ b/main/migrations/0089_user_stripe_subscription_id.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.6 on 2023-11-05 14:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0088_alter_page_is_hidden"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="stripe_subscription_id", + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/main/migrations/0090_onboard.py b/main/migrations/0090_onboard.py new file mode 100644 index 0000000000000000000000000000000000000000..fcdb12595aa6aba7a960f72d4e3ad036a8ffc717 --- /dev/null +++ b/main/migrations/0090_onboard.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.7 on 2023-11-09 20:03 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0089_user_stripe_subscription_id"), + ] + + operations = [ + migrations.CreateModel( + name="Onboard", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("problems", models.TextField()), + ("quality", models.TextField()), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/main/migrations/0091_onboard_created_at.py b/main/migrations/0091_onboard_created_at.py new file mode 100644 index 0000000000000000000000000000000000000000..7b53c6176096da9eb952fb7e18e545f01a1b7bad --- /dev/null +++ b/main/migrations/0091_onboard_created_at.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.7 on 2023-11-09 20:04 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0090_onboard"), + ] + + operations = [ + migrations.AddField( + model_name="onboard", + name="created_at", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + ] diff --git a/main/migrations/0092_alter_onboard_user.py b/main/migrations/0092_alter_onboard_user.py new file mode 100644 index 0000000000000000000000000000000000000000..344655152aa409acd2e5f8f7070e245b2dad79af --- /dev/null +++ b/main/migrations/0092_alter_onboard_user.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.7 on 2023-11-09 20:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0091_onboard_created_at"), + ] + + operations = [ + migrations.AlterField( + model_name="onboard", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/main/migrations/0093_alter_onboard_problems_alter_onboard_quality.py b/main/migrations/0093_alter_onboard_problems_alter_onboard_quality.py new file mode 100644 index 0000000000000000000000000000000000000000..030c92a7ee9c3de38c46cbe04972c9dac24a30c2 --- /dev/null +++ b/main/migrations/0093_alter_onboard_problems_alter_onboard_quality.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.7 on 2023-11-09 20:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0092_alter_onboard_user"), + ] + + operations = [ + migrations.AlterField( + model_name="onboard", + name="problems", + field=models.CharField(max_length=300), + ), + migrations.AlterField( + model_name="onboard", + name="quality", + field=models.CharField(max_length=300), + ), + ] diff --git a/main/migrations/0094_onboard_code.py b/main/migrations/0094_onboard_code.py new file mode 100644 index 0000000000000000000000000000000000000000..96d982bdbffd23fd6355d1c07b79fb7a2ada3c9e --- /dev/null +++ b/main/migrations/0094_onboard_code.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.7 on 2023-11-09 21:11 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0093_alter_onboard_problems_alter_onboard_quality"), + ] + + operations = [ + migrations.AddField( + model_name="onboard", + name="code", + field=models.UUIDField(default=uuid.uuid4, null=True), + ), + ] diff --git a/main/migrations/0095_auto_20231109_2111.py b/main/migrations/0095_auto_20231109_2111.py new file mode 100644 index 0000000000000000000000000000000000000000..64b7ceac0d98acb891e8511478053a4cab2fd1f4 --- /dev/null +++ b/main/migrations/0095_auto_20231109_2111.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.7 on 2023-11-09 21:11 + +import uuid + +from django.db import migrations + + +def gen_uuid(apps, schema_editor): + Onboard = apps.get_model("main", "Onboard") + for row in Onboard.objects.all(): + row.code = uuid.uuid4() + row.save(update_fields=["code"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0094_onboard_code"), + ] + + operations = [ + migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), + ] diff --git a/main/migrations/0096_auto_20231109_2111.py b/main/migrations/0096_auto_20231109_2111.py new file mode 100644 index 0000000000000000000000000000000000000000..670ece4262ed3b85f84706fe710500db2dc1362a --- /dev/null +++ b/main/migrations/0096_auto_20231109_2111.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.7 on 2023-11-09 21:11 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0095_auto_20231109_2111"), + ] + + operations = [ + migrations.AlterField( + model_name="onboard", + name="code", + field=models.UUIDField(default=uuid.uuid4, unique=True), + ), + ] diff --git a/main/migrations/0097_user_subscribe_note.py b/main/migrations/0097_user_subscribe_note.py new file mode 100644 index 0000000000000000000000000000000000000000..0290fffc48daea31f2d24b4e25a678d3cdf2eff9 --- /dev/null +++ b/main/migrations/0097_user_subscribe_note.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.1 on 2024-01-03 16:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0096_auto_20231109_2111"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="subscribe_note", + field=models.TextField( + blank=True, + default="Subscribe via [RSS](/rss/) / [via Email](/newsletter/).", + help_text="Supports markdown. Default: Subscribe via [RSS](/rss/) / [via Email](/newsletter/).", + null=True, + ), + ), + ] diff --git a/main/migrations/0098_alter_user_notifications_on.py b/main/migrations/0098_alter_user_notifications_on.py new file mode 100644 index 0000000000000000000000000000000000000000..3dfbc220eb5bc12b6c1854f98db951a1fe734b5a --- /dev/null +++ b/main/migrations/0098_alter_user_notifications_on.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.1 on 2024-01-03 16:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0097_user_subscribe_note"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="notifications_on", + field=models.BooleanField( + default=True, + help_text="Allow/disallow people subscribing for email newsletter for new posts.", + verbose_name="Newsletter", + ), + ), + ] diff --git a/main/migrations/0099_alter_user_subscribe_note.py b/main/migrations/0099_alter_user_subscribe_note.py new file mode 100644 index 0000000000000000000000000000000000000000..6bbaaf66493bd8c4d0713021a9145beda7436cc6 --- /dev/null +++ b/main/migrations/0099_alter_user_subscribe_note.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.1 on 2024-01-03 22:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0098_alter_user_notifications_on"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="subscribe_note", + field=models.CharField( + blank=True, + default="Subscribe via [RSS](/rss/) / [via Email](/newsletter/).", + help_text="Default: Subscribe via [RSS](/rss/) / [via Email](/newsletter/).", + max_length=350, + null=True, + ), + ), + ] diff --git a/main/migrations/0100_onboard_seo.py b/main/migrations/0100_onboard_seo.py new file mode 100644 index 0000000000000000000000000000000000000000..9250f029faba6851bff9150df379c78d85f7567f --- /dev/null +++ b/main/migrations/0100_onboard_seo.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-02-18 14:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0099_alter_user_subscribe_note"), + ] + + operations = [ + migrations.AddField( + model_name="onboard", + name="seo", + field=models.CharField(default="-", max_length=300), + preserve_default=False, + ), + ] diff --git a/main/migrations/0101_post_broadcasted_at.py b/main/migrations/0101_post_broadcasted_at.py new file mode 100644 index 0000000000000000000000000000000000000000..31f59a5785f9f81ce19f9a538d3c33fc7a6b82de --- /dev/null +++ b/main/migrations/0101_post_broadcasted_at.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.2 on 2024-07-12 19:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0100_onboard_seo"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="broadcasted_at", + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/main/migrations/0102_remove_notificationrecord_is_canceled.py b/main/migrations/0102_remove_notificationrecord_is_canceled.py new file mode 100644 index 0000000000000000000000000000000000000000..9dec208809d4966ca53fff63eb949fa9f9151a4c --- /dev/null +++ b/main/migrations/0102_remove_notificationrecord_is_canceled.py @@ -0,0 +1,16 @@ +# Generated by Django 5.0.2 on 2024-07-12 20:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0101_post_broadcasted_at"), + ] + + operations = [ + migrations.RemoveField( + model_name="notificationrecord", + name="is_canceled", + ), + ] diff --git a/main/migrations/0103_alter_post_broadcasted_at.py b/main/migrations/0103_alter_post_broadcasted_at.py new file mode 100644 index 0000000000000000000000000000000000000000..2945578fafeeadcd8d4dea5627b3e8d847115690 --- /dev/null +++ b/main/migrations/0103_alter_post_broadcasted_at.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.2 on 2024-07-14 14:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0102_remove_notificationrecord_is_canceled"), + ] + + operations = [ + migrations.AlterField( + model_name="post", + name="broadcasted_at", + field=models.DateTimeField(blank=True, default=None, null=True), + ), + ] diff --git a/main/migrations/0104_alter_onboard_problems_alter_onboard_quality_and_more.py b/main/migrations/0104_alter_onboard_problems_alter_onboard_quality_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..dc0aaccdaabb6a9a893a1d20013f0520238b7b3c --- /dev/null +++ b/main/migrations/0104_alter_onboard_problems_alter_onboard_quality_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.4 on 2025-08-09 12:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("main", "0103_alter_post_broadcasted_at"), + ] + + operations = [ + migrations.AlterField( + model_name="onboard", + name="problems", + field=models.CharField(blank=True, max_length=300, null=True), + ), + migrations.AlterField( + model_name="onboard", + name="quality", + field=models.CharField(blank=True, max_length=300, null=True), + ), + migrations.AlterField( + model_name="onboard", + name="seo", + field=models.CharField(blank=True, max_length=300, null=True), + ), + ] diff --git a/main/migrations/0105_user_blog_index_content_alter_user_footer_note_and_more.py b/main/migrations/0105_user_blog_index_content_alter_user_footer_note_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..8ccc947d1bc64ba691c370259477c2a6410982a8 --- /dev/null +++ b/main/migrations/0105_user_blog_index_content_alter_user_footer_note_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.5 on 2025-09-10 22:28 + +import main.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0104_alter_onboard_problems_alter_onboard_quality_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='blog_index_content', + field=models.TextField(blank=True, help_text='Supports markdown', null=True), + ), + migrations.AlterField( + model_name='user', + name='footer_note', + field=models.TextField(blank=True, default='published with [BōcPress](https://bocpress.co.uk/).', help_text='Supports markdown', null=True), + ), + migrations.AlterField( + model_name='user', + name='redirect_domain', + field=models.CharField(blank=True, help_text='Retiring your BōcPress blog? We can redirect to your new domain.', max_length=150, null=True, validators=[main.validators.validate_domain_name]), + ), + ] diff --git a/main/migrations/0106_user_show_posts_in_nav_user_show_posts_on_homepage_and_more.py b/main/migrations/0106_user_show_posts_in_nav_user_show_posts_on_homepage_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..7a873ff45910d07a5fc93714f07f3c664d30a958 --- /dev/null +++ b/main/migrations/0106_user_show_posts_in_nav_user_show_posts_on_homepage_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.5 on 2025-09-11 20:18 + +import main.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0105_user_blog_index_content_alter_user_footer_note_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='show_posts_in_nav', + field=models.BooleanField(default=False, help_text='Show/hide posts in the navigation bar.', verbose_name='Show Posts In Nav'), + ), + migrations.AddField( + model_name='user', + name='show_posts_on_homepage', + field=models.BooleanField(default=False, help_text='Show/hide posts on the homepage.', verbose_name='Show Posts On Homepage'), + ), + migrations.AlterField( + model_name='user', + name='custom_domain', + field=models.CharField(blank=True, help_text="To setup: Add an A record in your domain's DNS with IP 78.47.67.77", max_length=150, null=True, validators=[main.validators.validate_domain_name]), + ), + ] diff --git a/main/migrations/0107_alter_user_show_posts_on_homepage.py b/main/migrations/0107_alter_user_show_posts_on_homepage.py new file mode 100644 index 0000000000000000000000000000000000000000..eb3174cd8d91ecac2f206b2eeaf2837e396a1eeb --- /dev/null +++ b/main/migrations/0107_alter_user_show_posts_on_homepage.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-09-11 20:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0106_user_show_posts_in_nav_user_show_posts_on_homepage_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='show_posts_on_homepage', + field=models.BooleanField(default=True, help_text='Show/hide posts on the homepage.', verbose_name='Show Posts On Homepage'), + ), + ] diff --git a/main/migrations/0108_user_noindex_on.py b/main/migrations/0108_user_noindex_on.py new file mode 100644 index 0000000000000000000000000000000000000000..ca71c9461511e91ac72fe28e575807f478039800 --- /dev/null +++ b/main/migrations/0108_user_noindex_on.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-09-11 21:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0107_alter_user_show_posts_on_homepage'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='noindex_on', + field=models.BooleanField(default=False, help_text='Add a noindex meta tag so your blog is not indexed by search engines.', verbose_name='noindex: Prevent Search Engine Indexing'), + ), + ] diff --git a/main/migrations/0109_user_posts_page_title.py b/main/migrations/0109_user_posts_page_title.py new file mode 100644 index 0000000000000000000000000000000000000000..bdcdb0ea3db97ad5314a378ffb4273d3a3bd9312 --- /dev/null +++ b/main/migrations/0109_user_posts_page_title.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-09-11 21:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0108_user_noindex_on'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='posts_page_title', + field=models.CharField(blank=True, max_length=500, null=True), + ), + ] diff --git a/main/migrations/0110_user_robots_txt.py b/main/migrations/0110_user_robots_txt.py new file mode 100644 index 0000000000000000000000000000000000000000..345625b68fdc42ced06257e7949c6d3e5f478897 --- /dev/null +++ b/main/migrations/0110_user_robots_txt.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-09-11 21:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0109_user_posts_page_title'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='robots_txt', + field=models.TextField(blank=True, default='User-agent: *\nDisallow:\nAllow: /', help_text='Custom robots.txt content for this user. Leave blank for default.', null=True, verbose_name='robots.txt'), + ), + ] diff --git a/main/migrations/0111_user_reading_time_on_alter_user_robots_txt.py b/main/migrations/0111_user_reading_time_on_alter_user_robots_txt.py new file mode 100644 index 0000000000000000000000000000000000000000..5d0d7fa9adf6b231a8668d9a79e09b911ca69986 --- /dev/null +++ b/main/migrations/0111_user_reading_time_on_alter_user_robots_txt.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.5 on 2025-09-13 18:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0110_user_robots_txt'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='reading_time_on', + field=models.BooleanField(default=False, help_text='Add an estimated reading time - in minutes - to the top of the post body.', verbose_name='Show Reading Time on Posts'), + ), + migrations.AlterField( + model_name='user', + name='robots_txt', + field=models.TextField(blank=True, default='User-agent: *\nDisallow:\nAllow: /', help_text='Custom robots.txt content for your blog. Leave blank for default.', null=True, verbose_name='robots.txt'), + ), + ] diff --git a/main/migrations/0112_rsl_settings.py b/main/migrations/0112_rsl_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..1e34df3c8d9d591045992cbf974f0639cfbf9ed4 --- /dev/null +++ b/main/migrations/0112_rsl_settings.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.5 on 2025-09-13 19:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0111_user_reading_time_on_alter_user_robots_txt'), + ] + + operations = [ + migrations.CreateModel( + name='rsl_settings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('license', models.CharField(max_length=1024)), + ('show_http', models.BooleanField(default=True)), + ('show_rss', models.BooleanField(default=False)), + ('show_robotstxt', models.BooleanField(default=True)), + ('show_webpage', models.BooleanField(default=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/main/migrations/0113_rename_rsl_settings_reallysimplelicensing.py b/main/migrations/0113_rename_rsl_settings_reallysimplelicensing.py new file mode 100644 index 0000000000000000000000000000000000000000..164633f28c0f9ea1ebced87f4c384f512c6ad22e --- /dev/null +++ b/main/migrations/0113_rename_rsl_settings_reallysimplelicensing.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.5 on 2025-09-13 19:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0112_rsl_settings'), + ] + + operations = [ + migrations.RenameModel( + old_name='rsl_settings', + new_name='ReallySimpleLicensing', + ), + ] diff --git a/main/migrations/0114_alter_reallysimplelicensing_show_rss.py b/main/migrations/0114_alter_reallysimplelicensing_show_rss.py new file mode 100644 index 0000000000000000000000000000000000000000..4115d3c98bc05f9685928fd5955ada2f3952e093 --- /dev/null +++ b/main/migrations/0114_alter_reallysimplelicensing_show_rss.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-09-13 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0113_rename_rsl_settings_reallysimplelicensing'), + ] + + operations = [ + migrations.AlterField( + model_name='reallysimplelicensing', + name='show_rss', + field=models.BooleanField(default=True), + ), + ] diff --git a/main/migrations/0115_alter_reallysimplelicensing_license.py b/main/migrations/0115_alter_reallysimplelicensing_license.py new file mode 100644 index 0000000000000000000000000000000000000000..909ff58cd2ef73d49473be1991b375bf65ca1676 --- /dev/null +++ b/main/migrations/0115_alter_reallysimplelicensing_license.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-09-13 19:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0114_alter_reallysimplelicensing_show_rss'), + ] + + operations = [ + migrations.AlterField( + model_name='reallysimplelicensing', + name='license', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/main/migrations/0116_alter_reallysimplelicensing_user.py b/main/migrations/0116_alter_reallysimplelicensing_user.py new file mode 100644 index 0000000000000000000000000000000000000000..86fa7e701a4fec7a7db2960e55f44ba31d3b4a4c --- /dev/null +++ b/main/migrations/0116_alter_reallysimplelicensing_user.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.5 on 2025-09-13 19:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0115_alter_reallysimplelicensing_license'), + ] + + operations = [ + migrations.AlterField( + model_name='reallysimplelicensing', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reallysimplelicensing', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/main/migrations/0117_alter_reallysimplelicensing_license_and_more.py b/main/migrations/0117_alter_reallysimplelicensing_license_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..2c65668c6ef4eafb3cb5484191b6e95ba1762734 --- /dev/null +++ b/main/migrations/0117_alter_reallysimplelicensing_license_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.5 on 2025-09-13 20:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0116_alter_reallysimplelicensing_user'), + ] + + operations = [ + migrations.AlterField( + model_name='reallysimplelicensing', + name='license', + field=models.TextField(blank=True, help_text='License text in XML format defined by the Really Simple Licensing standard. Will be available at /license.xml.', null=True), + ), + migrations.AlterField( + model_name='reallysimplelicensing', + name='show_http', + field=models.BooleanField(default=True, help_text='Show/hide license in HTTP header.', verbose_name='Show HTTP License'), + ), + migrations.AlterField( + model_name='reallysimplelicensing', + name='show_robotstxt', + field=models.BooleanField(default=True, help_text='Show/hide license in robots.txt.', verbose_name='Show robots.txt License'), + ), + migrations.AlterField( + model_name='reallysimplelicensing', + name='show_rss', + field=models.BooleanField(default=True, help_text='Show/hide license in RSS feed.', verbose_name='Show RSS License'), + ), + migrations.AlterField( + model_name='reallysimplelicensing', + name='show_webpage', + field=models.BooleanField(default=True, help_text='Show/hide license on webpage header.', verbose_name='Show Webpage License'), + ), + ] diff --git a/main/migrations/__init__.py b/main/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/main/models.py b/main/models.py new file mode 100644 index 0000000000000000000000000000000000000000..86fc6f9d8ec9bd15516697b4d11a474a86eb7413 --- /dev/null +++ b/main/models.py @@ -0,0 +1,510 @@ +import base64 +import binascii +import os +import uuid + +import bleach +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.urls import reverse +from django.utils import timezone + +from main import util, validators + + +def _generate_key(): + """Return 32-char random string.""" + return binascii.b2a_hex(os.urandom(16)).decode("utf-8") + + +class User(AbstractUser): + username = models.CharField( + max_length=150, + unique=True, + help_text="This is your subdomain. Lowercase alphanumeric.", + validators=[ + validators.AlphanumericHyphenValidator(), + validators.HyphenOnlyValidator(), + ], + error_messages={"unique": "A user with that username already exists."}, + ) + email = models.EmailField( + blank=True, + null=True, + help_text="Optional, but also the only way to recover password if forgotten.", + ) + api_key = models.CharField(max_length=32, default=_generate_key, unique=True) + about = models.TextField(blank=True, null=True) + blog_title = models.CharField(max_length=500, blank=True, null=True) + posts_page_title = models.CharField(max_length=500, blank=True, null=True) + blog_byline = models.TextField( + blank=True, + null=True, + help_text="Supports markdown", + ) + blog_index_content = models.TextField( + blank=True, + null=True, + help_text="Supports markdown", + ) + subscribe_note = models.CharField( + max_length=350, + blank=True, + null=True, + default="Subscribe via [RSS](/rss/) / [via Email](/newsletter/).", + help_text="Default: Subscribe via [RSS](/rss/) / [via Email](/newsletter/).", + ) + footer_note = models.TextField( + blank=True, + null=True, + default="published with [BōcPress](https://bocpress.co.uk/).", + help_text="Supports markdown", + ) + theme_zialucia = models.BooleanField( + default=False, + verbose_name="Theme Zia Lucia", + help_text="Enable/disable Zia Lucia theme with larger default font size.", + ) + theme_sansserif = models.BooleanField( + default=False, + verbose_name="Theme Sans-serif", + help_text="Use sans-serif font in blog content.", + ) + + redirect_domain = models.CharField( + max_length=150, + blank=True, + null=True, + help_text="Retiring your BōcPress blog? We can redirect to your new domain.", + validators=[validators.validate_domain_name], + ) + custom_domain = models.CharField( + max_length=150, + blank=True, + null=True, + help_text="To setup: Add an A record in your domain's DNS with IP 78.47.67.77", + validators=[validators.validate_domain_name], + ) + + comments_on = models.BooleanField( + default=False, + help_text="Enable/disable comments for your blog.", + verbose_name="Comments", + ) + notifications_on = models.BooleanField( + default=True, + help_text="Allow/disallow people subscribing for email newsletter for new posts.", + verbose_name="Newsletter", + ) + mail_export_on = models.BooleanField( + default=False, + help_text="Enable/disable auto emailing of account exports every month.", + verbose_name="Mail export", + ) + post_backups_on = models.BooleanField( + default=False, + help_text="Enable/disable automatic post backups.", + verbose_name="Post Backups On", + ) + show_posts_on_homepage = models.BooleanField( + default=True, + help_text="Show/hide posts on the homepage.", + verbose_name="Show Posts On Homepage", + ) + show_posts_in_nav = models.BooleanField( + default=False, + help_text="Show/hide posts in the navigation bar.", + verbose_name="Show Posts In Nav", + ) + noindex_on = models.BooleanField( + default=False, + help_text="Add a noindex meta tag so your blog is not indexed by search engines.", + verbose_name="noindex: Prevent Search Engine Indexing", + ) + reading_time_on = models.BooleanField( + default=False, + help_text="Add an estimated reading time - in minutes - to the top of the post body.", + verbose_name="Show Reading Time on Posts", + ) + robots_txt = models.TextField( + blank=True, + help_text="Custom robots.txt content for your blog. Leave blank for default.", + verbose_name="robots.txt", + null=True, + default="User-agent: *\nDisallow:\nAllow: /", + ) + export_unsubscribe_key = models.UUIDField(default=uuid.uuid4, unique=True) + + # webring related + webring_name = models.CharField(max_length=200, blank=True, null=True) + webring_url = models.URLField( + blank=True, + null=True, + verbose_name="Webring info URL", + help_text="Informational URL.", + ) + webring_prev_url = models.URLField( + blank=True, + null=True, + verbose_name="Webring previous URL", + help_text="URL for your webring's previous website.", + ) + webring_next_url = models.URLField( + blank=True, + null=True, + verbose_name="Webring next URL", + help_text="URL for your webring's next website.", + ) + + # billing + stripe_customer_id = models.CharField(max_length=100, blank=True, null=True) + stripe_subscription_id = models.CharField(max_length=100, blank=True, null=True) + monero_address = models.CharField(max_length=95, blank=True, null=True) + is_premium = models.BooleanField(default=False) + is_grandfathered = models.BooleanField(default=False) + + # moderation + is_approved = models.BooleanField(default=False) + + class Meta: + ordering = ["-id"] + + @property + def blog_absolute_url(self): + protocol = f"{util.get_protocol()}" + return f"{protocol}//{self.username}.{settings.CANONICAL_HOST}" + + @property + def blog_url(self): + url = f"{util.get_protocol()}" + if self.custom_domain: + return url + f"//{self.custom_domain}" + else: + return url + f"//{self.username}.{settings.CANONICAL_HOST}" + + @property + def blog_byline_as_text(self): + linker = bleach.linkifier.Linker(callbacks=[lambda attrs, new: None]) + html_text = util.md_to_html(self.blog_byline, strip_tags=True) + return linker.linkify(html_text) + + @property + def blog_byline_as_html(self): + return util.md_to_html(self.blog_byline) + + @property + def blog_index_content_as_text(self): + linker = bleach.linkifier.Linker(callbacks=[lambda attrs, new: None]) + html_text = util.md_to_html(self.blog_index_content, strip_tags=True) + return linker.linkify(html_text) + + @property + def blog_index_content_as_html(self): + return util.md_to_html(self.blog_index_content) + + @property + def about_as_html(self): + return util.md_to_html(self.about, strip_tags=True) + + @property + def subscribe_note_as_html(self): + return util.md_to_html(self.subscribe_note) + + @property + def footer_note_as_html(self): + return util.md_to_html(self.footer_note) + + @property + def post_count(self): + return Post.objects.filter(owner=self).count() + + @property + def class_status(self): + if self.is_premium or self.is_grandfathered: + return "💠" + return "∅" + + def get_export_unsubscribe_url(self): + domain = self.custom_domain or f"{self.username}.{settings.CANONICAL_HOST}" + path = reverse("export_unsubscribe_key", args={self.export_unsubscribe_key}) + return f"//{domain}{path}" + + def reset_api_key(self): + self.api_key = _generate_key() + self.save() + + def __str__(self): + return self.username + + +class Post(models.Model): + title = models.CharField(max_length=300) + slug = models.CharField(max_length=300) + body = models.TextField(blank=True, null=True) + owner = models.ForeignKey(User, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + published_at = models.DateField( + default=timezone.now, + blank=True, + null=True, + help_text="Leave blank to keep as draft/unpublished. Use a future date for auto-posting.", + ) + broadcasted_at = models.DateTimeField(blank=True, null=True, default=None) + + class Meta: + ordering = ["-published_at", "-created_at"] + unique_together = [["slug", "owner"]] + + @property + def body_as_html(self): + return util.md_to_html(self.body) + + @property + def body_as_text(self): + as_html = util.md_to_html(self.body) + return bleach.clean(as_html, strip=True, tags=[]) + + @property + def is_draft(self): + return not self.published_at + + @property + def is_published(self): + # draft case + if not self.published_at: + return False + # future publishing date case + if self.published_at > timezone.now().date(): # noqa: SIM103 + return False + return True + + def get_absolute_url(self): + path = reverse("post_detail", kwargs={"slug": self.slug}) + return f"//{self.owner.username}.{settings.CANONICAL_HOST}{path}" + + def get_proper_url(self): + """Returns custom domain URL if custom_domain exists, else subdomain URL.""" + if self.owner.custom_domain: + path = reverse("post_detail", kwargs={"slug": self.slug}) + return f"//{self.owner.custom_domain}{path}" + else: + return self.get_absolute_url() + + def __str__(self): + return self.title + + +class Image(models.Model): + owner = models.ForeignKey(User, on_delete=models.CASCADE) + name = models.CharField(max_length=300) # original filename + slug = models.CharField(max_length=300, unique=True) + data = models.BinaryField() + extension = models.CharField(max_length=10) + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-uploaded_at"] + + @property + def filename(self): + return self.slug + "." + self.extension + + @property + def data_as_base64(self): + return base64.b64encode(self.data).decode("utf-8") + + @property + def data_size(self): + """Get image size in MB.""" + return round(len(self.data) / (1024 * 1024), 2) + + @property + def raw_url_absolute(self): + path = reverse( + "image_raw", kwargs={"slug": self.slug, "extension": self.extension} + ) + return f"//{settings.CANONICAL_HOST}{path}" + + def get_absolute_url(self): + path = reverse("image_detail", kwargs={"slug": self.slug}) + return f"//{settings.CANONICAL_HOST}{path}" + + def __str__(self): + return self.name + + +class Page(models.Model): + title = models.CharField(max_length=300) + slug = models.CharField( + max_length=300, + validators=[validators.AlphanumericHyphenValidator()], + help_text="Lowercase letters, numbers, and - (hyphen) allowed.", + ) + body = models.TextField(blank=True, null=True) + owner = models.ForeignKey(User, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + is_hidden = models.BooleanField( + default=False, + help_text="If checked, page link will not appear on the blog header.", + ) + + class Meta: + ordering = ["slug"] + unique_together = [["slug", "owner"]] + + @property + def body_as_html(self): + return util.md_to_html(self.body) + + def get_absolute_url(self): + path = reverse("page_detail", kwargs={"slug": self.slug}) + return f"//{self.owner.username}.{settings.CANONICAL_HOST}{path}" + + def __str__(self): + return self.title + + +class AnalyticPage(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + path = models.CharField(max_length=300) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return self.created_at.strftime("%c") + ": " + self.user.username + + +class AnalyticPost(models.Model): + post = models.ForeignKey(Post, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return self.created_at.strftime("%c") + ": " + self.post.title + + +class Comment(models.Model): + post = models.ForeignKey(Post, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + body = models.TextField() + name = models.CharField(max_length=150, default="Anonymous", null=True, blank=True) + email = models.EmailField(null=True, blank=True) + is_approved = models.BooleanField(default=False) + is_author = models.BooleanField( + default=False, help_text="True if logged in author has posted comment." + ) + + class Meta: + ordering = ["created_at"] + + @property + def body_as_html(self): + return util.md_to_html(self.body) + + def get_absolute_url(self): + path = reverse("post_detail", kwargs={"slug": self.post.slug}) + return f"//{self.post.owner.username}.{settings.CANONICAL_HOST}{path}#comment-{self.id}" + + def __str__(self): + return self.created_at.strftime("%c") + ": " + self.post.title + + +class Notification(models.Model): + blog_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + email = models.EmailField() + unsubscribe_key = models.UUIDField(default=uuid.uuid4, unique=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["email"] + unique_together = [["email", "blog_user"]] + + def get_unsubscribe_url(self): + domain = ( + self.blog_user.custom_domain + or f"{self.blog_user.username}.{settings.CANONICAL_HOST}" + ) + path = reverse("notification_unsubscribe_key", args={self.unsubscribe_key}) + return f"//{domain}{path}" + + def __str__(self): + return self.email + " – " + str(self.unsubscribe_key) + + +class NotificationRecord(models.Model): + """ + NotificationRecord model is to keep track of all notifications + for the newsletter feature. + """ + + notification = models.ForeignKey(Notification, on_delete=models.SET_NULL, null=True) + post = models.ForeignKey(Post, on_delete=models.SET_NULL, null=True) + sent_at = models.DateTimeField(default=timezone.now, null=True) + + class Meta: + ordering = ["-sent_at"] + unique_together = [["post", "notification"]] + + def __str__(self): + if not self.sent_at: + return str(self.id) + if self.notification: + return self.sent_at.strftime("%c") + " – " + self.notification.email + else: + return self.sent_at.strftime("%c") + " – NULL" + + +class ExportRecord(models.Model): + """ExportRecord model is to keep track of each export email.""" + + name = models.CharField(max_length=150) + user = models.ForeignKey(User, on_delete=models.CASCADE) + sent_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-sent_at"] + + def __str__(self): + return self.name + + +class Snapshot(models.Model): + """Snapshot model is used to keep track of all versions of Posts.""" + + title = models.CharField(max_length=300) + body = models.TextField(blank=True, null=True) + owner = models.ForeignKey(User, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return self.title + + +class Onboard(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) + code = models.UUIDField(default=uuid.uuid4, unique=True) + problems = models.CharField(max_length=300, null=True, blank=True) + quality = models.CharField(max_length=300, null=True, blank=True) + seo = models.CharField(max_length=300, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Prb: {self.problems} / Qlt: {self.quality}" + +class ReallySimpleLicensing(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="reallysimplelicensing") + license = models.TextField(blank=True, null=True, help_text="License text in XML format defined by the Really Simple Licensing standard. Will be available at /license.xml.") + show_http = models.BooleanField(default=True, verbose_name="Show HTTP License", help_text="Show/hide license in HTTP header.") + show_rss = models.BooleanField(default=True, verbose_name="Show RSS License", help_text="Show/hide license in RSS feed.") + show_robotstxt = models.BooleanField(default=True, verbose_name="Show robots.txt License", help_text="Show/hide license in robots.txt.") + show_webpage = models.BooleanField(default=True, verbose_name="Show Webpage License", help_text="Show/hide license on webpage header.") \ No newline at end of file diff --git a/main/sitemaps.py b/main/sitemaps.py new file mode 100644 index 0000000000000000000000000000000000000000..58f190f64d9c450b5b7d10b4d5c33bc0859326e6 --- /dev/null +++ b/main/sitemaps.py @@ -0,0 +1,56 @@ +from django.contrib.sitemaps import Sitemap +from django.urls import reverse +from django.utils import timezone + +from main import models + + +class StaticSitemap(Sitemap): + priority = 1.0 + changefreq = "always" + + def items(self): + return ["index"] + + def location(self, obj): + return reverse(obj) + + +class PostSitemap(Sitemap): + priority = 1.0 + changefreq = "daily" + + def __init__(self, subdomain): + self.subdomain = subdomain + + def items(self): + return models.Post.objects.filter( + owner__username=self.subdomain, + published_at__isnull=False, + published_at__lte=timezone.now().date(), + ).order_by("-published_at") + + def location(self, obj): + return reverse("post_detail", kwargs={"slug": obj.slug}) + + def lastmod(self, obj): + return obj.updated_at + + +class PageSitemap(Sitemap): + priority = 0.8 + changefreq = "daily" + + def __init__(self, subdomain): + self.subdomain = subdomain + + def items(self): + return models.Page.objects.filter( + owner__username=self.subdomain, is_hidden=False + ) + + def location(self, obj): + return reverse("page_detail", kwargs={"slug": obj.slug}) + + def lastmod(self, obj): + return obj.updated_at diff --git a/main/static/favicon.png b/main/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..f79521cae47a753be80bb94db473056b4a213c2a GIT binary patch literal 411 zcmV;M0c8G(P)kBAft@kQeLSw=}!C~j>;sVVk7qUj73<+Xy$^d3HMW19S4rOe6#rnJ25{bl5z5v2IXLb=$ZZH4<002ovPDHLk FV1lqGr?LP5 literal 0 HcmV?d00001 diff --git a/main/static/logo.svg b/main/static/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..ce2cb7f98fe5fe2e3d5a55eadaedde885d4340f4 --- /dev/null +++ b/main/static/logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/main/templates/400.html b/main/templates/400.html new file mode 100644 index 0000000000000000000000000000000000000000..07383e0df54fd26cea054530c9d6a47df863db19 --- /dev/null +++ b/main/templates/400.html @@ -0,0 +1,13 @@ +{% extends 'main/layout.html' %} + +{% block title %}400 — Bad Request{% endblock %} + +{% block content %} +
+

400

+

Bad Request

+

You made a request in a way that we don't understand. That's all we know.

+

← Go back or return to the homepage.

+
+ +{% endblock content %} diff --git a/main/templates/403.html b/main/templates/403.html new file mode 100644 index 0000000000000000000000000000000000000000..0b3de311f2c1afaefa992f9dde9d910f5c1cbe90 --- /dev/null +++ b/main/templates/403.html @@ -0,0 +1,13 @@ +{% extends 'main/layout.html' %} + +{% block title %}403 — Permission Denied{% endblock %} + +{% block content %} +
+

403

+

Permission Denied

+

Looks like you tried to do something you're not allowed to. Oops?

+

← Go back or return to the homepage.

+
+ +{% endblock content %} diff --git a/main/templates/404.html b/main/templates/404.html new file mode 100644 index 0000000000000000000000000000000000000000..7f248b0571cf0312c5839bced15a67a69f2fef05 --- /dev/null +++ b/main/templates/404.html @@ -0,0 +1,12 @@ +{% extends 'main/layout.html' %} + +{% block title %}404 — Page Not Found{% endblock %} + +{% block content %} +
+

404

+

Page Not Found

+

The URL you requested does not exist on this blog. Got lost?

+

← Go back or return to the homepage.

+
+{% endblock content %} diff --git a/main/templates/500.html b/main/templates/500.html new file mode 100644 index 0000000000000000000000000000000000000000..a2bbf41541741057419e9987098f03fd1c9b2015 --- /dev/null +++ b/main/templates/500.html @@ -0,0 +1,13 @@ +{% extends 'main/layout.html' %} + +{% block title %}500 — Internal Server Error{% endblock %} + +{% block content %} +
+

500

+

Internal Server Error

+

An error occurred processing your request. That's all we know.

+

← Go back or return to the homepage.

+
+ +{% endblock content %} diff --git a/main/templates/assets/drag-and-drop-upload.js b/main/templates/assets/drag-and-drop-upload.js new file mode 100644 index 0000000000000000000000000000000000000000..4048882096e374461a15fdc0a3d7586758e4b494 --- /dev/null +++ b/main/templates/assets/drag-and-drop-upload.js @@ -0,0 +1,82 @@ +// setup for drag and drop uploading +document.getElementById('js-show').style.display = 'inline'; +document.getElementById('js-status').style.color = '#f00'; + +// get body element, used for drag and drop onto it +var bodyElem = document.querySelector('textarea[name="body"]'); + +// prevent default drag and drop behaviours +[ + 'drag', + 'dragstart', + 'dragend', + 'dragover', + 'dragenter', + 'dragleave', + 'drop', +].forEach(function (event) { + bodyElem.addEventListener(event, function (e) { + e.preventDefault(); + e.stopPropagation(); + }); +}); + +function injectImageMarkdown(textInputElem, imageName, imageURL) { + // build markdown image code + var markdownImageCode = '![' + imageName + '](' + imageURL + ')'; + + // inject markdown image code in cursor position + if (textInputElem.selectionStart || textInputElem.selectionStart == '0') { + var startPos = textInputElem.selectionStart; + var endPos = textInputElem.selectionEnd; + textInputElem.value = textInputElem.value.substring(0, startPos) + + markdownImageCode + + '\n' + + textInputElem.value.substring(endPos, textInputElem.value.length); + + // set cursor location to after markdownImageCode +1 for the new line + textInputElem.selectionEnd = endPos + markdownImageCode.length + 1; + } else { + // there is no cursor, just append + textInputElem.value += markdownImageCode; + } +} + +bodyElem.addEventListener('drop', function (e) { + // only upload one file at a time + if (e.dataTransfer.files.length === 1) { + + // prepare form data + var formData = new FormData(); + var name = e.dataTransfer.files[0].name; + formData.append("file", e.dataTransfer.files[0]); + + // upload request + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function alertContents() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + // success, inject markdown snippet + injectImageMarkdown(bodyElem, name, xhr.responseURL); + } else { + alert('Image could not be uploaded. ' + xhr.responseText); + } + + // re-enable textarea + bodyElem.disabled = false; + + // update status message + document.getElementById('js-status').innerText = ''; + } else { + // this branch runs first + // uplading, so disable textarea and show status message + bodyElem.disabled = true; + document.getElementById('js-status').innerText = 'UPLOADING...'; + } + }; + + xhr.open('POST', '/images/?raw=true'); + xhr.setRequestHeader('X-CSRFToken', '{{ csrf_token }}'); + xhr.send(formData); + } +}); diff --git a/main/templates/assets/make-draft-button.js b/main/templates/assets/make-draft-button.js new file mode 100644 index 0000000000000000000000000000000000000000..cab13197d535753cc7eeaf1530e05ea590cff70e --- /dev/null +++ b/main/templates/assets/make-draft-button.js @@ -0,0 +1,35 @@ +function initPubDateButtons() { + // check if form instantiation is to create new post or edit existing one + var isCreateOp = {{ form.initial|yesno:"false,true" }}; + + var pubDateElem = document.querySelector('input[name="published_at"]'); + if (pubDateElem.value === '') { + + // add 'set to today' functionality on publication date + var setTodaySpan = document.getElementById('set-today'); + var setTodayAnchor = document.createElement('a'); + setTodayAnchor.innerText = 'set to today'; + setTodayAnchor.href='javascript:'; + setTodaySpan.appendChild(document.createTextNode(' — ')); + setTodaySpan.appendChild(setTodayAnchor); + setTodaySpan.addEventListener('click', function () { + var isoDate = new Date().toISOString().substring(0,10); + document.querySelector('input[name="published_at"]').value = isoDate; + }); + + } else if (isCreateOp) { + // add 'make draft / set to empty' functionality + var setEmptySpan = document.getElementById('set-empty'); + var setEmptyAnchor = document.createElement('a'); + setEmptyAnchor.innerText = 'set as draft'; + setEmptyAnchor.href='javascript:'; + setEmptySpan.appendChild(document.createTextNode(' — ')); + setEmptySpan.appendChild(setEmptyAnchor); + setEmptySpan.addEventListener('click', function () { + document.querySelector('input[name="published_at"]').value = ''; + }); + } +} + +// init +initPubDateButtons(); diff --git a/main/templates/assets/save-snapshot.js b/main/templates/assets/save-snapshot.js new file mode 100644 index 0000000000000000000000000000000000000000..26395686e76a5ab649b18ab1cb93b5350ac5ba3b --- /dev/null +++ b/main/templates/assets/save-snapshot.js @@ -0,0 +1,58 @@ +// keep timeout ids in an array so we reset them +var TIMEOUT_IDS = []; + +// save post title and body as a Snapshot connected to current user +function saveLogEntry() { + console.log("saving..."); + var title = document.getElementById('id_title').value; + if (!title) { + title = "Untitled" + } + var body = document.getElementById('id_body').value; + + // prepare form data + var formData = new FormData(); + formData.append("title", title); + formData.append("body", body); + + // upload request + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function alertContents() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + console.log("success"); + // success, show feedback + } else { + console.log("failure"); + // failure, show feedback + } + } else { + // this branch runs first + // uplading, show feedback + console.log("uplading..."); + } + }; + + xhr.open('POST', '/post-backups/create/'); + xhr.setRequestHeader('X-CSRFToken', '{{ csrf_token }}'); + xhr.send(formData); +} + +// clear timeout ids from given array +function clearTimeoutList(timeoutList) { + timeoutList.forEach(function (timeoutId) { + clearTimeout(timeoutId); + }); +} + +// listen for body textarea changes +function initAutoSave() { + document.getElementById('id_body').addEventListener('keyup', function () { + clearTimeoutList(TIMEOUT_IDS); + var timeoutId = setTimeout(saveLogEntry, 2500); + TIMEOUT_IDS.push(timeoutId); + }); +} + +// init +initAutoSave(); diff --git a/main/templates/assets/style.css b/main/templates/assets/style.css new file mode 100644 index 0000000000000000000000000000000000000000..51c220bd21359b26bae08c19e3ea23a68312c2c0 --- /dev/null +++ b/main/templates/assets/style.css @@ -0,0 +1,949 @@ +/* reset */ +html { + line-height: 1.15; + /* prevent adjustments of font size after orientation changes in iOS */ + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; +} + +small { + /* fix font size in all browsers */ + font-size: 80%; + font-family: "Inter", "Arial", sans-serif; +} + +/* prevent sub and sup from affecting the line height in all browsers */ +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} +sub { + bottom: -0.25em; +} +sup { + top: -0.5em; +} + +table { + /* remove text indentation from table contents in Chrome and Safari */ + text-indent: 0; + /* fix table border color inheritance in Chrome and Safari */ + border-color: inherit; +} + +button, +input, +optgroup, +select, +textarea { + /* fix font styles in all browsers */ + font-family: inherit; + font-size: 100%; + line-height: 1.15; + /* remove margin in Firefox and Safari */ + margin: 0; +} + +button, +select { + /* remove inheritance of text transform in Edge and Firefox */ + text-transform: none; +} + +button, +[type='button'], +[type='reset'], +[type='submit'] { + /* fix inability to style clickable types in iOS and Safari */ + -webkit-appearance: button; +} + +/* general */ +:root { + {% if request.theme_zialucia %} + font-size: 20px; + {% endif %} + font-family: "Merriweather", "Georgia", "Times New Roman", serif; + + line-height: 1.5; + font-size: 16px; + + color-scheme: light dark; + + /* this colour is only used in dark mode */ + --dark-mode-color: #212529; + + --link-color: #1175e2; + --ascent-color: #006cdf; + --dark-grey-color: #757575; + --light-grey-color: #eff1f5; + --airy-grey-color: #fafafa; + --green-color: #26bd60; + --red-color: #ff0000; + --purple-color: #cd1ecd; +} + +@media (max-width: 34rem) { + :root { + /* mobile base font size remains 16px whether zialucia on or off */ + font-size: 16px; + } +} + +/* dark mode + * create a new color (dark-mode-color) + * override existing colors */ +@media (prefers-color-scheme: dark) { + :root { + color: #fff; + background: var(--dark-mode-color); + + --link-color: #70b3ff; + --dark-grey-color: #b1b6bb; + --light-grey-color: #353535; + --airy-grey-color: #2b2b2b; + --purple-color: #dd47ec; + } +} + +a { + text-decoration: none; + color: var(--link-color); +} + +a:hover { + text-decoration: underline; +} + +a.btn { + display: inline-block; + cursor: pointer; + background: var(--link-color); + color: #fff; + border: 1px solid var(--ascent-color); + padding: 8px 24px; +} + +a.btn:hover, +a.btn:active { + background: var(--ascent-color); + text-decoration: none; +} + +a.btn:disabled { + pointer-events: none; + background: var(--ascent-color); +} + + +h1 { + font-size: 1.8rem; + font-weight: 500; + padding-bottom: 4px; + border-bottom: 2px solid var(--light-grey-color); + margin: 16px 0; +} + +h2, +h3 { + font-weight: 500; + margin: 16px 0; +} + +main { + max-width: 34rem; + margin-bottom: 16px; + margin-left: auto; + margin-right: auto; + padding-left: 8px; + padding-right: 8px; +} +@media print { + main { + max-width: unset; + } +} + +@media print { + article { + page-break-before: always; + } +} + +aside { + max-width: 34rem; + border: 1px dashed #000; + padding: 2px 6px; + margin-top: 8px; + margin-left: auto; + margin-right: auto; + box-sizing: border-box; +} +@media (max-width: 34rem) { + aside { + margin-left: 8px; + margin-right: 8px; + } +} + +.alert-error { + color: var(--red-color); +} + +section { + max-width: 34rem; + margin-bottom: 16px; + margin-left: auto; + margin-right: auto; + padding-left: 8px; + padding-right: 8px; +} + +nav { + max-width: 34rem; + margin-top: 16px; + margin-left: auto; + margin-right: auto; + padding-left: 8px; + padding-right: 8px; +} +@media print { + nav { + display: none; + } +} + +ol, +ul { + padding-left: 24px; +} + +blockquote { + border-left: 4px solid var(--light-grey-color); + padding-left: 16px; + margin-left: 0; + color: var(--dark-grey-color); +} + +figure { + border: 1px var(--light-grey-color) solid; + margin: auto; + color: var(--dark-grey-color); +} + +figcaption { + font-size: 12px; + text-align: center; +} + +dt { + font-weight: 700; +} + +dd { + margin-left: 0; +} + +table { + border-collapse: collapse; + border: 1px solid var(--light-grey-color); + width: 100%; + box-sizing: border-box; +} + +thead:nth-child(odd), +tr:nth-child(even) { + background: var(--light-grey-color); +} + +th, +td { + padding: 4px; +} + +pre { + background: var(--airy-grey-color); + overflow-x: auto; +} + +code { + background: var(--airy-grey-color); + padding: 2px; +} + +hr { + border-top: 1px solid var(--light-grey-color); + border-bottom: none; + border-left: none; + border-right: none; +} + +summary { + cursor: pointer; + user-select: none; +} + +footer { + max-width: 34rem; + margin-left: auto; + margin-right: auto; + margin-top: 56px; + margin-bottom: 16px; + padding-left: 8px; + padding-right: 8px; + color: var(--dark-grey-color); +} + +.footer-comment { + margin-bottom: 8px; +} + +.help { + cursor: help; + text-decoration: dotted underline; +} + +/* mods + * they override specific classes with specific styles */ +.type-approve { + color: var(--green-color) !important; +} + +.type-delete { + color: var(--red-color) !important; +} + +.type-danger { + background: var(--red-color) !important; + border-color: var(--red-color) !important; +} + +/* form */ +label { + display: block; + margin-top: 16px; +} + +input[type="text"], +input[type="url"], +input[type="email"], +input[type="password"], +textarea { + display: block; + border: 2px solid var(--light-grey-color); + box-sizing: border-box; + width: 34rem; +} +@media (max-width: 34rem) { + input[type="text"], + input[type="url"], + input[type="email"], + input[type="password"], + textarea { + width: 100%; + } +} +@media (prefers-color-scheme: dark) { + input[type="text"], + input[type="url"], + input[type="email"], + input[type="password"], + textarea { + color: #fff; + background: var(--light-grey-color); + } +} + +input[type="submit"] { + cursor: pointer; + background: var(--link-color); + color: #fff; + border: 1px solid var(--ascent-color); + padding: 8px 24px; +} + +input[type="submit"]:hover, +input[type="submit"]:active { + background: var(--ascent-color); +} + +input[type="submit"]:disabled { + pointer-events: none; + background: var(--ascent-color); +} + +form .helptext { + color: var(--dark-grey-color); +} + +.form-error { + color: var(--red-color); +} + +.form-inline { + display: inline-block; +} + +.form-inline input[type="submit"] { + border: none; + background: unset; + color: var(--link-color); + padding: 0; +} + +.form-inline input[type="submit"]:hover { + text-decoration: underline; +} + +/* landing */ +.lead { + max-width: 34rem; + border: 1px dashed #000; + padding-top: 8px; + padding-bottom: 8px; +} +@media (prefers-color-scheme: dark) { + .lead { + border-color: #fff; + } +} + +.cta { + margin-top: 32px; + margin-bottom: 32px; +} + +.cta-link { + font-size: 20px; +} + +/* comparisons */ +.comparisons { + max-width: 1000px; +} + +.comparisons h1, +.comparisons p, +.comparisons ol, +.comparisons ul { + max-width: 34rem; + margin-left: auto; + margin-right: auto; +} + +.comparisons-matrix { + overflow: auto; +} + +.comparisons table { + min-width: 800px; + white-space: nowrap; +} + +.comparisons thead > tr > th:not(:first-child), +.comparisons tbody > tr > td:not(:first-child) { + text-align: center; +} + +.comparisons th { + text-align: left; +} + +.comparisons tr { + min-width: 300px; +} + +/* blog index */ +.blog a:hover { + text-decoration: underline; +} + +.blog a:visited { + color: var(--purple-color); +} + +.blog a:active { + color: var(--red-color); +} + +.drafts { + border: 1px solid var(--light-grey-color); + padding: 16px 16px 0; + margin-bottom: 24px; +} + +.posts { + list-style: none; + padding-left: 0; +} + +.posts li { + margin-bottom: 24px; +} + +.posts small { + white-space: nowrap; + color: var(--dark-grey-color); +} + +.posts time { + white-space: nowrap; +} + +.byline { + color: var(--dark-grey-color); + margin: 16px 0 24px; +} + +.webring { + margin-top: 64px; + display: flex; + justify-content: space-between; +} + +.webring-name { + color: #000; +} +@media (prefers-color-scheme: dark) { + .webring-name { + color: #fff; + } +} + +/* post detail */ +.posts-item-brand { + display: block; + margin-top: 16px; + margin-bottom: 16px; + color: var(--dark-grey-color); +} + +.posts-item-brand:hover { + text-decoration: none; +} + +.posts-item-brand::before { + content: "« "; +} + +.posts-item-title { + margin-bottom: 8px; +} + +.posts-item-byline { + color: var(--dark-grey-color); + margin-bottom: 8px; +} + +.posts-item-body p { + {% if not request.theme_sansserif %} + font-family: serif; + font-size: 1.1rem; + {% endif %} +} + +.posts-item-body li { + {% if not request.theme_sansserif %} + font-family: serif; + font-size: 1.1rem; + {% endif %} +} + +.posts-item-body img { + max-width: 100%; + display: block; + margin-left: auto; + margin-right: auto; +} + +/* comments */ +.comments { + margin-top: 64px; +} + +.comments-title { + font-size: 1.2rem; + margin-top: 32px; + padding-bottom: 4px; + border-bottom: 2px solid var(--light-grey-color); +} + +.comments-body { + margin-bottom: 16px; +} + +.comments-body p { + margin-top: 4px; +} + +/* page detail */ +.pages-item-brand { + display: block; + margin-top: 16px; + margin-bottom: 16px; + color: var(--dark-grey-color); +} + +.pages-item-brand:hover { + text-decoration: none; +} + +.pages-item-brand::before { + content: "« "; +} + +.pages-item-title { + margin-bottom: 8px; +} + +.pages-item-byline { + color: var(--dark-grey-color); + margin-bottom: 8px; +} + +.pages-item-body p { + {% if not request.theme_sansserif %} + font-family: serif; + font-size: 1.1rem; + {% endif %} +} + +.pages-item-body li { + {% if not request.theme_sansserif %} + font-family: serif; + font-size: 1.1rem; + {% endif %} +} + +.pages-item-body img { + max-width: 100%; +} + +.pages-generic-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + column-gap: 16px; + row-gap: 16px; + margin-top: 16px; +} +@media (max-width: 490px) { + .pages-generic-grid { + grid-template-columns: 1fr 1fr; + } +} +@media (max-width: 340px) { + .pages-generic-grid { + grid-template-columns: 1fr; + } +} + +/* dashboard */ +.dashboard-cta { + font-size: 1.1rem; +} + +.dashboard-list { + margin: 1rem 0; + line-height: 1.6; +} + +/* images */ +.images-grid { + max-width: 100%; + margin-top: 32px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-template-rows: 302px; +} + +.images-grid-item { + display: flex; + justify-content: center; + border: 1px solid var(--light-grey-color); +} + +.images-grid-item img { + max-width: 300px; + max-height: 300px; + object-fit: contain; +} + +/* image detail */ +.images-item { + max-width: 100%; + margin-top: 32px; + margin-bottom: 32px; + text-align: center; +} + +.images-item img { + max-width: 100%; + max-height: 100vh; +} + +.images-item-byline { + color: var(--dark-grey-color); +} + +/* analytics */ +.analytics-chart { + background: var(--light-grey-color); + line-height: 0; +} + +svg .analytics-chart-bar { + fill: var(--link-color); +} + +svg .analytics-chart-bar:hover { + fill: var(--ascent-color); +} + +svg .analytics-chart-text { + font-family: monospace; + font-size: 10px; +} + +/* billing stripe */ +#subscription-form input[type="submit"] { + margin-top: 8px; +} + +#card-element { + border: 1px solid var(--light-grey-color); + padding: 4px 8px; +} + +#card-element-errors { + margin-top: 8px; + color: var(--red-color); +} + +/* moderation */ +.moderation-content { + max-width: 100%; + margin-bottom: 32px; + overflow: auto; +} + +.filterbar { + margin: 0 0 12px 0; + display: flex; + gap: 12px; + align-items: center; +} + +.filterbar a { + padding: 2px 6px; + border: 1px solid var(--light-grey-color); +} + +.filterbar .filterbar-clear { + margin-left: auto; + border: none; + padding: 0; +} + +.filterbar .filterbar-form { + margin-left: 12px; + display: inline-flex; + gap: 6px; + align-items: center; +} + +.filterbar .filterbar-form input[type="number"] { + width: 90px; + box-sizing: border-box; +} + +.filterbar .filterbar-form button[type="submit"] { + display: inline-block; + padding: 2px 8px; + border: 1px solid var(--light-grey-color); + background: transparent; + color: var(--link-color); + text-decoration: none; + cursor: pointer; +} + +.filterbar .filterbar-form button[type="submit"]:hover { + text-decoration: underline; +} + +/* ensure vertical alignment for compact inline controls */ +.filterbar .filterbar-form label, +.pagination form label { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; +} + +.moderation-content-row { + display: flex; + flex-wrap: wrap; + border-bottom: 1px solid var(--light-grey-color); +} +@media (max-width: 34rem) { + .moderation-content-row { + padding: 8px 0; + } +} + +.moderation-content-row:nth-child(even) { + background: var(--airy-grey-color); +} + +.moderation-content-row div { + box-sizing: border-box; + padding: 4px; +} + +.moderation-content-row ul { + margin: 0; +} + +.moderation-content-row-id { + width: 45px; + white-space: nowrap; + font-family: monospace; + line-height: 1.8; +} +@media (max-width: 34rem) { + .moderation-content-row-id a { + color: darksalmon; + } +} + +.moderation-content-row-id2 { + width: 115px; + font-family: monospace; + font-size: 70%; + line-height: 2; + white-space: nowrap; +} + +.moderation-content-row-username { + width: 250px; + white-space: nowrap; + font-family: monospace; + line-height: 1.8; + overflow: hidden; + text-overflow: ellipsis; +} + +.moderation-content-row-actions { + width: 170px; + white-space: nowrap; + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; +} + +.moderation-content-row-url { + width: 400px; + white-space: nowrap; + font-family: monospace; + line-height: 1.8; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (max-width: 34rem) { + .moderation-content-row-url a { + color: var(--dark-grey-color); + text-decoration: underline; + } +} + +.moderation-content-row-email { + width: 300px; + white-space: nowrap; + font-family: monospace; + line-height: 1.8; + overflow: hidden; + text-overflow: ellipsis; +} +@media (max-width: 34rem) { + .moderation-content-row-email { + color: var(--dark-grey-color); + } +} + +.moderation-content-row-posts { + overflow: hidden; + text-overflow: ellipsis; +} + +.moderation-content-cards { + display: flex; +} + +.moderation-content-cards-col1 { + width: 20vw; +} + +.moderation-content-cards-col2 { + width: 20vw; +} + +.moderation-content-cards-col3 { + width: 30vw; + padding: 0 4px; +} + +.moderation-content-cards-col4 { + width: 30vw; + padding: 0 4px; +} + +/* pagination */ +.pagination { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin: 16px 0; +} + +.pagination a, +.pagination button[type="submit"] { + display: inline-block; + padding: 2px 8px; + border: 1px solid var(--light-grey-color); + background: transparent; + color: var(--link-color); + text-decoration: none; + cursor: pointer; +} + +.pagination a:hover, +.pagination button[type="submit"]:hover { + text-decoration: underline; +} + +.pagination .disabled { + color: var(--dark-grey-color); +} + +.pagination .status { + white-space: nowrap; +} + +.pagination form { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; +} + +.pagination input[type="number"] { + width: 70px; + box-sizing: border-box; +} diff --git a/main/templates/main/analytic_detail.html b/main/templates/main/analytic_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..87ec2be590df6f760d6dfa300f792323135158d4 --- /dev/null +++ b/main/templates/main/analytic_detail.html @@ -0,0 +1,60 @@ +{% extends 'main/layout.html' %} + +{% block title %}Analytics for {{ title }} — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

{{ title }}

+ + « all analytics + +

Analytics since {{ date_25d_ago }}

+

+ Note: Graph does not include visits when one is logged in on their blog. +

+ +
+ + + + {% for day, analytic in analytics_per_day.items %} + + {{ analytic.count_exact }} hits during {{ day|date:'F j, Y' }} + + + + {{ analytic.count_approx }} + + + + | {{ day|date:'Y-m-d' }} + + {% endfor %} + +
+ +
+{% endblock content %} diff --git a/main/templates/main/analytic_list.html b/main/templates/main/analytic_list.html new file mode 100644 index 0000000000000000000000000000000000000000..3fd7a7d76cbb739701d4cef92fa2612a7fd68bfe --- /dev/null +++ b/main/templates/main/analytic_list.html @@ -0,0 +1,33 @@ +{% extends 'main/layout.html' %} + +{% block title %}Analytics — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Analytics

+

+ List of pages: +

+ + {% if post_list %} +

+ List of posts: +

+ + {% endif %} +
+{% endblock content %} diff --git a/main/templates/main/api_docs.html b/main/templates/main/api_docs.html new file mode 100644 index 0000000000000000000000000000000000000000..ffdefb3b9b85cb5f6309855b0e04304c96673c6e --- /dev/null +++ b/main/templates/main/api_docs.html @@ -0,0 +1,203 @@ +{% extends 'main/layout.html' %} + +{% block title %}API{% endblock %} + +{% block content %} +
+

API

+

+ We provide an API to allow programmatic updating of one’s blog. +

+ +
+
API Key
+
{{ request.user.api_key|default:"your-api-key" }}
+
+ {% if request.user.is_authenticated %} +

+ Reset API key » +

+ {% endif %} + +

Notes

+
    +
  • + One can reset their API key. + This will invalidate their key and issue a new one. +
  • +
  • The API is at https://mataroa.blog/api/
  • +
  • All API endpoints end with a trailing slash.
  • +
  • + Content type + application/json is expected. +
  • +
  • There is no rate limiting.
  • +
+ +
+ +

Authentication

+

+ We authenticate requests using the + Authorization + HTTP header, using the Bearer scheme. +

+
Authorization: Bearer {{ request.user.api_key|default:"your-api-key" }}
+ +

POST /api/posts/

+

+ Create new post. +

+ + Parameters +
    +
  • title: string [required]
  • +
  • body: string [optional]
  • +
  • published_at: string (ISO date eg. 2006-01-31) [optional]
  • +
+ + Request +
{
+    "title": "New blog",
+    "body": "## Why?\n\nEveryone wants a blog, right?",
+    "published_at": "2020-09-21"
+}
+ + Response +
{
+    "ok": true,
+    "slug": "new-blog",
+    "url": "{{ protocol }}//{{ request.user.username|default:"your-username" }}.{{ host }}/blog/new-blog/"
+}
+ + curl +
$ curl -X POST \
+    -H 'Authorization: Bearer {{ request.user.api_key|default:"your-api-key" }}' \
+    -d '{"title": "New blog", "body": "## Why?\n\nEveryone needs a blog, right?"}' \
+    {{ protocol }}//{{ host }}/api/posts/
+
+ +

GET /api/posts/<post-slug>/

+

+ Get single post. +

+ + Parameters +
    +
  • (no parameters)
  • +
+ + Response +
{
+    "ok": true,
+    "slug": "new-blog",
+    "title": "New blog",
+    "body": "Welcome!"
+    "published_at": "2020-09-21"
+    "url": "{{ protocol }}//{{ request.user.username|default:"your-username" }}.{{ host }}/blog/new-blog/"
+}
+ + curl +
$ curl -X GET \
+    -H 'Authorization: Bearer {{ request.user.api_key|default:"your-api-key" }}' \
+    {{ protocol }}//{{ host }}/api/posts/new-blog/
+
+ +

PATCH /api/posts/<post-slug>/

+

+ Update existing post. +

+ + Parameters +
    +
  • title: string [optional]
  • +
  • slug: string (slug; no spaces) [optional]
  • +
  • body: string [optional]
  • +
  • published_at: string (ISO date eg. 2006-01-31; or empty to unpublish) [optional]
  • +
+ + Request +
{
+    "title": "Updating my new blog",
+    "slug": "updating-blog",
+    "body": "Welcome back!"
+    "published_at": "2020-09-21"
+}
+ + Response +
{
+    "ok": true,
+    "slug": "updating-blog",
+    "url": "{{ protocol }}//{{ request.user.username|default:"your-username" }}.{{ host }}/blog/updating-blog/"
+}
+ + curl +
$ curl -X PATCH \
+    -H 'Authorization: Bearer {{ request.user.api_key|default:"your-api-key" }}' \
+    -d '{"title": "Updating my new blog", "body": "Rethinking and rewriting."}' \
+    {{ protocol }}//{{ host }}/api/posts/introducing-my-new-blog/
+
+ + +

DELETE /api/posts/<post-slug>/

+

+ Delete post. +

+ + Parameters +
    +
  • (no parameters)
  • +
+ + Response +
{
+    "ok": true
+}
+ + curl +
$ curl -X DELETE \
+    -H 'Authorization: Bearer {{ request.user.api_key|default:"your-api-key" }}' \
+    {{ protocol }}//{{ host }}/api/posts/introducing-my-new-blog/
+
+ + +

GET /api/posts/

+

+ List all posts. +

+ + Parameters +
    +
  • (no parameters)
  • +
+ + Response +
{
+    "ok": true,
+    "post_list": [
+        {
+            "title": "On life",
+            "slug": "on-life",
+            "body": "What is life?\n\nAn illusion, a shadow, a story.",
+            "published_at": null,
+            "url": "{{ protocol }}//{{ request.user.username|default:"your-username" }}.mataroa.blog/blog/on-life/"
+        },
+        {
+            "title": "New blog",
+            "slug": "new-blog",
+            "body": "With health!",
+            "published_at": "2020-10-19",
+            "url": "{{ protocol }}//{{ request.user.username|default:"your-username" }}.mataroa.blog/blog/new-blog/"
+        }
+    ]
+}
+ + curl +
$ curl -X GET \
+    -H 'Authorization: Bearer {{ request.user.api_key|default:"your-api-key" }}' \
+    {{ protocol }}//{{ host }}/api/posts/
+
+ +
+
+{% endblock content %} diff --git a/main/templates/main/api_key_reset.html b/main/templates/main/api_key_reset.html new file mode 100644 index 0000000000000000000000000000000000000000..05e2e7ab194a230f6160478332b4670ff5aef860 --- /dev/null +++ b/main/templates/main/api_key_reset.html @@ -0,0 +1,21 @@ +{% extends 'main/layout.html' %} + +{% block title %}Reset API key{% endblock %} + +{% block content %} +
+

Reset API key

+

+ This will invalidate your current API key: +

+ {{ request.user.api_key }} +

+ and create a random new one in its place. +

+ +
+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/billing_card.html b/main/templates/main/billing_card.html new file mode 100644 index 0000000000000000000000000000000000000000..7763e1f3641bf3696a4a16e70bd62ffecf32024c --- /dev/null +++ b/main/templates/main/billing_card.html @@ -0,0 +1,103 @@ +{% extends 'main/layout.html' %} + +{% block title %}Add card{% endblock %} + +{% block head_extra %} + + +{% endblock head_extra %} + +{% block content %} +
+

Add card

+ +
+ +
+ {# stripe.js will create stripe elements forms here #} +
+ + {% csrf_token %} + +
+ +
+
+ +
+
+ +

+
+ Once you click Submit: +

+
    +
  1. + Your card details will be sent to Stripe (payment processor). +
  2. +
  3. + We will store your card within Stripe, so that we can charge you + next year. +
  4. +
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/main/templates/main/billing_card_confirm_delete.html b/main/templates/main/billing_card_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..b7bf6301ed8ff313053c4e22a360198861a3a896 --- /dev/null +++ b/main/templates/main/billing_card_confirm_delete.html @@ -0,0 +1,24 @@ +{% extends 'main/layout.html' %} + +{% block title %}Deleting card — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Delete {{ card.brand|capfirst }} {{ card.last4 }} for sure?

+

+ Are you sure you want to oust this card from your account? +

+
+ {{ card.brand|capfirst }} +
Ends in {{ card.last4 }} +
Expires in {{ card.exp_month }}/{{ card.exp_year }} +
+

+ It's not such a big deal anyway, you can add it again later, should you want. +

+
+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/billing_index.html b/main/templates/main/billing_index.html new file mode 100644 index 0000000000000000000000000000000000000000..3139ce02a7627e669285bd1a61fea91d7db516af --- /dev/null +++ b/main/templates/main/billing_index.html @@ -0,0 +1,163 @@ +{% extends 'main/layout.html' %} + +{% block title %}Billing{% endblock %} + +{% block content %} +
+

Billing

+ + {# grandfather case #} + {% if request.user.is_grandfathered %} + Currently and forever on the + Grandfather Plan. + Rejoice eternally. + {% endif %} + + {# monero case #} + {% if not request.user.is_grandfathered and request.user.monero_address %} + {% if request.user.is_premium %} +

+ Currently on Premium Plan. +

+

+ Our Premium Plan costs 0.05 XMR per year and includes all features including + the ability to set a custom domain. +

+

+ Your Monero address is: +

+ + {{ request.user.monero_address }} + + {% else %} +

+ Currently on Free Plan. +

+

+ Our Premium Plan costs 0.05 XMR per year and includes all features including + the ability to set a custom domain. +

+

+ Subscribe by sending 0.05 XMR to the Monero address below. +

+ + {{ request.user.monero_address }} + +

+ Please allow 48H for the transaction to be verified and BōcPress to get up-to-date. +

+ {% endif %} + {% endif %} + + {# stripe case #} + {% if not request.user.is_grandfathered and not request.user.monero_address %} + + {# stripe case - premium intro #} + {% if request.user.is_premium %} +

+ Currently on Premium Plan. +

+
    +
  • Last payment of $9 was charged {{ current_period_start|date:"F j, Y" }}.
  • +
  • Next payment will be at {{ current_period_end|date:"F j, Y" }}.
  • +
  • $0.45 – 5% of your previous payment was used to fund CO₂ removal.
  • +
+ {% endif %} + + {# stripe case - non-premium #} + {% if not request.user.is_premium %} +

+ Currently on Free Plan. +

+

+ Our Premium Plan costs £12 per year and includes all features including + the ability to set a custom domain. +

+ {% endif %} + + {# stripe case - payment cards #} + {% if payment_methods %} +

Cards:

+
    + + {% for pm in payment_methods %} + {% if pm.is_default %} +
  • + {{ pm.brand|capfirst }} {{ pm.last4 }} (exp. {{ pm.exp_month }}/{{ pm.exp_year }}) — default + + + {% if not request.user.is_premium %} + | remove + {% endif %} +
  • + {% endif %} + {% endfor %} + + + {% for pm in payment_methods %} + {% if not pm.is_default %} +
  • + {{ pm.brand|capfirst }} {{ pm.last4 }} (exp. {{ pm.exp_month }}/{{ pm.exp_year }}) + —
    + {% csrf_token %} + +
    + | remove +
  • + {% endif %} + {% endfor %} +
+ {% endif %} + + {# stripe case - premium add card #} + {% if request.user.is_premium %} +

+ Add new card » +

+ {% endif %} + + {# stripe case - invoices for premium #} + {% if request.user.is_premium %} +

+ Invoices: +

+
    + {% for invoice in invoice_list %} +
  • + + {{ invoice.created|date }} + + {{ invoice.created|time:"H:i:s" }} + — + see invoice + | + download pdf +
  • + {% endfor %} +
+ {% endif %} + + {# stripe case - non-premium controls #} + {% if not request.user.is_premium %} +

+ {% if payment_methods %} +

+ {% csrf_token %} + +
+ {% else %} + Subscribe to Premium » + {% endif %} +

+ {% endif %} + + {# stripe case - premium cancel #} + {% if request.user.is_premium %} +

+ Cancel subscription +

+ {% endif %} + + {% endif %} +
+{% endblock content %} diff --git a/main/templates/main/billing_subscribe.html b/main/templates/main/billing_subscribe.html new file mode 100644 index 0000000000000000000000000000000000000000..93536a463106922ee8ce85f7def12d009391509f --- /dev/null +++ b/main/templates/main/billing_subscribe.html @@ -0,0 +1,119 @@ +{% extends 'main/layout.html' %} + +{% block title %}Subscribe to Premium{% endblock %} + +{% block head_extra %} + + +{% endblock head_extra %} + +{% block content %} +
+

Subscribe to Premium

+ +
+ +
+ {# stripe.js will create stripe elements forms here #} +
+ + {% csrf_token %} + +
+ +
+
+ +
+
+ +

+
+ Once you click Submit: +

+
    +
  1. + Your card details will be sent to Stripe (payment processor). +
  2. +
  3. + We will charge your card immediately, through Stripe. +
  4. +
  5. + We will store your card within Stripe, so that we can charge you + next year. +
  6. +
  7. + You will be able to cancel your subscription immediately as well, + should you want to. You can cancel your subscription at any point, + 24/7/365, from this billing dashboard with one click. +
  8. +
  9. + You can also get a refund, no questions asked. +
  10. +
  11. + All terms of service can be found in our + Platform Methodology + page. +
  12. +
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/main/templates/main/billing_subscription_cancel.html b/main/templates/main/billing_subscription_cancel.html new file mode 100644 index 0000000000000000000000000000000000000000..e5f45ee7939bb4eb56810ed3600abe344d564a8a --- /dev/null +++ b/main/templates/main/billing_subscription_cancel.html @@ -0,0 +1,14 @@ +{% extends 'main/layout.html' %} + +{% block title %}Cancel subscription — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Cancel Premium subscription for sure?

+ +
+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/blog_import.html b/main/templates/main/blog_import.html new file mode 100644 index 0000000000000000000000000000000000000000..b79585b584e44ac155528b3fbe8c768404b64139 --- /dev/null +++ b/main/templates/main/blog_import.html @@ -0,0 +1,29 @@ +{% extends 'main/layout.html' %} + +{% block title %}Import posts — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Import posts

+

+ BōcPress allows you to import text/markdown files. +

+
    +
  • You can choose multiple files at once.
  • +
  • They will be uploaded as drafts (unpublished).
  • +
+
+ {{ form.non_field_errors }} +

+ + {% if form.file.errors %} + {% for error in form.file.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} +

+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/blog_index.html b/main/templates/main/blog_index.html new file mode 100644 index 0000000000000000000000000000000000000000..84be26e8ab439d15274768eae05f1c194ab494cd --- /dev/null +++ b/main/templates/main/blog_index.html @@ -0,0 +1,97 @@ +{% extends 'main/layout.html' %} + +{% block title %}{{ blog_user.blog_title|default:blog_user.username }}{% endblock %} + +{% block meta_description %}{{ blog_user.blog_byline|default:"" }}{% endblock %} + +{% block head_extra %} + +{% if blog_user.blog_byline %} + +{% endif %} +{% endblock head_extra %} + +{% block head_rsl_license %} +{% include 'partials/rsl_license_head.html' %} +{% endblock head_rsl_license %} + +{% block content %} + + +
+ {% if blog_user.blog_title %} +

{{ blog_user.blog_title }}

+ {% endif %} + + {% if blog_user.blog_byline %} + + {% endif %} + + {% if blog_user.blog_index_content %} +
+ {{ blog_user.blog_index_content_as_html|safe }} +
+ {% endif %} + + {% if blog_user.show_posts_on_homepage %} + {% if request.user.is_authenticated and request.subdomain == request.user.username and drafts %} +
+ + Drafts + + +
+ {% endif %} + +
    + {% for p in posts %} + {% if p.published_at %} +
  • + {{ p.title }} + + — + + {% if not p.is_published %} + — SCHEDULED + {% endif %} + +
  • + {% endif %} + {% endfor %} +
+ {% endif %} +
+ +{% include 'partials/webring.html' %} + +{% include 'partials/footer_blog.html' %} + +{% endblock content %} diff --git a/main/templates/main/blog_posts.html b/main/templates/main/blog_posts.html new file mode 100644 index 0000000000000000000000000000000000000000..69a14092af54e830d5a131d5a5dfa3a7f0cdbd8d --- /dev/null +++ b/main/templates/main/blog_posts.html @@ -0,0 +1,85 @@ +{% extends 'main/layout.html' %} + +{% block title %}Posts | {{ blog_user.blog_title|default:blog_user.username }}{% endblock %} + +{% block meta_description %}{{ blog_user.blog_byline|default:"" }}{% endblock %} + +{% block head_extra %} + +{% if blog_user.blog_byline %} + +{% endif %} +{% endblock head_extra %} + +{% block head_rsl_license %} +{% include 'partials/rsl_license_head.html' %} +{% endblock head_rsl_license %} + +{% block content %} + + +
+ {% if blog_user.blog_title %} +

{{ blog_user.blog_title }}

+ {% endif %} + +

{% if blog_user.posts_page_title %}{{ blog_user.posts_page_title }}{% else %}Posts{% endif %}

+ + {% if request.user.is_authenticated and request.subdomain == request.user.username and drafts %} +
+ + Drafts + + +
+ {% endif %} + +
    + {% for p in posts %} + {% if p.published_at %} +
  • + {{ p.title }} + + — + + {% if not p.is_published %} + — SCHEDULED + {% endif %} + +
  • + {% endif %} + {% endfor %} +
+
+ +{% include 'partials/webring.html' %} + +{% include 'partials/footer_blog.html' %} + +{% endblock content %} diff --git a/main/templates/main/comment_approve.html b/main/templates/main/comment_approve.html new file mode 100644 index 0000000000000000000000000000000000000000..4624eff5b72a54a4863a1ba4a16f052271d957b4 --- /dev/null +++ b/main/templates/main/comment_approve.html @@ -0,0 +1,23 @@ +{% extends 'main/layout.html' %} + +{% block title %}Comment approve — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Comment approve

+ +

+ Published at {{ comment.created_at }} +
On {{ comment.post }} +
By {{ comment.name|default:"(no name)" }} + / + {{ comment.email|default:"(no email)" }} +

+
+ + {% csrf_token %} + +
+ {{ comment.body_as_html|safe }} +
+{% endblock content %} diff --git a/main/templates/main/comment_confirm_delete.html b/main/templates/main/comment_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..ef80970c62daacf015b67505b157feef1fbe9893 --- /dev/null +++ b/main/templates/main/comment_confirm_delete.html @@ -0,0 +1,33 @@ +{% extends 'main/layout.html' %} + +{% block title %}Deleting comment — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Are you sure you want to delete this comment?

+ + {{ comment.body_as_html|safe }} + + {% if comment.is_author %} +

+ This is a comment by you as blog author. +

+ {% else %} +

+ by + {{ comment.name|default:"(no name)" }} + / {{ comment.email|default:"(no email)" }} +

+ {% if comment.is_approved %} +

+ This is an approved comment. +

+ {% endif %} + {% endif %} + +
+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/comment_form.html b/main/templates/main/comment_form.html new file mode 100644 index 0000000000000000000000000000000000000000..7f29c944aae54fc6a99111eb231323e8e5c81869 --- /dev/null +++ b/main/templates/main/comment_form.html @@ -0,0 +1,64 @@ +{% extends 'main/layout.html' %} + +{% block title %} + {% if form.initial %} + Editing {{ form.title.value }} + {% else %} + Post a comment for {{ post.title }} + {% endif %} +{% endblock title %} + +{% block head_viewport %} + +{% endblock head_viewport %} + +{% block content %} +
+

+ {% if form.initial %} + Editing comment + {% else %} + Post a comment for {{ post.title }} + {% endif %} +

+
+ {{ form.non_field_errors }} + +

+ + {% if form.name.errors %} + {% for error in form.name.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + +

+ +

+ + {% if form.email.errors %} + {% for error in form.email.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + +

+ +

+ + {% if form.body.errors %} + {% for error in form.body.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + +

+ + {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/comment_list.html b/main/templates/main/comment_list.html new file mode 100644 index 0000000000000000000000000000000000000000..35e8fb47b73abb8ec9455494d2e29e7232461ded --- /dev/null +++ b/main/templates/main/comment_list.html @@ -0,0 +1,35 @@ +{% extends 'main/layout.html' %} + +{% block title %}Comments pending — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Comments pending ({{ comment_list|length }})

+ +
    + {% for comment in comment_list %} +
  • + Published at {{ comment.created_at }} + +
    On {{ comment.post }} + +
    By {{ comment.name|default:"(no name)" }} + / {{ comment.email|default:"(no email)" }} + +
    Comment: + {{ comment.body_as_html|safe }} + + Approve + | + + Delete + +
      +
  • + {% empty %} +
  • (no comments)
  • + {% endfor %} + + +
+{% endblock content %} diff --git a/main/templates/main/comparisons.html b/main/templates/main/comparisons.html new file mode 100644 index 0000000000000000000000000000000000000000..4131035edd24e74d5194f8c5796c209eb4267e54 --- /dev/null +++ b/main/templates/main/comparisons.html @@ -0,0 +1,164 @@ +{% extends 'main/layout.html' %} + +{% block title %}Comparing with other platforms{% endblock %} + +{% block content %} +
+

Comparing Mataroa with other platforms

+ +

+ In this matrix we compare some of the features of various hosted + blogging platforms. +

+

+ This is not a comprehensive list. It’s a selection to highlight the + differences that we think are interesting. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MataroaSubstackWordPressMediumGhostWrite.as
Core blogging features
Open source
Newsletter(via plugins)
Comments(debatable)(via integrations)
Paid subscribers(via plugins)
+ Ad revenue for author1 + (via integrations)
+ Themes2 +
+ Extensions2 +
+ Redirect to new domain3 +
+ Recurring auto-export3 + (via plugins)(via integrations)
+ Export for self-hosting3 + (via plugins)(via integrations)
+
+ +
    +
  1. We never plan to support anything related to ads.
  2. +
  3. + Having no themes or extensions is part of our minimalism philosophy. +
  4. +
  5. + We have a strong commitment to vendor independence, by offering + three features that make leaving mataroa possible, easy, and + risk-free. +
  6. +
+ +
+ +{% include 'partials/footer.html' %} + +{% endblock content %} diff --git a/main/templates/main/dashboard.html b/main/templates/main/dashboard.html new file mode 100644 index 0000000000000000000000000000000000000000..499e589e5dedf418e202de4f1d47acabdf744583 --- /dev/null +++ b/main/templates/main/dashboard.html @@ -0,0 +1,75 @@ +{% extends 'main/layout.html' %} + +{% block title %}Dashboard — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Dashboard

+

+ New post » +

+ + Content +
+ {% if request.user.comments_on %} + + Comments pending + ({{ comments_pending_count }}) + +
+ {% endif %} + + Images +
Posts +
Pages + + {% if request.user.notifications_on %} +
Newsletter + {% endif %} + +
Analytics + {% if request.user.post_backups_on %} +
Post Backups + {% endif %} +
+ + Manage +
+ Webring +
API + + {% if billing_enabled %} +
Billing + {% endif %} + +
Import posts +
Export blog +
+ + Account + + + Licensing + + + {% if request.user.is_superuser %} + Administration + + {% endif %} +
+{% endblock content %} diff --git a/main/templates/main/export_index.html b/main/templates/main/export_index.html new file mode 100644 index 0000000000000000000000000000000000000000..b88f6ec8e53d770a34461ed0de725d7a0c34aaaf --- /dev/null +++ b/main/templates/main/export_index.html @@ -0,0 +1,137 @@ +{% extends 'main/layout.html' %} + +{% block title %}Export blog — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Export blog

+

+ BōcPress allows you to export your blog posts into a zip archive that you + can directly use to self-host your website. We support five options: +

+
    +
  • Markdown: all blog posts as .md files, zip archived
  • +
  • Book: all blog posts as chapters in an .epub book
  • +
  • Print: renders all blog posts in one page
  • +
  • Zola: reliable and simple static site generator
  • +
  • Hugo: reliable and very popular static site generators
  • +
+ + {% if not request.user.is_authenticated %} +

Redirect

+ {% endif %} +

+ If you are retiring your BōcPress blog, we can also + redirect to your new domain. + You can configure this in the bottom last field of your + blog settings. +

+ +

Markdown

+

+ Markdown format export in a zip archive. +

+ {% if request.user.is_authenticated %} +
+ {% csrf_token %} + +
+ {% endif %} + +

Book

+

+ Book export in an epub format, with blog posts as chapters, a table of + contents, and author page. +

+ + {% if request.user.is_authenticated %} +
+ {% csrf_token %} + +
+ {% endif %} + +

Print

+

+ Renders all blog posts in one page. Useful for exporting the blog into + PDF, or printing in dead trees form, or anything that might require bulk + copying. +

+ + {% if request.user.is_authenticated %} + Generate all posts in one page + {% endif %} + +

Zola

+

+ Zola + is a + static site generator + which focuses in simplicity and lack of verbosity. +

+

+ To install Zola + see here. + To use after downloading the zip archive: +

+
    +
  1. cd into the directory
  2. +
  3. run zola serve
  4. +
  5. go to http://127.0.0.1:1111 in your browser
  6. +
+

Ready!

+ +

+ You can also host it for free on a number of platforms. There are how-to guides on + getzola.org for + Netlify, + GitHub Pages, + GitLab Pages, + Vercel, + Cloudflare Pages, + and one can also use sourcehut pages. +

+ + {% if request.user.is_authenticated %} +
+ {% csrf_token %} + +
+ {% endif %} + +

Hugo

+

+ Hugo is a very popular and reliable open source + static site generator. +

+

+ To install Hugo + see here. + To use after downloading the zip archive: +

+
    +
  1. cd into the directory
  2. +
  3. run hugo server
  4. +
  5. go to http://127.0.0.1:1313 in your browser
  6. +
+

Ready!

+ +

+ You can also host it for free on a number of platforms. There is a large number of guides on + gohugo.io including + Netlify, + GitHub, + GitLab, + Render, + and many others. +

+ + {% if request.user.is_authenticated %} +
+ {% csrf_token %} + +
+ {% endif %} + +
+{% endblock content %} diff --git a/main/templates/main/export_print.html b/main/templates/main/export_print.html new file mode 100644 index 0000000000000000000000000000000000000000..d2377388f8037796cd67517890c3171263657c0c --- /dev/null +++ b/main/templates/main/export_print.html @@ -0,0 +1,53 @@ +{% extends 'main/layout.html' %} + +{% block title %}{{ request.user.blog_title|default:request.user.username }}{% endblock %} + +{% block meta_description %}{{ request.user.blog_byline|default:"" }}{% endblock %} + +{% block content %} +
+ {% if request.user.blog_title %} +

{{ request.user.blog_title }}

+ {% endif %} + + {% if request.user.blog_byline %} +

+ {{ request.user.blog_byline_as_html|safe }} +

+ {% endif %} + +

+ ~{{ request.user.username }} +

+ + {% for p in posts %} +
+

{{ p.title }}

+ + + +
+ {{ p.body_as_html|safe }} +
+
+
+ {% endfor %} + +
+

About the Author

+

+ {{ request.user.about_as_html|safe }} +

+
+
+
+ +{% endblock content %} diff --git a/main/templates/main/export_unsubscribe_success.html b/main/templates/main/export_unsubscribe_success.html new file mode 100644 index 0000000000000000000000000000000000000000..aafe7354129bed68ab1c55b0c290867f275f6c6b --- /dev/null +++ b/main/templates/main/export_unsubscribe_success.html @@ -0,0 +1,26 @@ +{% extends 'main/layout.html' %} + +{% block title %}Unsubscribing from monthly mail exports — {{ blog_user.username }}{% endblock %} + +{% block content %} +
+ {% if blog_user.blog_title %} + {{ blog_user.blog_title }} + {% endif %} + + {% if unsubscribed %} +

Unsubscribed success

+

+ You ({{ email }}) will stop receiving monthly blog exports on your email. +

+

+ To re-enable go to your blog settings. +

+ {% else %} +

Not subscribed

+

+ Email not subscribed to auto email exports. +

+ {% endif %} +
+{% endblock content %} diff --git a/main/templates/main/guides_comments.html b/main/templates/main/guides_comments.html new file mode 100644 index 0000000000000000000000000000000000000000..08ddb0c41e1f3911ab96540bb8a6d0b915027c40 --- /dev/null +++ b/main/templates/main/guides_comments.html @@ -0,0 +1,50 @@ +{% extends 'main/layout.html' %} + +{% load static %} + +{% block title %}Comments Guide{% endblock %} + +{% block content %} +
+

Comments Guide

+

+ BōcPress supports comments on a blog post level. Comments are optional + and can be enabled blog-wide from the + settings page. +

+
    +
  • Comments are optionally eponymous.
  • +
  • + There are no configuration options for comments other than enabling + them. +
  • +
  • + Comments by blog authors are in bold font to make it clear that + they are from them. +
  • +
  • + No visitor comments are published immediately. They are all held + for moderation by the blog author. +
  • +
  • + Blog authors receive an email notification when there is a new + comment in any of their posts. +
  • +
  • + Blog authors have to approve a comment if they want it to be + public. By default, it remains hidden and not approved. +
  • +
  • + Blog authors can approve a comment either from each blog post + page or from the + Comments pending + dashboard page. +
  • +
  • Commenters do not get any notification, in any case.
  • +
+ +
+ +{% include 'partials/footer.html' %} + +{% endblock content %} diff --git a/main/templates/main/guides_customdomain.html b/main/templates/main/guides_customdomain.html new file mode 100644 index 0000000000000000000000000000000000000000..19d4b9b2e267476438eeb3e0bdda369d0b0851cd --- /dev/null +++ b/main/templates/main/guides_customdomain.html @@ -0,0 +1,42 @@ +{% extends 'main/layout.html' %} + +{% load static %} + +{% block title %}Custom Domain Guide{% endblock %} + +{% block content %} +
+

Custom Domain Guide

+

+ We offer custom domains with managed TLS (https) to users who pay for the + Premium Plan. +

+ +

Set up

+

+ Add an A record in your domain's DNS settings with IP: +

+
+ 95.217.30.133 +
+ +

Notes

+

+ We only support one domain. This means that we cannot + auto-redirect a naked domain (e.g. example.com) to its www version (i.e. + www.example.com). To do this, you would need to choose one version and then + host/serve/create some kind of redirect mechanism for the other version. +

+

+ If you use Cloudflare, take care to have "Proxied" turned + off. This means that the setting should be in mode "DNS Only". +

+

+ Other things to try: dig, online DNS lookup tools, private/incognito mode, + different browser, different computer/phone. +

+
+ +{% include 'partials/footer.html' %} + +{% endblock content %} diff --git a/main/templates/main/guides_images.html b/main/templates/main/guides_images.html new file mode 100644 index 0000000000000000000000000000000000000000..be6cc543fb6d7b4f971bb62a43fb9bf041d0e2a0 --- /dev/null +++ b/main/templates/main/guides_images.html @@ -0,0 +1,46 @@ +{% extends 'main/layout.html' %} + +{% load static %} + +{% block title %}Images Guide{% endblock %} + +{% block content %} +
+ +

Images Guide

+

+ Even though we are very text focused, we do support image uploading and hosting. + The limits are: +

+
    +
  • Max file size is 1MB
  • +
  • Total hosting up to 100MB
  • +
  • Total image count up to 1000
  • +
  • Bandwidth 100GB per year
  • +
+ +

Upload

+

+ There are two ways to upload an image. Either via the + image dashboard, or via any post or page editing page. +

+

Image dashboard: To upload, use the file selector at the top.

+

+ Post/page edit: To upload, just drag and drop onto the text area of the new post or page. +

+ +

Show images

+

+ To show the uploaded images on a BōcPress blog post or page, one can write the following: +
![image description here](https://mataroa.blog/images/896f9b41.png) +

+

+ This, of course, works with any image on the web: +
![lens](https://upload.wikimedia.org/wikipedia/commons/d/d8/BiconvexLens.jpg) +

+ +
+ +{% include 'partials/footer.html' %} + +{% endblock content %} diff --git a/main/templates/main/guides_markdown.html b/main/templates/main/guides_markdown.html new file mode 100644 index 0000000000000000000000000000000000000000..802ac586caa8940f680915c69fedb4d40aa501af --- /dev/null +++ b/main/templates/main/guides_markdown.html @@ -0,0 +1,106 @@ +{% extends 'main/layout.html' %} + +{% load static %} + +{% block title %}Markdown Guide{% endblock %} + +{% block content %} +
+

Markdown Guide

+

+ BōcPress supports Markdown in the blog posts body. Markdown is a very + simple formatting language. +

+ +

Italics

+

+ One can use *asterisks* between words for italics. +

+ +

Bold

+

+ One can use **double asterisks* for bold letters. +

+ +

Image

+
+ orange dot +
+

+ To display an image (such as the above dot), write: +
![image description - orange dot](https://mataroa.blog/static/favicon.png) +

+ + +

+ Write a link like this: [website link](https://sourcehut.org/) +
for it to appear like this website link. +

+ +

Headings

+

+ Headings on markdown are defined in a hierarchy of 6 levels. Heading level 1 + is the main title, heading level 2 a secondary, under level 1, heading 3 + tertiary under level 2, et al. +

+

+ Each level is defined with the number of hash signs as prefixes. Eg: +
+ # Markdown Guide + or + ## Italics. +

+ +

Monospace font

+

+ For a monospace font `use backticks`; it will appear like this. +

+ +

Line breaks

+

+ To change lines without changing paragraphs, one can add two spaces at the end of + the line, and then continue below. Example: +

0:00<space><space>
+This is the second line
+

+ +

Footnotes

+

+ To add a footnote, use the following notation: +

+

+ 1. On the main text, just after the word you want to have the footnote + superscript, add a word as reference, eg: [^picaso] +

+

+ 2. At the end of the post, add the footnote content, on its own line, + as such: +
+ [^picaso]: Pablo Ruiz Picasso was born on October 25th, 1881. +

+

+ When saved, this will automatically create the numbering and make them + links. NB: the square brackets, the caret, and the colon are important + parts of the syntax. +

+ + +

+ Every heading has an anchor automatically attached to it. For example: +
+ ## Footnotes is #footnotes +
+ ## Table of Contents is #table-of-contents +

+

+ One can create markdown links with the anchors as targets. Eg: + [Bold](#bold) will become + Bold. This enables jumping between parts of + a single post and can be used to create a table of contents as well. +

+ +
+ +{% include 'partials/footer.html' %} + +{% endblock content %} diff --git a/main/templates/main/image_confirm_delete.html b/main/templates/main/image_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..1ba58ced4b76e13fb0de318bd3443006bde50d3b --- /dev/null +++ b/main/templates/main/image_confirm_delete.html @@ -0,0 +1,13 @@ +{% extends 'main/layout.html' %} + +{% block title %}Deleting {{ image.name }} — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Delete {{ image.name }} for sure?

+
+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/image_detail.html b/main/templates/main/image_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..c5965ba9296ce1345769a79320ae137340f85d3a --- /dev/null +++ b/main/templates/main/image_detail.html @@ -0,0 +1,43 @@ +{% extends 'main/layout.html' %} + +{% block title %}{{ image.name }} — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

{{ image.name }}

+ + + +

+ Use markdown syntax to add this image in a post: +

+ + ![{{ image.name }}]({{ request.scheme }}:{{ image.raw_url_absolute }}) + + +

+ Markdown syntax for linkified image to full size version: +

+ + [![{{ image.name }}]({{ request.scheme }}:{{ image.raw_url_absolute }})]({{ request.scheme }}:{{ image.raw_url_absolute }}) + + + {% if used_by_posts %} +

Used by posts:

+ + {% endif %} +
+ +
+ {{ image.name }} +
+{% endblock content %} diff --git a/main/templates/main/image_form.html b/main/templates/main/image_form.html new file mode 100644 index 0000000000000000000000000000000000000000..86e26b35ec0acc65eb1882ae46c1682091d39914 --- /dev/null +++ b/main/templates/main/image_form.html @@ -0,0 +1,21 @@ +{% extends 'main/layout.html' %} + +{% block title %}Editing {{ form.name.value }} image — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Editing image

+ +
+ {{ form.as_p }} + {% csrf_token %} + +
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/main/templates/main/image_list.html b/main/templates/main/image_list.html new file mode 100644 index 0000000000000000000000000000000000000000..991ef26112bb215e5ded08a095f831cb0ac7d538 --- /dev/null +++ b/main/templates/main/image_list.html @@ -0,0 +1,37 @@ +{% extends 'main/layout.html' %} + +{% block title %}Images — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Images

+
+ {{ form.non_field_errors }} +

+ + {% if form.file.errors %} + {% for error in form.file.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} +

+ {% csrf_token %} + +
+
+ +
+

+ Using: {{ images|length }} out of 1000 images. + {{ total_quota }}MB out of 1000MB. +

+
+ +
+ {% for image in images %} + + {{ image.name }} + + {% endfor %} +
+{% endblock content %} diff --git a/main/templates/main/landing.html b/main/templates/main/landing.html new file mode 100644 index 0000000000000000000000000000000000000000..baae227549350628a1c68c948f523f9cf9ed00c7 --- /dev/null +++ b/main/templates/main/landing.html @@ -0,0 +1,241 @@ +{% extends 'main/layout.html' %} + +{% block title %}BōcPress — From Quill to Cloud{% endblock %} + +{% block content %} +
+

BōcPress

+

+ From Quill to Cloud. A blogging platform where words endure and stories take flight. +

+
    +
  • A matter of mere moments to joinSign up
  • +
      +
    • Hosted at <username>.bocpress.co.uk or yourdomain.com
    • +
    • Write your posts in markdown
    • +
    +
  • + View example post + / example blog +
  • +
+ +

Features

+ + +

Pricing

+
    +
  • Free — most features
  • +
      +
    • No custom domain
    • +
    • No auto-exports via email
    • +
    +
  • Premium — all features
  • + +
+ + + +

What does "BōcPress" mean?

+

BōcPress was chosen because it blends heritage, literature, and modern publishing in a single, memorable name. +

+

Let's break it down:

+
    +
  • “Bōc” comes from the Old English word for book, evoking centuries of literary tradition and the enduring + power of the written word.
  • +
  • “Press” signals publishing and dissemination, connecting the platform to both historic printing presses and + today’s digital tools.
  • +
+ +

Together, BōcPress represents a modern platform for writers while honoring the timeless craft of storytelling. +

+ +

This name reflects our mission: to provide a digital space where stories, ideas, and voices endure, combining + elegance, literary heritage, and modern accessibility.

+ +

FAQ

+ +
+ Do you support custom CSS? +
+

+ Not at the moment. The philosophy of BōcPress is to be as minimal as possible without enabling + too many decisions about styling. We understand that this is not what some prefer and are open to ideas. +

+
+
+ +
+ Can I self-host BōcPress? +
+

+ Absolutely. The project is + open source + and you can find deployment documentation + in the docs. +

+
+
+ +
+ Why should I use BōcPress instead of Substack/WordPress/etc? +
+

+ BōcPress embraces a minimalist ethos: swift, unadorned, and focused on the heart of your words. + It values simplicity over bells and whistles, while offering strong data interoperability. + Your content is yours to keep, and exporting it is effortless. +

+
+
+ +
+ Will you add themes? +
+

+ At present, there are no plans to add custom themes or templates. It's + part of our philosophy to enable one to just write and publish without + having to make too many decisions about styling. We understand that this is not what some prefer and are open to ideas. +

+

+ Nonetheless, there are discussions around adding some minor styling decisions for authors, + but these are unplanned at the moment. Feel free to get in touch with any ideas you may have around this topic. +

+
+
+ +
+ What about tags or categories? +
+

+ These are not currently supported, however there are plans to add them. We want to keep the platform simple, + but understand that tags or categories can help with organization and discovery. If implemented, + they will be designed to remain minimal and unobtrusive. +

+
+
+ +
+ What about pagination? +
+

+ Pagination can be difficult as it breaks the browser search across all post titles. + Additionally, because of how fast and simple BōcPress is, speed is not an issue + even with thousands of posts. This is a feature we may consider in the future, but + it is not currently planned. +

+
+
+ +
+ Why doesn't strikethrough markdown notation work? +
+

+ The markdown library we use + (Python-Markdown) + supports + + John Gruber’s markdown spec, + which does not define strikethrough text. Discussions have been had on migrating to another + markdown library at some point in the future, though, this is not + yet planned. +

+
+
+ +
+ Would you consider a rich text editor option? +
+

+ At present, a rich text editor is too much for BōcPress. Something like Trix does look like it would be great, + however the added complexity is too high with 200K of JavaScript code. Saving HTML in the database instead of + simple text is also less than ideal. +

+
+
+ +
+ Would you add support for LaTeX-style math expressions? +
+

+ This was recently introduced in V1.3.1. Please see the pull request for more information or give it a try yourself. +

+
+
+ +
+ What about supporting Mermaid flowcharts? +
+

+ We would not add + mermaid-js + as it is + + 2.7MB minified + + JavaScript which is far too heavy for BōcPress' mission. However, we would be interested + in implementing something similar if it was rendered on the backend. +

+
+
+ +
+ Do you support ActivityPub Federation? +
+

+ We do not, however, we'd like to. This is unplanned work that might + happen at some point in the future. +

+
+
+ +

Forked from mataroa

+
+ +{% include 'partials/footer.html' %} + +{% endblock content %} \ No newline at end of file diff --git a/main/templates/main/layout.html b/main/templates/main/layout.html new file mode 100644 index 0000000000000000000000000000000000000000..f1e3b60b4bfd835fd70df705adf925180e9424d9 --- /dev/null +++ b/main/templates/main/layout.html @@ -0,0 +1,88 @@ +{% load static %} + + + + + + {% block title %}{% endblock %} + + {% if not request.subdomain %} + + + {% endif %} + + {% block head_viewport %} + + {% endblock head_viewport %} + + + + {% if request.user.noindex_on %} + + {% endif %} + + {% block head_extra %} + {% endblock head_extra %} + + + + {% block head_rsl_license %} + {% endblock head_rsl_license %} + + + + {% if messages %} + + {% endif %} + + {% if request.user.is_authenticated %} + + {% if not request.subdomain or request.subdomain == request.user.username %} + + {% endif %} + + {% if request.user.redirect_domain %} + + {% endif %} + + {% else %} + {% if not request.subdomain %} + + {% endif %} + {% endif %} + + {% block content %} + {% endblock content %} + + {% block scripts %} + {% endblock scripts %} + + diff --git a/main/templates/main/methodology.html b/main/templates/main/methodology.html new file mode 100644 index 0000000000000000000000000000000000000000..40850d332c2d5a6b1c7f3289848c13f1301af393 --- /dev/null +++ b/main/templates/main/methodology.html @@ -0,0 +1,346 @@ +{% extends 'main/layout.html' %} + +{% block title %}Platform Methodology{% endblock %} + +{% block content %} +
+

Platform Methodology

+

+ Details on the what and how the mataroa platform is designed to work. +

+ +

Contents

+

Values

+ + +

Business

+ + +

Maintenance

+ + +

Infrastructure

+ + +

Meta

+ + +

Purpose

+

+ mataroa.blog exists to enable people to have their own voice on the web + without needing to rely on the platforms and infrastructure of the + most powerful. +

+

+ We want to do that by empowering personal independent blogs. +

+ +

Ethics

+

We are committed to:

+
    +
  • No tracking of user or visitor behaviour.
  • +
  • Never sell any user or visitor data.
  • +
  • No ads — ever.
  • +
+ +

Code of Publication

+

+ Mataroa is designed to be a place for people to voice their thoughts. +

+

+ However, we do not want to provide a platform for thoughts that + are spiteful or malevolent to an individual or a group on account of + their race, colour, nationality, sex, disability, religion, or sexual + orientation. +

+

+ Additionally, we do not want to contribute to the current state of the + web, which is ridden with ads, SEO tricks, and bot content. This + includes blogs with extremely low quality content that is designed to + serve as marketing for a specific shop or professional. Eg: a blog + named "Dentist in London" which contains a post titled "How to find the + best dentist", which itself contains a few paragraphs of random advice + and a link to one specific dentist in London. +

+

+ Any blogs found that match the above descriptions will be immediately + deleted, with an final markdown export emailed to the blog author (in + case they have an email on their account), and a notice of why their + blog was deleted. +

+ +

Comments Moderation

+

+ Comments in mataroa are filtered and reviewed by blog authors. +

+ +

Business Transparency

+

+ We aim to be as transparent as possible. We maintain a + Business Transparency + page with data on our revenue and costs. +

+ +

Account Terms

+
    +
  • + The user is responsible for all content posted and all actions + performed with their account. +
  • +
  • + The user is responsible for maintaining the security of their + account and password. +
  • +
  • + We reserve the right to disable or delete a user's account for any + reason at any time. We have this clause because, statistically + speaking, there will be people trying to do something nefarious. +
  • +
  • + We do not require an email address to register an account. However, + it is the only way for us to contact a user in cases of any + service update or account access restoration. For this reason, + having an email registered is very useful. +
  • +
+ +

Account Data

+

+ In order to have a functional mataroa.blog account a username and a + password are required. An email is also asked as it is the only way for + a user to restore their account in case of a forgotten password. + However, an email is not required. +

+

+ A user is able to change their username and password and any other + details (eg. email) through their + dashboard. +

+

+ A user is able to export all their data directly and at any point + through the export page. +

+

+ A user is able to completely delete their account and all information + related to their account through the dashboard, and then navigating to + blog settings, and scrolling all + the way down. In this case, the user account will be immediately + purged from our primary servers and 20 days later from our database + backups. +

+ +

Payments

+

+ We offer a Premium Plan of our service which requires payment. If a + user opts for the Premium Plan they are billed immediately for the + next one year term, and automatically billed every year unless cancelled. +

+

+ We accept card payments through Stripe, + but if one is not fond of this method we support alternatives. Please, + email admin@mataroa.blog for + and with details. +

+

+ We also fund CO₂ removal from the atmosphere using 5% of our + subscription revenue through + Stripe Climate. +

+ +

Refunds

+

+ We wouldn’t want to cause unhappiness. Any dissatisfied with our + service user can ask—and most probably get—a refund at + admin@mataroa.blog. +

+ +

Liability

+

+ The user expressly understands and agrees that Zermelo Fraenkel LTD, + the operators of this website shall not be liable, in law or in equity, + to them or to any third party for any direct, indirect, incidental, + lost profits, special, consequential, punitive or exemplary damages. +

+ +

Third-parties

+

+ We have a strong commitment to never share any user data with any + third-parties. The only neccessary exception to this rule is the + payment processor we use to accept card payments. That processor is + Stripe and the data sent over are + card numbers. This enables us to never—not even temporarily—store card + details on our servers and benefit from Stripe’s secure, + PCI-compliant + payment infrastructure. +

+ +

+ Please bear in mind that Stripe may also collect other data, such as IP + address and browser user agent. +

+ +

Service Availability

+

+ We provide the mataroa.blog service on an “as is” and “as available” + basis. We do not offer service-level agreements—but do take uptime + seriously. You can find a record of outages at + status.mataroa.blog. +

+ +

Contact and Support

+

+ Email us at admin@mataroa.blog + with any queries. +

+ +

Open Source

+

+ We have a creed to write free software. + Mataroa is developed publicly on sr.ht + and GitHub. +

+

+ There is no backlog or roadmap or issue/ticket system for mataroa projects. +

+

+ We use + a mailing list + and GitHub Issues + for bug tracking and other discussions. +

+ +

Infrastructure Policies

+
    +
  • + We maintain a + Dependency Policy + for all our top-level code dependencies. +
  • +
  • We take daily backups of our database.
  • +
  • Our backup retention policy is 20 days.
  • +
  • We test our backups every 6 months.
  • +
  • All passwords are stored in a hashed form.
  • +
  • All data centers we use have an ISO 27001 certification.
  • +
  • + All rights under + GDPR + are exercisable: + +
  • +
+ +

Encryption

+
    +
  • + All user passwords are stored + SHA256-hashed + using + PBKDF2. +
  • +
  • + We support and require encryption in transit via + TLS + 1.2 and 1.3. +
  • +
  • We do not implement data encryption at rest.
  • +
+ +

Cookies

+

+ We do not use any cookies for analytics, advertising, preferences, or + for any third-party service. +

+

+ We do use two cookies, one for account authentication (keeping users + logged in) and another for security (to prevent + CSRF). +

+ +

Server Providers

+
    +
  • + Our servers are operated by + Hetzner Online GmbH, + an EU company based in Gunzenhausen, Germany. +
  • +
  • + The main data center we use is + HEL1-DC2 + and is located in Helsinki, Finland. +
  • +
  • + We store backups with + Scaleway + in Paris, France. +
  • +
+ +

Acknowledgements

+

+ Mataroa was inspired by Bear Blog, + another minimal blogging platform. +

+

+ Mataroa is built using many existing open source technologies, + which we deeply appreciate and want to thank for their beyond stellar + work. +

+

In somewhat particular order but not of importance:

+
    +
  • + The Django Project, community, and the + Django Software Foundation. +
  • +
  • The PostgreSQL community.
  • +
  • The psycopg team.
  • +
  • The Caddy community.
  • +
  • + The contributors of markdown, + pygments, + bleach packages. +
  • +
  • + and of course the creators and contributors of Python, Ubuntu, Debian, Linux kernel, + Bash, GNU Project, rclone, Let’s Encrypt, C, Git, vim, and the list is never ending... +
  • +
+ +

Changes

+

+ Maybe we’ll change our minds for some of these statements. In cases of + major changes, users with an email to their account will receive a + notice 14 days prior. +

+
+ +{% include 'partials/footer.html' %} + +{% endblock content %} diff --git a/main/templates/main/moderation_activity.html b/main/templates/main/moderation_activity.html new file mode 100644 index 0000000000000000000000000000000000000000..634e5d0d72b7d186ad5e5099a5f792a40bf6b7e9 --- /dev/null +++ b/main/templates/main/moderation_activity.html @@ -0,0 +1,183 @@ +{% extends 'main/layout.html' %} + +{% load humanize %} + +{% block title %}Moderation Activity{% endblock %} + +{% block content %} +
+

Moderation Activity

+
+ +
+
+
+ New users (daily, last 20 shown) + + {% for row in chart_new_users_daily %} + + {{ row.count }} new users on {{ row.period|date:'Y-m-d' }} + + + {{ row.count }} + + {% endfor %} + + + New posts (daily, last 20 shown) + + {% for row in chart_new_posts_daily %} + + {{ row.count }} new posts on {{ row.period|date:'Y-m-d' }} + + + {{ row.count }} + + {% endfor %} + +
+ +
+ New users (weekly, last 12 shown) + + {% for row in chart_new_users_weekly %} + + {{ row.count }} new users (week of {{ row.period|date:'Y-m-d' }}) + + + {{ row.count }} + + {% endfor %} + + + New posts (weekly, last 12 shown) + + {% for row in chart_new_posts_weekly %} + + {{ row.count }} new posts (week of {{ row.period|date:'Y-m-d' }}) + + + {{ row.count }} + + {% endfor %} + +
+
+ +

Cumulative

+
+
+ Users (daily cumulative) +
    + {% for row in cum_users_daily|slice:'-20:' %} +
  • {{ row.period|date:'Y-m-d' }}: {{ row.cumulative }}
  • + {% endfor %} +
+
+
+ Posts (daily cumulative) +
    + {% for row in cum_posts_daily|slice:'-20:' %} +
  • {{ row.period|date:'Y-m-d' }}: {{ row.cumulative|intcomma }}
  • + {% endfor %} +
+
+
+ +

Cumulative posts by month (since 2020-05-01)

+ + + + + + + + + {% for row in cumulative_posts_monthly_from_2020 %} + + + + + {% endfor %} + +
MonthCumulative posts
{{ row.period|date:'Y-m-01' }}{{ row.cumulative|intcomma }}
+ +
+ Cumulative posts by month — chart +
+ + {% for row in chart_cumulative_posts_monthly %} + + {{ row.count }} posts total by {{ row.period|date:'Y-m-01' }} + + + + {{ row.count|intcomma }} + + + {% endfor %} + +
+
+
+{% endblock content %} diff --git a/main/templates/main/moderation_cohorts.html b/main/templates/main/moderation_cohorts.html new file mode 100644 index 0000000000000000000000000000000000000000..dd26c3594a7beef44d218b327c02f443587ec9fc --- /dev/null +++ b/main/templates/main/moderation_cohorts.html @@ -0,0 +1,28 @@ +{% extends 'main/layout.html' %} + +{% block title %}Moderation Cohorts{% endblock %} + +{% block content %} +
+

Moderation Cohorts

+
+ +
+
+ Most published posts (last 30d) +
    + {% for r in leaders.most_published_30 %} +
  • {{ r.username }}: {{ r.cnt }}
  • + {% endfor %} +
+
+
+ Largest blogs by total post body bytes +
    + {% for r in leaders.largest_blogs_by_bytes %} +
  • {{ r.username }}: {{ r.total_bytes|filesizeformat }}
  • + {% endfor %} +
+
+
+{% endblock content %} diff --git a/main/templates/main/moderation_images.html b/main/templates/main/moderation_images.html new file mode 100644 index 0000000000000000000000000000000000000000..807d0baa931a50fe0bc57a817d7cbaf4183dd6e2 --- /dev/null +++ b/main/templates/main/moderation_images.html @@ -0,0 +1,46 @@ +{% extends 'main/layout.html' %} + +{% block title %}Moderation Images{% endblock %} + +{% block content %} +
+

Moderation Images

+
+ +
+
+ sort: + {% for f in filters %} + {% if f.active %} + {{ f.key }} × + {% else %} + {{ f.key }} + {% endif %} + {% endfor %} + clear +
+ +

Top {{ user_list|length }} users.

+ +
+
ID
+
User
+
Images
+
Size (MB)
+
Joined
+ + {% for user in user_list %} + +
+ {{ user.username }} + {% if user.is_premium %}{% endif %} +
+
{{ user.image_count }}
+
{{ user.image_megabytes }}
+
{{ user.date_joined|date:'Y-m-d' }}
+ {% endfor %} +
+
+{% endblock content %} diff --git a/main/templates/main/moderation_index.html b/main/templates/main/moderation_index.html new file mode 100644 index 0000000000000000000000000000000000000000..159bf1fbc1e866da7e7980d25b5dee65332da5ce --- /dev/null +++ b/main/templates/main/moderation_index.html @@ -0,0 +1,22 @@ +{% extends 'main/layout.html' %} + +{% block title %}Moderation — Index{% endblock %} + +{% block content %} +
+

Moderation

+ + {% now "Y-m-d" as today_str %} + + +
+{% endblock content %} diff --git a/main/templates/main/moderation_posts.html b/main/templates/main/moderation_posts.html new file mode 100644 index 0000000000000000000000000000000000000000..8c7f765c8c1ad8b81cd4b601f5980e35e3794ca5 --- /dev/null +++ b/main/templates/main/moderation_posts.html @@ -0,0 +1,58 @@ +{% extends 'main/layout.html' %} + +{% block title %}Moderation Posts{% endblock %} + +{% block content %} +
+

Moderation Posts

+
+ +
+
+ sort: + {% for f in filters %} + {% if f.active %} + {{ f.key }} × + {% else %} + {{ f.key }} + {% endif %} + {% endfor %} + clear +
+ {% for key, value in request.GET.items %} + {% if key != 'limit' %} + + {% endif %} + {% endfor %} + + +
+
+ +

Top {{ user_list|length }} users.

+ +
+
ID
+
User
+
Total
+
Published
+
Drafts
+
Last Published
+ + {% for user in user_list %} + + +
{{ user.posts_total }}
+
{{ user.posts_published }}
+
{{ user.posts_drafts }}
+
{{ user.last_post_date|date:'Y-m-d' }}
+ {% endfor %} +
+
+{% endblock content %} diff --git a/main/templates/main/moderation_stats.html b/main/templates/main/moderation_stats.html new file mode 100644 index 0000000000000000000000000000000000000000..0d978eb6468c152baf4d55ffc519d0054197e23f --- /dev/null +++ b/main/templates/main/moderation_stats.html @@ -0,0 +1,66 @@ +{% extends 'main/layout.html' %} + +{% block title %}Moderation Stats{% endblock %} + +{% block content %} +
+

Moderation Stats

+
+ +
+

Totals

+
+
Users
+
{{ totals.users }}
+
Approved Users
+
{{ totals.users_approved }}
+ +
Premium Users
+
{{ totals.users_premium }}
+
Users w/ Custom Domain
+
{{ totals.users_with_custom_domain }}
+ +
Posts (total)
+
{{ totals.posts }}
+
Posts (published)
+
{{ totals.posts_published }}
+ +
Posts (draft)
+
{{ totals.posts_draft }}
+
Pages
+
{{ totals.pages }}
+ +
Images
+
{{ totals.images }}
+
Images Size (MB)
+
{{ totals.images_mb }}
+ +
Comments
+
{{ totals.comments }}
+
Comments (approved)
+
{{ totals.comments_approved }}
+ +
Subscribers
+
{{ totals.subscribers }}
+
Subscribers (active)
+
{{ totals.subscribers_active }}
+ +
Notification Sends
+
{{ totals.notification_sends }}
+
Post Snapshots
+
{{ totals.snapshots }}
+
+ +

Averages

+
+
Posts per User (avg)
+
{{ averages.posts_per_user }}
+
+ +

Latest

+
+
Last Post Published
+
{{ latest.last_post_date|date:'Y-m-d' }}
+
+
+{% endblock content %} diff --git a/main/templates/main/moderation_summary.html b/main/templates/main/moderation_summary.html new file mode 100644 index 0000000000000000000000000000000000000000..a88817d8fada1ddeb1ae787fc0db9343188649d9 --- /dev/null +++ b/main/templates/main/moderation_summary.html @@ -0,0 +1,107 @@ +{% extends 'main/layout.html' %} +{% load humanize %} + +{% block title %}Moderation — Summary {{ target_date|date:'Y-m-d' }}{% endblock %} + +{% block content %} +
+

Summary for {{ target_date|date:'Y-m-d' }}

+ + + +
+

Counts

+
    +
  • New users: {{ counts.users }}
  • +
  • New posts: {{ counts.posts }}
  • +
  • New pages: {{ counts.pages }}
  • +
  • New comments: {{ counts.comments }}
  • +
  • Post visits: {{ counts.post_visits|intcomma }}
  • +
+ +

Top Posts by Visits

+ {% if top_posts_by_visits %} +
+
Post
+
Visits
+
Author
+ + {% for p in top_posts_by_visits %} + +
{{ p.visit_count|intcomma }}
+ + {% endfor %} +
+ {% else %} +

None.

+ {% endif %} + +

New Posts

+ {% if new_posts %} + + {% else %} +

None.

+ {% endif %} + +

New Users

+ {% if new_users %} +
    + {% for u in new_users %} +
  • + {{ u.username }} + ({{ u.date_joined|date:'H:i' }}) +
  • + {% endfor %} +
+ {% else %} +

None.

+ {% endif %} + +

New Pages

+ {% if new_pages %} + + {% else %} +

None.

+ {% endif %} + +

New Comments

+ {% if new_comments %} + + {% else %} +

None.

+ {% endif %} +
+
+{% endblock content %} diff --git a/main/templates/main/moderation_user_list.html b/main/templates/main/moderation_user_list.html new file mode 100644 index 0000000000000000000000000000000000000000..85e60314f6806de12e27885b62ca89d002110ad7 --- /dev/null +++ b/main/templates/main/moderation_user_list.html @@ -0,0 +1,265 @@ +{% extends 'main/layout.html' %} + +{% block title %}Moderation Users{% endblock %} + +{% block head_extra %} + +{% endblock head_extra %} + +{% block content %} +
+

Moderation Users

+
+
+ +
+ filters: + {% for f in filters %} + {% if f.active %} + {{ f.key }} × + {% else %} + {{ f.key }} + {% endif %} + {% endfor %} + clear +
+ + {% if is_paginated %} + + {% endif %} + + {% for user in user_list %} +
+ +
+ {{ user.stripe_customer_id|default:"" }} +
+
+ {{ user.class_status }} + {% if user.is_approved %} + + {% endif %} + + {{ user.username }} + + + {% if user.onboard_set.first is not None %} +
+
    +
  • {{ user.onboard_set.first.problems }}
  • +
  • {{ user.onboard_set.first.quality }}
  • +
  • {{ user.onboard_set.first.seo }}
  • +
+
+ {% endif %} +
+
+ [ + {% if not user.is_approved %} + approve + | delete + {% else %} + unapprove + {% endif %} + ] +
+ +
+ {{ user.email }} +
+
+
    + {% for post in user.post_set.all %} +
  • + + {{ post.title }} + + + +
  • + {% endfor %} +
+
+
+ {% endfor %} + + {% if is_paginated %} + + {% endif %} + +
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/main/templates/main/moderation_user_single.html b/main/templates/main/moderation_user_single.html new file mode 100644 index 0000000000000000000000000000000000000000..20ef8f0fe6d0b56d8fc085a83d73e065011829c4 --- /dev/null +++ b/main/templates/main/moderation_user_single.html @@ -0,0 +1,189 @@ +{% extends 'main/layout.html' %} + +{% block title %}Moderation Cards{% endblock %} + +{% block head_extra %} + +{% endblock head_extra %} + +{% block content %} +
+

+ Moderation Cards + ({{ user_count }}) +

+
+
+
+ +
+

Account

+ +

+ ID: {% if user %} + + {{ user.id }} + + {% endif %} +

+

+ {{ user.stripe_customer_id|default:"" }} +

+

+ {{ user.class_status }} + {% if user.is_approved %} + + {% endif %} + + {{ user.username }} + + + {% if user.onboard_set.first is not None %} +

+
    +
  • {{ user.onboard_set.first.problems }}
  • +
  • {{ user.onboard_set.first.quality }}
  • +
  • {{ user.onboard_set.first.seo }}
  • +
+
+ {% endif %} +

+

+ [ + {% if not user.is_approved %} + + approve + | delete + + {% else %} + + unapprove + + {% endif %} + ] +

+

+ + {{ user.blog_url }} + +

+

+ {{ user.email }} +

+
+ +
+
+
+

Post Titles

+
+ +
    + {% for post in user.post_set.all %} +
  • + + {{ post.title }} +
  • + {% empty %} +
  • + (empty) +
  • + {% endfor %} +
+
+
+ +
+
+ {% for post in post_list_a %} +

+ # + {{ post.title }} +

+
+ {{ post.body_as_html|safe }} +
+
+ {% empty %} + (empty) + {% endfor %} +
+
+ +
+
+ {% for post in post_list_b %} +

+ # + {{ post.title }} +

+
+ {{ post.body_as_html|safe }} +
+
+ {% empty %} + (empty) + {% endfor %} +
+
+ +
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/main/templates/main/notification.html b/main/templates/main/notification.html new file mode 100644 index 0000000000000000000000000000000000000000..d79c7aaa26b117e387bfcf11d48c8603874c0c3e --- /dev/null +++ b/main/templates/main/notification.html @@ -0,0 +1,25 @@ +{% extends 'main/layout.html' %} + +{% block title %}Email newsletter — {{ blog_user.username }}{% endblock %} + +{% block content %} +
+ {% if blog_user.blog_title %} + {{ blog_user.blog_title }} + {% endif %} + +

Email newsletter

+

Receive an email when a new post is published.

+
+ {{ form.as_p }} + {% csrf_token %} + +
+ +
+

+ Or to stop receiving emails, + unsubscribe. +

+
+{% endblock content %} diff --git a/main/templates/main/notification_list.html b/main/templates/main/notification_list.html new file mode 100644 index 0000000000000000000000000000000000000000..45ddbcdbca2ca7d9f71f8e10c89b43647064f654 --- /dev/null +++ b/main/templates/main/notification_list.html @@ -0,0 +1,28 @@ +{% extends 'main/layout.html' %} + +{% block title %}Subscribers — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Subscribers

+

+ These emails will receive an email notification on new post publications at + 10:00 UTC on the day after the post’s publication date. +

+
    + {% for n in notification_list %} +
  • + {{ n.email }} +
  • + {% empty %} +
  • No subscribers
  • + {% endfor %} +
+ {% if notification_list %} +

+ To delete a subscriber, one can use the + unsubscribe form. +

+ {% endif %} +
+{% endblock content %} diff --git a/main/templates/main/notification_unsubscribe.html b/main/templates/main/notification_unsubscribe.html new file mode 100644 index 0000000000000000000000000000000000000000..5d613e13a3901f4f92b7e7b8f4c73f351f663172 --- /dev/null +++ b/main/templates/main/notification_unsubscribe.html @@ -0,0 +1,25 @@ +{% extends 'main/layout.html' %} + +{% block title %}Unsubscribe from the email newsletter — {{ blog_user.username }}{% endblock %} + +{% block content %} +
+ {% if blog_user.blog_title %} + {{ blog_user.blog_title }} + {% endif %} + +

Unsubscribe from the email newsletter

+ +
+ {{ form.as_p }} + {% csrf_token %} + +
+ +
+

+ Or to start receiving the email newsletter, + subscribe. +

+
+{% endblock content %} diff --git a/main/templates/main/notification_unsubscribe_success.html b/main/templates/main/notification_unsubscribe_success.html new file mode 100644 index 0000000000000000000000000000000000000000..0358c348bc844c0927301711042dc9614ca9aef3 --- /dev/null +++ b/main/templates/main/notification_unsubscribe_success.html @@ -0,0 +1,27 @@ +{% extends 'main/layout.html' %} + +{% block title %}Unsubscribing from the email newsletter — {{ blog_user.username }}{% endblock %} + +{% block content %} +
+ {% if blog_user.blog_title %} + {{ blog_user.blog_title }} + {% endif %} + + {% if unsubscribed %} +

Unsubscribed success

+

+ {{ email }} was successfully deleted and will stop receiving emails. +

+

+ To re-subscribe go here. +

+ {% else %} +

Not subscribed

+

+ Subscribe + to start receiving email notifications. +

+ {% endif %} +
+{% endblock content %} diff --git a/main/templates/main/notificationrecord_confirm_delete.html b/main/templates/main/notificationrecord_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..038d2d941a308dd4f0c0bce04d9ad79ed56f0435 --- /dev/null +++ b/main/templates/main/notificationrecord_confirm_delete.html @@ -0,0 +1,18 @@ +{% extends 'main/layout.html' %} + +{% block title %}Cancel email for {{ notificationrecord.notification.email }} — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Cancel sending this email to {{ notificationrecord.notification.email }}?

+

+ The email content is the post: + {{ notificationrecord.post.title }}. +

+ +
+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/notificationrecord_list.html b/main/templates/main/notificationrecord_list.html new file mode 100644 index 0000000000000000000000000000000000000000..150f3a5b469cd42bae5b95a6414d16eb9fb66fcc --- /dev/null +++ b/main/templates/main/notificationrecord_list.html @@ -0,0 +1,49 @@ +{% extends 'main/layout.html' %} + +{% block title %}Newsletter — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Newsletter

+ +

+ Who: + For the current list of registered emails, see + Subscribers. +

+

+ When: + Every day at 10:00 UTC all posts published the day before are emailed to + subscribers. +

+

+ What: + Newsletter emails are delivered in raw plain text (markdown source) as seen in + the editor. Images are included as links only. +

+ +

+ Past newsletters: +

+
    + {% regroup notificationrecord_list_sent by post as post_list %} + {% for post, notificationrecord_list in post_list %} +
  • + {{ post.title }} + + sent at + + to these {{ notificationrecord_list|length }} subscribers: + + {% for nr in notificationrecord_list %} +
    {{ nr.notification.email }} + {% endfor %} +
  • + {% empty %} +
  • No records
  • + {% endfor %} +
+
+{% endblock content %} diff --git a/main/templates/main/page_confirm_delete.html b/main/templates/main/page_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..674c1577c93ed7c10fba13b4c4834437537619dc --- /dev/null +++ b/main/templates/main/page_confirm_delete.html @@ -0,0 +1,13 @@ +{% extends 'main/layout.html' %} + +{% block title %}Deleting {{ page.title }} — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Are you sure you'd like to delete {{ page.title }}?

+
+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/page_detail.html b/main/templates/main/page_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..5c6179e3300e3a4e9c40175a8e7d83c7ff9d9555 --- /dev/null +++ b/main/templates/main/page_detail.html @@ -0,0 +1,51 @@ +{% extends 'main/layout.html' %} + +{% block title %}{{ page.title }} — {{ blog_user.blog_title }}{% endblock %} + +{% block meta_description %}{{ page.body|truncatewords:16 }}{% endblock %} + +{% block head_rsl_license %} +{% include 'partials/rsl_license_head.html' %} +{% endblock head_rsl_license %} + +{% block content %} +
+ {% if blog_user.blog_title %} + {{ blog_user.blog_title }} + {% endif %} + +

{{ page.title }}

+ + {% if request.user.is_authenticated and request.subdomain == user.username %} + + {% endif %} + +
+ {{ page.body_as_html|safe }} +
+
+ +{% include 'partials/webring.html' %} + +{% include 'partials/footer_blog.html' %} + +{% endblock content %} + +{% block scripts %} +{% if not blog_user.comments_on %} + +{% endif %} +{% endblock scripts %} diff --git a/main/templates/main/page_form.html b/main/templates/main/page_form.html new file mode 100644 index 0000000000000000000000000000000000000000..0eba5d845cdc98756d44e7167abb34da43546092 --- /dev/null +++ b/main/templates/main/page_form.html @@ -0,0 +1,70 @@ +{% extends 'main/layout.html' %} + +{% block title %} + {% if form.initial %}Editing {{ form.title.value }}{% else %}Create a new page{% endif %} +{% endblock title %} + +{% block content %} +
+

+ {% if form.initial %} + Editing page + {% else %} + Create a new page + {% endif %} +

+
+ {{ form.non_field_errors }} + +

+ + {% if form.title.errors %} + {% for error in form.title.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + +

+ +

+ + {% if form.slug.errors %} + {% for error in form.slug.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + + {{ form.slug.help_text }} +

+ +

+ + +
{{ form.is_hidden.help_text }} +

+ +

+ + {% if form.body.errors %} + {% for error in form.body.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + + +

+ + {% csrf_token %} + +
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/main/templates/main/page_list.html b/main/templates/main/page_list.html new file mode 100644 index 0000000000000000000000000000000000000000..73d8178dedef0e7fbf677fe72cf4757b50abb97f --- /dev/null +++ b/main/templates/main/page_list.html @@ -0,0 +1,26 @@ +{% extends 'main/layout.html' %} + +{% block title %}Pages — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Pages

+

+ Create a new page » +

+ {% if page_list %} +

+ List of pages: +

+ + {% endif %} +
+{% endblock content %} diff --git a/main/templates/main/post_confirm_delete.html b/main/templates/main/post_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..38006b649b514c13174a4bff2599cec6c91c1d0d --- /dev/null +++ b/main/templates/main/post_confirm_delete.html @@ -0,0 +1,13 @@ +{% extends 'main/layout.html' %} + +{% block title %}Deleting {{ post.title }} — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Are you sure you'd like to delete {{ post.title }}?

+
+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/post_detail.html b/main/templates/main/post_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..6dca49bc0890c81052f249dbaf3e77a61f6cc124 --- /dev/null +++ b/main/templates/main/post_detail.html @@ -0,0 +1,196 @@ +{% extends 'main/layout.html' %} + +{% block title %}{{ post.title }} — {{ blog_user.blog_title }}{% endblock %} + +{% block meta_description %}{{ post.body_as_text|truncatewords:16 }}{% endblock %} + +{% block head_rsl_license %} +{% include 'partials/rsl_license_head.html' %} +{% endblock head_rsl_license %} + +{% block content %} +
+ {% if blog_user.blog_title %} + {{ blog_user.blog_title }} + {% endif %} + +
+

{{ post.title }}

+ + + + {% if blog_user.reading_time_on %} +
+ Estimated reading time: {{ reading_time }} min +
+ {% endif %} + +
+ {{ post.body_as_html|safe }} +
+
+
+ +{% include 'partials/webring.html' %} + +{% if blog_user.comments_on %} +
+ {% if request.user.is_authenticated and request.subdomain == request.user.username %} +
Post comment as blog author
+
+
+ {{ form.non_field_errors }} +

+ {% if form.body.errors %} + {% for error in form.body.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + +

+ {% csrf_token %} + +
+
+ + {% else %} + +
Thoughts? Leave a comment
+
+
+ {{ form.non_field_errors }} + +

+ + {% if form.name.errors %} + {% for error in form.name.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + +

+ +

+ + {% if form.email.errors %} + {% for error in form.email.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + +

+ +

+ + {% if form.body.errors %} + {% for error in form.body.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + +

+ + {% csrf_token %} + +
+
+ {% endif %} + + {% if comments_pending and request.user.is_authenticated and request.subdomain == request.user.username %} +
Comments pending ({{ comments_pending|length }})
+
    + {% for comment in comments_pending %} +
  • + + + {{ comment.name|default_if_none:"Anonymous" }} + {% if comment.email %} + ({{ comment.email }}) + {% endif %}— + {{ comment.created_at|date:'M j, Y' }}: + + + (approve + / delete) + +
    {{ comment.body_as_html|safe }}
    +
  • + {% endfor %} +
+ {% endif %} + + {% if comments %} +
Comments
+
    + {% for comment in comments %} +
  1. + + {% if comment.is_author %} + + {{ comment.name }} + — + {{ comment.created_at|date:'M j, Y' }}: + + {% else %} + + {{ comment.name|default_if_none:"Anonymous" }} + {% if request.user.is_authenticated and request.subdomain == request.user.username and comment.email %} + ({{ comment.email }}) + {% endif %}— + {{ comment.created_at|date:'M j, Y' }}: + + {% endif %} + + {% if request.user.is_authenticated and request.subdomain == request.user.username %} + (delete) + {% endif %} + +
    {{ comment.body_as_html|safe }}
    +
  2. + {% endfor %} +
+ {% endif %} +
+{% endif %} + +{% include 'partials/footer_blog.html' %} + +{% endblock content %} + +{% block scripts %} +{% if not blog_user.comments_on %} + +{% endif %} +{% endblock scripts %} diff --git a/main/templates/main/post_form.html b/main/templates/main/post_form.html new file mode 100644 index 0000000000000000000000000000000000000000..f1e4601f3459f446f45597665eb434d7c9dbbd5d --- /dev/null +++ b/main/templates/main/post_form.html @@ -0,0 +1,90 @@ +{% extends 'main/layout.html' %} + +{% block title %} + {% if form.initial %}Editing {{ form.title.value }}{% else %}Create a new post{% endif %} +{% endblock title %} + +{% block head_viewport %} + +{% endblock head_viewport %} + +{% block content %} +
+

+ {% if form.initial %} + Editing post + {% else %} + Create a new post + {% endif %} +

+
+ {{ form.non_field_errors }} + +

+ + {% if form.title.errors %} + {% for error in form.title.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + +

+ + {% if form.initial %} +

+ + {% if form.slug.errors %} + {% for error in form.slug.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + +

+ {% endif %} + +

+ + {% if form.published_at.errors %} + {% for error in form.published_at.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + + {{ form.published_at.help_text }} +

+ +

+ + {% if form.body.errors %} + {% for error in form.body.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + + +

+ + {% csrf_token %} + +
+
+{% endblock content %} + +{% block scripts %} + +{% endblock scripts %} diff --git a/main/templates/main/post_list.html b/main/templates/main/post_list.html new file mode 100644 index 0000000000000000000000000000000000000000..cd3c43f6dd16529c3a7973f37ddb1ece21d97e69 --- /dev/null +++ b/main/templates/main/post_list.html @@ -0,0 +1,36 @@ +{% extends 'main/layout.html' %} + +{% block title %}Posts — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Posts

+

+ Create a new post » +

+ {% if post_list %} +

+ List of posts: +

+
    + {% for post in post_list %} +
  • + + {{ post.title }} + + + {% if post.is_published %} + + {% endif %} + {% if not post.is_published %} + — DRAFT/SCHEDULED + {% endif %} + +
  • + {% endfor %} +
+ {% endif %} +
+{% endblock content %} diff --git a/main/templates/main/rsl_update.html b/main/templates/main/rsl_update.html new file mode 100644 index 0000000000000000000000000000000000000000..de22b0d8fbaf534e4db12871cb75882f054dcb1b --- /dev/null +++ b/main/templates/main/rsl_update.html @@ -0,0 +1,29 @@ +{% extends 'main/layout.html' %} + +{% block title %}Really Simple Licensing settings{% endblock %} + +{% block content %} +
+

Really Simple Licensing settings

+ +

Really Simple Licensing (RSL) builds on the foundational ideas of the RSS standard, which allowed publishers to syndicate content in a machine-readable format for third-party clients and crawlers, often in exchange for increased traffic. +

+

+ RSL expands these concepts by incorporating explicit licensing terms, letting publishers specify machine-readable conditions for content use and compensation. The RSL Technical Steering Committee guides the development of the standard in partnership with publishers, technology companies, industry groups, and other stakeholders. +

+ +

+ BōcPress supports the RSL standard by allowing you to add your license text (XML) and choose where to display it. +

+ +

+ For more information, see Getting started with RSL and pick your license at RSL Licenses. +

+ +
+ {{ form.as_p }} + {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/snapshot_confirm_delete.html b/main/templates/main/snapshot_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..eb365e3d1c7dbd5052c100f5b38c778d6ce79bbb --- /dev/null +++ b/main/templates/main/snapshot_confirm_delete.html @@ -0,0 +1,13 @@ +{% extends 'main/layout.html' %} + +{% block title %}Deleting {{ snapshot.title }} — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Are you sure you'd like to delete snapshot #{{ snapshot.id }} - {{ snapshot.title }}?

+
+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/snapshot_detail.html b/main/templates/main/snapshot_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..63a21a894dad1d77d7d0bc4ff3ac68602ce7cc61 --- /dev/null +++ b/main/templates/main/snapshot_detail.html @@ -0,0 +1,37 @@ +{% extends 'main/layout.html' %} + +{% block title %}Post Backup #{{ object.id }} — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Post Backup #{{ object.id }}

+

+ Saved at + . +

+

+ This is a backup of a post, saved while editing it. No changes here + will be saved anywhere. Its only purpose is to be read or copied. +

+ +

+ + +

+

+ + +

+ + Delete snapshot + +

+ « Post Backups +

+
+{% endblock content %} diff --git a/main/templates/main/snapshot_list.html b/main/templates/main/snapshot_list.html new file mode 100644 index 0000000000000000000000000000000000000000..bddc8bdc55a0efeb874c90394067fdf2c5e798d1 --- /dev/null +++ b/main/templates/main/snapshot_list.html @@ -0,0 +1,25 @@ +{% extends 'main/layout.html' %} + +{% block title %}Post Backups — {{ request.user.username }}{% endblock %} + +{% block content %} +
+

Post Backups

+

+ These are the latest snapshots automatically saved, across all posts. + Post backups are saved automatically, every few seconds, from the post + create and post edit pages. +

+ + {% for snapshot in snapshot_list %} +
+ {{ snapshot.created_at|date:"Y-m-d H:i:s" }}: + + #{{ snapshot.id }} + {{ snapshot.title }} +
+ {% empty %} +
(none)
+ {% endfor %} +
+{% endblock content %} diff --git a/main/templates/main/transparency.html b/main/templates/main/transparency.html new file mode 100644 index 0000000000000000000000000000000000000000..f323040dc53f7a14d8b92b7188e0c70f862ddf86 --- /dev/null +++ b/main/templates/main/transparency.html @@ -0,0 +1,117 @@ +{% extends 'main/layout.html' %} + +{% load static %} + +{% block title %}Transparency{% endblock %} + +{% block content %} +
+

Business Transparency

+ +
+ + {% for day, analytic in new_users_per_day.items %} + + {{ analytic.count }} new users signed up during {{ day|date:'F j, Y' }} + + + + {{ analytic.count }} + + {% endfor %} + +
+ +
+
+ Total users +
{{ users }}
+
+
+ Total premium users +
{{ premium_users }}
+
+
+ Monthly revenue +
£{{ monthly_revenue|floatformat:2 }}
+
+ +
+ Total posts +
{{ posts }}
+
+
+ Total published posts +
{{ published_posts }}
+
+
+ Total pages +
{{ pages }}
+
+ +
+ User count with 0 posts +
{{ zero_users }} ({{ zero_users_percentage }}%)
+
+
+ User count with 1 post +
{{ one_users }} ({{ one_users_percentage }}%)
+
+
+ User count with 2+ posts +
{{ twoplus_users }} ({{ twoplus_users_percentage }}%)
+
+ +
+ Number of users who have edited at least one post in the last month +
{{ active_users }}
+
+
+ + Number of users who have edited at least one post in the last + month and signed up at least a month ago + +
{{ active_nonnew_users }}
+
+
+ + +
+
+ Service Maintainance Costs +
    +
  • Server £3.65/mo
  • +
  • Email Delivery £0.00/mo
  • +
  • Off-site backups £1.00/mo
  • +
  • DNS £0/mo
  • +
  • Domain name £1.49/mo
  • +
  • Programmer €0.00/mo
  • +
+ Subscription Revenue +
    +
  • Monthly revenue £{{ monthly_revenue|floatformat:2 }}/mo
  • +
  • + Revenue used to + fund CO2 removal + £{{ revenue_co2|floatformat:2 }}/mo +
  • +
+
+ +
+ +{% include 'partials/footer.html' %} + +{% endblock content %} diff --git a/main/templates/main/user_confirm_delete.html b/main/templates/main/user_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..ed6e1cd9c29bcd5c526745fe209e301b1c1a3fb6 --- /dev/null +++ b/main/templates/main/user_confirm_delete.html @@ -0,0 +1,16 @@ +{% extends 'main/layout.html' %} + +{% block title %}Delete everything?{% endblock %} + +{% block content %} +
+

Delete {{ user.username }} forever?

+

+ No soft deletes here—we really delete everything and forever. +

+
+ {% csrf_token %} + +
+
+{% endblock content %} diff --git a/main/templates/main/user_create_step_one.html b/main/templates/main/user_create_step_one.html new file mode 100644 index 0000000000000000000000000000000000000000..5c4674cf246aab5f564adc260caf55cb49933153 --- /dev/null +++ b/main/templates/main/user_create_step_one.html @@ -0,0 +1,54 @@ +{% extends 'main/layout.html' %} + +{% block title %}Sign up{% endblock %} + +{% block head_viewport %} + +{% endblock head_viewport %} + +{% block content %} +
+

Welcome to BōcPress!

+ +

+ Once upon a time, the web was born. One of the early residents of the web + was the so-called weblog. The weblog — or just blog — was a log on the + web. A log as in a ship's log: a record of important events in a ship's history. +

+

+ A blog is a mode of publication; as is the book, the magazine, the newspaper, + even the television show. We see the blog as a first-class citizen in online + publishing. The simplest form to express and distribute ideas. The simplest + model for just saying something on the internet. +

+

+ Originally, a blog was an online diary or a list of updates for an online + product. Today, a blog is a newsletter subscription, or a company's newsroom, or + anything that anybody writes in the online medium. +

+

+ We, the internet users, now have an amazing selection of publishing and + distribution options. From tweets to articles to books, from text to image to videos, + from free software to walled gardens, from simple email delivery to personalised + algorithms, from bare text to fully-featured online shops. The present is + bright. There is something for everyone and we hope BōcPress fulfills + the needs of those who want to just write. +

+

+ + We offer a place of no distractions to facilitate the most foundational mode + of intellectual internetic exchange: write a blog entry. + +

+ +
+ {% csrf_token %} + +
+ +

+ Read our Platform Methodology + for details on how we operate and how we protect our users’ privacy. +

+
+{% endblock content %} diff --git a/main/templates/main/user_create_step_two.html b/main/templates/main/user_create_step_two.html new file mode 100644 index 0000000000000000000000000000000000000000..8ec6206a7b9a4c11446b039ab8e75678377dede1 --- /dev/null +++ b/main/templates/main/user_create_step_two.html @@ -0,0 +1,71 @@ +{% extends 'main/layout.html' %} + +{% block title %}Sign up{% endblock %} + +{% block head_viewport %} + +{% endblock head_viewport %} + +{% block content %} +
+

Sign up to start your blog

+ +
+ {{ form.non_field_errors }} + +

+ + {% if form.username.errors %} + {% for error in form.username.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + {{ form.username }} + {{ form.username.help_text }} +

+ +

+ + {% if form.email.errors %} + {% for error in form.email.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + {{ form.email }} + {{ form.email.help_text }} +

+ +

+ + {% if form.password1.errors %} + {% for error in form.password1.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + {{ form.password1 }} +

+ {# this goes outside p element because it contains a ul element #} + {{ form.password1.help_text }} + +

+ + {% if form.password2.errors %} + {% for error in form.password2.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + {{ form.password2 }} + {{ form.password2.help_text }} +

+ + {% csrf_token %} + + +
+ +

+ Read our Platform Methodology + for details on how we operate and how we protect our users’ privacy. +

+
+{% endblock content %} diff --git a/main/templates/main/user_update.html b/main/templates/main/user_update.html new file mode 100644 index 0000000000000000000000000000000000000000..8affa6fb32a6df7f439711bd4282e15478d1a668 --- /dev/null +++ b/main/templates/main/user_update.html @@ -0,0 +1,20 @@ +{% extends 'main/layout.html' %} + +{% block title %}Blog settings{% endblock %} + +{% block content %} +
+

Blog settings

+ +
+ {{ form.as_p }} + {% csrf_token %} + +
+ +

+
+ Delete account +

+
+{% endblock content %} diff --git a/main/templates/main/webring.html b/main/templates/main/webring.html new file mode 100644 index 0000000000000000000000000000000000000000..ed8494ed764bc114bbe1e8dc0a3279ae4b412abe --- /dev/null +++ b/main/templates/main/webring.html @@ -0,0 +1,34 @@ +{% extends 'main/layout.html' %} + +{% block title %}Webring Integration{% endblock %} + +{% block content %} +
+

Webring Integration

+ +

+ A webring is a + circular list of website links with a common theme. +

+

+ On BōcPress, one can add three links on their blog that will appear + at the bottom of every post/page. +

+

+ These links are: +

+
    +
  1. the previous website in the circular list
  2. +
  3. the next website in the circular list
  4. +
  5. an informational website about the webring
  6. +
+ + {% if request.user.is_authenticated %} +
+ {{ form.as_p }} + {% csrf_token %} + +
+ {% endif %} +
+{% endblock content %} diff --git a/main/templates/partials/footer.html b/main/templates/partials/footer.html new file mode 100644 index 0000000000000000000000000000000000000000..a6d2cb4fe720c72c0dd39f4077c045093965bda9 --- /dev/null +++ b/main/templates/partials/footer.html @@ -0,0 +1,5 @@ + diff --git a/main/templates/partials/footer_blog.html b/main/templates/partials/footer_blog.html new file mode 100644 index 0000000000000000000000000000000000000000..7519d8507cc605de45a9cda3917a513ff434322e --- /dev/null +++ b/main/templates/partials/footer_blog.html @@ -0,0 +1,7 @@ +
+ {{ blog_user.subscribe_note_as_html|safe }} + + {% if blog_user.footer_note %} + {{ blog_user.footer_note_as_html|safe }} + {% endif %} +
diff --git a/main/templates/partials/rsl_license_head.html b/main/templates/partials/rsl_license_head.html new file mode 100644 index 0000000000000000000000000000000000000000..3e743f422a2182e31cd0d980f4d7397fc91f3b63 --- /dev/null +++ b/main/templates/partials/rsl_license_head.html @@ -0,0 +1,7 @@ +{% if blog_user.reallysimplelicensing.license and blog_user.reallysimplelicensing.show_webpage %} + + + +{% endif %} diff --git a/main/templates/partials/webring.html b/main/templates/partials/webring.html new file mode 100644 index 0000000000000000000000000000000000000000..7e7a17245e58712f06e3406a16807c1ef3c677cc --- /dev/null +++ b/main/templates/partials/webring.html @@ -0,0 +1,20 @@ +{% if blog_user.webring_prev_url or blog_user.webring_url or blog_user.webring_next_url %} +
+ {% if blog_user.webring_prev_url %} + ← Previous + {% endif %} +
+ {% if blog_user.webring_url %} + ○ + + {{ blog_user.webring_name|default_if_none:'Webring' }} + + {% else %} + {{ blog_user.webring_name|default_if_none:'' }} + {% endif %} +
+ {% if blog_user.webring_next_url %} + Next → + {% endif %} +
+{% endif %} diff --git a/main/templates/registration/logged_out.html b/main/templates/registration/logged_out.html new file mode 100644 index 0000000000000000000000000000000000000000..44720ab3e0e8f58af927c85b60fd7d456dbb4f02 --- /dev/null +++ b/main/templates/registration/logged_out.html @@ -0,0 +1,10 @@ +{% extends 'main/layout.html' %} + +{% block title %}Logged out{% endblock %} + +{% block content %} +
+

Logged out

+

You have successfully logged out.

+
+{% endblock content %} diff --git a/main/templates/registration/login.html b/main/templates/registration/login.html new file mode 100644 index 0000000000000000000000000000000000000000..26335386e6694dafd14f05a162e26cba5f54f46c --- /dev/null +++ b/main/templates/registration/login.html @@ -0,0 +1,56 @@ +{% extends 'main/layout.html' %} + +{% block title %}Log in{% endblock %} + +{% block head_viewport %} + +{% endblock head_viewport %} + +{% block content %} +
+ {% if next %} + {% if user.is_authenticated %} +

+ Your account doesn't have access to this page. + To proceed please login with an account that has access. +

+ {% endif %} + {% endif %} + +

Log in

+ +
+ {{ form.non_field_errors }} + +

+ + {% if form.username.errors %} + {% for error in form.username.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + {{ form.username }} + This is your blog subdomain. +

+ +

+ + {% if form.password.errors %} + {% for error in form.password.errors %} + {{ error|escape }}
+ {% endfor %} + {% endif %} + {{ form.password }} + {{ form.password.help_text }} +

+ + {% csrf_token %} + + +
+ +

+ Forgot password? +

+
+{% endblock content %} diff --git a/main/templates/registration/password_change_done.html b/main/templates/registration/password_change_done.html new file mode 100644 index 0000000000000000000000000000000000000000..72b70d47c850cf72a4c85c52074d0e204997247e --- /dev/null +++ b/main/templates/registration/password_change_done.html @@ -0,0 +1,10 @@ +{% extends 'main/layout.html' %} + +{% block title %}Password change successful{% endblock %} + +{% block content %} +
+

Password change successful

+

Your password was changed.

+
+{% endblock content %} diff --git a/main/templates/registration/password_change_form.html b/main/templates/registration/password_change_form.html new file mode 100644 index 0000000000000000000000000000000000000000..2b8b85077cfd3a0863e48af8a7d0643c92ba45a7 --- /dev/null +++ b/main/templates/registration/password_change_form.html @@ -0,0 +1,15 @@ +{% extends 'main/layout.html' %} + +{% block title %}Password change{% endblock %} + +{% block content %} +
+

Password change

+

Please enter your old password, for security's sake, and then enter your new password twice so we can verify you typed it in correctly.

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+{% endblock content %} diff --git a/main/templates/registration/password_reset_complete.html b/main/templates/registration/password_reset_complete.html new file mode 100644 index 0000000000000000000000000000000000000000..b214c2ed68ee51191fea5fb8c35cecdc76df7564 --- /dev/null +++ b/main/templates/registration/password_reset_complete.html @@ -0,0 +1,10 @@ +{% extends 'main/layout.html' %} + +{% block title %}Email Sent{% endblock %} + +{% block content %} +
+

Password reset complete

+

Your new password has been set. You can log in now on the log in page.

+
+{% endblock content %} diff --git a/main/templates/registration/password_reset_confirm.html b/main/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000000000000000000000000000000000000..cd8f4a2351fbfb609a51b44fd73768a9f9489c47 --- /dev/null +++ b/main/templates/registration/password_reset_confirm.html @@ -0,0 +1,14 @@ +{% extends 'main/layout.html' %} + +{% block title %}Enter new password{% endblock %} + +{% block content %} +
+

Set a new password!

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+{% endblock content %} diff --git a/main/templates/registration/password_reset_done.html b/main/templates/registration/password_reset_done.html new file mode 100644 index 0000000000000000000000000000000000000000..add155fb0dbec4de5a8c5653549a95127e9645f1 --- /dev/null +++ b/main/templates/registration/password_reset_done.html @@ -0,0 +1,10 @@ +{% extends 'main/layout.html' %} + +{% block title %}Email Sent{% endblock %} + +{% block content %} +
+

Check your inbox.

+

We've emailed you instructions for setting your password. You should receive the email shortly!

+
+{% endblock content %} diff --git a/main/templates/registration/password_reset_email.html b/main/templates/registration/password_reset_email.html new file mode 100644 index 0000000000000000000000000000000000000000..0cd2e5139533e4d395e209928a0b7f8af1bbb3e8 --- /dev/null +++ b/main/templates/registration/password_reset_email.html @@ -0,0 +1,14 @@ +{% load i18n %}{% autoescape off %} +{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %} + +{% trans "Please go to the following page and choose a new password:" %} +{% block reset_link %} +{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} +{% endblock reset_link %} +{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} + +{% endautoescape %} diff --git a/main/templates/registration/password_reset_form.html b/main/templates/registration/password_reset_form.html new file mode 100644 index 0000000000000000000000000000000000000000..f79f5def6f3bed2016ee042c9ae17de52a84b910 --- /dev/null +++ b/main/templates/registration/password_reset_form.html @@ -0,0 +1,15 @@ +{% extends 'main/layout.html' %} + +{% block title %}Forgot Your Password?{% endblock %} + +{% block content %} +
+

Forgot your password?

+

Enter your email address below, and we'll email instructions for setting a new one.

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+{% endblock content %} diff --git a/main/tests/__init__.py b/main/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/main/tests/test_analytics.py b/main/tests/test_analytics.py new file mode 100644 index 0000000000000000000000000000000000000000..43cb78da7c8d5f55201e5e09edbd465c059b12ba --- /dev/null +++ b/main/tests/test_analytics.py @@ -0,0 +1,296 @@ +from django.conf import settings +from django.test import TestCase +from django.urls import reverse + +from main import models + + +class PostAnalyticAnonTestCase(TestCase): + """Test post analytics for non logged in users.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + self.client.logout() + + def test_post_analytic_anon(self): + response = self.client.get( + reverse("post_detail", args=(self.post.slug,)), + # needs HTTP_HOST because we need to request it on the subdomain + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.AnalyticPost.objects.filter(post=self.post).count(), 1) + + +class PostAnalyticTestCase(TestCase): + """Test post analytics for logged in users do not count.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_post_analytic_logged_in(self): + response = self.client.get( + reverse("post_detail", args=(self.post.slug,)), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(models.AnalyticPost.objects.filter(post=self.post).exists()) + + +class PageAnalyticAnonTestCase(TestCase): + """Test page analytics for non logged in users.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "About", + "slug": "about", + "body": "About this blog.", + "is_hidden": False, + } + self.page = models.Page.objects.create(owner=self.user, **self.data) + self.client.logout() + + def test_page_analytic_anon(self): + response = self.client.get( + reverse("page_detail", args=(self.page.slug,)), + # needs HTTP_HOST because we need to request it on the subdomain + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + models.AnalyticPage.objects.filter(path=self.page.slug).count(), 1 + ) + + +class PageAnalyticTestCase(TestCase): + """Test generic page analytics for logged in users do not count.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "About", + "slug": "about", + "body": "About this blog.", + "is_hidden": False, + } + self.page = models.Page.objects.create(owner=self.user, **self.data) + + def test_page_analytic_logged_in(self): + response = self.client.get( + reverse("page_detail", args=(self.page.slug,)), + # needs HTTP_HOST because we need to request it on the subdomain + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertFalse( + models.AnalyticPage.objects.filter(path=self.page.slug).exists() + ) + + +class PageAnalyticIndexTestCase(TestCase): + """Test 'index' special page analytics.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + + def test_index_analytic(self): + response = self.client.get( + reverse("index"), + # needs HTTP_HOST because we need to request it on the subdomain + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.AnalyticPage.objects.filter(path="index").count(), 1) + + +class PageAnalyticRSSTestCase(TestCase): + """Test 'rss' special page analytics.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + + def test_rss_analytic(self): + response = self.client.get( + reverse("rss_feed"), + # needs HTTP_HOST because we need to request it on the subdomain + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.AnalyticPage.objects.filter(path="rss").count(), 1) + + +class AnalyticListTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_analytic_list(self): + response = self.client.get( + reverse("analytic_list"), + ) + self.assertEqual(response.status_code, 200) + + self.assertContains(response, "List of pages:") + self.assertContains(response, "index") + self.assertContains(response, "rss") + + self.assertContains(response, "List of posts:") + self.assertContains(response, "Welcome post") + + +class PostAnalyticDetailTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + # register one sample post analytic + self.client.logout() # need to logout for analytic to be counted + self.client.get( + reverse("post_detail", args=(self.post.slug,)), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + + # need to login again to access analytic post detail dashboard page + self.client.force_login(self.user) + + def test_post_analytic_detail(self): + response = self.client.get( + reverse("analytic_post_detail", args=(self.post.slug,)), + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '
') + self.assertContains( + response, + '', + ) + self.assertContains(response, "1 hits") + + +class PageAnalyticDetailTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "About", + "slug": "about", + "body": "About this blog.", + "is_hidden": False, + } + self.page = models.Page.objects.create(owner=self.user, **self.data) + + # register one sample page analytic + self.client.logout() # need to logout for analytic to be counted + + # register one sample page analytic + self.client.get( + reverse("page_detail", args=(self.page.slug,)), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + + # need to login again to access analytic page detail dashboard page + self.client.force_login(self.user) + + def test_page_analytic_detail(self): + response = self.client.get( + reverse("analytic_page_detail", args=(self.page.slug,)), + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '
') + self.assertContains( + response, + '', + ) + self.assertContains(response, "1 hits") + + +class PageAnalyticDetailIndexTestCase(TestCase): + """Test analytic detail for 'index' special page.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + + # logout so that analytic is counted + self.client.logout() + + # register one sample index page analytic + self.client.get( + reverse("index"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + + # login again to access analytic page detail dashboard page + self.client.force_login(self.user) + + def test_page_analytic_detail(self): + response = self.client.get( + reverse("analytic_page_detail", args=("index",)), + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '
') + self.assertContains( + response, + '', + ) + self.assertContains(response, "1 hits") + + +class PageAnalyticDetailRSSTestCase(TestCase): + """Test analytic detail for 'rss' special page.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + + # logout so that analytic is counted + self.client.logout() + + # register one sample rss page analytic + self.client.get( + reverse("rss_feed"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + + # login again to access analytic page detail dashboard page + self.client.force_login(self.user) + + def test_page_analytic_detail(self): + response = self.client.get( + reverse("analytic_page_detail", args=("rss",)), + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '
') + self.assertContains( + response, + '', + ) + self.assertContains(response, "1 hits") diff --git a/main/tests/test_api.py b/main/tests/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..01ccffeb13c36304f6fb2c950743a3df10e8485a --- /dev/null +++ b/main/tests/test_api.py @@ -0,0 +1,745 @@ +from datetime import date + +from django.conf import settings +from django.test import TestCase +from django.urls import reverse + +from main import models, util + + +class APIDocsAnonTestCase(TestCase): + def test_docs_get(self): + response = self.client.get(reverse("api_docs")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "API") + + +class APIDocsTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + + def test_docs_get(self): + response = self.client.get(reverse("api_docs")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "API") + self.assertContains(response, self.user.api_key) + + +class APIResetKeyTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.api_key = self.user.api_key + self.client.force_login(self.user) + + def test_api_key_reset_get(self): + response = self.client.get(reverse("api_reset")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Reset API key") + + def test_api_key_reset_post(self): + response = self.client.post(reverse("api_reset"), follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "API key has been reset") + new_api_key = models.User.objects.get(username="alice").api_key + self.assertNotEqual(self.api_key, new_api_key) + + +class APIListAnonTestCase(TestCase): + """Test cases for anonymous POST / GET / PATCH / DELETE on /api/posts/.""" + + def test_posts_get(self): + response = self.client.get(reverse("api_posts")) + self.assertEqual(response.status_code, 403) + + def test_posts_post(self): + response = self.client.post(reverse("api_posts")) + self.assertEqual(response.status_code, 403) + + def test_posts_patch(self): + response = self.client.patch(reverse("api_posts")) + self.assertEqual(response.status_code, 405) + + def test_posts_delete(self): + response = self.client.delete(reverse("api_posts")) + self.assertEqual(response.status_code, 405) + + +class APISingleAnonTestCase(TestCase): + """Test cases for anonymous GET / PATCH / DELETE on /api/posts//.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + data = { + "owner": self.user, + "title": "Hello world", + "slug": "hello-world", + "body": "## Hey\n\nHey world.", + "published_at": date(2020, 7, 2), + } + self.post = models.Post.objects.create(**data) + + def test_post_get(self): + response = self.client.get( + reverse("api_post", args=(self.post.slug,)), + content_type="application/json", + ) + self.assertEqual(response.status_code, 403) + + def test_post_post(self): + response = self.client.post( + reverse("api_post", args=(self.post.slug,)), + content_type="application/json", + ) + self.assertEqual(response.status_code, 405) + + def test_post_patch(self): + response = self.client.patch( + reverse("api_post", args=(self.post.slug,)), + content_type="application/json", + ) + self.assertEqual(response.status_code, 403) + + def test_post_delete(self): + response = self.client.delete( + reverse("api_post", args=(self.post.slug,)), + content_type="application/json", + ) + self.assertEqual(response.status_code, 403) + + +class APIListPostAuthTestCase(TestCase): + """Test cases for auth-related POST /api/posts/ aka post creation.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + + def test_posts_post_no_auth(self): + response = self.client.post(reverse("api_posts")) + self.assertEqual(response.status_code, 403) + + def test_posts_post_bad_auth(self): + response = self.client.post( + reverse("api_posts"), HTTP_AUTHORIZATION=f"Nearer {self.user.api_key}" + ) + self.assertEqual(response.status_code, 403) + + def test_posts_post_wrong_auth(self): + response = self.client.post( + reverse("api_posts"), + HTTP_AUTHORIZATION="Bearer 12345678901234567890123456789012", + ) + self.assertEqual(response.status_code, 403) + + def test_posts_post_good_auth(self): + response = self.client.post( + reverse("api_posts"), HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}" + ) + self.assertEqual(response.status_code, 400) + + +class APIListPostTestCase(TestCase): + """Test cases for POST /api/posts/ aka post creation.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + + def test_posts_post_no_title(self): + data = { + "body": "This is my post with no title key", + } + response = self.client.post( + reverse("api_posts"), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data=data, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(models.Post.objects.all().count(), 0) + + def test_posts_post_no_body(self): + data = { + "title": "First Post no body key", + } + response = self.client.post( + reverse("api_posts"), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data=data, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().first().title, data["title"]) + self.assertEqual(models.Post.objects.all().first().body, "") + self.assertEqual(models.Post.objects.all().count(), 1) + + def test_posts_post_bogus_key(self): + data = { + "randomkey": "random value", + } + response = self.client.post( + reverse("api_posts"), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data=data, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(models.Post.objects.all().count(), 0) + + def test_posts_post_no_published_at(self): + data = { + "title": "First Post", + "body": "## Welcome\n\nThis is my first sentence.", + } + response = self.client.post( + reverse("api_posts"), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data=data, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().count(), 1) + self.assertEqual(models.Post.objects.all().first().title, data["title"]) + self.assertEqual(models.Post.objects.all().first().body, data["body"]) + self.assertEqual(models.Post.objects.all().first().published_at, None) + models.Post.objects.all().first().delete() + + def test_posts_post_other_owner(self): + user_b = models.User.objects.create(username="bob") + data = { + "title": "First Post", + "body": "## Welcome\n\nThis is my first sentence.", + "owner_id": user_b.id, + } + response = self.client.post( + reverse("api_posts"), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data=data, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().count(), 1) + self.assertEqual(models.Post.objects.all().first().owner_id, self.user.id) + models.Post.objects.all().first().delete() + + def test_posts_post(self): + data = { + "title": "First Post", + "body": "## Welcome\n\nThis is my first sentence.", + "published_at": "2020-01-23", + } + response = self.client.post( + reverse("api_posts"), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data=data, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().count(), 1) + self.assertEqual(models.Post.objects.all().first().title, data["title"]) + self.assertEqual(models.Post.objects.all().first().body, data["body"]) + self.assertEqual( + models.Post.objects.all().first().published_at, date(2020, 1, 23) + ) + self.assertTrue(response.json()["ok"]) + self.assertEqual( + response.json()["slug"], models.Post.objects.all().first().slug + ) + self.assertEqual( + response.json()["url"], + util.get_protocol() + models.Post.objects.all().first().get_absolute_url(), + ) + models.Post.objects.all().first().delete() + + +class APIListPatchAuthTestCase(TestCase): + """Test cases for auth-related PATCH /api/posts// aka post update.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + body="## Hey\n\nHey world.", + owner=self.user, + ) + + def test_post_get(self): + response = self.client.get(reverse("api_post", args=(self.post.slug,))) + self.assertEqual(response.status_code, 403) + + def test_post_post(self): + response = self.client.post(reverse("api_post", args=(self.post.slug,))) + self.assertEqual(response.status_code, 405) + + def test_post_patch_no_auth(self): + response = self.client.patch(reverse("api_post", args=(self.post.slug,))) + self.assertEqual(response.status_code, 403) + + def test_post_patch_bad_auth(self): + response = self.client.patch( + reverse("api_post", args=(self.post.slug,)), + HTTP_AUTHORIZATION=f"Nearer {self.user.api_key}", + ) + self.assertEqual(response.status_code, 403) + + def test_post_patch_wrong_auth(self): + response = self.client.patch( + reverse("api_post", args=(self.post.slug,)), + HTTP_AUTHORIZATION="Bearer 12345678901234567890123456789012", + ) + self.assertEqual(response.status_code, 403) + + +class APIListPatchTestCase(TestCase): + """Test cases for PATCH /api/posts// aka post update.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + + def test_post_patch(self): + data = { + "owner": self.user, + "title": "Hello world", + "slug": "hello-world", + "body": "## Hey\n\nHey world.", + "published_at": date(2020, 7, 2), + } + post = models.Post.objects.create(**data) + response = self.client.patch( + reverse("api_post", args=(post.slug,)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data={ + "title": "New world", + "slug": "new-world", + "body": "new body", + "published_at": "2019-07-02", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().count(), 1) + self.assertEqual(models.Post.objects.all().first().title, "New world") + self.assertEqual(models.Post.objects.all().first().slug, "new-world") + self.assertEqual(models.Post.objects.all().first().body, "new body") + self.assertEqual( + models.Post.objects.all().first().published_at, date(2019, 7, 2) + ) + self.assertTrue(response.json()["ok"]) + self.assertEqual( + response.json()["url"], + util.get_protocol() + models.Post.objects.all().first().get_absolute_url(), + ) + models.Post.objects.all().first().delete() + + def test_post_patch_nonexistent_post(self): + response = self.client.get( + reverse("api_post", args=("nonexistent-post",)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data={ + "title": "New world", + }, + ) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json(), {"ok": False, "error": "Not found."}) + + def test_post_patch_no_body(self): + data = { + "owner": self.user, + "title": "Hello world", + "slug": "hello-world", + "body": "## Hey\n\nHey world.", + "published_at": date(2020, 7, 2), + } + post = models.Post.objects.create(**data) + response = self.client.patch( + reverse("api_post", args=(post.slug,)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data={ + "title": "New world", + "slug": "new-world", + "published_at": "2019-07-02", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().count(), 1) + self.assertEqual(models.Post.objects.all().first().title, "New world") + self.assertEqual(models.Post.objects.all().first().slug, "new-world") + self.assertEqual(models.Post.objects.all().first().body, data["body"]) + self.assertEqual( + models.Post.objects.all().first().published_at, date(2019, 7, 2) + ) + models.Post.objects.all().first().delete() + + def test_post_patch_no_slug(self): + data = { + "owner": self.user, + "title": "Hello world", + "slug": "hello-world", + "body": "## Hey\n\nHey world.", + "published_at": date(2020, 7, 2), + } + post = models.Post.objects.create(**data) + response = self.client.patch( + reverse("api_post", args=(post.slug,)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data={ + "title": "New world", + "published_at": "2019-07-02", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().count(), 1) + self.assertEqual(models.Post.objects.all().first().title, "New world") + self.assertEqual(models.Post.objects.all().first().slug, data["slug"]) + self.assertEqual(models.Post.objects.all().first().body, data["body"]) + self.assertEqual( + models.Post.objects.all().first().published_at, date(2019, 7, 2) + ) + models.Post.objects.all().first().delete() + + def test_post_patch_invalid_slug(self): + data = { + "owner": self.user, + "title": "Hello world", + "slug": "hello-world", + "body": "## Hey\n\nHey world.", + "published_at": date(2020, 7, 2), + } + post = models.Post.objects.create(**data) + response = self.client.patch( + reverse("api_post", args=(post.slug,)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data={ + "slug": "slug with spaces is invalid", + }, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(models.Post.objects.all().count(), 1) + self.assertEqual(models.Post.objects.all().first().title, data["title"]) + self.assertEqual(models.Post.objects.all().first().slug, data["slug"]) + self.assertEqual(models.Post.objects.all().first().body, data["body"]) + self.assertEqual( + models.Post.objects.all().first().published_at, data["published_at"] + ) + models.Post.objects.all().first().delete() + + def test_post_patch_invalid_key(self): + data = { + "owner": self.user, + "title": "Hello world", + "slug": "hello-world", + "body": "## Hey\n\nHey world.", + "published_at": date(2020, 7, 2), + } + post = models.Post.objects.create(**data) + response = self.client.patch( + reverse("api_post", args=(post.slug,)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data={ + "invalid": "random key value", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().count(), 1) + self.assertEqual(models.Post.objects.all().first().title, data["title"]) + self.assertEqual(models.Post.objects.all().first().slug, data["slug"]) + self.assertEqual(models.Post.objects.all().first().body, data["body"]) + self.assertEqual( + models.Post.objects.all().first().published_at, data["published_at"] + ) + models.Post.objects.all().first().delete() + + def test_post_patch_other_user_post(self): + """Test changing another user's blog post is not allowed.""" + + user_b = models.User.objects.create(username="bob") + data = { + "owner": user_b, + "title": "Hello world", + "slug": "hello-world", + "body": "## Hey\n\nHey world.", + "published_at": date(2020, 7, 2), + } + post = models.Post.objects.create(**data) + response = self.client.patch( + reverse("api_post", args=(post.slug,)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + data={ + "title": "Hi Bob, it's Alice", + }, + ) + self.assertEqual(response.status_code, 404) + self.assertEqual(models.Post.objects.all().count(), 1) + self.assertEqual(models.Post.objects.all().first().title, data["title"]) + models.Post.objects.all().first().delete() + + +class APIGetAuthTestCase(TestCase): + """Test cases for auth-related GET /api/posts// aka post retrieve.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + body="## Hey\n\nHey world.", + owner=self.user, + ) + + def test_post_get_no_auth(self): + response = self.client.get(reverse("api_post", args=(self.post.slug,))) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json(), {"ok": False, "error": "Not authorized."}) + + def test_post_get_bad_auth(self): + response = self.client.get( + reverse("api_post", args=(self.post.slug,)), + HTTP_AUTHORIZATION=f"Nearer {self.user.api_key}", + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json(), {"ok": False, "error": "Not authorized."}) + + def test_post_get_wrong_auth(self): + response = self.client.get( + reverse("api_post", args=(self.post.slug,)), + HTTP_AUTHORIZATION="Bearer 12345678901234567890123456789012", + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json(), {"ok": False, "error": "Not authorized."}) + + +class APIGetTestCase(TestCase): + """Test cases for GET /api/posts// aka post retrieve.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + + def test_post_get(self): + data = { + "owner": self.user, + "title": "Hello world", + "slug": "hello-world", + "body": "## Hey\n\nHey world.", + "published_at": date(2020, 7, 2), + } + post = models.Post.objects.create(**data) + response = self.client.get( + reverse("api_post", args=(post.slug,)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().count(), 1) + self.assertEqual(models.Post.objects.all().first().title, data["title"]) + self.assertEqual(models.Post.objects.all().first().body, data["body"]) + self.assertEqual(models.Post.objects.all().first().slug, data["slug"]) + self.assertEqual(models.Post.objects.all().first().owner, self.user) + self.assertEqual( + models.Post.objects.all().first().published_at, data["published_at"] + ) + self.assertTrue(response.json()["ok"]) + self.assertEqual( + response.json()["url"], + util.get_protocol() + models.Post.objects.all().first().get_absolute_url(), + ) + models.Post.objects.all().first().delete() + + def test_post_get_nonexistent(self): + response = self.client.get( + reverse("api_post", args=("nonexistent-post",)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + ) + self.assertEqual(response.status_code, 404) + self.assertEqual(models.Post.objects.all().count(), 0) + self.assertFalse(response.json()["ok"]) + + +class APIDeleteAuthTestCase(TestCase): + """Test cases for auth-related DELETE /api/posts// aka post retrieve.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + body="## Hey\n\nHey world.", + owner=self.user, + ) + + def test_post_delete_no_auth(self): + response = self.client.delete(reverse("api_post", args=(self.post.slug,))) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json(), {"ok": False, "error": "Not authorized."}) + + def test_post_delete_bad_auth(self): + response = self.client.delete( + reverse("api_post", args=(self.post.slug,)), + HTTP_AUTHORIZATION=f"Nearer {self.user.api_key}", + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json(), {"ok": False, "error": "Not authorized."}) + + def test_post_delete_wrong_auth(self): + response = self.client.delete( + reverse("api_post", args=(self.post.slug,)), + HTTP_AUTHORIZATION="Bearer 12345678901234567890123456789012", + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json(), {"ok": False, "error": "Not authorized."}) + + def test_post_delete_other_user(self): + user_b = models.User.objects.create(username="bob") + response = self.client.delete( + reverse("api_post", args=(self.post.slug,)), + HTTP_AUTHORIZATION=f"Bearer {user_b.api_key}", + ) + self.assertEqual(response.status_code, 404) + + +class APIDeleteTestCase(TestCase): + """Test cases for DELETE /api/posts// aka post retrieve.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + + def test_post_delete(self): + data = { + "owner": self.user, + "title": "Hello world", + "slug": "hello-world", + "body": "## Hey\n\nHey world.", + "published_at": date(2020, 7, 2), + } + post = models.Post.objects.create(**data) + response = self.client.delete( + reverse("api_post", args=(post.slug,)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().count(), 0) + self.assertTrue(response.json()["ok"]) + + def test_post_get_nonexistent(self): + response = self.client.get( + reverse("api_post", args=("nonexistent-post",)), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + ) + self.assertEqual(response.status_code, 404) + self.assertEqual(models.Post.objects.all().count(), 0) + self.assertFalse(response.json()["ok"]) + + +class APIListGetTestCase(TestCase): + """Test cases for GET /api/posts/ aka post list.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.post_a = models.Post.objects.create( + title="Hello world", + slug="hello-world", + body="## Hey\n\nHey world.", + published_at=date(2020, 1, 1), + owner=self.user, + ) + self.post_b = models.Post.objects.create( + title="Bye world", + slug="bye-world", + body="## Bye\n\nBye world.", + published_at=date(2020, 9, 14), + owner=self.user, + ) + + def test_posts_get(self): + response = self.client.get( + reverse("api_posts"), + HTTP_AUTHORIZATION=f"Bearer {self.user.api_key}", + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Post.objects.all().count(), 2) + self.assertTrue(response.json()["ok"]) + post_list = response.json()["post_list"] + self.assertEqual(len(post_list), 2) + self.assertIn( + { + "title": "Hello world", + "slug": "hello-world", + "body": "## Hey\n\nHey world.", + "published_at": "2020-01-01", + "url": f"{util.get_protocol()}//{self.user.username}.{settings.CANONICAL_HOST}/blog/hello-world/", + }, + post_list, + ) + self.assertIn( + { + "title": "Bye world", + "slug": "bye-world", + "body": "## Bye\n\nBye world.", + "published_at": "2020-09-14", + "url": f"{util.get_protocol()}//{self.user.username}.{settings.CANONICAL_HOST}/blog/bye-world/", + }, + post_list, + ) + + +class APISingleGetTestCase(TestCase): + """Test posts with the same slug return across different users.""" + + def setUp(self): + # user 1 + self.user1 = models.User.objects.create(username="alice") + self.data = { + "title": "Test 1", + "published_at": "2021-06-01", + } + response = self.client.post( + reverse("api_posts"), + HTTP_AUTHORIZATION=f"Bearer {self.user1.api_key}", + content_type="application/json", + data=self.data, + ) + self.assertEqual(response.status_code, 200) + # user 2, same post + self.user2 = models.User.objects.create(username="bob") + self.data = { + "title": "Test 1", + "published_at": "2021-06-02", + } + response = self.client.post( + reverse("api_posts"), + HTTP_AUTHORIZATION=f"Bearer {self.user2.api_key}", + content_type="application/json", + data=self.data, + ) + self.assertEqual(response.status_code, 200) + # verify objects + self.assertEqual(models.Post.objects.all().count(), 2) + self.assertEqual(models.Post.objects.all()[0].slug, "test-1") + self.assertEqual(models.Post.objects.all()[1].slug, "test-1") + + def test_get(self): + # user 1 + response = self.client.get( + reverse("api_post", args=("test-1",)), + HTTP_AUTHORIZATION=f"Bearer {self.user1.api_key}", + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["published_at"], "2021-06-01") + # user 2 + response = self.client.get( + reverse("api_post", args=("test-1",)), + HTTP_AUTHORIZATION=f"Bearer {self.user2.api_key}", + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["published_at"], "2021-06-02") diff --git a/main/tests/test_billing.py b/main/tests/test_billing.py new file mode 100644 index 0000000000000000000000000000000000000000..d7a2a3e76cf7566d332230d068f31b4c4c0729b9 --- /dev/null +++ b/main/tests/test_billing.py @@ -0,0 +1,294 @@ +from datetime import datetime, timedelta +from unittest.mock import patch + +import stripe +from django.test import TestCase +from django.urls import reverse + +from main import models +from main.views import billing + + +class BillingCannotChangeIsPremiumTestCase(TestCase): + """Test user cannot change their is_premium flag without going through billing.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + + def test_update_billing_settings(self): + data = { + "username": "alice", + "is_premium": True, + } + self.client.post(reverse("user_update"), data) + self.assertFalse(models.User.objects.get(id=self.user.id).is_premium) + + +class BillingIndexGrandfatherTestCase(TestCase): + """Test billing pages work accordingly for grandathered user.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.is_grandfathered = True + self.user.save() + self.client.force_login(self.user) + + def test_index(self): + response = self.client.get(reverse("billing_index")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, b"Grandfather Plan") + + def test_cannot_subscribe(self): + response = self.client.post(reverse("billing_subscription")) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, reverse("dashboard")) + + def test_cannot_cancel_get(self): + response = self.client.get(reverse("billing_subscription_cancel")) + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, reverse("dashboard")) + + +class BillingIndexFreeTestCase(TestCase): + """Test billing index works for free user.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.save() + self.client.force_login(self.user) + + def test_index(self): + with ( + patch.object( + stripe.Customer, "create", return_value={"id": "cus_123abcdefg"} + ), + patch.object(billing, "_get_stripe_subscription", return_value=None), + patch.object( + billing, + "_get_payment_methods", + ), + patch.object(billing, "_get_invoices"), + ): + response = self.client.get(reverse("billing_index")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, b"Free Plan") + + +class BillingIndexPremiumTestCase(TestCase): + """Test billing index works for premium user.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.is_premium = True + self.user.save() + self.client.force_login(self.user) + + def test_index(self): + one_year_later = datetime.now() + timedelta(days=365) + subscription = { + "current_period_end": one_year_later.timestamp(), + "current_period_start": datetime.now().timestamp(), + } + with ( + patch.object( + stripe.Customer, "create", return_value={"id": "cus_123abcdefg"} + ), + patch.object( + billing, + "_get_stripe_subscription", + return_value=subscription, + ), + patch.object(billing, "_get_payment_methods"), + patch.object(billing, "_get_invoices"), + ): + response = self.client.get(reverse("billing_index")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, b"Premium Plan") + + +class BillingCardAddTestCase(TestCase): + """Test billing card add functionality.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.is_premium = True + self.user.save() + self.client.force_login(self.user) + + def test_card_add_get(self): + with patch.object( + stripe.SetupIntent, "create", return_value={"client_secret": "seti_123abc"} + ): + response = self.client.get(reverse("billing_card")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, b"Add card") + + def test_card_add_post(self): + one_year_later = datetime.now() + timedelta(days=365) + subscription = { + "current_period_end": one_year_later.timestamp(), + "current_period_start": datetime.now().timestamp(), + } + with ( + patch.object( + stripe.Customer, "create", return_value={"id": "cus_123abcdefg"} + ), + patch.object( + billing, + "_get_stripe_subscription", + return_value=subscription, + ), + patch.object(billing, "_get_payment_methods"), + patch.object(billing, "_get_invoices"), + ): + response = self.client.post( + reverse("billing_card"), + data={"card_token": "tok_123"}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, b"Premium Plan") + + +class BillingCancelSubscriptionTestCase(TestCase): + """Test billing cancel subscription.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.is_premium = True + self.user.stripe_customer_id = "cus_123abcdefg" + self.user.save() + self.client.force_login(self.user) + + def test_cancel_subscription_get(self): + one_year_later = datetime.now() + timedelta(days=365) + subscription = { + "current_period_end": one_year_later.timestamp(), + "current_period_start": datetime.now().timestamp(), + } + with patch.object( + billing, + "_get_stripe_subscription", + return_value=subscription, + ): + response = self.client.get(reverse("billing_subscription_cancel")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, b"Cancel Premium") + + def test_cancel_subscription_post(self): + with ( + patch.object(stripe.Subscription, "delete"), + patch.object( + billing, + "_get_stripe_subscription", + return_value={"id": "sub_123"}, + ), + ): + response = self.client.post(reverse("billing_subscription_cancel")) + + self.assertEqual(response.status_code, 302) + self.assertFalse(models.User.objects.get(id=self.user.id).is_premium) + + +class BillingCancelSubscriptionTwiceTestCase(TestCase): + """Test billing cancel subscription when already canceled.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.stripe_customer_id = "cus_123abcdefg" + self.user.save() + self.client.force_login(self.user) + + def test_cancel_subscription_get(self): + with ( + patch.object(billing, "_get_stripe_subscription", return_value=None), + patch.object( + stripe.Customer, "create", return_value={"id": "cus_123abcdefg"} + ), + patch.object( + billing, + "_get_payment_methods", + ), + patch.object(billing, "_get_invoices"), + ): + response = self.client.get(reverse("billing_subscription_cancel")) + + # need to check inside with context because billing_index needs + # __get_stripe_subscription patch + self.assertRedirects(response, reverse("billing_index")) + + def test_cancel_subscription_post(self): + with ( + patch.object(stripe.Subscription, "delete"), + patch.object( + billing, + "_get_stripe_subscription", + return_value=None, + ), + patch.object( + stripe.Customer, "create", return_value={"id": "cus_123abcdefg"} + ), + patch.object( + billing, + "_get_payment_methods", + ), + patch.object(billing, "_get_invoices"), + ): + response = self.client.post(reverse("billing_subscription_cancel")) + + self.assertRedirects(response, reverse("billing_index")) + self.assertFalse(models.User.objects.get(id=self.user.id).is_premium) + + +class BillingReenableSubscriptionTestCase(TestCase): + """Test re-enabling subscription after cancelation.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.stripe_customer_id = "cus_123abcdefg" + self.user.save() + self.client.force_login(self.user) + + def test_reenable_subscription_post(self): + one_year_later = datetime.now() + timedelta(days=365) + subscription = { + "current_period_end": one_year_later.timestamp(), + "current_period_start": datetime.now().timestamp(), + } + created_subscription = { + "id": "sub_456abcdefg", + "latest_invoice": { + "payment_intent": { + "client_secret": "seti_123abc", + }, + }, + } + with ( + patch.object(stripe.Subscription, "delete"), + patch.object( + billing, + "_get_stripe_subscription", + return_value=subscription, + ), + patch.object( + stripe.Customer, "create", return_value={"id": "cus_123abcdefg"} + ), + patch.object( + stripe.Subscription, + "create", + return_value=created_subscription, + ), + patch.object( + billing, + "_get_payment_methods", + ), + patch.object(billing, "_get_invoices"), + ): + response = self.client.post(reverse("billing_subscription")) + + self.assertRedirects(response, reverse("billing_index")) + self.assertTrue(models.User.objects.get(id=self.user.id).is_premium) diff --git a/main/tests/test_blog.py b/main/tests/test_blog.py new file mode 100644 index 0000000000000000000000000000000000000000..78a694e7a44a73f5ea77ef1463d0886f2b2238c3 --- /dev/null +++ b/main/tests/test_blog.py @@ -0,0 +1,420 @@ +from django.conf import settings +from django.test import TestCase +from django.urls import reverse + +from main import models + + +class IndexTestCase(TestCase): + """Test canonical mataroa.blog works.""" + + def test_index(self): + response = self.client.get(reverse("index")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Mataroa") + + +class BlogIndexTestCase(TestCase): + """Test blog index works for logged in.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.blog_title = "Blog of Alice" + self.user.save() + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_blog_index(self): + response = self.client.get( + reverse("index"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.data["title"]) + + +class BlogIndexAnonTestCase(TestCase): + """Test blog index works for anon.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.blog_title = "Blog of Alice" + self.user.save() + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_blog_index_non(self): + response = self.client.get( + reverse("index"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.data["title"]) + + +class BlogIndexRedirTestCase(TestCase): + """Test logged in user is redirected from canonical to blog index.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.blog_title = "Blog of Alice" + self.user.save() + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_blog_index_redir(self): + response = self.client.get(reverse("blog_index")) + self.assertEqual(response.status_code, 302) + self.assertTrue( + f"{self.user.username}.{settings.CANONICAL_HOST}" in response.url + ) + + +class BlogRetiredRedirTestCase(TestCase): + """ + Test anon user is redirected to redirect_domain, + when redirect_domain exists without protocol prefix. + """ + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.blog_title = "Blog of Alice" + self.user.redirect_domain = "example.com" + self.user.save() + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_blog_retired_redir(self): + response = self.client.get( + reverse("index"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(f"//{self.user.redirect_domain}", response.url) + + +class BlogRetiredRedirProtocolTestCase(TestCase): + """ + Test anon user is redirected to redirect_domain, + when redirect_domain exists with protocol prefix http. + """ + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.blog_title = "Blog of Alice" + self.user.redirect_domain = "http://example.com" + self.user.save() + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_blog_retired_redir(self): + response = self.client.get( + reverse("index"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(self.user.redirect_domain, response.url) + + +class BlogImportTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.blog_title = "Blog of Alice" + self.user.save() + self.client.force_login(self.user) + + def test_blog_import(self): + filename = "main/tests/testdata/lorem.md" + with open(filename) as fp: + self.client.post(reverse("blog_import"), {"file": fp}) + self.assertTrue(models.Post.objects.filter(title="lorem.md").exists()) + self.assertTrue( + "Curabitur pretium tincidunt lacus" + in models.Post.objects.get(title="lorem.md").body + ) + + +class BlogExportMarkdownTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_blog_export(self): + response = self.client.post(reverse("export_markdown")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/zip") + self.assertContains(response, b"export-markdown") + self.assertContains(response, self.data["slug"].encode("utf-8")) + + +class BlogExportPrintTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.blog_title = "Alice Blog" + self.user.blog_byline = "a blog about wonderland" + self.user.save() + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_blog_export(self): + response = self.client.post(reverse("export_print")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.user.blog_title) + self.assertContains(response, self.user.blog_byline) + self.assertContains(response, self.user.username) + self.assertContains(response, self.data["title"].encode("utf-8")) + + +class BlogExportZolaTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_blog_export(self): + response = self.client.post(reverse("export_zola")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/zip") + self.assertContains(response, b"export-zola") + self.assertContains(response, self.data["slug"].encode("utf-8")) + + +class BlogExportHugoTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_blog_export(self): + response = self.client.post(reverse("export_hugo")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/zip") + self.assertContains(response, b"export-hugo") + self.assertContains(response, self.data["slug"].encode("utf-8")) + + +class BlogExportEpubTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_blog_export(self): + response = self.client.post(reverse("export_epub")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/epub") + self.assertContains(response, b"OEBPS/titlepage.xhtml") + self.assertContains(response, b"OEBPS/toc.xhtml") + self.assertContains(response, b"OEBPS/author.xhtml") + + +class BlogNotificationListTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.notification = models.Notification.objects.create( + blog_user=self.user, + email="s@example.com", + ) + + def test_subscibers_list(self): + response = self.client.get(reverse("notification_list")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, b"s@example.com") + + +class BlogNotificationSubscribeTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.notifications_on = True + self.user.save() + + def test_blog_subscribe(self): + response = self.client.post( + reverse("notification_subscribe"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + data={"email": "s@example.com"}, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue( + models.Notification.objects.filter( + blog_user=self.user, email="s@example.com", is_active=True + ).exists() + ) + + +class BlogNotificationUnsubscribeTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.notification = models.Notification.objects.create( + blog_user=self.user, + email="s@example.com", + ) + + def test_blog_unsubscribe(self): + response = self.client.post( + reverse("notification_unsubscribe"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + data={"email": "s@example.com"}, + ) + self.assertEqual(response.status_code, 302) + self.assertFalse( + models.Notification.objects.get(email="s@example.com").is_active + ) + + +class BlogNotificationUnsubscribeKeyTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.notification = models.Notification.objects.create( + blog_user=self.user, + email="s@example.com", + ) + + def test_blog_unsubscribe_key(self): + response = self.client.get( + reverse( + "notification_unsubscribe_key", + args=(self.notification.unsubscribe_key,), + ), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertFalse( + models.Notification.objects.filter( + email="s@example.com", is_active=False + ).exists() + ) + + +class BlogNotificationResubscribeTestCase(TestCase): + """Test one can subscribe after having unsubscribed.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.notifications_on = True + self.user.save() + + self.notification = models.Notification.objects.create( + blog_user=self.user, + email="s@example.com", + is_active=False, + ) + + def test_blog_resubscribe(self): + response = self.client.post( + reverse("notification_subscribe"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + data={"email": "s@example.com"}, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue( + models.Notification.objects.filter( + email="s@example.com", is_active=True + ).exists() + ) + + +class BlogNotificationUnsubscriberNotShownTestCase(TestCase): + """Test someone who unsubscribes does not appear on list.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.user.notifications_on = True + self.user.save() + self.client.force_login(self.user) + + self.notification_active = models.Notification.objects.create( + blog_user=self.user, + email="active@example.com", + is_active=True, + ) + self.notification_inactive = models.Notification.objects.create( + blog_user=self.user, + email="inactive@example.com", + is_active=False, + ) + + def test_blog_unsubscribed_not_shown(self): + response = self.client.get( + reverse("notification_list"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.notification_active.email) + self.assertNotContains(response, self.notification_inactive.email) + + +class BlogNotificationRecordListTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + self.notification = models.Notification.objects.create( + blog_user=self.user, + email="s@example.com", + ) + self.notificationrecord = models.NotificationRecord.objects.create( + notification=self.notification, + post=self.post, + sent_at="2020-01-01", + ) + + def test_notificationrecord_list(self): + response = self.client.get(reverse("notificationrecord_list")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, b"s@example.com") + self.assertContains(response, b"2020-01-01") diff --git a/main/tests/test_comments.py b/main/tests/test_comments.py new file mode 100644 index 0000000000000000000000000000000000000000..b353556046e72577f4b50f2c1dce68c8b1c349e8 --- /dev/null +++ b/main/tests/test_comments.py @@ -0,0 +1,563 @@ +from django.conf import settings +from django.test import TestCase +from django.urls import reverse + +from main import models + + +class CommentCreateAuthorTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + self.client.force_login(self.user) + + def test_comment_create_author(self): + data = { + "body": "Content sentence.", + } + response = self.client.post( + reverse("comment_create_author", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(response.status_code, 302) + + self.assertEqual(models.Comment.objects.all().count(), 1) + self.assertEqual(models.Comment.objects.all().first().name, self.user.username) + self.assertIsNone(models.Comment.objects.all().first().email) + self.assertEqual(models.Comment.objects.all().first().body, data["body"]) + self.assertEqual(models.Comment.objects.all().first().post, self.post) + + response = self.client.get( + reverse("post_detail", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "your comment is public") + + +class CommentCreateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + + def test_comment_create(self): + data = { + "name": "Jon", + "email": "jon@wick.com", + "body": "Content sentence.", + } + response = self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(response.status_code, 302) + + self.assertEqual(models.Comment.objects.all().count(), 1) + self.assertEqual(models.Comment.objects.all().first().name, data["name"]) + self.assertEqual(models.Comment.objects.all().first().email, data["email"]) + self.assertEqual(models.Comment.objects.all().first().body, data["body"]) + self.assertEqual(models.Comment.objects.all().first().post, self.post) + + response = self.client.get( + reverse("post_detail", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "your comment will be published soon") + + +class CommentApprovedCreateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + self.comment = models.Comment.objects.create( + post=self.post, + name="Jon", + email="jon@wick.com", + body="Content sentence.", + is_approved=True, + ) + + def test_comment_create(self): + response = self.client.get( + reverse("post_detail", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Jon") + self.assertContains(response, "Content sentence.") + + +class CommentNotApprovedCreateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + self.comment = models.Comment.objects.create( + post=self.post, + name="Jon", + email="jon@wick.com", + body="Content sentence.", + is_approved=False, + ) + + def test_comment_create(self): + response = self.client.get( + reverse("post_detail", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, "Jon") + self.assertNotContains(response, "Content sentence.") + + +class CommentNameCreateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + + def test_comment_create(self): + data = { + "name": "Jon", + "body": "Content sentence.", + } + response = self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(models.Comment.objects.all().count(), 1) + self.assertEqual(models.Comment.objects.all().first().name, data["name"]) + self.assertEqual(models.Comment.objects.all().first().body, data["body"]) + self.assertEqual(models.Comment.objects.all().first().post, self.post) + + +class CommentEmailCreateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + + def test_comment_create(self): + data = { + "email": "jon@wick.com", + "body": "Content sentence.", + } + response = self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(models.Comment.objects.all().count(), 1) + self.assertEqual(models.Comment.objects.all().first().name, "Anonymous") + self.assertEqual(models.Comment.objects.all().first().email, data["email"]) + self.assertEqual(models.Comment.objects.all().first().body, data["body"]) + self.assertEqual(models.Comment.objects.all().first().post, self.post) + + +class CommentNoAuthCreateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + + def test_comment_create(self): + data = { + "body": "Content sentence.", + } + response = self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(models.Comment.objects.all().count(), 1) + self.assertEqual(models.Comment.objects.all().first().name, "Anonymous") + self.assertEqual(models.Comment.objects.all().first().body, data["body"]) + self.assertEqual(models.Comment.objects.all().first().post, self.post) + + +class CommentNoBodyCreateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + + def test_comment_create(self): + data = { + "body": "", + } + response = self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Comment.objects.all().count(), 0) + + +class CommentDisallowedCreateTestCase(TestCase): + def setUp(self): + # user.comments_on=False is the default + self.user = models.User.objects.create(username="alice") + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + + def test_comment_create(self): + data = { + "body": "", + } + response = self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(models.Comment.objects.all().count(), 0) + + +class CommentDeleteTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.client.force_login(self.user) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + self.comment = models.Comment.objects.create( + name="Jon", + email="jon@wick.com", + body="Content sentence.", + post=self.post, + ) + + def test_comment_delete(self): + response = self.client.post( + reverse("comment_delete", args=(self.post.slug, self.comment.id)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(models.Comment.objects.all().count(), 0) + + +class CommentNonOwnerDeleteTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + self.comment = models.Comment.objects.create( + name="Jon", + email="jon@wick.com", + body="Content sentence.", + post=self.post, + ) + self.non_owner = models.User.objects.create(username="bob") + self.client.force_login(self.non_owner) + + def test_comment_delete(self): + response = self.client.post( + reverse("comment_delete", args=(self.post.slug, self.comment.id)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(models.Comment.objects.all().count(), 1) + + +class CommentNoAuthDeleteTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + title="Hello world", + slug="hello-world", + owner=self.user, + ) + self.comment = models.Comment.objects.create( + name="Jon", + email="jon@wick.com", + body="Content sentence.", + post=self.post, + ) + + def test_comment_delete(self): + response = self.client.post( + reverse("comment_delete", args=(self.post.slug, self.comment.id)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(models.Comment.objects.all().count(), 1) + + +class CommentsApproveTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + owner=self.user, + title="Welcome post", + slug="welcome-post", + body="Content sentence.", + published_at="2020-02-06", + ) + data = { + "body": "Hey, I am a comment.", + } + self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(models.Comment.objects.all().count(), 1) + + def test_comment_approve(self): + self.comment = models.Comment.objects.all().first() + self.client.force_login(self.user) + data = { + "is_approved": True, + } + response = self.client.post( + reverse( + "comment_approve", + args=( + self.comment.post.slug, + self.comment.id, + ), + ), + HTTP_HOST=f"{self.user.username}.{settings.CANONICAL_HOST}", + data=data, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(models.Comment.objects.all().count(), 1) + self.assertEqual(models.Comment.objects.all().first().is_approved, True) + + +class CommentsApproveNoAuthTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + owner=self.user, + title="Welcome post", + slug="welcome-post", + body="Content sentence.", + published_at="2020-02-06", + ) + data = { + "body": "Hey, I am a comment.", + } + self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(models.Comment.objects.all().count(), 1) + + def test_comment_approve_not_authorized(self): + self.comment = models.Comment.objects.all().first() + data = { + "is_approved": True, + } + response = self.client.post( + reverse( + "comment_approve", + args=( + self.comment.post.slug, + self.comment.id, + ), + ), + HTTP_HOST=f"{self.user.username}.{settings.CANONICAL_HOST}", + data=data, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(models.Comment.objects.all().count(), 1) + self.assertEqual(models.Comment.objects.all().first().is_approved, False) + + +class CommentsApproveNonOwnerTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + owner=self.user, + title="Welcome post", + slug="welcome-post", + body="Content sentence.", + published_at="2020-02-06", + ) + data = { + "body": "Hey, I am a comment.", + } + self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(models.Comment.objects.all().count(), 1) + + def test_comment_approve_not_owner(self): + self.comment = models.Comment.objects.all().first() + self.second_user = models.User.objects.create(username="bob", comments_on=True) + self.client.force_login(self.second_user) + data = { + "is_approved": True, + } + response = self.client.post( + reverse( + "comment_approve", + args=( + self.comment.post.slug, + self.comment.id, + ), + ), + HTTP_HOST=f"{self.user.username}.{settings.CANONICAL_HOST}", + data=data, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(models.Comment.objects.all().count(), 1) + self.assertEqual(models.Comment.objects.all().first().is_approved, False) + + +class CommentsDeleteTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + owner=self.user, + title="Welcome post", + slug="welcome-post", + body="Content sentence.", + published_at="2020-02-06", + ) + data = { + "body": "Hey, I am a comment.", + } + self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(models.Comment.objects.all().count(), 1) + + def test_comment_delete(self): + self.comment = models.Comment.objects.all().first() + self.client.force_login(self.user) + response = self.client.post( + reverse( + "comment_delete", + args=( + self.comment.post.slug, + self.comment.id, + ), + ), + HTTP_HOST=f"{self.user.username}.{settings.CANONICAL_HOST}", + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(models.Comment.objects.all().count(), 0) + + +class CommentsDeleteNoAuthTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + owner=self.user, + title="Welcome post", + slug="welcome-post", + body="Content sentence.", + published_at="2020-02-06", + ) + data = { + "body": "Hey, I am a comment.", + } + self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(models.Comment.objects.all().count(), 1) + + def test_comment_delete_not_authorized(self): + self.comment = models.Comment.objects.all().first() + response = self.client.post( + reverse( + "comment_delete", + args=( + self.comment.post.slug, + self.comment.id, + ), + ), + HTTP_HOST=f"{self.user.username}.{settings.CANONICAL_HOST}", + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(models.Comment.objects.all().count(), 1) + + +class CommentsDeleteNonOwnerTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice", comments_on=True) + self.post = models.Post.objects.create( + owner=self.user, + title="Welcome post", + slug="welcome-post", + body="Content sentence.", + published_at="2020-02-06", + ) + data = { + "body": "Hey, I am a comment.", + } + self.client.post( + reverse("comment_create", args=(self.post.slug,)), + HTTP_HOST="alice." + settings.CANONICAL_HOST, + data=data, + ) + self.assertEqual(models.Comment.objects.all().count(), 1) + + def test_comment_delete_not_owner(self): + self.comment = models.Comment.objects.all().first() + self.second_user = models.User.objects.create(username="bob", comments_on=True) + self.client.force_login(self.second_user) + response = self.client.post( + reverse( + "comment_delete", + args=( + self.comment.post.slug, + self.comment.id, + ), + ), + HTTP_HOST=f"{self.user.username}.{settings.CANONICAL_HOST}", + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(models.Comment.objects.all().count(), 1) diff --git a/main/tests/test_feeds.py b/main/tests/test_feeds.py new file mode 100644 index 0000000000000000000000000000000000000000..739024b9438aeb74a6dfabdb112820d8cf4e803f --- /dev/null +++ b/main/tests/test_feeds.py @@ -0,0 +1,129 @@ +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from main import models +from mataroa import settings + + +class RSSFeedTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + "published_at": timezone.now(), + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_rss_feed(self): + response = self.client.get( + reverse("rss_feed"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/rss+xml; charset=utf-8") + self.assertContains(response, self.data["title"]) + self.assertContains(response, self.data["slug"]) + self.assertContains(response, self.data["body"]) + self.assertContains( + response, + f"//{self.user.username}.{settings.CANONICAL_HOST}/blog/{self.data['slug']}/", + ) + + +class RSSFeedDraftsTestCase(TestCase): + """Tests draft posts do not appear in the RSS feed.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.post_published = { + "title": "Welcome post", + "slug": "welcome-post", + "body": "Content sentence.", + "published_at": timezone.now(), + } + models.Post.objects.create(owner=self.user, **self.post_published) + self.post_draft = { + "title": "Hidden post", + "slug": "hidden-post", + "body": "Hidden sentence.", + "published_at": None, + } + models.Post.objects.create(owner=self.user, **self.post_draft) + + def test_rss_feed(self): + response = self.client.get( + reverse("rss_feed"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/rss+xml; charset=utf-8") + self.assertContains(response, self.post_published["title"]) + self.assertContains(response, self.post_published["slug"]) + self.assertContains(response, self.post_published["body"]) + self.assertNotContains(response, self.post_draft["title"]) + self.assertNotContains(response, self.post_draft["slug"]) + self.assertNotContains(response, self.post_draft["body"]) + self.assertNotContains( + response, + f"//{self.user.username}.{settings.CANONICAL_HOST}/blog/{self.post_draft['slug']}/", + ) + + +class RSSFeedFuturePostTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "New Future Post", + "slug": "new-future-post", + "body": "future post body", + "published_at": timezone.now() + timedelta(1), + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_future_post_hidden(self): + response = self.client.get( + reverse("rss_feed"), + # needs HTTP_HOST because we need to request it on the subdomain + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/rss+xml; charset=utf-8") + self.assertNotContains(response, self.data["title"]) + self.assertNotContains(response, self.data["slug"]) + self.assertNotContains(response, self.data["body"]) + self.assertNotContains( + response, + f"//{self.user.username}.{settings.CANONICAL_HOST}/blog/{self.data['slug']}/", + ) + + +class RSSFeedFormatTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create( + username="alice", blog_title="test title", blog_byline="test about text" + ) + + def test_feed_valid(self): + response = self.client.get( + reverse("rss_feed"), + # needs HTTP_HOST because we need to request it on the subdomain + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, '') + self.assertContains( + response, + '', + ) + self.assertContains(response, f"{self.user.blog_title}") + self.assertContains( + response, f"{self.user.blog_byline}" + ) diff --git a/main/tests/test_images.py b/main/tests/test_images.py new file mode 100644 index 0000000000000000000000000000000000000000..c4392e2f161295df073e19255ca150375c254e40 --- /dev/null +++ b/main/tests/test_images.py @@ -0,0 +1,244 @@ +from django.test import TestCase +from django.urls import reverse + +from main import models + + +class ImageCreateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + + def test_image_upload(self): + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.assertTrue(models.Image.objects.filter(name="vulf").exists()) + self.assertEqual(models.Image.objects.get(name="vulf").extension, "jpeg") + self.assertIsNotNone(models.Image.objects.get(name="vulf").slug) + + +class ImageCreateAnonTestCase(TestCase): + def test_image_upload_anon(self): + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + response = self.client.post(reverse("image_list"), {"file": fp}) + self.assertEqual(response.status_code, 302) + self.assertTrue(reverse("login") in response.url) + + +class ImageDetailTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + + def test_image_detail(self): + response = self.client.get( + reverse("image_detail", args=(self.image.slug,)), + ) + self.assertEqual(response.status_code, 200) + self.assertInHTML("

vulf

", response.content.decode("utf-8")) + self.assertContains(response, "Uploaded on") + + +class ImageDetailNotOwnTestCase(TestCase): + """Tests user cannot open image detail page of another user's image.""" + + def setUp(self): + self.victim = models.User.objects.create(username="bob") + self.client.force_login(self.victim) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + self.client.logout() + + self.attacker = models.User.objects.create(username="alice") + self.client.force_login(self.attacker) + + def test_image_detail_not_own(self): + response = self.client.get(reverse("image_detail", args=(self.image.slug,))) + self.assertEqual(response.status_code, 403) + + +class ImageDetailUsedByTestCase(TestCase): + """Tests used by posts feature works.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + + self.data = { + "title": "New post", + "slug": "new-post", + "body": f'This is Vulfpeck\n', + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_image_detail(self): + response = self.client.get( + reverse("image_detail", args=(self.image.slug,)), + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Used by posts:") + self.assertContains(response, "New post") + + +class ImageRawTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + + def test_image_raw(self): + response = self.client.get( + reverse("image_raw", args=(self.image.slug, self.image.extension)), + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.image.data, response.content) + + +class ImageRawWrongExtTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + + def test_image_raw(self): + response = self.client.get( + reverse("image_raw", args=(self.image.slug, "png")), + ) + self.assertEqual(response.status_code, 404) + + +class ImageRawNotFoundTestCase(TestCase): + def setUp(self): + self.slug = "nonexistent-slug" + self.extension = "jpeg" + + def test_image_raw(self): + response = self.client.get( + reverse("image_raw", args=(self.slug, self.extension)), + ) + self.assertEqual(response.status_code, 404) + + +class ImageUpdateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + + def test_image_update(self): + new_data = { + "name": "new vulf", + } + self.client.post(reverse("image_update", args=(self.image.slug,)), new_data) + updated_image = models.Image.objects.get(id=self.image.id) + self.assertEqual(updated_image.name, new_data["name"]) + + +class ImageUpdateAnonTestCase(TestCase): + """Tests non logged in user cannot update image.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + self.client.logout() + + def test_image_update(self): + new_data = { + "name": "new vulf", + } + self.client.post(reverse("image_update", args=(self.image.slug,)), new_data) + image_now = models.Image.objects.get(id=self.image.id) + self.assertEqual(image_now.name, "vulf") + + +class ImageUpdateNotOwnTestCase(TestCase): + """Tests user cannot update other user's image name.""" + + def setUp(self): + self.victim = models.User.objects.create(username="bob") + self.client.force_login(self.victim) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + self.client.logout() + + self.attacker = models.User.objects.create(username="alice") + self.client.force_login(self.attacker) + + def test_image_update_not_own(self): + new_data = { + "name": "bad vulf", + } + self.client.post(reverse("image_update", args=(self.image.slug,)), new_data) + image_now = models.Image.objects.get(id=self.image.id) + self.assertEqual(image_now.name, "vulf") + + +class ImageDeleteTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + + def test_image_delete(self): + self.client.post(reverse("image_delete", args=(self.image.slug,))) + self.assertFalse( + models.Image.objects.filter(name="vulf", owner=self.user).exists() + ) + + +class ImageDeleteAnonTestCase(TestCase): + """Tests non logged in user cannot delete image.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + self.client.logout() + + def test_image_delete_anon(self): + self.client.post(reverse("image_delete", args=(self.image.slug,))) + self.assertTrue( + models.Image.objects.filter(name="vulf", owner=self.user).exists() + ) + + +class ImageDeleteNotOwnTestCase(TestCase): + """Tests user cannot delete other's image.""" + + def setUp(self): + self.victim = models.User.objects.create(username="bob") + self.client.force_login(self.victim) + with open("main/tests/testdata/vulf.jpeg", "rb") as fp: + self.client.post(reverse("image_list"), {"file": fp}) + self.image = models.Image.objects.get(name="vulf") + self.client.logout() + + self.attacker = models.User.objects.create(username="alice") + self.client.force_login(self.attacker) + + def test_image_delete_not_own(self): + self.client.post(reverse("image_delete", args=(self.image.slug,))) + self.assertTrue( + models.Image.objects.filter(name="vulf", owner=self.victim).exists() + ) diff --git a/main/tests/test_management.py b/main/tests/test_management.py new file mode 100644 index 0000000000000000000000000000000000000000..b10902bd4bab4b3dd17d7b922a2d626b083d6c3b --- /dev/null +++ b/main/tests/test_management.py @@ -0,0 +1,200 @@ +from datetime import datetime +from io import StringIO +from unittest.mock import patch + +from django.conf import settings +from django.core import mail +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone + +from main import models +from main.management.commands import mailexports, processnotifications + + +class ProcessNotificationsTest(TestCase): + """ + Test processnotifications sends emails to the blog's subscibers. + """ + + def setUp(self): + self.user = models.User.objects.create( + username="alice", email="alice@mataroa.blog", notifications_on=True + ) + + post_data = { + "title": "Yesterday post", + "slug": "yesterday-post", + "body": "Content sentence.", + "published_at": timezone.make_aware(datetime(2020, 1, 1)), + } + self.post_yesterday = models.Post.objects.create(owner=self.user, **post_data) + + post_data = { + "title": "Today post", + "slug": "today-post", + "body": "Content sentence.", + "published_at": timezone.make_aware(datetime(2020, 1, 2)), + } + self.post_today = models.Post.objects.create(owner=self.user, **post_data) + + self.notification = models.Notification.objects.create( + blog_user=self.user, email="subscriber@example.com" + ) + + def test_mail_backend(self): + connection = processnotifications.get_mail_connection() + self.assertEqual(connection.host, settings.EMAIL_HOST_BROADCASTS) + + def test_command(self): + output = StringIO() + + with ( + patch.object(timezone, "now", return_value=datetime(2020, 1, 2, 13, 00)), + patch.object( + # Django default test runner overrides SMTP EmailBackend with locmem, + # but because we re-import the SMTP backend in + # processnotifications.get_mail_connection, we need to mock it here too. + processnotifications, + "get_mail_connection", + return_value=mail.get_connection( + "django.core.mail.backends.locmem.EmailBackend" + ), + ), + ): + call_command("processnotifications", "--no-dryrun", stdout=output) + + # notification records + records = models.NotificationRecord.objects.all() + self.assertEqual(len(records), 1) + record = records[0] + + # notification record for yesterday's post + self.assertEqual(record.notification.email, self.notification.email) + self.assertEqual(record.post.title, "Yesterday post") + + # logging + self.assertIn("Processing notifications.", output.getvalue()) + self.assertIn( + "Email sent for 'Yesterday post' to 'subscriber@example.com'", + output.getvalue(), + ) + + # email + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, "Yesterday post") + self.assertIn("Unsubscribe", mail.outbox[0].body) + + # email headers + self.assertEqual(mail.outbox[0].to, [self.notification.email]) + self.assertEqual(mail.outbox[0].reply_to, []) + self.assertEqual( + mail.outbox[0].from_email, + f"{self.user.username} <{self.user.username}@{settings.EMAIL_FROM_HOST}>", + ) + + self.assertEqual( + mail.outbox[0].extra_headers["X-PM-Message-Stream"], "newsletters" + ) + self.assertIn( + "/newsletter/unsubscribe/", + mail.outbox[0].extra_headers["List-Unsubscribe"], + ) + self.assertEqual( + mail.outbox[0].extra_headers["List-Unsubscribe-Post"], + "List-Unsubscribe=One-Click", + ) + + def tearDown(self): + models.User.objects.all().delete() + models.Post.objects.all().delete() + + +class MailExportsTest(TestCase): + """ + Test mail_export sends emails to users with `mail_export_on` enabled. + """ + + def setUp(self): + self.user = models.User.objects.create( + username="alice", email="alice@mataroa.blog", mail_export_on=True + ) + + post_data = { + "title": "A post", + "slug": "a-post", + "body": "Content sentence.", + "published_at": timezone.make_aware(datetime(2020, 1, 1)), + } + self.post_a = models.Post.objects.create(owner=self.user, **post_data) + + post_data = { + "title": "Second post", + "slug": "second-post", + "body": "Content sentence two.", + "published_at": timezone.make_aware(datetime(2020, 1, 2)), + } + self.post_b = models.Post.objects.create(owner=self.user, **post_data) + + def test_mail_backend(self): + connection = mailexports.get_mail_connection() + self.assertEqual(connection.host, settings.EMAIL_HOST_BROADCASTS) + + def test_command(self): + output = StringIO() + + with ( + patch.object(timezone, "now", return_value=datetime(2020, 1, 1, 00, 00)), + patch.object( + # Django default test runner overrides SMTP EmailBackend with locmem, + # but because we re-import the SMTP backend in + # processnotifications.get_mail_connection, we need to mock it here too. + mailexports, + "get_mail_connection", + return_value=mail.get_connection( + "django.core.mail.backends.locmem.EmailBackend" + ), + ), + ): + call_command("mailexports", stdout=output) + + # export records + records = models.ExportRecord.objects.all() + self.assertEqual(len(records), 1) + self.assertEqual(records[0].user, self.user) + self.assertIn("export-markdown-", records[0].name) + + # logging + self.assertIn("Processing email exports.", output.getvalue()) + self.assertIn(f"Processing user {self.user.username}.", output.getvalue()) + self.assertIn(f"Export sent to {self.user.username}.", output.getvalue()) + self.assertIn( + f"Logging export record for '{records[0].name}'.", output.getvalue() + ) + self.assertIn("Emailing all exports complete.", output.getvalue()) + + # email + self.assertEqual(len(mail.outbox), 1) + self.assertIn("Mataroa export", mail.outbox[0].subject) + self.assertIn("Stop receiving exports", mail.outbox[0].body) + + # email headers + self.assertEqual(mail.outbox[0].to, [self.user.email]) + self.assertEqual( + mail.outbox[0].from_email, + settings.DEFAULT_FROM_EMAIL, + ) + + self.assertEqual(mail.outbox[0].extra_headers["X-PM-Message-Stream"], "exports") + self.assertIn( + "/export/unsubscribe/", + mail.outbox[0].extra_headers["List-Unsubscribe"], + ) + self.assertEqual( + mail.outbox[0].extra_headers["List-Unsubscribe-Post"], + "List-Unsubscribe=One-Click", + ) + + def tearDown(self): + models.User.objects.all().delete() + models.Post.objects.all().delete() diff --git a/main/tests/test_pages.py b/main/tests/test_pages.py new file mode 100644 index 0000000000000000000000000000000000000000..243968b4786cf7d6cd1fc3f83a0f6450659f8b74 --- /dev/null +++ b/main/tests/test_pages.py @@ -0,0 +1,297 @@ +from django.conf import settings +from django.test import TestCase +from django.urls import reverse + +from main import models + + +class PageCreateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + + def test_page_create(self): + data = { + "title": "New page", + "slug": "new-page", + "is_hidden": False, + "body": "Content sentence.", + } + response = self.client.post(reverse("page_create"), data) + self.assertEqual(response.status_code, 302) + self.assertTrue(models.Page.objects.filter(title=data["title"]).exists()) + self.assertEqual( + models.Page.objects.get(title=data["title"]).slug, data["slug"] + ) + self.assertEqual( + models.Page.objects.get(title=data["title"]).body, data["body"] + ) + + def test_page_invalid_slug(self): + data = { + "title": "New page", + "slug": "rss", + "is_hidden": False, + "body": "Content sentence.", + } + response = self.client.post(reverse("page_create"), data) + self.assertContains(response, "slug is not allowed") + self.assertFalse(models.Page.objects.filter(title=data["title"]).exists()) + + +class PageCreateAnonTestCase(TestCase): + def test_page_create_anon(self): + data = { + "title": "New page", + "slug": "new-page", + "is_hidden": False, + "body": "Content sentence.", + } + response = self.client.post(reverse("page_create"), data) + self.assertEqual(response.status_code, 302) + self.assertTrue("login/" in response.url) + self.assertFalse(models.Page.objects.filter(title=data["title"]).exists()) + + +class PageDetailTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "New page", + "slug": "new-page", + "is_hidden": False, + "body": "Content sentence.", + } + self.page = models.Page.objects.create(owner=self.user, **self.data) + + def test_page_detail(self): + response = self.client.get( + reverse("page_detail", args=(self.page.slug,)), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.data["title"]) + self.assertContains(response, self.data["body"]) + + +class PageNonHiddenTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "New page", + "slug": "new-page", + "is_hidden": False, + "body": "Content sentence.", + } + self.page = models.Page.objects.create(owner=self.user, **self.data) + + def test_page_non_hidden(self): + response = self.client.get( + reverse("index"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.data["title"]) + self.assertContains(response, self.data["slug"]) + + +class PageHiddenTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "New page", + "slug": "new-page", + "is_hidden": True, + "body": "Content sentence.", + } + self.page = models.Page.objects.create(owner=self.user, **self.data) + + def test_page_hidden(self): + response = self.client.get( + reverse("index"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, self.data["title"]) + self.assertNotContains(response, self.data["slug"]) + + +class PageUpdateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "New page", + "slug": "new-page", + "is_hidden": False, + "body": "Content sentence.", + } + self.page = models.Page.objects.create(owner=self.user, **self.data) + + def test_get_update(self): + response = self.client.get( + reverse("page_update", args=(self.page.slug,)), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + + def test_page_update(self): + new_data = { + "title": "Updated page", + "slug": "updated-page", + "is_hidden": True, + "body": "Updated sentence.", + } + self.client.post( + reverse("page_update", args=(self.page.slug,)), + new_data, + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + page_now = models.Page.objects.get(id=self.page.id) + self.assertEqual(page_now.title, new_data["title"]) + self.assertEqual(page_now.slug, new_data["slug"]) + self.assertEqual(page_now.is_hidden, new_data["is_hidden"]) + self.assertEqual(page_now.body, new_data["body"]) + + +class PageUpdateAnonTestCase(TestCase): + """Tests non logged in user cannot update page.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.data = { + "title": "New page", + "slug": "new-page", + "is_hidden": False, + "body": "Content sentence.", + } + self.page = models.Page.objects.create(owner=self.user, **self.data) + + def test_page_update_anon(self): + new_data = { + "title": "Updated page", + "slug": "updated-page", + "is_hidden": True, + "body": "Updated sentence.", + } + self.client.post( + reverse("page_update", args=(self.page.slug,)), + new_data, + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + page_now = models.Page.objects.get(id=self.page.id) + self.assertEqual(page_now.title, self.data["title"]) + self.assertEqual(page_now.slug, self.data["slug"]) + self.assertEqual(page_now.is_hidden, self.data["is_hidden"]) + self.assertEqual(page_now.body, self.data["body"]) + + +class PageUpdateNotOwnTestCase(TestCase): + """Tests user cannot update other user's page.""" + + def setUp(self): + self.victim = models.User.objects.create(username="bob") + self.data = { + "title": "New page", + "slug": "new-page", + "is_hidden": False, + "body": "Content sentence.", + } + self.page = models.Page.objects.create(owner=self.victim, **self.data) + + self.attacker = models.User.objects.create(username="alice") + self.client.force_login(self.attacker) + + def test_page_update_not_own(self): + new_data = { + "title": "Updated page", + "slug": "updated-page", + "is_hidden": True, + "body": "Updated sentence.", + } + self.client.post( + reverse("page_update", args=(self.page.slug,)), + new_data, + HTTP_HOST=self.victim.username + "." + settings.CANONICAL_HOST, + ) + page_now = models.Page.objects.get(id=self.page.id) + self.assertEqual(page_now.title, self.data["title"]) + self.assertEqual(page_now.slug, self.data["slug"]) + self.assertEqual(page_now.is_hidden, self.data["is_hidden"]) + self.assertEqual(page_now.body, self.data["body"]) + + +class PageDeleteTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "New page", + "slug": "new-page", + "is_hidden": False, + "body": "Content sentence.", + } + self.page = models.Page.objects.create(owner=self.user, **self.data) + + def test_page_delete(self): + self.client.post( + reverse("page_delete", args=(self.page.slug,)), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertFalse( + models.Page.objects.filter(slug=self.data["slug"], owner=self.user).exists() + ) + + +class PageDeleteAnonTestCase(TestCase): + """Tests non logged in user cannot delete page.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.data = { + "title": "New page", + "slug": "new-page", + "is_hidden": False, + "body": "Content sentence.", + } + self.page = models.Page.objects.create(owner=self.user, **self.data) + + def test_page_delete_anon(self): + self.client.post( + reverse("page_delete", args=(self.page.slug,)), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertTrue( + models.Page.objects.filter(slug=self.data["slug"], owner=self.user).exists() + ) + + +class PageDeleteNotOwnTestCase(TestCase): + """Tests user cannot delete other's page.""" + + def setUp(self): + self.victim = models.User.objects.create(username="bob") + self.data = { + "title": "New page", + "slug": "new-page", + "is_hidden": False, + "body": "Content sentence.", + } + self.page = models.Page.objects.create(owner=self.victim, **self.data) + + self.attacker = models.User.objects.create(username="alice") + self.client.force_login(self.attacker) + + def test_page_delete_not_own(self): + self.client.post( + reverse("page_delete", args=(self.page.slug,)), + HTTP_HOST=self.victim.username + "." + settings.CANONICAL_HOST, + ) + self.assertTrue( + models.Page.objects.filter( + slug=self.data["slug"], owner=self.victim + ).exists() + ) diff --git a/main/tests/test_posts.py b/main/tests/test_posts.py new file mode 100644 index 0000000000000000000000000000000000000000..8ed98ed7b5a7ed359510344a47104d92821db862 --- /dev/null +++ b/main/tests/test_posts.py @@ -0,0 +1,432 @@ +from django.conf import settings +from django.test import TestCase +from django.urls import reverse + +from main import models + + +class PostCreateTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + + def test_post_create(self): + data = { + "title": "New post", + "slug": "new-post", + "body": "Content sentence.", + } + response = self.client.post(reverse("post_create"), data) + self.assertEqual(response.status_code, 302) + self.assertTrue(models.Post.objects.get(title=data["title"])) + + def test_post_multiline_create(self): + data = { + "title": "multiline post", + "slug": "multiline-post", + "body": """What I’m really concerned about is reaching +one person. And that person may be myself for all I know. + + — Jorge Luis Borges.""", + } + response = self.client.post(reverse("post_create"), data) + self.assertEqual(response.status_code, 302) + self.assertTrue(models.Post.objects.get(title=data["title"])) + self.assertEqual( + models.Post.objects.get(title=data["title"]).body, data["body"] + ) + + +class PostCreateAnonTestCase(TestCase): + """Test non logged in user cannot create post.""" + + def test_post_create_anon(self): + data = { + "title": "New post", + "slug": "new-post", + "body": "Content sentence.", + } + response = self.client.post(reverse("post_create"), data) + self.assertEqual(response.status_code, 302) + self.assertTrue(reverse("login") in response.url) + + +class PostCreateDraftTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "New post", + "slug": "new-post", + "body": "Content sentence.", + "published_at": "", + } + self.client.post(reverse("post_create"), self.data) + + def test_post_create_draft(self): + """Test draft post gets created.""" + self.assertTrue(models.Post.objects.get(title=self.data["title"])) + + def test_post_draft_index(self): + """Test draft post appears on blog index as draft.""" + response = self.client.get( + reverse("index"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Drafts") + + +class PostCreateDraftAnonTestCase(TestCase): + """Test draft post does not appear on blog index for non-logged in users.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.data_published = { + "title": "New post", + "slug": "new-post", + "body": "Content sentence.", + } + models.Post.objects.create(owner=self.user, **self.data_published) + self.data_nonpublished = { + "title": "Draft post", + "slug": "draft-post", + "body": "Incomplete content sentence.", + "published_at": None, + } + models.Post.objects.create(owner=self.user, **self.data_nonpublished) + + def test_post_draft_index(self): + response = self.client.get( + reverse("index"), + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.data_published["title"]) + self.assertNotContains(response, self.data_nonpublished["title"]) + self.assertNotContains(response, "Drafts") + + +class PostDetailTestCase(TestCase): + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.client.force_login(self.user) + self.data = { + "title": "New post", + "slug": "new-post", + "body": "Content sentence.", + } + self.post = models.Post.objects.create(owner=self.user, **self.data) + + def test_post_detail(self): + response = self.client.get( + reverse("post_detail", args=(self.post.slug,)), + # needs HTTP_HOST because we need to request it on the subdomain + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.data["title"]) + self.assertContains(response, self.data["body"]) + + def test_post_detail_redir_a(self): + response = self.client.get( + reverse("post_detail_redir_a", args=(self.post.slug,)), + # needs HTTP_HOST because we need to request it on the subdomain + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 301) + self.assertEqual(response.url, reverse("post_detail", args=(self.post.slug,))) + + def test_post_detail_redir_b(self): + response = self.client.get( + reverse("post_detail_redir_b", args=(self.post.slug,)), + # needs HTTP_HOST because we need to request it on the subdomain + HTTP_HOST=self.user.username + "." + settings.CANONICAL_HOST, + ) + self.assertEqual(response.status_code, 301) + self.assertEqual(response.url, reverse("post_detail", args=(self.post.slug,))) + + +class PostSanitizeHTMLTestCase(TestCase): + """Test is bleach is sanitizing illegal tags.""" + + def setUp(self): + self.user = models.User.objects.create(username="alice") + self.data = { + "title": "New post", + "slug": "new-post", + "body": "Content sentence. ", + } + models.Post.objects.create(owner=self.user, **self.data) + + def test_get_sanitized(self): + post = models.Post.objects.get(slug=self.data["slug"]) + self.assertTrue("<script>" in post.body_as_html) + self.assertFalse("