From 91a2a77711f793ec3820fea7e8a5881c74677cb5 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 26 Dec 2021 07:44:05 -0500 Subject: [PATCH] time to open sause & place into public domain! Merry Christmas! I AM READY I love you all! new file: .gitignore new file: MANIFEST.in new file: Makefile new file: README.rst new file: development.ini new file: index.html new file: index2.html new file: journal.rst new file: readme-postgres-notes.rst new file: readme-themes.rst new file: remarkbox/__init__.py new file: remarkbox/lib/__init__.py new file: remarkbox/lib/mail.py new file: remarkbox/lib/mail_messages.py new file: remarkbox/lib/notify.py new file: remarkbox/lib/render.py new file: remarkbox/lib/svgtar/__init__.py new file: remarkbox/lib/svgtar/svg.py new file: remarkbox/models/__init__.py new file: remarkbox/models/event.py new file: remarkbox/models/meta.py new file: remarkbox/models/namespace.py new file: remarkbox/models/namespace_request.py new file: remarkbox/models/namespace_user.py new file: remarkbox/models/node.py new file: remarkbox/models/node_cache.py new file: remarkbox/models/notification.py new file: remarkbox/models/oauth.py new file: remarkbox/models/pay_what_you_can.py new file: remarkbox/models/uri.py new file: remarkbox/models/user.py new file: remarkbox/models/vote.py new file: remarkbox/models/watcher.py new file: remarkbox/routes.py new file: remarkbox/scripts/__init__.py new file: remarkbox/scripts/alembic/README new file: remarkbox/scripts/alembic/env.py new file: remarkbox/scripts/alembic/script.py.mako new file: remarkbox/scripts/alembic/versions/00211ed642cc_add_ip_address_column_to_rb_node.py new file: remarkbox/scripts/alembic/versions/0208c10ea67f_create_new_node_cache_table.py new file: remarkbox/scripts/alembic/versions/04e52337756e_autogenerated_indicies.py new file: remarkbox/scripts/alembic/versions/051f8b810f26_namespace_ignore_query_string.py new file: remarkbox/scripts/alembic/versions/1040ee4836af_namespace_description.py new file: remarkbox/scripts/alembic/versions/184a349cd589_uri_accessed_timestamp.py new file: remarkbox/scripts/alembic/versions/256468780798_root_nodes_to_namespace_.py new file: remarkbox/scripts/alembic/versions/2e00b92495e4_node_disabled_timestamp.py new file: remarkbox/scripts/alembic/versions/3ee195d142fb_add_subscription_type_to_namespace.py new file: remarkbox/scripts/alembic/versions/40ded1163f95_add_user_surrogate_columns.py new file: remarkbox/scripts/alembic/versions/43c1e5a99d4e_new_namespace_column_3.py new file: remarkbox/scripts/alembic/versions/4aeda8e797ab_new_namespace_column_for_stylesheet_uris.py new file: remarkbox/scripts/alembic/versions/503a2cef2378_root_stats.py new file: remarkbox/scripts/alembic/versions/591800942adf_node_data_html_column.py new file: remarkbox/scripts/alembic/versions/59592cf325fb_stylesheet_timestamp.py new file: remarkbox/scripts/alembic/versions/5a5d0b68db78_add_root_id_field.py new file: remarkbox/scripts/alembic/versions/6a440d3921d2_added_email_id_column_to_user_table.py new file: remarkbox/scripts/alembic/versions/6bbc92ccc112_add_changed_column_to_node_table.py new file: remarkbox/scripts/alembic/versions/6db39621f46c_namespace_group_conversations.py new file: remarkbox/scripts/alembic/versions/6fc1d0cf350c_notification_columns_to_rb_user_table.py new file: remarkbox/scripts/alembic/versions/76deff88cfd6_create_graph_depth_column_on_node_model.py new file: remarkbox/scripts/alembic/versions/771ef7c868de_added_created_timestamp_column_to_.py new file: remarkbox/scripts/alembic/versions/7a3a4755b4a8_add_hide_unless_approved_column.py new file: remarkbox/scripts/alembic/versions/87fbd2b4e06e_namespace_theme_embed_column.py new file: remarkbox/scripts/alembic/versions/953d42406dbb_namespace_public_column.py new file: remarkbox/scripts/alembic/versions/98a90d2add21_namespace_model.py new file: remarkbox/scripts/alembic/versions/98ced5f6ff36_stripe_id_for_user.py new file: remarkbox/scripts/alembic/versions/a26302bcfa25_add_created_timestamp_column_to_oauth_.py new file: remarkbox/scripts/alembic/versions/a38c12eeff2d_locked_rb_node.py new file: remarkbox/scripts/alembic/versions/a738d6859f18_new_namespace_columns_2.py new file: remarkbox/scripts/alembic/versions/aa2dfb4decda_hide_powered_by_column.py new file: remarkbox/scripts/alembic/versions/b132ec968f0c_pay_what_you_can_table.py new file: remarkbox/scripts/alembic/versions/ba8e83a3b8fd_namespace_stylesheet_embed_column.py new file: remarkbox/scripts/alembic/versions/c9793937e756_make_all_verified_namespace_request_.py new file: remarkbox/scripts/alembic/versions/c97ddfcbc406_approve_column.py new file: remarkbox/scripts/alembic/versions/cbdf3cd4ccba_avatar_size_in_namespace.py new file: remarkbox/scripts/alembic/versions/d1a0ab4f617f_add_has_uri_to_node.py new file: remarkbox/scripts/alembic/versions/f5f88293428a_add_mathjax_column_to_rb_namespace.py new file: remarkbox/scripts/alembic/versions/f7b42b2024fb_namespace_reverse_node_order.py new file: remarkbox/scripts/alembic/versions/fa8402aa1a00_add_link_protection_column_to_namespace.py new file: remarkbox/scripts/dump_sqlite3_to_csv.sh new file: remarkbox/scripts/init_db.py new file: remarkbox/scripts/invalidate_node_cache.py new file: remarkbox/scripts/json_import.py new file: remarkbox/scripts/load_csv_into_postgres.sh new file: remarkbox/scripts/merge_dupes.py new file: remarkbox/scripts/modify_namespace.py new file: remarkbox/scripts/modify_node.py new file: remarkbox/scripts/modify_uris.py new file: remarkbox/scripts/modify_user.py new file: remarkbox/scripts/recompute_node_depths.py new file: remarkbox/scripts/safe_approve_all_nodes.py new file: remarkbox/scripts/send_node_digest_notifications.py new file: remarkbox/static/css/common.css new file: remarkbox/static/css/embed.css new file: remarkbox/static/css/pygments.css new file: remarkbox/static/favicon.ico new file: remarkbox/static/img/remarkbox-logo.png new file: remarkbox/static/img/remarkbox-minified.png new file: remarkbox/static/js/custom.js new file: remarkbox/static/js/iframe-resizer/ie8.polyfils.map new file: remarkbox/static/js/iframe-resizer/ie8.polyfils.min.js new file: remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.js new file: remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.map new file: remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.min.js new file: remarkbox/static/js/iframe-resizer/iframeResizer.js new file: remarkbox/static/js/iframe-resizer/iframeResizer.map new file: remarkbox/static/js/iframe-resizer/iframeResizer.min.js new file: remarkbox/static/js/iframe-resizer/index.js new file: remarkbox/static/js/iframe-resizer/readme.rst new file: remarkbox/static/js/jquery-2.1.3.min.js new file: remarkbox/static/theme/readme.rst new file: remarkbox/templates/base.j2 new file: remarkbox/templates/billing.j2 new file: remarkbox/templates/dynamic-remarkbox.css.j2 new file: remarkbox/templates/edit-node.j2 new file: remarkbox/templates/embed-iframe.txt.j2 new file: remarkbox/templates/home.j2 new file: remarkbox/templates/join-or-log-in.j2 new file: remarkbox/templates/list-namespace-owners.j2 new file: remarkbox/templates/list-namespace-requests.j2 new file: remarkbox/templates/list-nodes.j2 new file: remarkbox/templates/list-notifications.j2 new file: remarkbox/templates/mail_digest_html.j2 new file: remarkbox/templates/mail_digest_text.j2 new file: remarkbox/templates/mail_immediate_html.j2 new file: remarkbox/templates/mail_immediate_text.j2 new file: remarkbox/templates/mail_snippet_html.j2 new file: remarkbox/templates/mail_snippet_text.j2 new file: remarkbox/templates/namespace-settings.j2 new file: remarkbox/templates/new.j2 new file: remarkbox/templates/rss.xml.j2 new file: remarkbox/templates/setup-namespace.j2 new file: remarkbox/templates/show-count.j2 new file: remarkbox/templates/show-node.j2 new file: remarkbox/templates/show-node2.j2 new file: remarkbox/templates/sitemap.xml.j2 new file: remarkbox/templates/snippets/add-to-slack.j2 new file: remarkbox/templates/snippets/create.j2 new file: remarkbox/templates/snippets/csrf.j2 new file: remarkbox/templates/snippets/email.j2 new file: remarkbox/templates/snippets/flash-alerts.j2 new file: remarkbox/templates/snippets/forms.j2 new file: remarkbox/templates/snippets/google-analytics.j2 new file: remarkbox/templates/snippets/javascript-includes.j2 new file: remarkbox/templates/snippets/owner-key-comment.j2 new file: remarkbox/templates/snippets/phone-menu.j2 new file: remarkbox/templates/snippets/powered-by.j2 new file: remarkbox/templates/snippets/search.j2 new file: remarkbox/templates/snippets/snippets.j2 new file: remarkbox/templates/snippets/stripe.j2 new file: remarkbox/templates/snippets/stylesheet-includes.j2 new file: remarkbox/templates/snippets/submit.j2 new file: remarkbox/templates/update-card.j2 new file: remarkbox/templates/user-settings.j2 new file: remarkbox/templates/user-watching.j2 new file: remarkbox/tests/test_models.py new file: remarkbox/tests/test_render.py new file: remarkbox/tests/test_views.py new file: remarkbox/views/__init__.py new file: remarkbox/views/authenticated/__init__.py new file: remarkbox/views/authenticated/authenticated.py new file: remarkbox/views/authenticated/lock.py new file: remarkbox/views/authenticated/notifications.py new file: remarkbox/views/authenticated/oauth.py new file: remarkbox/views/authenticated/stripe.py new file: remarkbox/views/authenticated/topsecret.py new file: remarkbox/views/authenticated/watch.py new file: remarkbox/views/authentication.py new file: remarkbox/views/list_nodes.py new file: remarkbox/views/misc.py new file: remarkbox/views/modify_node.py new file: remarkbox/views/new_thread.py new file: remarkbox/views/reply_node.py new file: requirements-dev.txt new file: requirements-prod.txt new file: requirements-test.txt new file: requirements.py3.txt new file: requirements.txt new file: setup.py new file: test.ini --- .gitignore | 33 + MANIFEST.in | 2 + Makefile | 34 + README.rst | 198 +++ development.ini | 154 +++ index.html | 95 ++ index2.html | 90 ++ journal.rst | 260 ++++ readme-postgres-notes.rst | 101 ++ readme-themes.rst | 14 + remarkbox/__init__.py | 490 ++++++++ remarkbox/lib/__init__.py | 36 + remarkbox/lib/mail.py | 88 ++ remarkbox/lib/mail_messages.py | 139 +++ remarkbox/lib/notify.py | 260 ++++ remarkbox/lib/render.py | 60 + remarkbox/lib/svgtar/__init__.py | 54 + remarkbox/lib/svgtar/svg.py | 46 + remarkbox/models/__init__.py | 88 ++ remarkbox/models/event.py | 41 + remarkbox/models/meta.py | 152 +++ remarkbox/models/namespace.py | 428 +++++++ remarkbox/models/namespace_request.py | 143 +++ remarkbox/models/namespace_user.py | 44 + remarkbox/models/node.py | 673 ++++++++++ remarkbox/models/node_cache.py | 88 ++ remarkbox/models/notification.py | 97 ++ remarkbox/models/oauth.py | 64 + remarkbox/models/pay_what_you_can.py | 39 + remarkbox/models/uri.py | 139 +++ remarkbox/models/user.py | 416 +++++++ remarkbox/models/vote.py | 30 + remarkbox/models/watcher.py | 80 ++ remarkbox/routes.py | 127 ++ remarkbox/scripts/__init__.py | 7 + remarkbox/scripts/alembic/README | 11 + remarkbox/scripts/alembic/env.py | 73 ++ remarkbox/scripts/alembic/script.py.mako | 29 + ...d642cc_add_ip_address_column_to_rb_node.py | 24 + ...208c10ea67f_create_new_node_cache_table.py | 57 + .../04e52337756e_autogenerated_indicies.py | 38 + ...f8b810f26_namespace_ignore_query_string.py | 26 + .../1040ee4836af_namespace_description.py | 26 + .../184a349cd589_uri_accessed_timestamp.py | 26 + .../256468780798_root_nodes_to_namespace_.py | 55 + .../2e00b92495e4_node_disabled_timestamp.py | 42 + ...42fb_add_subscription_type_to_namespace.py | 32 + ...40ded1163f95_add_user_surrogate_columns.py | 25 + .../43c1e5a99d4e_new_namespace_column_3.py | 27 + ...ew_namespace_column_for_stylesheet_uris.py | 32 + .../versions/503a2cef2378_root_stats.py | 48 + .../591800942adf_node_data_html_column.py | 42 + .../59592cf325fb_stylesheet_timestamp.py | 26 + .../5a5d0b68db78_add_root_id_field.py | 40 + ...1d2_added_email_id_column_to_user_table.py | 48 + ...ccc112_add_changed_column_to_node_table.py | 31 + ...39621f46c_namespace_group_conversations.py | 24 + ...c_notification_columns_to_rb_user_table.py | 52 + ...create_graph_depth_column_on_node_model.py | 31 + ...68de_added_created_timestamp_column_to_.py | 31 + ...755b4a8_add_hide_unless_approved_column.py | 30 + ...fbd2b4e06e_namespace_theme_embed_column.py | 26 + .../953d42406dbb_namespace_public_column.py | 24 + .../versions/98a90d2add21_namespace_model.py | 36 + .../98ced5f6ff36_stripe_id_for_user.py | 26 + ..._add_created_timestamp_column_to_oauth_.py | 41 + .../versions/a38c12eeff2d_locked_rb_node.py | 24 + .../a738d6859f18_new_namespace_columns_2.py | 37 + .../aa2dfb4decda_hide_powered_by_column.py | 26 + .../b132ec968f0c_pay_what_you_can_table.py | 33 + ...3b8fd_namespace_stylesheet_embed_column.py | 31 + ...56_make_all_verified_namespace_request_.py | 38 + .../versions/c97ddfcbc406_approve_column.py | 24 + .../cbdf3cd4ccba_avatar_size_in_namespace.py | 24 + .../d1a0ab4f617f_add_has_uri_to_node.py | 24 + ...428a_add_mathjax_column_to_rb_namespace.py | 26 + ...b42b2024fb_namespace_reverse_node_order.py | 24 + ...add_link_protection_column_to_namespace.py | 26 + remarkbox/scripts/dump_sqlite3_to_csv.sh | 16 + remarkbox/scripts/init_db.py | 40 + remarkbox/scripts/invalidate_node_cache.py | 32 + remarkbox/scripts/json_import.py | 155 +++ remarkbox/scripts/load_csv_into_postgres.sh | 12 + remarkbox/scripts/merge_dupes.py | 49 + remarkbox/scripts/modify_namespace.py | 159 +++ remarkbox/scripts/modify_node.py | 176 +++ remarkbox/scripts/modify_uris.py | 55 + remarkbox/scripts/modify_user.py | 83 ++ remarkbox/scripts/recompute_node_depths.py | 21 + remarkbox/scripts/safe_approve_all_nodes.py | 29 + .../scripts/send_node_digest_notifications.py | 19 + remarkbox/static/css/common.css | 678 ++++++++++ remarkbox/static/css/embed.css | 38 + remarkbox/static/css/pygments.css | 61 + remarkbox/static/favicon.ico | Bin 0 -> 5538 bytes remarkbox/static/img/remarkbox-logo.png | Bin 0 -> 13177 bytes remarkbox/static/img/remarkbox-minified.png | Bin 0 -> 11978 bytes remarkbox/static/js/custom.js | 93 ++ .../static/js/iframe-resizer/ie8.polyfils.map | 1 + .../js/iframe-resizer/ie8.polyfils.min.js | 4 + .../iframeResizer.contentWindow.js | 1106 +++++++++++++++++ .../iframeResizer.contentWindow.map | 1 + .../iframeResizer.contentWindow.min.js | 10 + .../static/js/iframe-resizer/iframeResizer.js | 1040 ++++++++++++++++ .../js/iframe-resizer/iframeResizer.map | 1 + .../js/iframe-resizer/iframeResizer.min.js | 9 + remarkbox/static/js/iframe-resizer/index.js | 2 + remarkbox/static/js/iframe-resizer/readme.rst | 13 + remarkbox/static/js/jquery-2.1.3.min.js | 4 + remarkbox/static/theme/readme.rst | 5 + remarkbox/templates/base.j2 | 85 ++ remarkbox/templates/billing.j2 | 44 + remarkbox/templates/dynamic-remarkbox.css.j2 | 29 + remarkbox/templates/edit-node.j2 | 29 + remarkbox/templates/embed-iframe.txt.j2 | 41 + remarkbox/templates/home.j2 | 121 ++ remarkbox/templates/join-or-log-in.j2 | 34 + remarkbox/templates/list-namespace-owners.j2 | 27 + .../templates/list-namespace-requests.j2 | 29 + remarkbox/templates/list-nodes.j2 | 59 + remarkbox/templates/list-notifications.j2 | 45 + remarkbox/templates/mail_digest_html.j2 | 32 + remarkbox/templates/mail_digest_text.j2 | 15 + remarkbox/templates/mail_immediate_html.j2 | 27 + remarkbox/templates/mail_immediate_text.j2 | 8 + remarkbox/templates/mail_snippet_html.j2 | 40 + remarkbox/templates/mail_snippet_text.j2 | 27 + remarkbox/templates/namespace-settings.j2 | 269 ++++ remarkbox/templates/new.j2 | 14 + remarkbox/templates/rss.xml.j2 | 40 + remarkbox/templates/setup-namespace.j2 | 165 +++ remarkbox/templates/show-count.j2 | 15 + remarkbox/templates/show-node.j2 | 189 +++ remarkbox/templates/show-node2.j2 | 146 +++ remarkbox/templates/sitemap.xml.j2 | 11 + remarkbox/templates/snippets/add-to-slack.j2 | 7 + remarkbox/templates/snippets/create.j2 | 29 + remarkbox/templates/snippets/csrf.j2 | 3 + remarkbox/templates/snippets/email.j2 | 31 + remarkbox/templates/snippets/flash-alerts.j2 | 11 + remarkbox/templates/snippets/forms.j2 | 100 ++ .../templates/snippets/google-analytics.j2 | 12 + .../templates/snippets/javascript-includes.j2 | 29 + .../templates/snippets/owner-key-comment.j2 | 6 + remarkbox/templates/snippets/phone-menu.j2 | 41 + remarkbox/templates/snippets/powered-by.j2 | 9 + remarkbox/templates/snippets/search.j2 | 7 + remarkbox/templates/snippets/snippets.j2 | 272 ++++ remarkbox/templates/snippets/stripe.j2 | 133 ++ .../templates/snippets/stylesheet-includes.j2 | 18 + remarkbox/templates/snippets/submit.j2 | 1 + remarkbox/templates/update-card.j2 | 23 + remarkbox/templates/user-settings.j2 | 92 ++ remarkbox/templates/user-watching.j2 | 75 ++ remarkbox/tests/test_models.py | 350 ++++++ remarkbox/tests/test_render.py | 120 ++ remarkbox/tests/test_views.py | 479 +++++++ remarkbox/views/__init__.py | 128 ++ remarkbox/views/authenticated/__init__.py | 0 .../views/authenticated/authenticated.py | 403 ++++++ remarkbox/views/authenticated/lock.py | 95 ++ .../views/authenticated/notifications.py | 44 + remarkbox/views/authenticated/oauth.py | 125 ++ remarkbox/views/authenticated/stripe.py | 94 ++ remarkbox/views/authenticated/topsecret.py | 80 ++ remarkbox/views/authenticated/watch.py | 94 ++ remarkbox/views/authentication.py | 130 ++ remarkbox/views/list_nodes.py | 206 +++ remarkbox/views/misc.py | 127 ++ remarkbox/views/modify_node.py | 100 ++ remarkbox/views/new_thread.py | 93 ++ remarkbox/views/reply_node.py | 133 ++ requirements-dev.txt | 6 + requirements-prod.txt | 5 + requirements-test.txt | 6 + requirements.py3.txt | 74 ++ requirements.txt | 79 ++ setup.py | 49 + test.ini | 140 +++ 179 files changed, 15738 insertions(+) create mode 100644 .gitignore create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README.rst create mode 100644 development.ini create mode 100644 index.html create mode 100644 index2.html create mode 100644 journal.rst create mode 100644 readme-postgres-notes.rst create mode 100644 readme-themes.rst create mode 100644 remarkbox/__init__.py create mode 100644 remarkbox/lib/__init__.py create mode 100644 remarkbox/lib/mail.py create mode 100644 remarkbox/lib/mail_messages.py create mode 100644 remarkbox/lib/notify.py create mode 100644 remarkbox/lib/render.py create mode 100644 remarkbox/lib/svgtar/__init__.py create mode 100644 remarkbox/lib/svgtar/svg.py create mode 100644 remarkbox/models/__init__.py create mode 100644 remarkbox/models/event.py create mode 100644 remarkbox/models/meta.py create mode 100644 remarkbox/models/namespace.py create mode 100644 remarkbox/models/namespace_request.py create mode 100644 remarkbox/models/namespace_user.py create mode 100644 remarkbox/models/node.py create mode 100644 remarkbox/models/node_cache.py create mode 100644 remarkbox/models/notification.py create mode 100644 remarkbox/models/oauth.py create mode 100644 remarkbox/models/pay_what_you_can.py create mode 100644 remarkbox/models/uri.py create mode 100644 remarkbox/models/user.py create mode 100644 remarkbox/models/vote.py create mode 100644 remarkbox/models/watcher.py create mode 100644 remarkbox/routes.py create mode 100644 remarkbox/scripts/__init__.py create mode 100644 remarkbox/scripts/alembic/README create mode 100644 remarkbox/scripts/alembic/env.py create mode 100644 remarkbox/scripts/alembic/script.py.mako create mode 100644 remarkbox/scripts/alembic/versions/00211ed642cc_add_ip_address_column_to_rb_node.py create mode 100644 remarkbox/scripts/alembic/versions/0208c10ea67f_create_new_node_cache_table.py create mode 100644 remarkbox/scripts/alembic/versions/04e52337756e_autogenerated_indicies.py create mode 100644 remarkbox/scripts/alembic/versions/051f8b810f26_namespace_ignore_query_string.py create mode 100644 remarkbox/scripts/alembic/versions/1040ee4836af_namespace_description.py create mode 100644 remarkbox/scripts/alembic/versions/184a349cd589_uri_accessed_timestamp.py create mode 100644 remarkbox/scripts/alembic/versions/256468780798_root_nodes_to_namespace_.py create mode 100644 remarkbox/scripts/alembic/versions/2e00b92495e4_node_disabled_timestamp.py create mode 100644 remarkbox/scripts/alembic/versions/3ee195d142fb_add_subscription_type_to_namespace.py create mode 100644 remarkbox/scripts/alembic/versions/40ded1163f95_add_user_surrogate_columns.py create mode 100644 remarkbox/scripts/alembic/versions/43c1e5a99d4e_new_namespace_column_3.py create mode 100644 remarkbox/scripts/alembic/versions/4aeda8e797ab_new_namespace_column_for_stylesheet_uris.py create mode 100644 remarkbox/scripts/alembic/versions/503a2cef2378_root_stats.py create mode 100644 remarkbox/scripts/alembic/versions/591800942adf_node_data_html_column.py create mode 100644 remarkbox/scripts/alembic/versions/59592cf325fb_stylesheet_timestamp.py create mode 100644 remarkbox/scripts/alembic/versions/5a5d0b68db78_add_root_id_field.py create mode 100644 remarkbox/scripts/alembic/versions/6a440d3921d2_added_email_id_column_to_user_table.py create mode 100644 remarkbox/scripts/alembic/versions/6bbc92ccc112_add_changed_column_to_node_table.py create mode 100644 remarkbox/scripts/alembic/versions/6db39621f46c_namespace_group_conversations.py create mode 100644 remarkbox/scripts/alembic/versions/6fc1d0cf350c_notification_columns_to_rb_user_table.py create mode 100644 remarkbox/scripts/alembic/versions/76deff88cfd6_create_graph_depth_column_on_node_model.py create mode 100644 remarkbox/scripts/alembic/versions/771ef7c868de_added_created_timestamp_column_to_.py create mode 100644 remarkbox/scripts/alembic/versions/7a3a4755b4a8_add_hide_unless_approved_column.py create mode 100644 remarkbox/scripts/alembic/versions/87fbd2b4e06e_namespace_theme_embed_column.py create mode 100644 remarkbox/scripts/alembic/versions/953d42406dbb_namespace_public_column.py create mode 100644 remarkbox/scripts/alembic/versions/98a90d2add21_namespace_model.py create mode 100644 remarkbox/scripts/alembic/versions/98ced5f6ff36_stripe_id_for_user.py create mode 100644 remarkbox/scripts/alembic/versions/a26302bcfa25_add_created_timestamp_column_to_oauth_.py create mode 100644 remarkbox/scripts/alembic/versions/a38c12eeff2d_locked_rb_node.py create mode 100644 remarkbox/scripts/alembic/versions/a738d6859f18_new_namespace_columns_2.py create mode 100644 remarkbox/scripts/alembic/versions/aa2dfb4decda_hide_powered_by_column.py create mode 100644 remarkbox/scripts/alembic/versions/b132ec968f0c_pay_what_you_can_table.py create mode 100644 remarkbox/scripts/alembic/versions/ba8e83a3b8fd_namespace_stylesheet_embed_column.py create mode 100644 remarkbox/scripts/alembic/versions/c9793937e756_make_all_verified_namespace_request_.py create mode 100644 remarkbox/scripts/alembic/versions/c97ddfcbc406_approve_column.py create mode 100644 remarkbox/scripts/alembic/versions/cbdf3cd4ccba_avatar_size_in_namespace.py create mode 100644 remarkbox/scripts/alembic/versions/d1a0ab4f617f_add_has_uri_to_node.py create mode 100644 remarkbox/scripts/alembic/versions/f5f88293428a_add_mathjax_column_to_rb_namespace.py create mode 100644 remarkbox/scripts/alembic/versions/f7b42b2024fb_namespace_reverse_node_order.py create mode 100644 remarkbox/scripts/alembic/versions/fa8402aa1a00_add_link_protection_column_to_namespace.py create mode 100644 remarkbox/scripts/dump_sqlite3_to_csv.sh create mode 100644 remarkbox/scripts/init_db.py create mode 100644 remarkbox/scripts/invalidate_node_cache.py create mode 100644 remarkbox/scripts/json_import.py create mode 100644 remarkbox/scripts/load_csv_into_postgres.sh create mode 100644 remarkbox/scripts/merge_dupes.py create mode 100644 remarkbox/scripts/modify_namespace.py create mode 100644 remarkbox/scripts/modify_node.py create mode 100644 remarkbox/scripts/modify_uris.py create mode 100644 remarkbox/scripts/modify_user.py create mode 100644 remarkbox/scripts/recompute_node_depths.py create mode 100644 remarkbox/scripts/safe_approve_all_nodes.py create mode 100644 remarkbox/scripts/send_node_digest_notifications.py create mode 100644 remarkbox/static/css/common.css create mode 100644 remarkbox/static/css/embed.css create mode 100644 remarkbox/static/css/pygments.css create mode 100644 remarkbox/static/favicon.ico create mode 100644 remarkbox/static/img/remarkbox-logo.png create mode 100644 remarkbox/static/img/remarkbox-minified.png create mode 100644 remarkbox/static/js/custom.js create mode 100644 remarkbox/static/js/iframe-resizer/ie8.polyfils.map create mode 100644 remarkbox/static/js/iframe-resizer/ie8.polyfils.min.js create mode 100644 remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.js create mode 100644 remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.map create mode 100644 remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.min.js create mode 100644 remarkbox/static/js/iframe-resizer/iframeResizer.js create mode 100644 remarkbox/static/js/iframe-resizer/iframeResizer.map create mode 100644 remarkbox/static/js/iframe-resizer/iframeResizer.min.js create mode 100644 remarkbox/static/js/iframe-resizer/index.js create mode 100644 remarkbox/static/js/iframe-resizer/readme.rst create mode 100644 remarkbox/static/js/jquery-2.1.3.min.js create mode 100644 remarkbox/static/theme/readme.rst create mode 100644 remarkbox/templates/base.j2 create mode 100644 remarkbox/templates/billing.j2 create mode 100644 remarkbox/templates/dynamic-remarkbox.css.j2 create mode 100644 remarkbox/templates/edit-node.j2 create mode 100644 remarkbox/templates/embed-iframe.txt.j2 create mode 100644 remarkbox/templates/home.j2 create mode 100644 remarkbox/templates/join-or-log-in.j2 create mode 100644 remarkbox/templates/list-namespace-owners.j2 create mode 100644 remarkbox/templates/list-namespace-requests.j2 create mode 100644 remarkbox/templates/list-nodes.j2 create mode 100644 remarkbox/templates/list-notifications.j2 create mode 100644 remarkbox/templates/mail_digest_html.j2 create mode 100644 remarkbox/templates/mail_digest_text.j2 create mode 100644 remarkbox/templates/mail_immediate_html.j2 create mode 100644 remarkbox/templates/mail_immediate_text.j2 create mode 100644 remarkbox/templates/mail_snippet_html.j2 create mode 100644 remarkbox/templates/mail_snippet_text.j2 create mode 100644 remarkbox/templates/namespace-settings.j2 create mode 100644 remarkbox/templates/new.j2 create mode 100644 remarkbox/templates/rss.xml.j2 create mode 100644 remarkbox/templates/setup-namespace.j2 create mode 100644 remarkbox/templates/show-count.j2 create mode 100644 remarkbox/templates/show-node.j2 create mode 100644 remarkbox/templates/show-node2.j2 create mode 100644 remarkbox/templates/sitemap.xml.j2 create mode 100644 remarkbox/templates/snippets/add-to-slack.j2 create mode 100644 remarkbox/templates/snippets/create.j2 create mode 100644 remarkbox/templates/snippets/csrf.j2 create mode 100644 remarkbox/templates/snippets/email.j2 create mode 100644 remarkbox/templates/snippets/flash-alerts.j2 create mode 100644 remarkbox/templates/snippets/forms.j2 create mode 100644 remarkbox/templates/snippets/google-analytics.j2 create mode 100644 remarkbox/templates/snippets/javascript-includes.j2 create mode 100644 remarkbox/templates/snippets/owner-key-comment.j2 create mode 100644 remarkbox/templates/snippets/phone-menu.j2 create mode 100644 remarkbox/templates/snippets/powered-by.j2 create mode 100644 remarkbox/templates/snippets/search.j2 create mode 100644 remarkbox/templates/snippets/snippets.j2 create mode 100644 remarkbox/templates/snippets/stripe.j2 create mode 100644 remarkbox/templates/snippets/stylesheet-includes.j2 create mode 100644 remarkbox/templates/snippets/submit.j2 create mode 100644 remarkbox/templates/update-card.j2 create mode 100644 remarkbox/templates/user-settings.j2 create mode 100644 remarkbox/templates/user-watching.j2 create mode 100644 remarkbox/tests/test_models.py create mode 100644 remarkbox/tests/test_render.py create mode 100644 remarkbox/tests/test_views.py create mode 100644 remarkbox/views/__init__.py create mode 100644 remarkbox/views/authenticated/__init__.py create mode 100644 remarkbox/views/authenticated/authenticated.py create mode 100644 remarkbox/views/authenticated/lock.py create mode 100644 remarkbox/views/authenticated/notifications.py create mode 100644 remarkbox/views/authenticated/oauth.py create mode 100644 remarkbox/views/authenticated/stripe.py create mode 100644 remarkbox/views/authenticated/topsecret.py create mode 100644 remarkbox/views/authenticated/watch.py create mode 100644 remarkbox/views/authentication.py create mode 100644 remarkbox/views/list_nodes.py create mode 100644 remarkbox/views/misc.py create mode 100644 remarkbox/views/modify_node.py create mode 100644 remarkbox/views/new_thread.py create mode 100644 remarkbox/views/reply_node.py create mode 100644 requirements-dev.txt create mode 100644 requirements-prod.txt create mode 100644 requirements-test.txt create mode 100644 requirements.py3.txt create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 test.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74e16fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +*.xml +*.json + +*.pyc +*.swp +*.egg +*.egg-info +*.sqlite +*.sqlite.* +*.sqlite-journal +__pycache__ +.coverage +.cache +.env +.pytest_cache +env/* +build/* + +# ignore iframe-resizer repo +iframe-resizer/iframe-resizer/* + +# this is a place to put private vars for stripe or whatever. +vars.fish +vars.sh + +venv/ + +index3.html +index4.html +index5.html + +remarkbox-theme-meta/ +env.vanilla/ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b4c671a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include *.txt *.ini *.cfg *.rst +recursive-include remarkbox *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.j2 *.map diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a7dc920 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +env: + python3 -m venv env + . env/bin/activate + env/bin/pip install --upgrade pip + env/bin/pip install --upgrade -r requirements.py3.txt + cp -rp env env.vanilla + +dev: env + env/bin/pip install --editable . + env/bin/pip install --upgrade -r requirements-dev.txt + env/bin/pip install --upgrade -r requirements-test.txt + +prod: env + env/bin/pip install . + env/bin/pip install --upgrade -r requirements-prod.txt + +clean: + rm -rf env + rm -rf env.vanilla + +test: dev + env/bin/py.test + +serve: dev + # In the first shell, run a copy of remarkbox using: + env/bin/pserve development.ini --reload + +http: dev + # In the second shell, run a "mock" simple HTTP webserver to serve index.html: + env/bin/python -m http.server 8000 + +smtp: dev + # In the third shell, run a "mock" SMTP server to catch log in emails: + sudo env/bin/python -m smtpd -n -c DebuggingServer localhost:25 diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..484f9ce --- /dev/null +++ b/README.rst @@ -0,0 +1,198 @@ +Remarkbox +######### + +What is RemarkBox?: + A stand alone question and answer site (forum) or an embedded comments or product reviews service. + Works anywhere that supports HTML. + +Original Developer: + Russell Ballestrini (https://russell.ballestrini.net) + +Project Goals +============= + +Note: + These goals are not in priority order. + +#. To be a suitable for: + + * question and answer sites (StackOverflow) + * embedded comment system for static sites + * forums + * product review sections of e-commerce sites + + | + +#. To choose popular libraries instead of proper libraries, for example: + + * Github over Bitbucket (seriously considering GitLab) + * Git over HG Mercurial + * Jinja2 templates over Mako templates + * Markdown over ReStructuredText + * etc + + | + + Basically I have been burned too many times trying to pick the proper + library or tool for the job, so this time around, I will make effort + to choose solutions that the majority uses. + +#. To be popular + +#. To be safe from spammers + +#. To be easy to manage and clean up spam if it happens + +#. To be passwordless. Registration, verification and authentication happen via one-time-password links sent via email. + +#. To scale horizontally + +#. To be multitenant + +#. To have low friction for new users to join (posters and commenters) + +#. To be engaging for users (posters and commenters) + +#. To be search engine optimized + +#. To have great test coverage + + +Local Installation +================== + +We utilize a ``Makefile`` to capture targets for building a local Remarkbox environment. Please make sure you have ``make`` installed. + +#. ``make dev`` +#. ``make test`` + + +Functional testing environment +------------------------------- + +To setup a "functional testing" environment on your personal workstation, open three terminal shells. + +In the first shell, run a copy of remarkbox using: + +.. code-block:: bash + + make serve + +In the second shell, run a "mock" simple HTTP webserver to serve index.html: + +.. code-block:: bash + + make http + +In the third shell, run a "mock" SMTP server to catch log in emails: + +.. code-block:: bash + + make smtp + +Now browse to http://127.0.0.1:8000 and index.html will load and will have an embeded copy of Remarkbox which running on localhost. +If you attempt to log in, your third shell will capture the email and you can copy / paste the verification link to log in! + + + +SQL Migrations +=============== + +If your deployment is brand new, you don't need to run any migrations. +You should however run this to stamp the database as ready: + +.. code-block:: bash + + alembic -c development.ini stamp head + +Otherwise, it should be safe to run this at anytime to catch your database up: + +.. code-block:: bash + + alembic -c development.ini upgrade head + +To look at the current revision and the history run these: + +.. code-block:: bash + + alembic -c development.ini history + alembic -c development.ini current + +If you ever want to cut a new migration script, you can run this: + +.. code-block:: bash + + alembic -c development.ini revision -m "Added email_id column to User table." + +Then you can edit / modify the generated ``.py`` file with your changes. + +You can also autogenerate a new migration script using `--autogenerate`. +Alembic will prepare a migration script by comparing the state of the +database with the state of the model: + +.. code-block:: bash + + alembic -c development.ini revision --autogenerate -m "autogenerated indicies." + +You should review the recommended migration script before `upgrade`. + + +Cleaning the homepage +======================== + +Sometimes (all the times) it's nice to clear all the test comments from the +the homepage of our marketing site. Use this query. + +.. code-block:: sql + + sqlite> UPDATE rb_uri SET data = "https://www.remarkbox.com/?cleaned=2018-09-28" WHERE data = "https://www.remarkbox.com/"; + + sqlite> SELECT * FROM rb_uri WHERE data LIKE "%https://www.remarkbox.com/?cleaned%"; + 1e631dd85d104555b41b300961d2f909|82008b2b178f4daab64c35ab5c5f9b56|https://www.remarkbox.com/?cleaned=2017-11-01 + 6b2a4772679611e8ad95040140774501|6b2a42ae679611e8ad95040140774501|https://www.remarkbox.com/?cleaned=2018-09-28 + + + +Looking up paying customers +============================== + +.. code-block:: sql + + + SELECT * FROM rb_pay_what_you_can + INNER JOIN rb_user ON rb_user.id = rb_pay_what_you_can.user_id + WHERE amount > 0 and rb_user.stripe_id is not null; + + + +Python Pyramid Shell +========================== + +If you want to use an interactive Python interpreter to interact with the Remarkbox app/models and database: + +.. code-block:: bash + + pshell development.ini + +Here is a full `pshell` script to modify every `Node` who has a `Uri`: + +.. code-block:: python + + # begin the database transaction. + request.tm.begin() + + # get all Uri objects. + uris = m.uri.get_all_uris(request.dbsession) + + # interate over all Uri objects. + for uri in uris: + # modify the Uri's related Node. + uri.node.has_uri = True + # add the related Node object to the sqlalchemy session. + request.dbsession.add(uri.node) + + # flush / commit all changes stored the the sqlachemy session. + request.dbsession.flush() + + # commit/close the database transaction to really make changes. + request.tm.commit() diff --git a/development.ini b/development.ini new file mode 100644 index 0000000..977d03e --- /dev/null +++ b/development.ini @@ -0,0 +1,154 @@ +# Config File Reference: +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html + +[alembic] +script_location = remarkbox:scripts/alembic +sqlalchemy.url = sqlite:///%(here)s/remarkbox.sqlite + +[app:main] +use = egg:remarkbox + +#sqlalchemy.url = postgres://localhost/remarkbox +sqlalchemy.url = sqlite:///%(here)s/remarkbox.sqlite + +### +# Pyramid configuration. +### +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_debugtoolbar + pyramid_tm + +# By default, the toolbar only appears for clients from IP addresses +# '127.0.0.1' and '::1'. +# debugtoolbar.hosts = 127.0.0.1 ::1 + +### +# session / cookie configuration. +# http://docs.pylonsproject.org/docs/pyramid/en/latest/api/session.html +### +session.hashalg = sha512 +session.secret = test-secret +session.timeout = 31104000 +session.max_age = 31104000 +session.reissue_time = 15552000 +session.samesite = None + +### +# custom app configuration. +### +app.avatar.size = 35 + +# In prod I set this to: remarkbox.com (default = "") +#app.root_domain = +app.root_domain = ${REMARKBOX_APP_ROOT_DOMAIN} + +# If set we force all links related to `join or log in` to this base url +# and use this url for links in email notifications. +# Also useful when multiple subdomains point at the same Remarkbox cluster. +# In prod I set this to: https://my.remarkbox.com (default = request.host_url) +#app.app_url = +app.app_url = ${REMARKBOX_APP_URL} + +# if your marketing / main landing page base url is different then app url. +# In prod I set this to: https://www.remarkbox.com (default = request.app_url) +#app.marketing_url = + +# if Node is None, we may safely redirect to this uri. optional. +#app.safe_redirect = https://www.remarkbox.com + +app.email.relay = localhost +app.email.sender = no-reply@remarkbox.com + +# set this to the path of your private DKIM key. +# reference: https://russell.ballestrini.net/quickstart-to-dkim-signed-email-with-python/ +#app.email.dkim_private_key_path = +#app.email.dkim_selector = + +# The app name in the email subject (default = request.app_domain) +# In production I set this to "Remarkbox". +app.email.subject_postfix = Remarkbox + +# optional theme plugin name. +#app.theme = westworld +app.theme = meta + +# optional theme plugin name for embed mode. +#app.theme_embed = meta + +# stand-alone mode disables a bunch of views and templates +# related to billing and registering Namespaces. +# choices enabled or disabled. defaults to disabled. +#app.stand_alone_mode = disabled + +# stripe: credit card storage and processing. +# This syntax will automatically expand an ENV var of the same name. +app.stripe.secret = ${REMARKBOX_APP_STRIPE_SECRET} +app.stripe.public = ${REMARKBOX_APP_STRIPE_PUBLIC} + +# slack: bot notifications. +app.slack.secret = ${REMARKBOX_APP_SLACK_SECRET} +app.slack.public = ${REMARKBOX_APP_SLACK_PUBLIC} +app.slack.oauth_scope = channels:read,chat:write:bot + +# feature flag for email notifications. +#app.deliver_email_notifications = true + +# robots.txt contents. +#app.robots_dot_txt = +# User-agent: * +# Disallow: + +### +# wsgi server configuration +### +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### +[loggers] +keys = root, remarkbox, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_remarkbox] +level = DEBUG +handlers = +qualname = remarkbox + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s + +[pshell] +m = remarkbox.models diff --git a/index.html b/index.html new file mode 100644 index 0000000..260abe9 --- /dev/null +++ b/index.html @@ -0,0 +1,95 @@ + + + + Example of Remarkbox in an Iframe. + + + + + +
+
+

Menu is menu!

+

option 1

+

option 2

+

remarkbox

+
+
+ +

Example of Remarkbox in an Iframe

+ +

+Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. +

+ + +

+It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). +

+ +

+Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. +

+ + + +
+ +
+ + + + +
+
+ + + + diff --git a/index2.html b/index2.html new file mode 100644 index 0000000..5299e4b --- /dev/null +++ b/index2.html @@ -0,0 +1,90 @@ + + + + Example of Remarkbox in an Iframe. + + + + + +
+
+

Menu is menu!

+

option 1

+

option 2

+

remarkbox

+
+
+ +

Example of Remarkbox in an Iframe

+ +

+Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. +

+ + +

+It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like). +

+ +

+Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. +

+ + + +
+ +
+ + + +
+
+ + + + diff --git a/journal.rst b/journal.rst new file mode 100644 index 0000000..03f7a53 --- /dev/null +++ b/journal.rst @@ -0,0 +1,260 @@ +Tue 04 Aug 2020 12:48:06 AM EDT +===================================== + +- [ ]: TODO regressions are sad, let's write more functional tests. + + + +Tue 11 Aug 2020 09:50:33 PM EDT +===================================== + +- [x]: TODO fix the defect + +Found some common errors in prod:: + + sudo zgrep UnicodeEncodeError /var/log/syslog.*.gz | wc -l + 223 + +This was the bad code which didn't support unicode:: + + def _reduce_whitespace(text): + return " ".join(map(str.strip, map(str, text.splitlines()))) + +Here is the replacement which supports unicode:: + + def _reduce_whitespace(text): + """a way to remove newlines and leading/trailing whitespace per line.""" + return " ".join([line.strip() for line in text.splitlines()]) + +Check back in a few days to make sure the errors are gone, by running:: + + sudo zgrep UnicodeEncodeError /var/log/syslog.*.gz | wc -l + 223 + +Update: Fixed!:: + + fox@remarkbox:~$ date + Fri Aug 21 16:28:32 EDT 2020 + + fox@remarkbox:~$ sudo zgrep UnicodeEncodeError /var/log/syslog.*.gz | wc -l + 1 + + +Tue 11 Aug 2020 09:58:07 PM EDT +===================================== + +- [ ]: TODO fix the defect + +:: + + sudo zgrep "Incorrect padding" /var/log/syslog.*.gz + 214 + +The fix is maybe removing the premature optimization of "short" uuids. +I made the call, rightfully, to avoid this premature optimization in +the `make_post_sell` code base after seeing the issues it caused in +Remarkbox. + +Here is a snippet of the Traceback:: + + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: return get_object_by_id(dbsession, node_id, Node) + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/remarkbox/models/meta.py", line 116, in get_object_by_id + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: object_uuid = id_to_uuid(object_id) + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/remarkbox/models/meta.py", line 107, in id_to_uuid + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: return uuid.UUID(bytes=short_id_to_bytes(the_id)) + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/remarkbox/models/meta.py", line 80, in short_id_to_bytes + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: return (short_id + "==").replace("_", "/").replace("-", "+").decode("base64") + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: File "/usr/lib/python2.7/encodings/base64_codec.py", line 42, in base64_decode + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: output = base64.decodestring(input) + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: File "/usr/lib/python2.7/base64.py", line 328, in decodestring + Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: return binascii.a2b_base64(s) + +Some quick research suggests maybe a simple fix is appending 3 `=` signs as padding, instead of 2. + +I'll try that and check back to see if the errors go away. + + + +Wed 12 Aug 2020 08:54:24 AM EDT +==================================== + +- [ ]: TODO fix the defect + +In this error the user is not authenticated and is trying to click a log-in/verify link +derived from on an embeded thread which has unicode in it's URI path:: + + return-to=https://post-tenebras-lire.net/%D0%A7%D0%BE%D1%80%D0%BD%D0%BE%D0%B1%D0%B8%D0%BB%D1%8C/ + +When the log-in/verify view attempts to `return HTTPFound(return_to)` back to the +`thread_uri` (the page embedding Remarkbx) we get the following exception:: + + Aug 10 09:43:27 remarkbox.com demo.remarkbox.com[12893]: [pid: 12909|app: -|req: -/-] 213.55.244.1 (-) {48 vars in 1261 bytes} [Mon Aug 10 09:43:27 2020] GET /join-or-log-in?email=posttenebraslire%40caenevet.net&raw-otp=&return-to=https://post-tenebras-lire.net/%D0%A7%D0%BE%D1%80%D0%BD%D0%BE%D0%B1%D0%B8%D0%BB%D1%8C/ => generated 0 bytes in 344 msecs (HTTP/1.0 500) 0 headers in 0 bytes (0 switches on core 0) + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: Traceback (most recent call last): + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: return HTTPFound(return_to) + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/pyramid/httpexceptions.py", line 547, in __init__ + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: **kw + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/pyramid/httpexceptions.py", line 236, in __init__ + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: Response.__init__(self, status=status, **kw) + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/webob/response.py", line 321, in __init__ + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: setattr(self, name, value) + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/webob/descriptors.py", line 148, in fset + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: value = value.encode('latin-1') + Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: UnicodeEncodeError: 'latin-1' codec can't encode characters in position 31-39: ordinal not in range(256) + +The defect is seemingly related to +the `%` quoted `/%D0%A7%D0%BE%D1%80%D0%BD%D0%BE%D0%B1%D0%B8%D0%BB%D1%8C/` +in the `return-to` url which decodes to `/Чорнобиль/`. + +Looking at the webob code this `value = value.encode('latin-1')` is a Python2 +compatibility pathway which does not get called for Python3 + +Reference: +https://github.com/Pylons/webob/blob/ac2d238f50bed7fbbf115818ab7f208b71d7bdca/src/webob/descriptors.py#L147-L155 + +The obvious answer to this is to upgrade Remarkbox (and MakePostSell) to use +a Python3 environment. Likely this will bring it's own trouble so I'll defer +on fixing this until I have time and energy to tackle that project. + + +Fri 21 Aug 2020 02:18:55 PM EDT +======================================= + +- [X]: TODO tune uwsgi processes/threads +- [ ]: TODO tune uwsgi processes/threads for maximum performance measured by requests-per-second (RPS) on a semi-random traffic shape. + +.. code-block:: + + processes = 2 + threads = 8 + enable-threads = True + + +Fri 21 Aug 2020 02:56:20 PM EDT +==================================== + +[ ]: TODO adjust terms of service and/or privacy policy + +I need to adjust my terms of service to make Remarkbox more useful. + +My target market are developers and engineers who like to run their own systems. + +My target market cares deeply about page speed and computer performance. + +My target market likes numbers and statistics, they want to see growth, they want analytics and metrics, it might be vanity but it's important. + +When thinking about their site, I wanted them to think about checking Remarkbx for their stats and comments. + +Could Remarkbox become the next Google Analytics? No, but it does need to compete with YouTube studio for example. + +Remarkbox should be in a unique situation where we may have a competitive advantage over other metric systems. + +Analytics could power the desire of site owners to continue interacting with their user base. + +We could use the analytics data to power a goals system (for example measuring blog post throughput, etc) + +This data is really important. This data would be made available to site owners as a `.json` file. + + +Fri 21 Aug 2020 04:24:27 PM EDT +======================================== + +- [ ]: TODO start tracking `per request analytic data` for auditing the Remarkbox Service, for capacity planning, security, performance, as well as for finding or fueling feature, product, or service growth opportunities. + +Additionally this data is also just as important to you, our free and paying customers of the Remarkbox Service. + +In order to better serve all of us, and to facilitate a search for a sustainable path forward for Remarkbox to grow into, +I'm proposing alignment of what a `per request analytic datum` looks like:: + + id, namespace_id, uri_id, user_id, user_authed, timestamp + +With fresh eyes I'm not sure how much energy I want to devote to this idea considering things like Google Analytics already exists. + +Maybe just collecting hits per hour and rolled up per day after 2 weeks, whether or not the user was authenticated (so two buckets) +and then a separate table for particular actions like comments per day or something. + +Speaking with smarter people than me, they confirmed ElasticSearch would be a good choice as a data store so if I want to do it "right" +that's what I should use. + +But if I want to do it "fast / cheap / good enough" storing it in the relational database might be fine until it becomes painful. + +If I introduced elasticsearch, I could also have an index for ingesting the embeded pages content (strip out HTML, just plain-text) +and offer full-text search capabilities for static sites. This might cause the dataset to balloon up a lot more than +just `per request analytic data` so this would be a follow up project once we have an elasticsearch cluster. + +That said, as a first pass, we could just create a really tiny single node elasticsearch cluster for analytics type data with +many small documents. Additionally these statistics and analytics do not need to have a great uptime SLA. No redundancy and hourly +snapshots such that if the cluster ever went red and needed to be recovered from snapshot, we would only lose an hour of data. + +anyways, this isn't a high priority project, it's more of a fun and explority, nice to have rather than a need to have. + +Thought I'd get it out of my head and into a journal entry. + +https://www.elastic.co/guide/en/elasticsearch/reference/current/glossary.html + + +Sun Jan 24 06:13:30 PM EST 2021 +================================== + +After much internal debate, I have decided to YOLO and make Remarkbox pay-what-you-can! + +This means by default Namespaces will be created in the production subscription_type such that the use does not need to pay in order to unlock production settings. + +TODO: + +[x] modify codebase business logic to always create "production" Namespaces by default. +[x] modify codebase business logic to collect pay-what-you-can amount and frequency into a model/table +[x] modify sales funnel, setup sales copy +[x] modify pricing page to pay-what-you-can +[x] modify meta theme to let the customer know how to pay (paypal.me or /billing) +[x] modify landing page +[x] migrate all existing development users to production +[ ] create a mailchimp email list dump of all users in the database and send them an email + letting them know their Namespace was automatically upgraded and unlocked. +[ ] write a script and schedule a cronjob to trigger on the 1st of each month to charge credit cards based on user preferences. +[ ] modify Namespace settings page to remind people to check their "pay-what-you-can" settings. Only display this dialog to people who have never paid or people who have added a new namespace to their account but have not made an update to their pay-what-you-can model. + code this so it could have various upsells? + + + +Sat Jan 30 03:32:55 PM EST 2021 +===================================== + +We should build a Remarkbox to matrix bridge. I bet it is a lot like working with slack API by now or if not, maybe I could build that client as open source? + + +Sat Apr 3 10:40:05 PM EDT 2021 +===================================== + +this is a useful SQL query to SELECT users who want to pay and also gave a credit card. + +:: + SELECT * FROM rb_pay_what_you_can + INNER JOIN rb_user ON rb_user.id = rb_pay_what_you_can.user_id + WHERE amount > 0 and rb_user.stripe_id is not null; + + + + +Tue Nov 16 08:40:00 PM EST 2021 +==================================== + +we need to suppliment the magic link with a 6 alpha-numeric one-time-password + + + + +Sun Nov 28 01:51:30 PM EST 2021 +===================================== + + +we need to allow Namespace owner/moderator to edit the external uri of a thread. + + +Fri Dec 24 07:44:13 AM EST 2021 +==================================== + +[] It's time to open the sause and place it into the public domain! + +I was nervous but now I AM ready. + +I love you all! diff --git a/readme-postgres-notes.rst b/readme-postgres-notes.rst new file mode 100644 index 0000000..7b44b6e --- /dev/null +++ b/readme-postgres-notes.rst @@ -0,0 +1,101 @@ +Setting up a development environment on Ubuntu +############################################## + +#. we need ``git`` and ``PostgreSQL`` installed. If not, do: + + .. code-block:: bash + + sudo apt-get install git docker postgres-client libpq-dev (libpqxx-devel on fedora) + sudo docker pull postgres:9.6.9-alpine + + create PostgreSQL docker container: + + .. code-block:: bash + + docker run --name development-postgres -d -p 5432:5432 postgres:9.6.9-alpine + + make yourself a Postgres Superuser (in my case `russellballestrini`): + + .. code-block:: bash + + psql --host=localhost --port 5432 --username postgres --command \ + "CREATE USER russellballestrini with SUPERUSER;" + + make the ``remarkbox`` database. + + .. code-block:: bash + + psql --host=localhost --port 5432 --username postgres --command \ + "CREATE DATABASE remarkbox;" + + make the ``remarkbox_test`` database. + + .. code-block:: bash + + psql --host=localhost --port 5432 --username postgres --command \ + "CREATE DATABASE remarkbox_test;" + +#. checkout the Remarkbox codebase: + + .. code-block:: bash + + cd ~ + git clone https://russellballestrini@bitbucket.org/russellballestrini/remarkbox.git + cd remarkbox + +#. install ``python-pip``: + + .. code-block:: bash + + sudo apt-get install python-pip + +#. use ``pip`` to upgrade ``pip`` and ``setuptools``: + + .. code-block:: bash + + sudo pip install --upgrade pip setuptools + +#. use pip to install ``virtualenv``: + + .. code-block:: bash + + sudo pip install virtualenv + +#. create a ``virtualenv``: + + .. code-block:: bash + + virtualenv env + +#. source the new ``virtualenv``: + + .. code-block:: bash + + . env/bin/activate + +#. install Remarkbox, using setuptool's in development mode: + + .. code-block:: bash + + python setup.py develop + +#. install dev and test dependencies: + + .. code-block:: bash + + pip install -r requirements-dev.txt + pip install -r requirements-test.txt + +#. initialize the database schema: + + .. code-block:: bash + + remarkbox_init_db development.ini + +#. spin up the application server using ``waitress``: + + .. code-block:: bash + + pserve development.ini --reload + +Note: if you get an error about TemplateNotFound, you likely need to install the Remarkbox Meta theme. See readme-themes.rst for details. diff --git a/readme-themes.rst b/readme-themes.rst new file mode 100644 index 0000000..ab84bad --- /dev/null +++ b/readme-themes.rst @@ -0,0 +1,14 @@ +Install meta theme +----------------------- + +Remarkbox supports custom themes which makes it possible to provide "whitelabel" deployments which are rebranded. + +The default Remarkbox theme is called `meta` and may be installed in development like this: + +.. code-block:: bash + + git clone https://github.com/russellballestrini/remarkbox-theme-meta.git + cd remarkbox-theme-meta + python setup.py develop + +If you don't want to install this theme, that is ok, comment out `app.theme = meta` in the `development.ini` diff --git a/remarkbox/__init__.py b/remarkbox/__init__.py new file mode 100644 index 0000000..fc5b22f --- /dev/null +++ b/remarkbox/__init__.py @@ -0,0 +1,490 @@ +from pyramid.config import Configurator + +from sqlalchemy import engine_from_config + +from .models import ( + get_user_by_id, + get_or_create_user_by_email, + get_node_by_id, + get_or_create_node_by_uri, + get_nodes_who_share_root, + get_node_id_map, + get_graph_from_nodes, + get_or_create_namespace, + get_namespace_request_by_id, + get_conversation_graph_from_nodes, + flatten_graph, +) + +# reject spammers. +from pyramid.httpexceptions import HTTPUnauthorized + +# cookie only session, not encrypted but signed to prevent tampering! +from pyramid.session import SignedCookieSessionFactory + +# email validation. +import re + +# needed to load themes. +from pkg_resources import iter_entry_points + +# needed to support expanding ENV vars from ini. +from os.path import expandvars + +from miscutils import get_children_settings + +import logging +log = logging.getLogger(__name__) + +JINJA2_EXTENSION = ".j2" + + +def load_entry_points(group_name): + """Return a dictionary of entry_points related to given group_name""" + entry_points = {} + for entry_point in iter_entry_points(group=group_name, name=None): + entry_points[entry_point.name] = entry_point.load() + return entry_points + + +def load_jinja2_themes(config): + """Automatically load any entry_point registered Remarkbox theme.""" + themes = load_entry_points("remarkbox.themes") + for theme_name, theme_module in themes.items(): + theme_module_name = theme_module.__name__ + # teach Jinja2 about the template dir in the theme package. + config.add_jinja2_search_path( + "{}:templates/".format(theme_module_name), name=JINJA2_EXTENSION + ) + # teach Pyramid about the static assets in the theme package. + config.add_static_view( + "static/theme/{}".format(theme_name), + "{}:static/theme/{}".format(theme_module_name, theme_name), + cache_max_age=3600, + ) + return config + + +def is_ipv4(string): + try: + parts = list(map(int, string.split("."))) + except: + return False + if len(parts) != 4: + return False + for part in parts: + if part < 0 or part > 256: + return False + return True + + +def maybe_root_domain(string): + """ + Maybe get valid "root" domain from given string, for example - + + valid: + + * hi.www.example.com -> example.com + * www.example.com -> example.com + * example.com -> example.com + + invalid: + + * comments.cryptocoin.com.au -> com.au + """ + return ".".join(string.split(".")[-2:]) + + +def main(global_config, **settings): + """ This function returns a Pyramid WSGI application.""" + + app_settings = get_children_settings(settings, "app") + session_settings = get_children_settings(settings, "session") + + def wild_signed_cookie_session_factory(request): + """ + Hack SignedCookieSessionFactory to have `wild_domain`. + Pyramid devs do not want to support this. + + In addition the domain is determined per request! + + This means a single deployment of this app can support managing a + `wild_domain` cookie for any domain (likely using CNAMES). + """ + root_domain = maybe_root_domain(request.domain) + if is_ipv4(request.domain) or root_domain.startswith("com"): + session_settings["domain"] = request.domain + else: + session_settings["domain"] = root_domain + + factory = SignedCookieSessionFactory(**session_settings) + return factory(request) + + # setup session factory to use unencrypted but signed cookies. + # session_factory = SignedCookieSessionFactory(**session_settings) + session_factory = wild_signed_cookie_session_factory + + # build app config object from ini. + config = Configurator(settings=settings, session_factory=session_factory) + + # setup and require automatic CSRF checking. + config.set_default_csrf_options(require_csrf=True) + + # Create database engine from connection details in ini. + # make request.dbsession available for use in Pyramid. + config.include(".models") + + # setup jinja2 template support. + config.include("pyramid_jinja2") + config.add_jinja2_search_path("remarkbox:templates/", name=JINJA2_EXTENSION) + config.add_jinja2_renderer(JINJA2_EXTENSION) + config = load_jinja2_themes(config) + + # compile the email validator regex outside of the functions. + _email_regex = re.compile("^[^@]+@[^@]+\.[^.@]+$") + + ''' + def add_redis(request): + """Return Redis Connection""" + redis_host = request.app.get("redis.host", "localhost") + redis_port = request.app.get("redis.port", 6379) + redis_db = request.app.get("redis.db", 0) + return redis.Redis(host=redis_host, port=redis_port, db=redis_db) + ''' + + def add_email(request): + """Return Email or None. Email must pass regex.""" + email = request.params.get("email", "") + if _email_regex.match(email) is not None: + return email + + def add_user(request): + """Return User object or None. User.authenticated may be True or False.""" + user = None + authenticated_user_id = request.session.get("authenticated_user_id", None) + + if authenticated_user_id: + # attach the user object from DB to the request. + user = get_user_by_id(request.dbsession, authenticated_user_id) + if user is not None: + user.authenticated = True + + elif request.email: + user = get_or_create_user_by_email(request.dbsession, request.email) + + return user + + def add_csrf_token(request): + if request.user and request.user.authenticated: + return request.session.get_csrf_token() + + def add_node(request): + """Return Node object or None from matchdict or params.""" + thread_uri = request.params.get("thread_uri") + thread_title = request.params.get("thread_title") + node_id = request.matchdict.get("node_id") + nojs = request.params.get("nojs", False) + + node = None + + # TODO: thread_uri must take priority over node_id because our forms + # are dumb. If we fix our forms to only pass node_id/node_path when not + # None, the priority / order will not matter. + if thread_uri: + node = get_or_create_node_by_uri(request.dbsession, thread_uri, thread_title) + elif node_id: + node = get_node_by_id(request.dbsession, node_id) + elif nojs and request.referer: + # if javascript is disabled, use the referer. + node = get_or_create_node_by_uri(request.dbsession, request.referer) + + return node + + def add_root_node(request): + return request.node.root + + def add_nodes(request): + if request.node: + return get_nodes_who_share_root( + request.dbsession, + request.node.root, + request.node_order, + ) + + def add_node_id_map(request): + return get_node_id_map(request.nodes) + + def add_node_graph(request): + return get_graph_from_nodes(request.nodes) + + def add_node_flat_graph(request): + return flatten_graph(request.node.root.id, request.node_graph) + + def add_conversation_graph(request): + return get_conversation_graph_from_nodes( + request.nodes, + graph=request.node_graph, + node_id_map=request.node_id_map, + order=request.node_order, + ) + + def add_namespace(request): + namespace_name = request.matchdict.get( + "namespace", request.params.get("namespace", None) + ) + if namespace_name: + return get_or_create_namespace(request.dbsession, namespace_name) + elif request.node: + return request.node.root.namespace + return get_or_create_namespace(request.dbsession, request.domain) + + def add_mode(request): + """return mode of 'embed' or 'basic'""" + return "embed" if "embed" in request.matched_route.name else "basic" + + def add_link_prefix(request): + if request.mode == "embed": + return "/embed/ns/{}".format(request.namespace.name) + return "" + + def add_spam(request): + """Test if request looks spammy HTTP Error or False.""" + if request.params.get("email2", "") != "": + # spam filter, hidden email2 field tricks bots. + return HTTPUnauthorized("you smell like a spammer") + return False + + def add_owner_key(request): + return request.params.get("rb_owner_key", None) + + def add_namespace_request(request): + """Add namespace_request if pending, else None""" + # we reduce database lookups by only querying when owner_key is + # present and request.namespace has a pending owner_request. + if request.owner_key and request.namespace.owner_request_pending: + namespace_request = get_namespace_request_by_id( + request.dbsession, request.owner_key + ) + # log.info("owner_key={}, pending={}".format(request.owner_key,request.namespace.owner_request_pending)) + log.info("{} {}".format(namespace_request.namespace,request.namespace)) + if namespace_request and namespace_request.namespace == request.namespace: + return namespace_request + + def add_app(request): + """Attach app settings dictionary.""" + return app_settings + + def add_app_url(request): + """ + Use the app_url from config if request.domain ends with the + configured root_domain. Otherwise just use the request's host_url + """ + config_app_url = request.app.get("app_url", request.host_url) + root_domain = request.app.get("root_domain") + if root_domain and request.domain.endswith(root_domain): + return config_app_url + elif request.domain == "localhost": + return config_app_url + return request.host_url + + def add_app_domain(request): + return request.app_url.split("://")[-1].split(":")[0] + + def add_marketing_url(request): + return request.app.get("marketing_url", request.app_url) + + def add_marketing_domain(request): + return request.marketing_url.split("://")[-1].split(":")[0] + + def add_faq_home(request): + """Return True if this request's domain equals namespace name.""" + return request.domain == request.namespace.name + + def add_saas_home(request): + """Only one domain should have this method return True per deployment.""" + root_domain = request.app.get("root_domain") + return ( + root_domain and + request.domain.endswith(root_domain) and + request.app_domain == request.namespace.name + ) + + def add_stripe(request): + """Attach a stripe object with creds to request.""" + import stripe + + stripe.api_key = request.app.get("stripe.secret") + return stripe + + def add_stripe_customer(request): + if request.user: + if not request.user.stripe_id: + # create a new stripe customer. + customer = request.stripe.Customer.create(email=request.user.email) + request.user.stripe_id = customer.id + request.dbsession.add(request.user) + request.dbsession.flush() + return request.stripe.Customer.retrieve(request.user.stripe_id) + return None + + def add_stripe_saved_cards(request): + if request.user and request.user.stripe_id: + return request.stripe_customer.sources + return [] + + def add_stripe_active_card(request): + if request.user and request.user.stripe_id: + if request.stripe_customer.default_source: + return request.stripe_customer.sources.retrieve( + request.stripe_customer.default_source + ) + return None + + def add_avatar_size(request): + """Attach avatar size or default.""" + if request.namespace: + if request.namespace.avatar_size: + return request.namespace.avatar_size + return request.app.get("avatar.size", 30) + + def add_stand_alone_mode(request): + if request.app.get("stand_alone_mode", "disabled") == "enabled": + return True + return False + + def add_theme(request): + """Attach theme name or None.""" + if request.mode == "embed": + if request.namespace and request.namespace.theme_embed: + return request.namespace.theme_embed + return request.app.get("theme_embed", None) + + if request.namespace and request.namespace.theme: + return request.namespace.theme + return request.app.get("theme", None) + + def add_stylesheet(request): + """Attach stylesheet or empty string.""" + if request.mode == "embed": + return request.stylesheet_embed + return request.stylesheet_basic + + def add_stylesheet_basic(request): + """Attach stylesheet or empty string.""" + if request.namespace and request.namespace.stylesheet: + return request.namespace.stylesheet + return "" + + def add_stylesheet_embed(request): + """Attach stylesheet_embed or empty string.""" + if request.namespace and request.namespace.stylesheet_embed: + return request.namespace.stylesheet_embed + return "" + + def add_stylesheet_uri(request): + """Attach stylesheet uri or empty string.""" + if request.mode == "embed": + return request.stylesheet_embed_uri + return request.stylesheet_basic_uri + + def add_stylesheet_basic_uri(request): + """Attach stylesheet uri or empty string.""" + if request.namespace and request.namespace.stylesheet_uri: + return request.namespace.stylesheet_uri + return "" + + def add_stylesheet_embed_uri(request): + """Attach stylesheet_embed uri or empty string.""" + if request.namespace and request.namespace.stylesheet_embed_uri: + return request.namespace.stylesheet_embed_uri + return "" + + def add_base_template(request): + """Attach base_template filename.""" + if request.theme: + return "{}-base.j2".format(request.theme) + return "base.j2" + + def add_base_funnel_template(request): + """Attach base_funnel template filename.""" + if request.theme: + return "{}-base-funnel.j2".format(request.theme) + return "base.j2" + + def add_page_number(request): + """Attach page_number starting at 0""" + page_number = int(request.params.get("page", 1)) + return page_number if page_number >= 1 else 1 + + def add_page_size(request): + # if we want to support custom page sizes, set it here. + return 100 + + def add_page_offset(request): + return (request.page_number - 1) * request.page_size + + def add_node_order(request): + return request.params.get("order", request.namespace.node_order) + + def add_mathjax(request): + return "true" if request.namespace.mathjax else "false" + + # register functions to app config as request methods. + # each request instance will run these functions and attach results. + # cache result with `reify=True` to prevent multiple db lookups. + #config.add_request_method(add_redis, "redis", reify=True) + config.add_request_method(add_user, "user", reify=True) + config.add_request_method(add_csrf_token, "csrf_token", reify=True) + config.add_request_method(add_email, "email", reify=True) + config.add_request_method(add_node, "node", reify=True) + config.add_request_method(add_root_node, "root_node", reify=True) + config.add_request_method(add_nodes, "nodes", reify=True) + config.add_request_method(add_node_id_map, "node_id_map", reify=True) + config.add_request_method(add_node_graph, "node_graph", reify=True) + config.add_request_method(add_node_flat_graph, "node_flat_graph", reify=True) + config.add_request_method(add_conversation_graph, "conversation_graph", reify=True) + config.add_request_method(add_namespace, "namespace", reify=True) + config.add_request_method(add_mode, "mode", reify=True) + config.add_request_method(add_link_prefix, "link_prefix", reify=True) + config.add_request_method(add_spam, "spam", reify=True) + config.add_request_method(add_owner_key, "owner_key", reify=True) + config.add_request_method(add_namespace_request, "namespace_request", reify=True) + config.add_request_method(add_app, "app", reify=True) + config.add_request_method(add_app_url, "app_url", reify=True) + config.add_request_method(add_app_domain, "app_domain", reify=True) + config.add_request_method(add_marketing_url, "marketing_url", reify=True) + config.add_request_method(add_marketing_domain, "marketing_domain", reify=True) + config.add_request_method(add_faq_home, "faq_home", reify=True) + config.add_request_method(add_saas_home, "saas_home", reify=True) + config.add_request_method(add_stripe, "stripe", reify=True) + config.add_request_method(add_stripe_customer, "stripe_customer", reify=True) + config.add_request_method(add_stripe_saved_cards, "stripe_saved_cards", reify=True) + config.add_request_method(add_stripe_active_card, "stripe_active_card", reify=True) + config.add_request_method(add_stand_alone_mode, "stand_alone_mode", reify=True) + config.add_request_method(add_avatar_size, "avatar_size", reify=True) + config.add_request_method(add_theme, "theme", reify=True) + config.add_request_method(add_stylesheet_basic, "stylesheet_basic", reify=True) + config.add_request_method(add_stylesheet_embed, "stylesheet_embed", reify=True) + config.add_request_method(add_stylesheet, "stylesheet", reify=True) + config.add_request_method( + add_stylesheet_basic_uri, "stylesheet_basic_uri", reify=True + ) + config.add_request_method( + add_stylesheet_embed_uri, "stylesheet_embed_uri", reify=True + ) + config.add_request_method(add_stylesheet_uri, "stylesheet_uri", reify=True) + config.add_request_method(add_base_template, "base_template", reify=True) + config.add_request_method(add_base_funnel_template, "base_funnel_template", reify=True) + config.add_request_method(add_page_number, "page_number", reify=True) + config.add_request_method(add_page_size, "page_size", reify=True) + config.add_request_method(add_page_offset, "page_offset", reify=True) + config.add_request_method(add_node_order, "node_order", reify=True) + config.add_request_method(add_mathjax, "mathjax", reify=True) + + # all of the web application routes. + config.include(".routes") + + # Scan for views. + config.scan() + + return config.make_wsgi_app() diff --git a/remarkbox/lib/__init__.py b/remarkbox/lib/__init__.py new file mode 100644 index 0000000..dc3e25e --- /dev/null +++ b/remarkbox/lib/__init__.py @@ -0,0 +1,36 @@ +from hashlib import md5 + +from ago import human + +from datetime import datetime + + +def timestamp_to_datetime(timestamp): + """Accepts a timestamp and returns a date string""" + return datetime.fromtimestamp(timestamp / 1000.0) + + +def timestamp_to_date_string(timestamp): + return timestamp_to_datetime(timestamp).strftime("%b %d, %Y %I:%M %P") + + +def timestamp_to_ago_string(timestamp): + """Accepts a timestamp and returns a human readable string""" + return human(timestamp_to_datetime(timestamp), 2, abbreviate=True) + + +def gravatar_client(email, size=48, rating="g", default="retro", force_default=False): + """Return a gravatar API URI.""" + query_params = ["s={}".format(size), "r={}".format(rating), "d={}".format(default)] + if force_default: + query_params.append("f=y") + uri = "https://secure.gravatar.com/avatar/{0}?{1}" + + try: + # Python 2. + email_md5 = md5(email).hexdigest() + except TypeError: + # Python 3. + email_md5 = md5(email.encode("utf-8")).hexdigest() + + return uri.format(email_md5, "&".join(query_params)) diff --git a/remarkbox/lib/mail.py b/remarkbox/lib/mail.py new file mode 100644 index 0000000..aae159e --- /dev/null +++ b/remarkbox/lib/mail.py @@ -0,0 +1,88 @@ +from miscutils.mail import send_pyramid_email + +# quote email address in OTP so that a plus address +# is not decoded as a space during authentication. +try: + # Python 2. + from urllib import quote_plus +except ImportError: + # Python 3. + from urllib.parse import quote_plus + +from remarkbox.lib.mail_messages import ( + WELCOME_1_TEXT, + WELCOME_1_HTML, + WELCOME_2_TEXT, + WELCOME_2_HTML, + OPERATOR_HTML, +) + +from jinja2 import Environment, PackageLoader, select_autoescape + +jinja2_env = Environment( + loader=PackageLoader("remarkbox", "templates"), + autoescape=select_autoescape(["html", "xml"]), +) + + +def send_otp_email(request, to_email, raw_otp, return_to): + """ + Send email with OTP (one time password) link. + + request + the request (of the successful log in attempt) + + to_email + the email address to send the OTP link + + raw_otp + the raw (unencrypted) one time password + + return_to + the URI to return the user on successful authentication + """ + + query_params = [ + "email={}".format(quote_plus(to_email)), + "raw-otp={}".format(raw_otp), + ] + + if return_to: + query_params.append("return-to={}".format(return_to)) + + link = "{0}/join-or-log-in?{1}".format(request.host_url, "&".join(query_params)) + + subject = "Magic sign-in link for comments" + + if not request.user.verified: + message_text = WELCOME_1_TEXT.format(link) + message_html = WELCOME_1_HTML.format(subject, link) + else: + message_text = WELCOME_2_TEXT.format(link) + message_html = WELCOME_2_HTML.format(subject, link) + + send_pyramid_email(request, to_email, subject, message_text, message_html) + + +def send_operator_email(request, msg): + send_pyramid_email( + request, + "russell.ballestrini@gmail.com", + msg, + msg, + OPERATOR_HTML.format(msg), + ) + + +def send_template_email( + request, to_email, subject, text_template_name, html_template_name, context +): + text_template = jinja2_env.get_template(text_template_name) + html_template = jinja2_env.get_template(html_template_name) + send_pyramid_email( + request, + to_email, + subject, + text_template.render(**context), + html_template.render(**context), + ) diff --git a/remarkbox/lib/mail_messages.py b/remarkbox/lib/mail_messages.py new file mode 100644 index 0000000..e09c898 --- /dev/null +++ b/remarkbox/lib/mail_messages.py @@ -0,0 +1,139 @@ +WELCOME_1_TEXT = """ +Hello! + +Thanks for joining the discussion. + +To get reply notifications, you should paste this link into your browser: + + \n{0}\n + +This will verify your email and log you in. + +What's next? + + * A random username was generated just for you! + +Once signed in, you have the power to change your username as well as edit or delete past comments. +You may opt-in to get notified when people reply. + +Talk to you soon! +""" + +WELCOME_1_HTML = """ + + + +{0} + + +

Hello!

+ +

+ Thanks for joining the discussion. +

+ +

+ To get reply notifcations, you should click this link: +

+ +

+ Click here to verify and log in! +

+ +

+ + You may paste this link into your browser: + +
+
+ + {1} + +

+ +

+ This will verify your email and log you in. +

+ +

What's next?

+ +

+ A random username was generated just for you! +

+ +

+ Once signed in, you have the power to change your username as well as edit or delete past comments. + You may opt-in to get notified when people reply. +

+ +

+ Talk to you soon! +

+ + +""" + +WELCOME_2_TEXT = """ +Hello again! + +To log in please paste this link into your browser: + + \n{0}\n + +Don't forget to check out your notification settings. + +Talk to you soon! +""" + +WELCOME_2_HTML = """ + + + +{0} + + +

Hello again!

+ +

+ To log in please click the link below. +

+ +

+ Click here to log in! +

+ +

+ + You may paste this link into your browser: + +
+
+ + {1} + +

+ +

What's next?

+ +

+ Don't forget to check out your notification settings. +

+ +

+ Talk to you soon! +

+ + +""" + +OPERATOR_HTML = """ + + +Operator Notification + + +

Operator Notification!

+

{}

+ + +""" diff --git a/remarkbox/lib/notify.py b/remarkbox/lib/notify.py new file mode 100644 index 0000000..04f14ab --- /dev/null +++ b/remarkbox/lib/notify.py @@ -0,0 +1,260 @@ +from __future__ import unicode_literals + +from collections import defaultdict + +from slacker import Slacker + +from remarkbox.lib.mail import send_template_email + +from remarkbox.models import NodeEventNotification + +import logging +log = logging.getLogger(__name__) + + +SLACK_MESSAGE = """:mailbox_with_mail: *{}* added a {} on *{}*: + +>>> {} + +:link: {}/r/{} +""" + + +def immediately_notify_namespace_moderators_via_slack(request, node_event): + node = node_event.node + namespace = node.root.namespace + node_event_to_slack_event = {"created": "new thread", "commented": "new comment"} + if namespace.slack_oauth_records: + for oauth in namespace.slack_oauth_records: + slack = Slacker(oauth.token) + slack.chat.post_message( + "#remarks", + SLACK_MESSAGE.format( + node.user.name, + node_event_to_slack_event[node_event.action], + namespace.name, + node.data, + request.host_url, + node.id, + ), + ) + + +def filter_watchers(watchers, exclude_users=None, include_users=None): + f_watchers = [] + tally = defaultdict(list) + for watcher in watchers: + if not watcher.user.verified: + # skip unverified users to avoid sending unsolicited emails. + # example: somebody writes a comment with another user's email. + continue + if exclude_users is not None and watcher.user in exclude_users: + # skip all excluded users. + # example: don't notify the user who generated the event. + continue + if include_users is not None and watcher.user not in include_users: + # skip all users not in the include_users list. + # example: we only want to notify moderators. + continue + if watcher.user in tally[watcher.notification_method]: + # this user already has a watcher of this method, for this event. + # example: user was replied to and also follows the thread. + continue + # add the user to tally notify once per event, per method. + tally[watcher.notification_method].append(watcher.user) + # finally add watcher to the filtered watcher list. + f_watchers.append(watcher) + return f_watchers + + +def get_all_watchers(request, node_event): + """Given a request and node_event, return all watcher objects.""" + # Note: we only notify a user once per method per event. + + # aggregate a list of watcher objects. + watchers = [] + + # get node and namespace from event. + node = node_event.node + namespace = node.root.namespace + + # Note: the order that we collect watchers, acts as the order of priority + # when we encounter duplicate notification method: reply, node, namespace. + + if node.parent and node.parent.verified and node.parent != node.root: + # extend the watchers list to let the owner of the parent node + # know there was a new child node added to the conversation. + watchers.extend(node.parent.user.reply_watchers) + + # extend the watchers list with any watchers of the request's root (thread). + watchers.extend(node.root.watchers) + + # extend the watchers list with any watchers of the request's Namespace. + watchers.extend(namespace.watchers) + + # the user who causes an event does not need a notification. + exclude_users = [node_event.user] + include_users = None + + if not node.approved: + # this may happen if moderation is enabled for a Namespace. + # if the event node is not approved, only notify moderators. + include_users = namespace.moderators + + # filter dupes and excluded users. + return filter_watchers(watchers, exclude_users, include_users) + + +def schedule_notifications(request, node_event): + + # Slack notifications currently only supports created and commented. + if node_event.action in {"created", "commented"}: + immediately_notify_namespace_moderators_via_slack(request, node_event) + + # fan out and create a notification object for each watcher. + notifications = [] + for watcher in get_all_watchers(request, node_event): + notification = watcher.new_notification(node_event) + request.dbsession.add(notification) + notifications.append(notification) + + # really create all notifcation objects in database, in one transaction. + request.dbsession.flush() + + # filter out all the notifications which should be sent immediately. + immediate_notifications = [n for n in notifications if n.frequency == "immediately"] + + # send them. + send_immediate_notifications(request, immediate_notifications) + + +def get_email_notifications(dbsession, frequency): + """ + Returns a notification dictionary where each key holds a list of + notifications objects for a particular user. + """ + + notifications = ( + dbsession.query(NodeEventNotification) + .filter(NodeEventNotification.sent == False) + .filter(NodeEventNotification.watcher.has(notification_frequency=frequency)) + .filter(NodeEventNotification.watcher.has(notification_method="email")) + .order_by(NodeEventNotification.created_timestamp) + .all() + ) + + # a notification dictionary where the key is the user_id + # and value is a list of notification objects. + notification_dict = defaultdict(list) + for notification in notifications: + notification_dict[notification.user_id].append(notification) + return notification_dict + + +def deliver_scheduled_notifications(request=None): + from datetime import datetime + from pyramid.scripting import prepare + + # this allows us to pass a None request. + with prepare(request) as env, env["request"].tm as tm: + request = env["request"] + + notification_dict = get_email_notifications(request.dbsession, "daily") + send_digest_notifications(request, notification_dict, "daily") + + # Send weekly on Monday. + if datetime.today().weekday() == 0: + notification_dict = get_email_notifications(request.dbsession, "weekly") + send_digest_notifications(request, notification_dict, "weekly") + + +def send_immediate_notifications(request, notifications): + deliver_email_notifications = request.app.get("deliver_email_notifications", True) + + if len(notifications) == 0: + return None + + node_event = notifications[0].node_event + root = node_event.node.root + namespace = root.namespace + + subject = "[{}] new activity".format(namespace.name) + + for notification in notifications: + if deliver_email_notifications and notification.method == "email": + send_template_email( + request, + notification.user.email, + subject, + "mail_immediate_text.j2", + "mail_immediate_html.j2", + { + "request": request, + "notification": notification, + "root": root, + "subject": subject, + }, + ) + log.info( + "notification frequency=immediately email={}, count={}".format( + notification.user.email, + notification.id, + ) + ) + notification.sent = True + request.dbsession.add(notification) + request.dbsession.flush() + + +def group_notifications_by_root(notifications): + """ + group notifications by root node, where the key is + the root node and the value is a list of notification objects. + """ + groups = defaultdict(list) + for notification in notifications: + groups[notification.node_event.node.root].append(notification) + return groups + + +def send_digest_notifications(request, notification_dict, frequency="daily"): + day_or_week = "day" if frequency == "daily" else "week" + deliver_email_notifications = request.app.get("deliver_email_notifications", True) + if not deliver_email_notifications: + return None + + for user_id, notifications in notification_dict.items(): + user = notifications[0].user + recipient_email = user.email + notifications_count = len(notifications) + plural = "" if notifications_count == 1 else "s" + subject = "{} notification{} over the past {}".format(notifications_count, plural, day_or_week) + send_template_email( + request, + recipient_email, + subject, + "mail_digest_text.j2", + "mail_digest_html.j2", + { + "request": request, + "notifications": notifications, + "subject": subject, + "user": user, + "frequency": frequency, + "day_or_week": day_or_week, + "plural": plural, + "notifications_count": notifications_count, + "grouped_notifications": group_notifications_by_root(notifications), + }, + ) + log.info( + "notification frequency={} email={}, count={}".format( + frequency, + recipient_email, + notifications_count, + ) + ) + for notification in notifications: + notification.sent = True + request.dbsession.add(notification) + request.dbsession.flush() diff --git a/remarkbox/lib/render.py b/remarkbox/lib/render.py new file mode 100644 index 0000000..89c48d0 --- /dev/null +++ b/remarkbox/lib/render.py @@ -0,0 +1,60 @@ +from miscutils.sanitize_html import ( + default_cleaner, + default_tag_acl, + markdown_to_raw_html, + clean_raw_html, +) + +import logging + +log = logging.getLogger(__name__) + + +def remarkbox_tag_acl(namespace): + """ + Returns a tag_acl document, in this form: + + tag_acl = { + "script": [ + ("type", "math/tex; mode=display", "allow"), + ], + } + """ + # defaultdict which assumes new keys are lists. + tag_acl = default_tag_acl() + + allow_mathjax_acl = ("type", "math/tex; mode=display", "allow") + deny_mathjax_acl = ("type", "math/tex; mode=display", "deny") + + log.info("Building remarkbox_tag_acl.") + mathjax_acl = deny_mathjax_acl + if namespace.mathjax: + log.info("This Namespace has Mathjax enabled.") + mathjax_acl = allow_mathjax_acl + + tag_acl["script"].append(mathjax_acl) + + return tag_acl + + +def make_cleaner_from_namespace(namespace): + """Given a Namespace return a bleach Cleaner object.""" + cleaner = default_cleaner( + # pass tag_acl for Remarkbox. + remarkbox_tag_acl(namespace) + ) + # add some properties onto the cleaner object. + cleaner.link_protection = namespace.link_protection + cleaner.mathjax = namespace.mathjax + cleaner.whitelist_domains.append(namespace.name) + cleaner.absolute_domain = namespace.name + return cleaner + + +def markdown_to_html(data, namespace=None): + raw_html = markdown_to_raw_html(data, extra_extensions=["mdx_math"]) + if namespace: + cleaner = make_cleaner_from_namespace(namespace) + else: + cleaner = default_cleaner() + return clean_raw_html(raw_html, cleaner) diff --git a/remarkbox/lib/svgtar/__init__.py b/remarkbox/lib/svgtar/__init__.py new file mode 100644 index 0000000..62ac8c0 --- /dev/null +++ b/remarkbox/lib/svgtar/__init__.py @@ -0,0 +1,54 @@ +import hashlib + +try: + # Python 2. + from urllib import quote +except ImportError: + # Python 3. + from urllib.parse import quote + +from .svg import make_svg + + +def _bg_color(colors=None, text=u"", h=u"salty-salt"): + """Selects a background color to use. + If `colors` is given, the value is choosen from that. + If `colors` is not given, a unique and consistent color value is generated. + :return (str) + """ + h = hashlib.md5((h + text).encode("utf-8")).hexdigest() + if colors: + ind = int(h, 16) % len(colors) + return colors[ind] + cut = 142 + r = int(h[:2], 16) % cut + g = int(h[2:4], 16) % cut + b = int(h[4:6], 16) % cut + return "#{:02x}{:02x}{:02x}".format(r, g, b) + + +def _reduce_whitespace(text): + """a way to remove newlines and leading/trailing whitespace per line.""" + return " ".join([line.strip() for line in text.splitlines()]) + + +def inline_svgtar(*args, **kwargs): + quoted_svgtar = quote(_reduce_whitespace(svgtar(*args, **kwargs))) + return "data:image/svg+xml;charset=utf-8,{}".format(quoted_svgtar) + + +def svgtar( + text, h, size=55, color="#ffffff", bg=None, font_size=None, font_family="Arial" +): + """Return an multiline svg string.""" + bg = _bg_color(h=h) if bg is None else bg + font_size = size * .6 if font_size is None else font_size + options = { + "text": text, + "size": size, + "color": color, + "bg": bg, + "font_size": font_size, + "font_family": font_family, + } + return make_svg(**options) diff --git a/remarkbox/lib/svgtar/svg.py b/remarkbox/lib/svgtar/svg.py new file mode 100644 index 0000000..a8e3d6f --- /dev/null +++ b/remarkbox/lib/svgtar/svg.py @@ -0,0 +1,46 @@ + +defaults = { + "size": 100, + "font_size": 60, + "font_family": "Arial", + "bg": "black", + "color": "white", + "text": "KB", +} + + +def make_svg(**options): + """Builds a simple SVG text square with a centered text. + + options: size, font_size, font_family, bg, color, text + + :return (str) + """ + if options is not None and not isinstance(options, dict): + raise ValueError("options should be None or a type of dict.") + + if "size" in options and "font_size" not in options: + options["font_size"] = int(options["size"] * 0.6) + + dc = defaults.copy() + dc.update(options) + options = dc + + svg = u""" + + + + + + {text} + + + + """.format( + **options + ) + + return svg diff --git a/remarkbox/models/__init__.py b/remarkbox/models/__init__.py new file mode 100644 index 0000000..dfef524 --- /dev/null +++ b/remarkbox/models/__init__.py @@ -0,0 +1,88 @@ +from sqlalchemy import engine_from_config +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import configure_mappers +import zope.sqlalchemy + +# import all models here to ensure they are attached to the +# Base.metadata prior to any initialization routines ( init_db). +from .uri import * +from .user import * +from .namespace import * +from .namespace_user import * +from .namespace_request import * +from .node import * +from .node_cache import * +from .vote import * +from .oauth import * +from .watcher import * +from .event import * +from .notification import * +from .pay_what_you_can import * + +# run configure_mappers after defining all of the models to ensure +# all relationships can be setup +configure_mappers() + + +def get_engine(settings, prefix="sqlalchemy."): + return engine_from_config(settings, prefix) + + +def get_session_factory(engine): + factory = sessionmaker() + factory.configure(bind=engine) + return factory + + +def get_tm_session(session_factory, transaction_manager): + """ + Get a ``sqlalchemy.orm.Session`` instance backed by a transaction. + + This function will hook the session to the transaction manager which + will take care of committing any changes. + + - When using pyramid_tm it will automatically be committed or aborted + depending on whether an exception is raised. + + - When using scripts you should wrap the session in a manager yourself. + For example:: + + import transaction + + engine = get_engine(settings) + session_factory = get_session_factory(engine) + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + + """ + dbsession = session_factory() + zope.sqlalchemy.register(dbsession, transaction_manager=transaction_manager) + return dbsession + + +def includeme(config): + """ + Initialize the model for a Pyramid app. + + Activate this setup using ``config.include('pyratest.models')``. + + """ + settings = config.get_settings() + settings["tm.manager_hook"] = "pyramid_tm.explicit_manager" + + # use pyramid_tm to hook the transaction lifecycle to the request + config.include("pyramid_tm") + + # use pyramid_retry to retry a request when transient exceptions occur + config.include("pyramid_retry") + + session_factory = get_session_factory(get_engine(settings)) + config.registry["dbsession_factory"] = session_factory + + # make request.dbsession available for use in Pyramid + config.add_request_method( + # r.tm is the transaction manager used by pyramid_tm + lambda r: get_tm_session(session_factory, r.tm), + "dbsession", + reify=True, + ) diff --git a/remarkbox/models/event.py b/remarkbox/models/event.py new file mode 100644 index 0000000..1900d0e --- /dev/null +++ b/remarkbox/models/event.py @@ -0,0 +1,41 @@ +from .meta import NODE_EVENT_ACTIONS +from .meta import Base, RBase, UUIDType, foreign_key, get_object_by_id, now_timestamp + +from sqlalchemy import Column, BigInteger, Unicode, UnicodeText, Enum + +from sqlalchemy.orm import relationship, backref + +import uuid + + +class NodeEvent(RBase, Base): + id = Column(UUIDType, primary_key=True, index=True) + # the Node this event is for. + node_id = Column(UUIDType, foreign_key("Node", "id"), index=True) + # the User who performed the event action on the Node. + user_id = Column(UUIDType, foreign_key("User", "id"), index=True) + # the type of action that was performed on Node. + action = Column(Enum(*NODE_EVENT_ACTIONS, name="action"), nullable=False) + # the timestamp when the event was created. + created_timestamp = Column(BigInteger, nullable=False) + + def __init__(self, user, node, action): + self.id = uuid.uuid1() + self.user = user + self.node = node + self.action = action + self.created_timestamp = now_timestamp() + + # 1-to-1 relationships. + node = relationship(argument="Node", uselist=False, lazy="joined") + user = relationship( + argument="User", + uselist=False, + lazy="joined", + backref=backref("events", cascade="save-update, merge, delete"), + ) + + +def get_node_event_by_id(dbsession, node_event_id): + """Try to get NodeEvent object by id or return None.""" + return get_object_by_id(dbsession, node_event_id, NodeEvent) diff --git a/remarkbox/models/meta.py b/remarkbox/models/meta.py new file mode 100644 index 0000000..f7fee7d --- /dev/null +++ b/remarkbox/models/meta.py @@ -0,0 +1,152 @@ +from sqlalchemy.ext.declarative import declarative_base, declared_attr + +from sqlalchemy.orm.session import object_session + +from sqlalchemy.schema import MetaData +from sqlalchemy import ForeignKey + +from time import time + +import uuid +from sqlalchemy_utils import UUIDType as TempUUIDType + +UUIDType = TempUUIDType(binary=False) + +# Recommended naming convention used by Alembic, as various different database +# providers will autogenerate vastly different names making migrations more +# difficult. See: http://alembic.zzzcomputing.com/en/latest/naming.html +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + +CLASS_TO_TABLE = { + "Uri": "rb_uri", + "User": "rb_user", + "UserSurrogate": "rb_user_surrogate", + "Vote": "rb_vote", + "Node": "rb_node", + "NodeCache": "rb_node_cache", + "Namespace": "rb_namespace", + "NamespaceUser": "rb_namespace_user", + "NamespaceRequest": "rb_namespace_request", + "Oauth": "rb_oauth", + "Watcher": "rb_watcher", + "NodeEvent": "rb_node_event", + "NodeEventNotification": "rb_node_event_notification", + "PayWhatYouCan": "rb_pay_what_you_can", +} + +# node (threads), namespace (forum) +WATCHER_TYPES = {"reply", "node", "namespace"} + +NOTIFICATION_FREQUENCIES = {"never", "immediately", "daily", "weekly"} + +NOTIFICATION_METHODS = {"email"} + +NODE_EVENT_ACTIONS = { + "enabled", + "disabled", + "deleted", + "verified", + "created", # this is a new thread. + "commented", # this is a reply to an existing thread. + "approved", + "edited", + # These event type actions don't exist yet, but could fuel some ideas for the future. + # "flagged", + # "voted", +} + +SUBSCRIPTION_TYPES = {"development", "production", "trial"} + +# currently not used anywhere. +USER_EVENT_ACTIONS = {"granted", "promoted", "demoted", "blocked"} + +metadata = MetaData(naming_convention=NAMING_CONVENTION) +# Base = declarative_base(metadata=metadata) +Base = declarative_base() + + +def short_id_to_bytes(short_id): + """Accept a short_id (sanitized url safe base64 string) and return a byte string. + + >>> short_id_to_bytes('dbHeSEFLEeeuz5xONpxxWA') + 'u\xb1\xdeHAK\x11\xe7\xae\xcf\x9cN6\x9cqX' + + """ + return (short_id + "===").replace("_", "/").replace("-", "+").decode("base64") + + +def id_to_uuid(the_id): + """ + Accept an id string, return a UUID object. + + >>> id_to_uuid('dbHeSEFLEeeuz5xONpxxWA') + UUID('75b1de48-414b-11e7-aecf-9c4e369c7158') + + >>> id_to_uuid('75b1de48414b11e7aecf9c4e369c7158') + UUID('75b1de48-414b-11e7-aecf-9c4e369c7158') + + >>> id_to_uuid('75b1de48-414b-11e7-aecf-9c4e369c7158') + UUID('75b1de48-414b-11e7-aecf-9c4e369c7158') + """ + if isinstance(the_id, uuid.UUID): + # if the_id is already a UUID object, return it. + return the_id + + try: + # assume the_id is already a hex uuid string. + return uuid.UUID(hex=the_id) + + except ValueError: + try: + # assume the_id is a base64 "short_id" string. + return uuid.UUID(bytes=short_id_to_bytes(the_id)) + except ValueError: + pass + + return None + + +def get_object_by_id(dbsession, object_id, cls): + """Try to get object from database by id or return None""" + object_uuid = id_to_uuid(object_id) + if object_uuid: + return dbsession.query(cls).filter(cls.id == object_uuid).one_or_none() + + +def now_timestamp(): + return int(time() * 1000) + + +def foreign_key(class_name, column_name): + return ForeignKey("{}.{}".format(CLASS_TO_TABLE[class_name], column_name)) + + +class RBase(object): + """Mixin for RemarkBox models.""" + + def __eq__(self, other): + """Determine if equal by id.""" + if other: + return self.id == other.id + return False + + def __ne__(self, other): + """Determine if not equal by user id.""" + return not self.__eq__(other) + + def __hash__(self): + return hash(self.id) + + @declared_attr + def __tablename__(cls): + return CLASS_TO_TABLE[cls.__name__] + + @property + def dbsession(self): + return object_session(self) diff --git a/remarkbox/models/namespace.py b/remarkbox/models/namespace.py new file mode 100644 index 0000000..a19107f --- /dev/null +++ b/remarkbox/models/namespace.py @@ -0,0 +1,428 @@ +from sqlalchemy import ( + Integer, + BigInteger, + Boolean, + Column, + Enum, + Unicode, + UnicodeText, + or_, +) + +from sqlalchemy.orm import relationship, backref + +from sqlalchemy.ext.associationproxy import association_proxy + +import uuid + +from collections import defaultdict + +from .meta import Base, RBase +from .meta import UUIDType +from .meta import SUBSCRIPTION_TYPES +from .meta import now_timestamp, foreign_key + +from .node import Node, get_nodes_who_share_roots_query + +from .namespace_user import NamespaceUser + +from .oauth import Oauth + +from remarkbox.lib import timestamp_to_ago_string, timestamp_to_datetime + +try: + unicode("") +except: + from six import u as unicode + + +# these attributes are protected such that they return the defaults +# when a Namespace is frozen or subscription is expired. +PROTECTED_ATTRIBUTES = { + "description": None, + "placeholder_text": None, + "no_nodes_text": None, + "theme": None, + "theme_embed": None, + "stylesheet": None, + "stylesheet_uri": None, + "stylesheet_embed": None, + "stylesheet_embed_uri": None, + "wiki": False, + "google_analytics_id": None, + "hide_unverified": False, + "hide_unless_approved": False, + "hide_powered_by": False, + "mathjax": False, + "link_protection": False, + "ignore_query_string": False, + "reverse_order": False, + "group_conversations": False, +} + + +class Namespace(RBase, Base): + id = Column(UUIDType, primary_key=True, index=True) + name = Column(Unicode(256), nullable=False, unique=True, index=True) + description = Column(Unicode(256), default=None) + # optional placeholder text for new node textarea. + placeholder_text = Column(Unicode(256), default=None, nullable=True) + # optional placeholder text when there are no nodes in thread. + no_nodes_text = Column(Unicode(256), default=None, nullable=True) + # the basic mode theme. TODO: change this column name to `theme_basic`. + theme = Column(Unicode(256), default=None) + # the embed mode theme. + theme_embed = Column(Unicode(256), default=None) + avatar_size = Column(Integer, default=None) + # TODO: change this to `stylesheet_basic` and `stylesheet_basic_timestamp`. + stylesheet = Column(UnicodeText, default=None) + stylesheet_timestamp = Column(BigInteger) + stylesheet_uri = Column(Unicode(256), default=None) + stylesheet_embed = Column(UnicodeText, default=None) + stylesheet_embed_timestamp = Column(BigInteger) + stylesheet_embed_uri = Column(Unicode(256), default=None) + # when was the last time we tested/verified the namespace owner request? + owner_request_timestamp = Column(BigInteger, nullable=True) + # optional google analytics id for stand alone mode. + google_analytics_id = Column(Unicode(18), nullable=True) + # allow anyone to edit the root node. (not implemented yet) + wiki = Column(Boolean, default=False) + # by default we show all nodes but a Namespace can change this behavior. + # should a node be hidden if unverified by owner or moderator? + hide_unverified = Column(Boolean, default=False) + # should a node be hidden until approved by a moderator? + hide_unless_approved = Column(Boolean, default=False) + # should we hide the poweredby Remarkbox logo? + hide_powered_by = Column(Boolean, default=False) + # should the list of root nodes in this namespace be public or hidden? + public = Column(Boolean, default=False) + # should we enable MathJax? + mathjax = Column(Boolean, default=False) + # should we enable Link Protection to prevent comments from having links? + link_protection = Column(Boolean, default=False) + # check this box if the external site does _not_ use + # query strings for page identification or navigation. + ignore_query_string = Column(Boolean, default=False) + # should we reverse the node order and put newest on top? + reverse_order = Column(Boolean, default=False) + # should we group conversations and limit to nesting 2 deep? + group_conversations = Column(Boolean, default=False) + # the type of subscription of this Namespace. + subscription_type = Column( + Enum(*SUBSCRIPTION_TYPES, name="subscription_type"), + # Hooray, Remarkbox becomes pay-what-you-can! + # https://en.wikipedia.org/wiki/Pay_what_you_can + default="production", + nullable=False, + ) + + # lazy='dynamic' returns a query object instead of collection. + # Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html + roots = relationship(argument="Node", lazy="dynamic", back_populates="namespace") + + users = association_proxy( + "namespace_users", "user", creator=lambda u: NamespaceUser(user=u) + ) + + user_surrogates = relationship( + argument="UserSurrogate", lazy="dynamic", back_populates="namespace" + ) + + namespace_owner_requests = relationship( + argument="NamespaceRequest", lazy="dynamic", back_populates="namespace" + ) + + oauth_records = relationship( + argument="Oauth", lazy="dynamic", back_populates="namespace" + ) + + watchers = relationship( + argument="Watcher", lazy="dynamic", back_populates="namespace" + ) + + @property + def powered_up(self): + if self.subscription_type in ["production", "trial"]: + return True + return False + + def reload_memoized_attr_protection(self): + """If True, protect attributes from access.""" + if self.powered_up: + # do not protect attributes, the namespace is powered up. + self.memoized_attr_protection = False + else: + # protect attributes, the namespace is not powered up. + self.memoized_attr_protection = True + + def __getattribute__(self, attr): + """Protect certain attributes.""" + # return early if attribute isn't protected. + if attr not in PROTECTED_ATTRIBUTES: + return object.__getattribute__(self, attr) + + if not hasattr(self, "memoized_attr_protection"): + self.reload_memoized_attr_protection() + + if self.memoized_attr_protection: + # attributes currently under protection. + # load and return the attribute's default value. + return PROTECTED_ATTRIBUTES[attr] + + # attributes currently not under protection. + # load and return the attribute's real value. + return object.__getattribute__(self, attr) + + def __init__(self, name=None): + self.id = uuid.uuid1() + self.name = unicode(name) + + def add_oauth_record(self, user, service, token, data=None): + self.oauth_records.append( + Oauth(user=user, namespace=self, service=service, token=token, data=data) + ) + + @property + def slack_oauth_records(self): + return [oauth for oauth in self.oauth_records if oauth.service == "slack"] + + def get_namespace_user_for_user(self, user): + """Given a User object, return the NamespaceUser object.""" + for nsu in self.namespace_users: + if nsu.user == user: + return nsu + + def set_role_for_user(self, user, role=None): + """Add User object to NamespaceUser. Default role is moderator.""" + nsu = self.get_namespace_user_for_user(user) + if nsu is not None: + nsu.role = role + else: + nsu = NamespaceUser(role=role, namespace=self, user=user) + self.namespace_users.append(nsu) + + user.watch_namespace(self) + + def disable_user(self, user): + nsu = self.get_namespace_user_for_user(user) + if nsu is not None: + user.unwatch_namespace(self) + nsu.disabled = True + + def enable_user(self, user): + nsu = self.get_namespace_user_for_user(user) + if nsu is not None: + user.watch_namespace(self) + nsu.disabled = None + + def remove_user(self, user): + nsu = self.get_namespace_user_for_user(user) + if nsu is not None: + self.disable_user(user) + self.dbsession.delete(nsu) + + @property + def enabled_namespace_users(self): + """Return not disabled NamespaceUser objects for this Namespace.""" + return [nsu for nsu in self.namespace_users if not nsu.disabled] + + @property + def enabled_users(self): + """Return not disabled User objects for this Namespace.""" + return [nsu.user for nsu in self.enabled_namespace_users] + + @property + def roles(self): + """Return dict with role name as key and list of Users as value.""" + roles = defaultdict(list) + for ns_user in self.enabled_namespace_users: + roles[ns_user.role].append(ns_user.user) + return roles + + @property + def owners(self): + """Return a list of owner role User objects.""" + return self.roles.get("owner", []) + + @property + def moderators(self): + """Return a list of moderator role User objects.""" + return self.enabled_users + + @property + def visible_roots(self): + return self.roots.filter( + Node.verified == True, Node.disabled == False + ).order_by(Node.changed.desc()) + + @property + def visible_root_stats(self): + stats = {} + for root in self.visible_roots: + if root.title: + stats[root.path] = root.stats + elif root.uri: + stats[root.uri.data] = root.stats + return stats + + @property + def nodes(self): + return ( + get_nodes_who_share_roots_query(self.dbsession, self.roots) + .filter(Node.parent_id.isnot(None)) + .order_by(Node.changed.desc()) + ) + + def page_nodes(self, limit=100, offset=0): + if self.roots.count() == 0: + return [] + + query = get_nodes_who_share_roots_query(self.dbsession, self.roots).filter( + Node.disabled == False, Node.user_id != None + ) + + if self.hide_unless_approved: + query = query.filter(Node.approved == True) + + if self.hide_unverified: + query = query.filter(Node.verified == True) + + return query.order_by(Node.changed.desc()).limit(limit).offset(offset) + + @property + def verified_nodes(self): + return self.nodes.filter(Node.verified == True) + + @property + def disabled_nodes(self): + return self.nodes.filter(Node.disabled == True) + + @property + def approved_nodes(self): + return self.nodes.filter(Node.approved == True, Node.disabled == False) + + @property + def unapproved_nodes(self): + return self.nodes.filter( + or_(Node.approved == False, Node.approved.is_(None)), Node.disabled == False + ) + + @property + def visible_nodes(self): + return get_nodes_who_share_roots_query(self.dbsession, self.visible_roots) + + @property + def owner_request_pending(self): + """Is this Namespace due for a owner request verification?""" + if self.owner_request_timestamp: + delta = now_timestamp() - self.owner_request_timestamp + # 5 minutes. + if delta <= 300: + return False + return True + + @property + def node_order(self): + if self.reverse_order: + return "newest-first" + return "oldest-first" + + def update_owner_request_timestamp(self): + self.owner_request_timestamp = now_timestamp() + + def clear_owner_request_timestamp(self): + self.owner_request_timestamp = None + + def set_stylesheet(self, body): + self.stylesheet = body + self.stylesheet_timestamp = now_timestamp() + + def set_stylesheet_embed(self, body): + self.stylesheet_embed = body + self.stylesheet_embed_timestamp = now_timestamp() + + def is_owner(self, user): + """Return True if given user owns this namespace, else False.""" + if user and user.authenticated and user in self.owners: + return True + return False + + def is_moderator(self, user): + """Return True if given user moderates this namespace, else False.""" + if user and user.authenticated and user in self.moderators: + return True + return False + + def can_alter_node(self, node, user): + return self.is_moderator(user) or node.is_owner(user) + + def can_see_node(self, node, user): + visible = True + if node.disabled: + visible = False + if self.hide_unless_approved and not node.approved: + visible = False + if self.hide_unverified and not node.verified: + visible = False + + if user: + if self.is_moderator(user) or node.is_owner(user): + # owners can see node. + visible = True + + return visible + + @property + def dict_dump(self): + dump = {} + for root in self.roots: + if root.has_uri: + root_identifier = root.uri.data + else: + root_identifier = root.slug + root_dump = { + "name" : root_identifier, + "title" : root.title, + # TODO: fix when root.created becomes root.created_timestamp. + "timestamp" : root.created, + } + comments = [] + for node in root.children: + comment = { + "date" : node.created_date, + # TODO: when root.created becomes root.created_timestamp. + "timestamp" : node.created, + "content" : node.data, + "author" : node.user.name, + "author_ip" : node.ip_address, + } + if self.subscription_type == "production": + comment["email"] = node.user.email + comments.append(comment) + root_dump["comments"] = comments + dump[root_identifier] = root_dump + return dump + + +def get_topsecret_namespaces(dbsession): + return dbsession.query(Namespace).all() + + +def get_namespace_by_name(dbsession, name): + if name: + return ( + dbsession.query(Namespace) + .filter(Namespace.name == unicode(name)) + .one_or_none() + ) + + +def get_or_create_namespace(dbsession, name): + namespace = get_namespace_by_name(dbsession, name) + if not namespace: + namespace = Namespace(name) + # commit namespace to db right away to prevent detached instances. + # causes some trash namespaces to accumulate but better then errors. + dbsession.add(namespace) + dbsession.flush() + return namespace diff --git a/remarkbox/models/namespace_request.py b/remarkbox/models/namespace_request.py new file mode 100644 index 0000000..2cbb602 --- /dev/null +++ b/remarkbox/models/namespace_request.py @@ -0,0 +1,143 @@ +from sqlalchemy import Boolean, Column, Unicode, BigInteger + +from sqlalchemy.orm import relationship + +import uuid + +from .meta import Base, RBase +from .meta import UUIDType +from .meta import now_timestamp, foreign_key, get_object_by_id + +import requests + +import logging + +log = logging.getLogger(__name__) + + +class NamespaceRequest(RBase, Base): + id = Column(UUIDType, primary_key=True, index=True) + user_id = Column(UUIDType, foreign_key("User", "id"), index=True) + namespace_id = Column(UUIDType, foreign_key("Namespace", "id"), index=True) + created_timestamp = Column(BigInteger, nullable=False, index=True) + # this is the endpoint uri to scrape looking for the namespace request id. + target = Column(Unicode(256), nullable=True) + verified = Column(Boolean, default=False) + + user = relationship( + argument="User", + uselist=False, + lazy="joined", + back_populates="namespace_owner_requests", + ) + + namespace = relationship( + argument="Namespace", + uselist=False, + lazy="joined", + back_populates="namespace_owner_requests", + ) + + def __init__(self, user, namespace, target=None): + self.created_timestamp = now_timestamp() + self.id = uuid.uuid1() + self.user = user + self.namespace = namespace + self.target = target + + def verify(self): + self.namespace.owner_request_timestamp = now_timestamp() + # set this namespace_request to verified. + self.verified = True + + def unverify(self): + self.namespace.owner_request_timestamp = now_timestamp() + # set this namespace_request to unverified. + self.verified = False + + def verify_target(self, target): + self.target = target + if self.scrape_target(): + self.verify() + else: + self.unverify() + + def scrape_target(self): + """Scrape the given target for NamespaceRequest id + Return True if found else False. + """ + # TODO: this is a blocking external call to a resource we + # have no control over and should likely be a different service! + # https://github.com/russellballestrini/webwords + namespace_request_id = str(self.id) + if self.target: + # https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Request_fields + headers = { + "User-Agent": "remarkbox.com", + } + + log.info( + "scraping target={} looking for uuid={}".format( + self.target, namespace_request_id + ) + ) + resp = requests.get(self.target, timeout=8.50) + if resp.ok: + if namespace_request_id in resp.text: + log.info( + "scraping target={} looking for uuid={} status=hit".format( + self.target, + namespace_request_id, + ) + ) + return True + + log.info( + "scraping target={} looking for uuid={} status=miss".format( + self.target, + namespace_request_id, + ) + ) + return False + log.info( + "scraping target={} looking for uuid={} status={} reason={}".format( + self.target, + namespace_request_id, + resp.status_code, + resp.reason, + ) + ) + return False + + +def get_namespace_request_by_id(dbsession, namespace_request_id): + """Try to get NamespaceRequest object by id or return None.""" + return get_object_by_id(dbsession, namespace_request_id, NamespaceRequest) + + +def get_namespace_request(dbsession, user, namespace): + if user and namespace: + return ( + dbsession.query(NamespaceRequest) + .filter( + NamespaceRequest.user_id == user.id, + NamespaceRequest.namespace_id == namespace.id, + ) + .one_or_none() + ) + + +def get_or_create_namespace_request(dbsession, user, namespace): + namespace_request = get_namespace_request(dbsession, user, namespace) + if namespace_request is None: + namespace_request = NamespaceRequest(user, namespace) + return namespace_request + + +def get_topsecret_namespace_requests(dbsession, limit=100, offset=0): + return ( + dbsession.query(NamespaceRequest) + .order_by(NamespaceRequest.created_timestamp.desc()) + .limit(limit) + .offset(offset) + ) diff --git a/remarkbox/models/namespace_user.py b/remarkbox/models/namespace_user.py new file mode 100644 index 0000000..3b7440a --- /dev/null +++ b/remarkbox/models/namespace_user.py @@ -0,0 +1,44 @@ +from sqlalchemy import Integer, BigInteger, Boolean, Column, Unicode, UnicodeText, or_ + +from sqlalchemy.orm import relationship, backref + +from sqlalchemy.ext.associationproxy import association_proxy + +import uuid + +from collections import defaultdict + +from .meta import Base, RBase +from .meta import now_timestamp, foreign_key +from .meta import UUIDType + + +class NamespaceUser(RBase, Base): + user_id = Column(UUIDType, foreign_key("User", "id"), primary_key=True, index=True) + namespace_id = Column( + UUIDType, foreign_key("Namespace", "id"), primary_key=True, index=True + ) + role = Column(Unicode(64), default=None) + # TODO: someday this should be renamed to created_timestamp + created = Column(BigInteger, nullable=False) + # TODO: someday this should be renamed to changed_timestamp + changed = Column(BigInteger, nullable=False) + disabled = Column(Boolean, default=False) + verified = Column(Boolean, default=False) + + user = relationship( + argument="User", + backref=backref("user_namespaces", cascade="all, delete-orphan"), + ) + + namespace = relationship( + argument="Namespace", + backref=backref("namespace_users", cascade="all, delete-orphan"), + ) + + def __init__(self, role=None, namespace=None, user=None): + self.created = now_timestamp() + self.changed = now_timestamp() + self.role = role + self.namespace = namespace + self.user = user diff --git a/remarkbox/models/node.py b/remarkbox/models/node.py new file mode 100644 index 0000000..96d210b --- /dev/null +++ b/remarkbox/models/node.py @@ -0,0 +1,673 @@ +from collections import ( + OrderedDict, + deque, +) + +from sqlalchemy import ( + BigInteger, Boolean, Column, Integer, Unicode, UnicodeText, or_, func +) + +from sqlalchemy.orm import relationship, backref + +import uuid + +from slugify import slugify + +from remarkbox.lib import ( + timestamp_to_datetime, + timestamp_to_ago_string, + timestamp_to_date_string, +) + +from remarkbox.lib.render import markdown_to_html + +from .meta import Base, RBase +from .meta import UUIDType +from .meta import now_timestamp, foreign_key, get_object_by_id + +from .uri import get_or_create_uri + +from .event import NodeEvent + +import codecs + +import logging + +try: + unicode("") +except: + from six import u as unicode + +log = logging.getLogger(__name__) + + +class Node(RBase, Base): + """ + Node class acts like a linked list to support nested: + + Question -> Answers -> Comments + + Thread -> Posts -> Remarks + + The `parent_id` column is only used by children nodes. + + The class will allow 3 layer nesting like StackOverflow or unlimited + nesting like reddit. The frontend code determines the particular + nesting strategy. + + For more information checkout this section of the SQLAlchemy Docs: + + http://docs.sqlalchemy.org/en/latest/orm/relationships.html#adjacency-list-relationships + """ + + id = Column(UUIDType, primary_key=True, index=True) + root_id = Column(UUIDType, index=True, default=None) + parent_id = Column(UUIDType, foreign_key("Node", "id"), default=None) + namespace_id = Column(UUIDType, foreign_key("Namespace", "id"), default=None) + user_id = Column(UUIDType, foreign_key("User", "id"), default=None) + user_surrogate_id = Column( + UUIDType, foreign_key("UserSurrogate", "id"), default=None + ) + title = Column(Unicode(256), default=None) + data = Column(UnicodeText, default=None) + data_html = Column(UnicodeText, default=None) + # the depth of this node in the thread's graph / tree. + graph_depth = Column(Integer, nullable=False, default=-1) + # TODO: someday this should be renamed to created_timestamp + created = Column(BigInteger, nullable=False) + # TODO: someday this should be renamed to changed_timestamp + changed = Column(BigInteger, nullable=False) + disabled_timestamp = Column(BigInteger) + disabled = Column(Boolean, default=False) + verified = Column(Boolean, default=False) + # a locked thread (root node) prevents new commenting. + locked = Column(Boolean, default=False) + # by default comments are approved. unless Namespace hide_unless_approved. + approved = Column(Boolean, default=True) + # is there a related Uri model to this node? + has_uri = Column(Boolean, default=False, nullable=False) + ip_address = Column(Unicode(45), default=None) + + # lazy='joined' performs a left join to reduce queries, it's magic. + user = relationship(argument="User", lazy="joined") + + # lazy='joined' performs a left join to reduce queries, it's magic. + user_surrogate = relationship(argument="UserSurrogate", lazy="joined") + + # lazy='dynamic' returns a query object instead of collection. + # Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html + children = relationship( + argument="Node", + lazy="dynamic", + order_by="Node.created", + foreign_keys=[parent_id], + backref=backref("parent", remote_side=[id]), + ) + + # lazy='dynamic' returns a query object instead of collection. + # Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html + children_desc = relationship( + argument="Node", + lazy="dynamic", + order_by="desc(Node.created)", + foreign_keys=[parent_id], + ) + + votes = relationship(argument="Vote", backref="node", order_by="desc(Vote.created)") + + # set uselist to false to create one-to-one relationship. + uri = relationship(argument="Uri", uselist=False, back_populates="node") + + # set uselist to false to create one-to-one relationship. + namespace = relationship(argument="Namespace", uselist=False) + + # lazy='joined' performs a left join to reduce queries, it's magic. + # this is used for denormalized stats like counts, etc. + cache = relationship( + argument="NodeCache", lazy="joined", back_populates="node", uselist=False + ) + + watchers = relationship(argument="Watcher", lazy="dynamic", back_populates="node") + + events = relationship(argument="NodeEvent", lazy="dynamic", back_populates="node") + + def __init__(self): + self.id = uuid.uuid1() + self.created = now_timestamp() + self.changed = self.created + + def get_children(self, order_by="asc"): + if order_by == "desc": + return self.children_desc + return self.children + + @property + def verified_children(self): + return self.children.filter(Node.verified == True) + + @property + def unverified_children(self): + return self.children.filter(Node.verified == False) + + @property + def path_to_root(self): + """The path from this node to the root node.""" + # TODO: memoize this result? how / when to deal with cache busting? + node = self + path_to_root = [] + path_to_root.append(node) + while node.parent is not None: + node = node.parent + path_to_root.append(node) + return path_to_root + + @property + def path_from_root(self): + """The path from the root node to this node.""" + return list(reversed(self.path_to_root)) + + @property + def root2(self): + """The origin or top most parent node without a parent.""" + if self.is_root: + return self + return self.path_to_root[-1] + + @property + def root(self): + """The origin or top most parent node without a parent.""" + if self.is_root: + return self + return self.dbsession.query(Node).filter(Node.id == self.root_id).one() + + @property + def is_root(self): + if not self.parent_id or not self.root_id or self.root_id == self.id: + return True + return False + + def recompute_depth(self): + """recompute the depth of a node in the thread.""" + self.graph_depth = len(self.path_to_root) - 1 + + @property + def depth(self): + if self.graph_depth == -1: + self.recompute_depth() + return self.graph_depth + + @property + def stats(self): + """The denormalized stats for this node.""" + from .node_cache import NodeCache + + if self.cache is None: + self.cache = NodeCache(self.id) + return self.cache.stats + + def score(self): + # this doesn't actually exist right now. I removed voting from this + # iteration of the codebase. maybe worth adding in at some point. + return sum([vote.value for vote in self.votes]) + + def children_order_by_score(self): + # TODO: this should be optimized, we should craft a query and have + # database to return results in the expected order instead of multiple + # queries. First we select children then we sort by selecting each + # vote for each child and sum the result. + return sorted(self.children, key=lambda x: x.score(), reverse=True) + + def new_child(self): + """create and return new child node of this node, in memory""" + child = Node() + child.parent = self + child.root_id = self.root_id + return child + + def new_event(self, user, action): + """create and return new node_event for this node, in memory""" + event = NodeEvent(node=self, user=user, action=action) + self.events.append(event) + return event + + def avatar_uri(self, **kwargs): + """Return invatar URI. Optionally return gravatar URI.""" + u = self.user if self.user else self.user_surrogate + if self.disabled: + kwargs["text"] = "-" + kwargs["bg"] = "#aaaaaa" + if self.disabled or not self.verified: + return u.svgtar(**kwargs) + return u.avatar_uri(**kwargs) + + @property + def created_timestamp(self): + return self.created + + @property + def human_created_timestamp(self): + return timestamp_to_ago_string(self.created) + + # TODO: take into account user's timezone? + @property + def created_date(self): + return timestamp_to_date_string(self.created) + + # TODO: take into account user's timezone? + @property + def created_datetime(self): + return timestamp_to_datetime(self.created) + + @property + def changed_timestamp(self): + return self.changed + + @property + def human_changed_timestamp(self): + return timestamp_to_ago_string(self.changed) + + # TODO: take into account user's timezone? + @property + def changed_date(self): + return timestamp_to_date_string(self.changed) + + # TODO: take into account user's timezone? + @property + def changed_datetime(self): + return timestamp_to_datetime(self.changed) + + @property + def title_ascii(self): + """return the ascii version of the title, ignoring errors or None.""" + if self.title: + return self.title.encode('ascii', 'ignore') + + @property + def slug(self): + """return slug from title or None""" + if self.title: + return slugify(self.title) + + @property + def path(self): + """return a node's path part of uri""" + if self.slug is not None: + return "/{}/{}".format(self.id, self.slug) + else: + return "/{}".format(self.id) + + @property + def short_id(self): + """return the base64 representation of UUID: 36 -> 22 unicode chars""" + base_64_id = codecs.encode(self.id.bytes, "base64") + base_64_id = base_64_id.rstrip(b"=\n").replace(b"/", b"_").replace(b"+", b"-") + return base_64_id.decode("utf-8") + + @property + def was_edited(self): + return not self.created == self.changed + + @property + def enabled(self): + return not self.disabled + + def set_data(self, data, namespace=None): + if namespace is None: + namespace = self.root.namespace + self.data = data + self.data_html = markdown_to_html(data, namespace) + + def _invalidate_cache(self): + if self.root.cache: + self.root.cache.invalidate() + + def edit(self, data): + """edit node.""" + self.set_data(data) + self.changed = now_timestamp() + self._invalidate_cache() + + def disable(self): + """disable node.""" + self.disabled = True + self.disabled_timestamp = now_timestamp() + self._invalidate_cache() + + def enable(self): + """enable node.""" + self.disabled = False + self.disabled_timestamp = None + self._invalidate_cache() + + def verify(self): + """verify node.""" + self.verified = True + self._invalidate_cache() + + def unverify(self): + """unverify node.""" + self.verified = False + self._invalidate_cache() + + def approve(self): + """verify node.""" + self.approved = True + self._invalidate_cache() + + def deny(self): + """unverify node.""" + self.approved = False + self._invalidate_cache() + + def visible(self): + if self.disabled: + return False + return True + + def is_owner(self, user): + """Return True if given user owns this node, else False.""" + if user and user.authenticated and user == self.user: + return True + return False + + +def get_node_by_id(dbsession, node_id): + """Try to get Node object by id or return None.""" + return get_object_by_id(dbsession, node_id, Node) + + +def get_node_by_uri(dbsession, node_uri): + return get_or_create_uri(dbsession, node_uri).node + + +def create_root_node(): + node = Node() + node.root_id = node.id + node.graph_depth = 0 + return node + + +def get_or_create_node_by_uri(dbsession, node_uri, node_title=None): + """Try to get Node object by uri or return new Node""" + uri = get_or_create_uri(dbsession, node_uri) + + if uri.node is None: + # create new root node for external page (embed). + + # imported here to prevent circular import. + from .namespace import get_or_create_namespace + + uri.node = create_root_node() + uri.node.verified = True + uri.node.has_uri = True + uri.node.title = node_title + uri.node.namespace = get_or_create_namespace(dbsession, uri.parsed.hostname) + + elif node_title: + # if the external page title has changed, update our database. + if node_title != uri.node.title: + uri.node.title = node_title + dbsession.add(uri.node) + dbsession.flush() + + return uri.node + + +def get_topsecret_roots(dbsession): + """Return all verified parent nodes by descending created order.""" + return ( + dbsession.query(Node) + .filter(Node.parent_id == None, Node.verified == True, Node.disabled == False) + .order_by(Node.changed.desc()) + ) + + +def get_topsecret_nodes(dbsession, limit=100, offset=0): + """Return all nodes by descending created order.""" + return ( + dbsession.query(Node) + .filter(Node.disabled == False, Node.user_id != None) + .order_by(Node.changed.desc()) + .limit(limit) + .offset(offset) + ) + + +def get_root_nodes(dbsession): + """Return a list of all root nodes (nodes without a parent.)""" + return ( + dbsession.query(Node) + .filter(Node.parent_id == None) + .order_by(Node.changed.desc()) + ) + + +def get_root_nodes_by_keywords(dbsession, keywords, namespace=None): + + # make dictionary mapping of root_node_ids to accumulated scores + root_scores = {} + + # make dictionary mapping of root_node_ids to root_node_objects + root_nodes = {} + + # a list of matched node objects. + nodes = [] + + if namespace: + node_query = namespace.visible_nodes + else: + node_query = get_nodes_who_share_roots_query( + dbsession, get_topsecret_roots(dbsession) + ) + + for keyword in keywords: + + # extend nodes, with a list of nodes which match this keyword. + keyword_filter = Node.data.ilike("%{}%".format(keyword)) + nodes.extend(node_query.filter(keyword_filter).all()) + + # accumulate scores and root node objects. + for node in nodes: + if node.root.id not in root_scores: + root_scores[node.root.id] = 0 + root_scores[node.root.id] += 1 + + if node.root.id not in root_nodes: + root_nodes[node.root.id] = node.root + + # sort the scores dictionary by value (score), + # but get the key (object id), + # reversed (highest score first) + sorted_root_ids = sorted(root_scores, key=root_scores.get, reverse=True) + + # return root node objects in sorted order by score. + return [root_nodes[root_id] for root_id in sorted_root_ids] + + +def get_nodes_who_share_roots_query(dbsession, root_nodes): + # a giant n "or" query, where n is the number of root_nodes. + if root_nodes.count() == 0: + # return a "null" query object. still hits database... + # this allows us to pass around the query object. + from sqlalchemy.sql import text + return dbsession.query(Node).filter(text("0 == 1")) + return dbsession.query(Node).filter(or_(Node.root_id == n.id for n in root_nodes)) + + +def get_nodes_who_share_roots(dbsession, root_nodes): + return get_nodes_who_share_roots_query(dbsession, root_nodes).all() + + +def get_nodes_who_share_root(dbsession, root_node, order="oldest-first"): + nodes = dbsession.query(Node).filter(Node.root_id == root_node.id) + if order == "oldest-first": + nodes = nodes.order_by(Node.created) + elif order == "newest-first": + nodes = nodes.order_by(Node.created.desc()) + return nodes + + +def get_node_count_who_share_root(dbsession, root_node): + return ( + dbsession.query(func.count("id")) + .select_from(Node) + .filter(Node.root_id == root_node.id) + .scalar() + ) + + +def get_visible_node_count_who_share_root(dbsession, root_node): + return ( + dbsession.query(func.count("id")) + .select_from(Node) + .filter( + Node.root_id == root_node.id, Node.disabled == False, Node.approved == True + ) + .scalar() + ) + + +def get_node_id_map(nodes): + """return a dictionary of all nodes who share this root.""" + node_id_map = {} + for node in nodes: + node_id_map[node.id] = node + return node_id_map + + +def get_graph_from_nodes(nodes): + """infinite nested graph or tree of nodes.""" + graph = OrderedDict() + + # create all node keys in graph. + for node in nodes: + graph[node.id] = [] + + for node in nodes: + if node.parent is not None: + graph[node.parent.id].append(node.id) + + return graph + + +def get_group_conversation_ids(nodes): + """Return a list of node ids whose parent is root.""" + if len(list(nodes)) == 0: + return [] + root = nodes[0].root + return [node.id for node in nodes if node.parent and node.parent.id == root.id] + + +# Reference post: +# http://kmkeen.com/python-trees/2010-09-18-08-55-50-039.html +# strategy queue. +def flatten_graph(node_id, graph, include_given_node_id=False): + """ + Given a node_id and graph (or tree), + return a list of every descendant node_id, depth first order. + """ + flat_graph = [] + + to_crawl = deque([node_id]) + + while to_crawl: + current_id = to_crawl.pop() + + flat_graph.append(current_id) + + children_ids = graph[current_id] + + # reverse so that we extend the queue in the expected order. + children_ids.reverse() + + to_crawl.extend(children_ids) + + if include_given_node_id: + return flat_graph + return flat_graph[1:] + + +def get_conversation_graph_from_nodes(nodes, graph=None, node_id_map=None, order="oldest-first"): + """ + Given a list of nodes, return a grouped conversation graph which is a + dictionary of ids where the keys are the top level conversation node ids + and the values are a list of all decendant node ids. + + If you already have it computed, you may optionally provide an existing + graph (dictionary tree of node ids), and/or an existing node_id_map. + """ + def get_timestamp(node_id): + return node_id_map[node_id].created + + if graph is None: + graph = get_graph_from_nodes(nodes) + + if node_id_map is None: + node_id_map = get_node_id_map(nodes) + + _reverse = False + if order == "newest-first": + _reverse = True + + convo_graph = OrderedDict() + + convo_ids = sorted( + get_group_conversation_ids(nodes), + key=get_timestamp, + reverse=_reverse + ) + + for convo_node_id in convo_ids: + descendant_ids = flatten_graph( + convo_node_id, + graph, + ) + # sort by timestamp, oldest first. + convo_graph[convo_node_id] = sorted( + descendant_ids, + key=get_timestamp, + ) + + return convo_graph + + +def recompute_depth_on_all_node_objects(dbsession, chunk_lenth=500): + """Only use this if a many nodes were moved and/or re-nested.""" + + # + # WARNING, this could take a long time and be computationally expensive + # both on the host running it, and the database, respectively. + # + + import math + + log.info("loading and querying nodes...") + + node_count = dbsession.query(Node).count() + + nodes = dbsession.query(Node).all() + + log.info("node_count={}".format(node_count)) + + for i, node in enumerate(nodes): + + # this is the computationally intense part. + node.recompute_depth() + dbsession.add(node) + + if i % chunk_lenth == 0: + progress = int(math.floor((i / float(node_count)) * 100)) + log.info( + "Flushing chunk={}/{}, chunk_lenth={} progress={}%".format( + i, node_count, chunk_lenth, progress + ) + ) + dbsession.flush() + + # one final flush to database for the last partial chunk. + log.info( + "Flushing chunk={}/{}, chunk_lenth={} progress={}%".format( + i + 1, node_count, chunk_lenth, 100 + ) + ) + dbsession.flush() diff --git a/remarkbox/models/node_cache.py b/remarkbox/models/node_cache.py new file mode 100644 index 0000000..4017b8c --- /dev/null +++ b/remarkbox/models/node_cache.py @@ -0,0 +1,88 @@ +from sqlalchemy import BigInteger, Boolean, Column, UnicodeText + +from sqlalchemy.orm import relationship, backref + +import uuid + +from .meta import Base, RBase +from .meta import UUIDType +from .meta import now_timestamp, foreign_key + +import json + +from .node import get_node_count_who_share_root, get_visible_node_count_who_share_root + + +class NodeCache(RBase, Base): + """ + The NodeCache class duplicates and denormalizes results of intensive + queries, like full table scans, into a lookup table indexed by the node_id. + + You can think of this table and class as a cache for statistics. + + Always invalidate before writing to the underlying normalized relations. + """ + + id = Column(UUIDType, primary_key=True, index=True) + node_id = Column(UUIDType, foreign_key("Node", "id"), index=True, default=None) + valid = Column(Boolean, default=False) + updated_timestamp = Column(BigInteger, nullable=False) + # Statistics stored as JSON in the database. + json_stats = Column(UnicodeText, default=None) + + # lazy='joined' performs a left join and reduces amount of queries + # from N different users to 1. It's magic. + node = relationship( + argument="Node", + lazy="joined", + back_populates="cache", + uselist=False, + foreign_keys=[node_id], + ) + + def __init__(self, node_id): + self.id = uuid.uuid1() + self.node_id = node_id + self.updated_timestamp = now_timestamp() + + def _recompute_stats(self, new_stats=None): + # todo: this might make recomputations automatic / easier. + # https://sqlalchemy-utils.readthedocs.io/en/latest/aggregates.html + if new_stats is None: + new_stats = {} + if self.node.is_root: + new_stats["root"] = {} + # don't consider the root node itself in the counts, so minus 1. + new_stats["root"]["count"] = ( + get_node_count_who_share_root(self.dbsession, self.node) - 1 + ) + new_stats["root"]["visible_count"] = ( + get_visible_node_count_who_share_root(self.dbsession, self.node) - 1 + ) + + self.valid = True + self.json_stats = json.dumps(new_stats) + self.updated_timestamp = now_timestamp() + + def invalidate(self): + """invalidate stats to force a full recomputation on next read request.""" + self.valid = False + self.updated_timestamp = now_timestamp() + + @property + def stats(self): + if not self.valid: + self._recompute_stats() + return json.loads(self.json_stats) + + @stats.setter + def stats(self, new_stats): + self._recompute_stats(new_stats) + + +def invalidate_all_node_cache_objects(dbsession): + """Only use this if you change the NodeCache stats JSON schema.""" + dbsession.query(NodeCache).update( + {"valid": False, "updated_timestamp": now_timestamp()} + ) + dbsession.flush() diff --git a/remarkbox/models/notification.py b/remarkbox/models/notification.py new file mode 100644 index 0000000..d68048a --- /dev/null +++ b/remarkbox/models/notification.py @@ -0,0 +1,97 @@ +from .meta import Base, RBase, UUIDType, foreign_key, get_object_by_id, now_timestamp + +from sqlalchemy import Column, BigInteger, Boolean, Unicode, UnicodeText, Enum + +from sqlalchemy.orm import relationship + +import uuid + +from remarkbox.lib import ( + timestamp_to_datetime, + timestamp_to_ago_string, + timestamp_to_date_string, +) + + +class NodeEventNotification(RBase, Base): + id = Column(UUIDType, primary_key=True, index=True) + # the Node this event is for. + node_event_id = Column(UUIDType, foreign_key("NodeEvent", "id"), index=True) + # the watcher which caused this notification to exist. + watcher_id = Column(UUIDType, foreign_key("Watcher", "id"), index=True) + # the user to notify. + user_id = Column(UUIDType, foreign_key("User", "id"), index=True) + # the type of action that was performed on Node. + created_timestamp = Column(BigInteger, nullable=False) + # the timestamp when the notification was last updated. + updated_timestamp = Column(BigInteger, nullable=False) + # was the notification sent yet? + sent = Column(Boolean, default=False) + # was the notification read yet? + # not really used for email, but could be useful for + # push notifications, web notifications, or browser notifications + read = Column(Boolean, default=False) + + def __init__(self, node_event, watcher): + self.id = uuid.uuid1() + self.node_event = node_event + self.watcher = watcher + self.user = watcher.user + self.created_timestamp = now_timestamp() + self.updated_timestamp = now_timestamp() + + # 1-to-1 relationships. + node_event = relationship(argument="NodeEvent", uselist=False, lazy="joined") + watcher = relationship(argument="Watcher", uselist=False, lazy="joined") + user = relationship(argument="User", uselist=False, lazy="joined") + + @property + def frequency(self): + return self.watcher.notification_frequency + + @property + def method(self): + return self.watcher.notification_method + + # TODO: take into account user's timezone? + @property + def human_created_timestamp(self): + return timestamp_to_ago_string(self.created_timestamp) + + @property + def created_date(self): + return timestamp_to_date_string(self.created_timestamp) + + @property + def created_datetime(self): + return timestamp_to_datetime(self.created_timestamp) + + @property + def human_changed_timestamp(self): + return timestamp_to_ago_string(self.changed_timestamp) + + @property + def changed_date(self): + return timestamp_to_date_string(self.changed_timestamp) + + @property + def changed_datetime(self): + return timestamp_to_datetime(self.changed_timestamp) + + +def get_node_event_notification_by_id(dbsession, node_event_notification_id): + """Try to get NodeEventNotification object by id or return None.""" + return get_object_by_id(dbsession, node_event_notfication_id, NodeEventNotification) + + +def get_topsecret_notifications(dbsession, limit=100, offset=0): + """Return all NodeEventNotifications.""" + return ( + dbsession.query(NodeEventNotification) + .order_by( + NodeEventNotification.created_timestamp.desc(), + NodeEventNotification.user_id, + ) + .limit(limit) + .offset(offset) + ) diff --git a/remarkbox/models/oauth.py b/remarkbox/models/oauth.py new file mode 100644 index 0000000..af22f22 --- /dev/null +++ b/remarkbox/models/oauth.py @@ -0,0 +1,64 @@ +from .meta import Base, RBase, UUIDType, foreign_key, get_object_by_id, now_timestamp + +from sqlalchemy import Column, BigInteger, Unicode, UnicodeText + +from sqlalchemy.orm import relationship + +import uuid + +import json + +try: + unicode("") +except: + from six import u as unicode + + +class Oauth(RBase, Base): + id = Column(UUIDType, primary_key=True, index=True) + # the related user this oauth record belongs to. + user_id = Column(UUIDType, foreign_key("User", "id"), index=True) + # the related namespace this oauth record belongs to. + namespace_id = Column(UUIDType, foreign_key("Namespace", "id"), index=True) + # the name of the service (type of oauth record), for example slack. + service = Column(Unicode(16), nullable=False) + # the token provided after oauth dance used to communicate with 3rd party. + token = Column(Unicode(256), nullable=False) + # optional json_data without defined schema. + json_data = Column(UnicodeText, default=None) + # timestamp when Namespace owner authorized consent to connect with 3rd party oauth. + created_timestamp = Column(BigInteger, nullable=False) + # the last time this record was modified. + updated_timestamp = Column(BigInteger, nullable=False) + + def __init__(self, user, namespace, service, token, data=None): + self.id = uuid.uuid1() + self.user = user + self.namespace = namespace + self.service = unicode(service) + self.token = unicode(token) + self.created_timestamp = now_timestamp() + self.data = data + + # 1-to-1 relationships. + user = relationship(argument="User", uselist=False, lazy="joined") + namespace = relationship(argument="Namespace", uselist=False, lazy="joined") + + @property + def data(self): + if self.json_data: + return json.loads(self.json_data) + return None + + @data.setter + def data(self, new_data): + if new_data: + self.json_data = json.dumps(new_data) + else: + self.json_data = None + self.updated_timestamp = now_timestamp() + + +def get_oauth_by_id(dbsession, oauth_id): + """Try to get Oauth object by id or return None""" + return get_object_by_id(dbsession, oauth_id, Oauth) diff --git a/remarkbox/models/pay_what_you_can.py b/remarkbox/models/pay_what_you_can.py new file mode 100644 index 0000000..89db524 --- /dev/null +++ b/remarkbox/models/pay_what_you_can.py @@ -0,0 +1,39 @@ +from sqlalchemy import BigInteger, Boolean, Column, Enum + +from sqlalchemy.orm import relationship, backref + +from sqlalchemy.ext.associationproxy import association_proxy + +from .meta import Base, RBase +from .meta import now_timestamp, foreign_key +from .meta import UUIDType + + +class PayWhatYouCan(RBase, Base): + user_id = Column(UUIDType, foreign_key("User", "id"), primary_key=True, index=True) + # whether or not this is a one time charge or a yearly. + frequency = Column(Enum("once","yearly", name="frequency"), nullable=False) + # the amount in cents to charge this user. + amount = Column(BigInteger, nullable=False) + # the total contributions from this user in cents. + contributions = Column(BigInteger, default=0, nullable=False) + created_timestamp = Column(BigInteger, nullable=False) + updated_timestamp = Column(BigInteger, nullable=False) + + # 1-to-1 relationships. + user = relationship(argument="User", uselist=False, lazy="joined", back_populates="pay_what_you_can") + + def __init__(self, user, frequency, amount): + self.created_timestamp = now_timestamp() + self.updated_timestamp = now_timestamp() + self.user = user + self.frequency = frequency + self.amount = amount + + def update(self, frequency, amount): + if frequency != self.frequency: + self.frequency = frequency + self.updated_timestamp = now_timestamp() + if amount != self.amount: + self.amount = amount + self.updated_timestamp = now_timestamp() diff --git a/remarkbox/models/uri.py b/remarkbox/models/uri.py new file mode 100644 index 0000000..21fedcb --- /dev/null +++ b/remarkbox/models/uri.py @@ -0,0 +1,139 @@ +from .meta import Base, RBase +from .meta import foreign_key +from .meta import UUIDType + +from sqlalchemy import Unicode, Column, BigInteger + +from sqlalchemy.orm import relationship + +import uuid + +import miniuri + +from remarkbox.lib import ( + timestamp_to_datetime, + timestamp_to_ago_string, + timestamp_to_date_string, +) + +from .meta import now_timestamp + +import logging + +try: + unicode("") +except: + from six import u as unicode + +log = logging.getLogger(__name__) + + +class Uri(RBase, Base): + id = Column(UUIDType, primary_key=True, index=True) + node_id = Column(UUIDType, foreign_key("Node", "id")) + accessed_timestamp = Column(BigInteger, nullable=False, default=0, index=True) + data = Column(Unicode(256), nullable=False, index=True, unique=True) + + node = relationship(argument="Node", uselist=False, back_populates="uri") + + def __init__(self, data=None): + self.id = uuid.uuid1() + self.data = unicode(data) + + @property + def data_ascii(self): + return self.data.encode('ascii', 'ignore') + + @property + def parsed(self): + return miniuri.Uri(self.data) + + @property + def favicon(self): + favicon = self.parsed + favicon.path = "/favicon.ico" + return favicon + + @property + def robots(self): + robots = self.parsed + robots.path = "/robots.txt" + return robots + + # TODO: take into account user's timezone? + @property + def accessed_date(self): + return timestamp_to_date_string(self.accessed_timestamp) + + # TODO: take into account user's timezone? + @property + def accessed_datetime(self): + return timestamp_to_datetime(self.accessed_timestamp) + + +def get_all_uris(dbsession): + return dbsession.query(Uri).order_by(Uri.access_timestamp.desc()).all() + + +def get_uri_by_uri(dbsession, external_uri): + """Try to get Uri object by external_uri or return None""" + if external_uri: + return ( + dbsession.query(Uri).filter(Uri.data == unicode(external_uri)).one_or_none() + ) + + +def get_or_create_uri_by_uri(dbsession, external_uri): + uri = get_uri_by_uri(dbsession, external_uri) + if uri is None: + uri = Uri(external_uri) + return uri + + +def get_or_create_uri(dbsession, external_uri): + """Try to get Uri object by external_uri or return a new Uri.""" + # imported here to prevent circular import. + from .namespace import get_or_create_namespace + + if external_uri.count("://") >= 2: + # example: + # https://webcache.googleusercontent.com/search? + # q=cache:pSxEzC1gfeIJ:https://www.crazygames.com/game/shellshockersio+&cd=2&hl=en&ct=clnk&gl=us + if "webcache.googleusercontent.com" in external_uri: + # deal with google web cache to get at the real uri. + # https://www.crazygames.com/game/shellshockersio + external_uri_parts = external_uri.split(":") + external_uri = ":".join(external_uri_parts[-2:]) + external_uri = external_uri.split("+")[0] + else: + # ignore the query string without consulting Namespace. + # thread_uri=https://www.crazygames.com/game/happy-wheels? + # utm_source=https%3A%2F%2Fwww.crazygames.com%2Fgame%2Fhappy-wheels + external_uri = external_uri.split("?")[0] + + elif "?" in external_uri: + # check if we should ignore query string. + log.info("parsing hostname with miniuri: {}".format(external_uri)) + muri = miniuri.Uri(external_uri) + namespace = get_or_create_namespace(dbsession, muri.hostname) + + if namespace.ignore_query_string: + # search for uri without query string. + external_uri = external_uri.split("?")[0] + + # by default remove any anchor fragments from external uri. + # TODO: configurable from Namespace settings? + if "#" in external_uri: + # search for uri without fragment string. + external_uri = external_uri.split("#")[0] + + # https://www.example.com/index.html -> https://www.example.com + # https://www.example.com/asdf/index.php -> https://www.example.com/asdf + # TODO: configurable from Namespace settings? + if "/index." in external_uri: + for extension in ["html", "php"]: + file_part = "/index.{}".format(extension) + if external_uri.endswith(file_part): + external_uri = external_uri.rstrip(file_part) + + return get_or_create_uri_by_uri(dbsession, external_uri) diff --git a/remarkbox/models/user.py b/remarkbox/models/user.py new file mode 100644 index 0000000..0513017 --- /dev/null +++ b/remarkbox/models/user.py @@ -0,0 +1,416 @@ +from sqlalchemy import BigInteger, Boolean, Column, Unicode, Enum, func, or_ + +from sqlalchemy.orm import relationship, backref + +from sqlalchemy.ext.associationproxy import association_proxy + +import uuid + +import bcrypt + +from remarkbox.lib import timestamp_to_ago_string, gravatar_client + +from remarkbox.lib.svgtar import inline_svgtar + +from .meta import NOTIFICATION_FREQUENCIES +from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key, get_object_by_id + +from .node import Node + +from .namespace_request import NamespaceRequest + +from .watcher import Watcher + +from .notification import NodeEventNotification + +from miscutils import generate_password + +import logging + +log = logging.getLogger(__name__) + +try: + unicode("") +except: + from six import u as unicode + + + +class UserSurrogate(RBase, Base): + """ + This class represents a user surrogate account, a stand in. + + A user surrogate may not log in; has no email. It is not a "real" + User, but a surrogate, a ghost of a user imported from another system. + """ + + id = Column(UUIDType, primary_key=True, index=True) + # the namespace which imported this user surrogate. + namespace_id = Column(UUIDType, foreign_key("Namespace", "id"), index=True) + # the display name. + name = Column(Unicode(64), nullable=False) + created_timestamp = Column(BigInteger, nullable=False) + + namespace = relationship(argument="Namespace", uselist=False, lazy="joined") + + # lazy='dynamic' returns a query object instead of collection. + # Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html + nodes = relationship( + argument="Node", + lazy="dynamic", + order_by=Node.created.desc(), + back_populates="user_surrogate", + ) + + def __init__(self, name, namespace): + self.name = name + self.namespace_id = namespace.id + self.created_timestamp = now_timestamp() + self.id = uuid.uuid1() + + def svgtar(self, **kwargs): + if "text" not in kwargs: + kwargs["text"] = self.name[:1] + return inline_svgtar(h=str(self.id), **kwargs) + + def avatar_uri(self, **kwargs): + return self.svgtar(**kwargs) + + +class User(RBase, Base): + """This class represents a user account""" + + # was this User object authenticated? + authenticated = False + + id = Column(UUIDType, primary_key=True, index=True) + name = Column(Unicode(64), unique=True, nullable=False) + email = Column(Unicode(64), unique=True, nullable=False) + # the email_id is a random string added to otp links to prevent an + # attacker from looping/triggering otp emails for a list of addresses. + # we only share this string with the owner of an email box. not-in-use... + email_id = Column(Unicode(8)) + password = Column(Unicode(64)) + password_timestamp = Column(BigInteger) + # TODO: someday this should be renamed to created_timestamp + created = Column(BigInteger, nullable=False) + gravatar = Column(Boolean, default=False) + verified = Column(Boolean, default=False) + disabled = Column(Boolean, default=False) + # automatically watch any threads I create. + auto_watch_threads_i_create = Column(Boolean, default=True, nullable=False) + # automatically watch any threads I participate in. + auto_watch_threads_i_participate = Column(Boolean, default=False, nullable=False) + # By default, when I watch a Thread, I want to be notified: + default_node_watcher_frequency = Column( + Enum(*NOTIFICATION_FREQUENCIES, name="frequency"), + default="daily", + nullable=False, + ) + # example: cus_12345678AbCdEF but may be null. + stripe_id = Column(Unicode(18), unique=True, nullable=True) + + votes = relationship(argument="Vote", backref="user", order_by="desc(Vote.created)") + + # lazy='dynamic' returns a query object instead of collection. + # Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html + nodes = relationship( + argument="Node", + lazy="dynamic", + order_by=Node.created.desc(), + back_populates="user", + ) + + namespaces = association_proxy( + "user_namespaces", "namespace", creator=lambda n: NamespaceUser(namespace=n) + ) + + oauth_records = relationship( + argument="Oauth", lazy="dynamic", back_populates="user" + ) + + namespace_owner_requests = relationship( + argument="NamespaceRequest", lazy="dynamic", back_populates="user" + ) + + watchers = relationship( + argument="Watcher", + lazy="dynamic", + back_populates="user", + order_by=Watcher.created_timestamp, + cascade="save-update, merge, delete", + ) + + # 1-to-1 relationships. + pay_what_you_can = relationship(argument="PayWhatYouCan", uselist=False, lazy="joined") + + @property + def node_watchers(self): + return self.watchers.filter(Watcher.type == "node") + + @property + def namespace_watchers(self): + return self.watchers.filter(Watcher.type == "namespace") + + @property + def reply_watchers(self): + return self.watchers.filter(Watcher.type == "reply") + + @property + def reply_watcher(self): + """for now we only support one reply_watcher in the UI.""" + return self.watchers.filter(Watcher.type == "reply").one() + + def get_watcher_by_id(self, watcher_id): + """Return a Watcher object for by ID or None.""" + return self.node_watchers.filter(Watcher.id == watcher_id).one_or_none() + + def get_watcher_by_node_id(self, node_id): + """Return a Watcher object for the node (thread) or None.""" + return self.node_watchers.filter(Watcher.node_id == node_id).one_or_none() + + def watch_node(self, node): + """create and return new Watcher object for this node and user in memory""" + watcher = Watcher( + user=self, + type="node", + node=node, + notification_frequency=self.default_node_watcher_frequency, + ) + self.watchers.append(watcher) + return watcher + + def watch_namespace(self, namespace): + """create and return new Watcher object for this namespace and user in memory""" + watcher = self.get_watcher_by_namespace_id(namespace.id) + if watcher: + return watcher + + watcher = Watcher( + user=self, + type="namespace", + namespace=namespace, + notification_frequency="daily", + ) + self.watchers.append(watcher) + return watcher + + def get_watcher_by_namespace_id(self, namespace_id): + """Return a Watcher object for the Namespace or None.""" + return self.namespace_watchers.filter( + Watcher.namespace_id == namespace_id + ).one_or_none() + + def unwatch_namespace(self, namespace): + """Stop watching a Namespace.""" + watcher = self.get_watcher_by_namespace_id(namespace.id) + if watcher: + self.dbsession.delete(watcher) + self.dbsession.flush() + + node_notifications = relationship( + argument="NodeEventNotification", lazy="dynamic", back_populates="user" + ) + + @property + def unsent_node_notifications(self): + return self.node_notifications.filter(NodeEventNotification.sent == False) + + @property + def sent_node_notifications(self): + return self.node_notifications.filter(NodeEventNotification.sent == True) + + @property + def verified_namespace_owner_requests(self): + return [nr for nr in self.namespace_owner_requests if nr.verified] + + @property + def unverified_namespace_owner_requests(self): + return [nr for nr in self.namespace_owner_requests if not nr.verified] + + @property + def verified_nodes(self): + return self.nodes.filter(Node.verified == True, Node.disabled == False) + + @property + def unverified_nodes(self): + return self.nodes.filter(Node.verified == False, Node.disabled == False) + + @property + def disabled_nodes(self): + return self.nodes.filter(Node.verified == True, Node.disabled == True) + + @property + def unapproved_nodes(self): + return self.nodes.filter(or_(Node.approved == False, Node.approved.is_(None))) + + def page_nodes(self, limit=100, offset=0): + if self.nodes.count() == 0: + return [] + return ( + self.nodes.filter( + Node.disabled == False, Node.verified == True, Node.user_id != None, Node.approved == True + ) + .order_by(Node.changed.desc()) + .limit(limit) + .offset(offset) + ) + + def __init__(self, email): + self.name = unicode(generate_password(size=8)) + self.created = now_timestamp() + self.id = uuid.uuid1() + self.email = unicode(email) + self.email_id = unicode(self._generate_raw_password(8)) + + self.new_password() + # don't password throttle new User objects. + self.password_timestamp = 0 + + def _generate_raw_password(self, size=32): + """Return a system generated password""" + return generate_password(size) + + def new_password(self): + """Generate and return raw password, store password hash into DB.""" + raw_password = self._generate_raw_password() + + # bcrypt works with bytes so we encode to utf-8. + self.password = bcrypt.hashpw( + raw_password.encode("utf-8"), + bcrypt.gensalt() + ).decode("utf-8") + self.password_timestamp = now_timestamp() + return raw_password + + def check_password(self, password): + """Accept plain-text raw password, create hash, compare with DB.""" + stored_hash = self.password + + # bcrypt works with bytes so we encode to utf-8. + new_hash = bcrypt.hashpw( + password.encode("utf-8"), + stored_hash.encode("utf-8"), + ).decode("utf-8") + + log.info("new_hash={} stored_hash={}".format(new_hash, stored_hash)) + + if new_hash == stored_hash: + return True + return False + + def throttle_password(self, needed_delta=90000): + """Return True when throttled, else False""" + if self.password_timestamp_delta <= needed_delta: + return True + return False + + @property + def password_timestamp_delta(self): + return now_timestamp() - self.password_timestamp + + @property + def human_password_timestamp(self): + return timestamp_to_ago_string(self.password_timestamp) + + @property + def human_timestamp(self): + return timestamp_to_ago_string(self.created) + + def gravatar_uri(self, **kwargs): + return gravatar_client(self.email, **kwargs) + + def svgtar(self, **kwargs): + if "text" not in kwargs: + kwargs["text"] = self.name[:1] + return inline_svgtar(h=str(self.id), **kwargs) + + def avatar_uri(self, **kwargs): + if self.gravatar: + return self.gravatar_uri(**kwargs) + return self.svgtar(**kwargs) + + def create_default_reply_watcher(self): + if self.reply_watchers.count() == 0: + reply_watcher = Watcher(self, "reply") + self.watchers.append(reply_watcher) + return reply_watcher + return self.reply_watchers.first() + + +def _user_by_name_query(dbsession, name): + """query User by case insensitive name.""" + return dbsession.query(User).filter(func.lower(User.name) == unicode(name.lower())) + + +def is_user_name_available(dbsession, name): + return not _user_by_name_query(dbsession, unicode(name)).count() + + +def generate_user_name(dbsession, desired_name=""): + size = 8 + sep = "" + if desired_name: + size = 3 + sep = "-" + name = sep.join([desired_name, generate_password(size)]) + while is_user_name_available(dbsession, name) == False: + name = sep.join([desired_name, generate_password(size)]) + return name + + +def is_user_name_valid(name): + """Test if user name meets our criteria for validity.""" + # The choices here are subject to change. Right now we allow dashes. + name = name.replace("-", "") + return name.isalnum() + + +def get_user_by_name(dbsession, name): + """Try to get User object by name or return None""" + if name: + return _user_by_name_query(dbsession, name).one_or_none() + + +def get_user_by_id(dbsession, user_id): + """Try to get User object by id or return None.""" + return get_object_by_id(dbsession, user_id, User) + + +def get_user_by_email(dbsession, email): + """Try to get User object by email or return None""" + if email: + return dbsession.query(User).filter(User.email == unicode(email)).one_or_none() + + +def get_or_create_user_by_email(dbsession, email): + """Try to get User object by email or return new User""" + user = get_user_by_email(dbsession, email) + if user is None: + # create new User for unverified user. + user = User(email) + if not is_user_name_available(dbsession, user.name): + user.name = generate_user_name(dbsession) + return user + + +def get_user_surrogate_by_name(dbsession, name, namespace): + """Try to get UserSurrogate object by case insensitive name or return None""" + if name: + return ( + dbsession.query(UserSurrogate) + .filter(func.lower(User.name) == unicode(name.lower())) + .filter(UserSurrogate.namespace_id == namespace.id) + .one_or_none() + ) + + +def get_or_create_user_surrogate_by_name(dbsession, name, namespace): + """ + Try to get UserSurrogate object by case insensitive name + or create and return a new UserSurrogate + """ + user_surrogate = get_user_surrogate_by_name(dbsession, name, namespace) + if user_surrogate is None: + user_surrogate = UserSurrogate(name=name, namespace=namespace) + return user_surrogate diff --git a/remarkbox/models/vote.py b/remarkbox/models/vote.py new file mode 100644 index 0000000..a59d91c --- /dev/null +++ b/remarkbox/models/vote.py @@ -0,0 +1,30 @@ +from sqlalchemy import Integer, BigInteger, Column + +import uuid + +from remarkbox.lib import timestamp_to_ago_string, timestamp_to_date_string + +from .meta import Base, RBase +from .meta import now_timestamp, foreign_key +from .meta import UUIDType + + +class Vote(RBase, Base): + id = Column(UUIDType, primary_key=True, index=True) + user_id = Column(UUIDType, foreign_key("User", "id")) + node_id = Column(UUIDType, foreign_key("Node", "id")) + created = Column(BigInteger, nullable=False) + value = Column(Integer, nullable=False) + + def __init__(self): + self.id = uuid.uuid1() + self.created = now_timestamp() + + @property + def human_timestamp(self): + return timestamp_to_ago_string(self.created) + + # TODO: take into account user's timezone? + @property + def created_date(self): + return timestamp_to_date_string(self.created) diff --git a/remarkbox/models/watcher.py b/remarkbox/models/watcher.py new file mode 100644 index 0000000..3de493c --- /dev/null +++ b/remarkbox/models/watcher.py @@ -0,0 +1,80 @@ +from .meta import WATCHER_TYPES, NOTIFICATION_FREQUENCIES, NOTIFICATION_METHODS +from .meta import Base, RBase, UUIDType, foreign_key, get_object_by_id, now_timestamp + +from sqlalchemy import Column, BigInteger, Unicode, UnicodeText, Enum + +from sqlalchemy.orm import relationship + +import uuid + +from .notification import NodeEventNotification + + +class Watcher(RBase, Base): + id = Column(UUIDType, primary_key=True, index=True) + # the User watching the Node. + user_id = Column(UUIDType, foreign_key("User", "id"), index=True) + # the Node under watch. + node_id = Column(UUIDType, foreign_key("Node", "id"), index=True, nullable=True) + # the Namespace under watch. + namespace_id = Column( + UUIDType, foreign_key("Namespace", "id"), index=True, nullable=True + ) + # the type of watcher object. + type = Column(Enum(*WATCHER_TYPES, name="type")) + # how will this watcher object notify? + notification_method = Column( + Enum(*NOTIFICATION_METHODS, name="notification_method"), default="email" + ) + # how frequently should we notify of changes? + notification_frequency = Column( + Enum(*NOTIFICATION_FREQUENCIES, name="frequency"), default="daily" + ) + # the point in time when the User started authorized consent to watch. + created_timestamp = Column(BigInteger, nullable=False) + # the last time this record was modified. + updated_timestamp = Column(BigInteger, nullable=False) + + def __init__( + self, + user, + type, + notification_method="email", + notification_frequency="daily", + node=None, + namespace=None, + ): + self.id = uuid.uuid1() + self.user = user + self.type = type + self.notification_method = notification_method + self.notification_frequency = notification_frequency + self.created_timestamp = now_timestamp() + self.updated_timestamp = now_timestamp() + + self.node = node + self.namespace = namespace + + # 1-to-1 relationships. + node = relationship(argument="Node", uselist=False, lazy="joined") + namespace = relationship(argument="Namespace", uselist=False, lazy="joined") + user = relationship(argument="User", uselist=False, lazy="joined") + notifications = relationship( + argument="NodeEventNotification", + lazy="dynamic", + cascade="save-update, merge, delete", + back_populates="watcher" + ) + + def unsent_notifications(self): + self.notifications.filter(NodeEventNotification.sent == False) + + def new_notification(self, node_event): + notification = NodeEventNotification(watcher=self, node_event=node_event) + self.notifications.append(notification) + return notification + + +def get_watcher_by_id(dbsession, watcher_id): + """Try to get Watcher object by id or return None.""" + return get_object_by_id(dbsession, watcher_id, Watcher) diff --git a/remarkbox/routes.py b/remarkbox/routes.py new file mode 100644 index 0000000..ab660e1 --- /dev/null +++ b/remarkbox/routes.py @@ -0,0 +1,127 @@ +def includeme(config): + # shared routes + config.add_static_view("static", "static", cache_max_age=3600) + + config.add_route("favicon", "/favicon.ico") + config.add_route("robots", "/robots.txt") + config.add_route("embed-iframe", "/embed-iframe.txt") + config.add_route("embed-iframe-min", "/embed-iframe-min.txt") + + # stripe: credit card storage and processing. + config.add_route("billing", "/billing") + config.add_route("add-card", "/billing/add-card") + config.add_route( + "confirm-update-card", "/billing/confirm-update-card/{action}/{card_id}" + ) + config.add_route("update-card", "/billing/update-card") + config.add_route("pay-what-you-can", "/pay-what-you-can") + + # slack: bot notifications and oauth. + config.add_route("oauth-slack", "/oauth/slack") + config.add_route("oauth-slack-delete", "/oauth/slack/delete") + + # todo: if namespace.public == False: + # lock this routes down to only load for namespace owners + config.add_route("home", "/") + + config.add_route("sitemap", "/all.sitemap.xml") + config.add_route("sitemap2", "/ns/{namespace}/all.sitemap.xml") + + config.add_route("namespace-dump-to-json", "/ns/{namespace}/dump.json") + + # allows us to dynamically generate CSS regarding nesting based on + # the avatar_size configured in config file (move to Namespace settings?) + config.add_route("dynamic-remarkbox-css", "/dynamic-remarkbox.css") + + config.add_route("search", "/search") + + config.add_route("setup", "/setup") + config.add_route("setup-namespace-request-cancel", "/nsr/cancel") + + config.add_route("log-out", "/log-out") + + config.add_route("preview-post", "/preview-post") + + config.add_route("new", "/new") + + config.add_route("watch", "/watch") + config.add_route("unwatch", "/unwatch") + config.add_route("update-watching", "/update-watching") + + # lock and unlock a thread. + config.add_route("lock", "/lock") + config.add_route("unlock", "/unlock") + + # Given a node_id redirect to the node's root. + config.add_route("redirect-to-root", "/r/{node_id}") + + # superfly admins: locked down routes. + config.add_route("topsecret-namespace-requests", "/topsecret/namespace-requests") + config.add_route("topsecret-namespace-owners", "/topsecret/namespace-owners") + config.add_route("topsecret-namespaces", "/topsecret/namespaces") + config.add_route("topsecret-notifications", "/topsecret/notifications") + config.add_route("topsecret-nodes", "/topsecret/nodes") + config.add_route("topsecret", "/topsecret") + + # embed routes: + config.add_route("embed-join-or-log-in", "/embed/ns/{namespace}/join-or-log-in") + config.add_route("embed-log-out", "/embed/ns/{namespace}/log-out") + + config.add_route("embed-namespace-nodes", "/embed/ns/{namespace}/nodes") + config.add_route("embed-namespace-settings", "/embed/ns/{namespace}/settings") + config.add_route( + "embed-namespace-stylesheet", "/embed/ns/{namespace}/{filename}.css" + ) + config.add_route("embed-namespace", "/embed/ns/{namespace}") + + config.add_route("embed-user-settings", "/embed/ns/{namespace}/u/settings") + config.add_route("embed-user-watching", "/embed/ns/{namespace}/u/watching") + config.add_route("embed-user-notifications", "/embed/ns/{namespace}/u/notifications") + config.add_route("embed-user", "/embed/ns/{namespace}/u/{user_name}") + + config.add_route("embed-verify", "/embed/ns/{namespace}/{node_id}/verify") + config.add_route("embed-disable", "/embed/ns/{namespace}/{node_id}/disable") + config.add_route("embed-enable", "/embed/ns/{namespace}/{node_id}/enable") + config.add_route("embed-approve", "/embed/ns/{namespace}/{node_id}/approve") + config.add_route("embed-deny", "/embed/ns/{namespace}/{node_id}/deny") + config.add_route("embed-edit", "/embed/ns/{namespace}/{node_id}/edit") + config.add_route("embed-reply", "/embed/ns/{namespace}/reply") + config.add_route("embed-reply2", "/embed/ns/{namespace}/{node_id}/reply") + config.add_route("embed-show-node2", "/embed/ns/{namespace}/{node_id}") + + config.add_route("embed-show-count", "/embed/count") + config.add_route("embed-show-node", "/embed") + config.add_route("embed-show-node3", "/embed/{node_id}") + + # basic routes: + config.add_route("basic-join-or-log-in", "/join-or-log-in") + + config.add_route("basic-namespace-nodes", "/ns/{namespace}/nodes") + config.add_route("basic-namespace-settings", "/ns/{namespace}/settings") + config.add_route("basic-namespace-stats-json", "/ns/{namespace}/stats.json") + config.add_route("basic-namespace-stylesheet", "/ns/{namespace}/{filename}.css") + config.add_route("basic-namespace-threads-rss", "/ns/{namespace}.threads.xml") + config.add_route("basic-namespace-nodes-rss", "/ns/{namespace}.nodes.xml") + config.add_route( + "basic-namespace-pending-rss", "/ns/{namespace}.pending.{feed_key}.xml" + ) + config.add_route("basic-namespace", "/ns/{namespace}") + + config.add_route("basic-user-settings", "/u/settings") + config.add_route("basic-user-watching", "/u/watching") + config.add_route("basic-user-notifications", "/u/notifications") + config.add_route("basic-user", "/u/{user_name}") + + config.add_route("basic-verify", "/{node_id}/verify") + config.add_route("basic-disable", "/{node_id}/disable") + config.add_route("basic-enable", "/{node_id}/enable") + config.add_route("basic-approve", "/{node_id}/approve") + config.add_route("basic-deny", "/{node_id}/deny") + config.add_route("basic-edit", "/{node_id}/edit") + config.add_route("basic-edit2", "/{node_id}/{slug:.*}/edit") + config.add_route("basic-reply", "/{node_id}/reply") + config.add_route("basic-reply2", "/{node_id}/{slug:.*}/reply") + config.add_route("basic-show-count", "/{node_id}/count") + config.add_route("basic-show-count2", "/{node_id}/{slug:.*}/count") + config.add_route("basic-show-node", "/{node_id}") + config.add_route("basic-show-node2", "/{node_id}/{slug:.*}") # must be last. diff --git a/remarkbox/scripts/__init__.py b/remarkbox/scripts/__init__.py new file mode 100644 index 0000000..0e6044e --- /dev/null +++ b/remarkbox/scripts/__init__.py @@ -0,0 +1,7 @@ +import argparse + + +def base_parser(description): + parser = argparse.ArgumentParser(description=description) + parser.add_argument("-c", "--config", default="development.ini") + return parser diff --git a/remarkbox/scripts/alembic/README b/remarkbox/scripts/alembic/README new file mode 100644 index 0000000..c58fdd5 --- /dev/null +++ b/remarkbox/scripts/alembic/README @@ -0,0 +1,11 @@ +Change Log +################ + +**5a5d0b68db78**: + + add_root_id_field.py + +**6bbc92ccc112**: + + add_changed_column_to_node_table.py + diff --git a/remarkbox/scripts/alembic/env.py b/remarkbox/scripts/alembic/env.py new file mode 100644 index 0000000..6e04219 --- /dev/null +++ b/remarkbox/scripts/alembic/env.py @@ -0,0 +1,73 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig + +# from remarkbox import models + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +# target_metadata = None + +from remarkbox.models import Base +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/remarkbox/scripts/alembic/script.py.mako b/remarkbox/scripts/alembic/script.py.mako new file mode 100644 index 0000000..22c269a --- /dev/null +++ b/remarkbox/scripts/alembic/script.py.mako @@ -0,0 +1,29 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + + +from sqlalchemy_utils import UUIDType as TempUUIDType +UUIDType = TempUUIDType(binary=False) + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/remarkbox/scripts/alembic/versions/00211ed642cc_add_ip_address_column_to_rb_node.py b/remarkbox/scripts/alembic/versions/00211ed642cc_add_ip_address_column_to_rb_node.py new file mode 100644 index 0000000..2b4ce47 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/00211ed642cc_add_ip_address_column_to_rb_node.py @@ -0,0 +1,24 @@ +"""Add ip_address column to rb_node + +Revision ID: 00211ed642cc +Revises: fa8402aa1a00 +Create Date: 2018-05-02 02:33:50.107923 + +""" + +# revision identifiers, used by Alembic. +revision = "00211ed642cc" +down_revision = "fa8402aa1a00" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column("rb_node", sa.Column("ip_address", sa.String(45))) + + +def downgrade(): + op.drop_column("rb_node", "ip_address") diff --git a/remarkbox/scripts/alembic/versions/0208c10ea67f_create_new_node_cache_table.py b/remarkbox/scripts/alembic/versions/0208c10ea67f_create_new_node_cache_table.py new file mode 100644 index 0000000..9b083ba --- /dev/null +++ b/remarkbox/scripts/alembic/versions/0208c10ea67f_create_new_node_cache_table.py @@ -0,0 +1,57 @@ +"""create new node_cache table + +Revision ID: 0208c10ea67f +Revises: 503a2cef2378 +Create Date: 2018-03-28 13:31:34.018012 + +""" + +# revision identifiers, used by Alembic. +revision = "0208c10ea67f" +down_revision = "503a2cef2378" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +from sqlalchemy_utils import UUIDType as TempUUIDType + +UUIDType = TempUUIDType(binary=False) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "rb_node_cache", + sa.Column("id", UUIDType, nullable=False), + sa.Column("node_id", UUIDType, nullable=True), + sa.Column("valid", sa.Boolean(), nullable=True), + sa.Column("updated_timestamp", sa.BigInteger(), nullable=False), + sa.Column("json_stats", sa.UnicodeText(), nullable=True), + sa.ForeignKeyConstraint(["node_id"], ["rb_node.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rb_node_cache_id"), "rb_node_cache", ["id"], unique=False) + op.create_index( + op.f("ix_rb_node_cache_node_id"), "rb_node_cache", ["node_id"], unique=False + ) + op.drop_table("rb_root_cache") + + +def downgrade(): + op.create_table( + "rb_root_cache", + sa.Column("id", sa.CHAR(length=32), nullable=False), + sa.Column("root_id", sa.CHAR(length=32), nullable=True), + sa.Column("valid", sa.BOOLEAN(), nullable=True), + sa.Column("updated_timestamp", sa.BIGINT(), nullable=False), + sa.Column("json_stats", sa.TEXT(), nullable=True), + sa.CheckConstraint(u"valid IN (0, 1)"), + sa.ForeignKeyConstraint(["root_id"], [u"rb_node.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.drop_index(op.f("ix_rb_node_cache_node_id"), table_name="rb_node_cache") + op.drop_index(op.f("ix_rb_node_cache_id"), table_name="rb_node_cache") + op.drop_table("rb_node_cache") diff --git a/remarkbox/scripts/alembic/versions/04e52337756e_autogenerated_indicies.py b/remarkbox/scripts/alembic/versions/04e52337756e_autogenerated_indicies.py new file mode 100644 index 0000000..0d012a3 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/04e52337756e_autogenerated_indicies.py @@ -0,0 +1,38 @@ +"""autogenerated indicies. + +Revision ID: 04e52337756e +Revises: 6a440d3921d2 +Create Date: 2017-07-03 13:45:32.742934 + +""" + +# revision identifiers, used by Alembic. +revision = "04e52337756e" +down_revision = "6a440d3921d2" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f("ix_Node_id"), "Node", ["id"], unique=False) + op.create_index(op.f("ix_Node_root_id"), "Node", ["root_id"], unique=False) + op.create_index(op.f("ix_Uri_data"), "Uri", ["data"], unique=False) + op.create_index(op.f("ix_Uri_id"), "Uri", ["id"], unique=False) + op.create_index(op.f("ix_User_id"), "User", ["id"], unique=False) + op.create_index(op.f("ix_Vote_id"), "Vote", ["id"], unique=False) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_Vote_id"), table_name="Vote") + op.drop_index(op.f("ix_User_id"), table_name="User") + op.drop_index(op.f("ix_Uri_id"), table_name="Uri") + op.drop_index(op.f("ix_Uri_data"), table_name="Uri") + op.drop_index(op.f("ix_Node_root_id"), table_name="Node") + op.drop_index(op.f("ix_Node_id"), table_name="Node") + ### end Alembic commands ### diff --git a/remarkbox/scripts/alembic/versions/051f8b810f26_namespace_ignore_query_string.py b/remarkbox/scripts/alembic/versions/051f8b810f26_namespace_ignore_query_string.py new file mode 100644 index 0000000..174f9fb --- /dev/null +++ b/remarkbox/scripts/alembic/versions/051f8b810f26_namespace_ignore_query_string.py @@ -0,0 +1,26 @@ +"""namespace ignore_query_string + +Revision ID: 051f8b810f26 +Revises: 3ee195d142fb +Create Date: 2019-02-20 10:27:29.804965 + +""" + +# revision identifiers, used by Alembic. +revision = '051f8b810f26' +down_revision = '3ee195d142fb' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + from sqlalchemy.sql import expression + op.add_column('rb_namespace', sa.Column('ignore_query_string', sa.Boolean(), nullable=True, server_default=expression.false())) + + +def downgrade(): + pass diff --git a/remarkbox/scripts/alembic/versions/1040ee4836af_namespace_description.py b/remarkbox/scripts/alembic/versions/1040ee4836af_namespace_description.py new file mode 100644 index 0000000..5e593e0 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/1040ee4836af_namespace_description.py @@ -0,0 +1,26 @@ +"""namespace description + +Revision ID: 1040ee4836af +Revises: cbdf3cd4ccba +Create Date: 2017-07-15 23:25:12.010472 + +""" + +# revision identifiers, used by Alembic. +revision = "1040ee4836af" +down_revision = "cbdf3cd4ccba" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "Namespace", sa.Column("description", sa.Unicode(length=256), nullable=True) + ) + + +def downgrade(): + op.drop_column("Namespace", "description") diff --git a/remarkbox/scripts/alembic/versions/184a349cd589_uri_accessed_timestamp.py b/remarkbox/scripts/alembic/versions/184a349cd589_uri_accessed_timestamp.py new file mode 100644 index 0000000..b439781 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/184a349cd589_uri_accessed_timestamp.py @@ -0,0 +1,26 @@ +"""uri-accessed-timestamp + +Revision ID: 184a349cd589 +Revises: a38c12eeff2d +Create Date: 2020-04-20 22:13:20.339912 + +""" + +# revision identifiers, used by Alembic. +revision = '184a349cd589' +down_revision = 'a38c12eeff2d' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('rb_uri', sa.Column('accessed_timestamp', sa.BigInteger(), nullable=False, server_default="0")) + op.create_index(op.f('ix_rb_uri_accessed_timestamp'), 'rb_uri', ['accessed_timestamp'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_rb_uri_accessed_timestamp'), table_name='rb_uri') + op.drop_column('rb_uri', 'accessed_timestamp') diff --git a/remarkbox/scripts/alembic/versions/256468780798_root_nodes_to_namespace_.py b/remarkbox/scripts/alembic/versions/256468780798_root_nodes_to_namespace_.py new file mode 100644 index 0000000..4054563 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/256468780798_root_nodes_to_namespace_.py @@ -0,0 +1,55 @@ +"""loop over root nodes and create namespace + +Revision ID: 256468780798 +Revises: 98a90d2add21 +Create Date: 2017-07-03 16:37:07.937232 + +""" + +# revision identifiers, used by Alembic. +revision = "256468780798" +down_revision = "98a90d2add21" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from sqlalchemy.orm.exc import NoResultFound + +from remarkbox.models import Node, Namespace + + +def upgrade(): + + bind = op.get_bind() + DBSession = sa.orm.Session(bind=bind) + + def get_or_create_namespace(name): + try: + return DBSession.query(Namespace).filter(Namespace.name == name).one() + except NoResultFound: + namespace = Namespace(name) + DBSession.add(namespace) + DBSession.flush() + DBSession.commit() + return namespace + + root_nodes = DBSession.query(Node).filter(Node.parent_id == None) + + for root_node in root_nodes: + + if root_node.uri: + namespace = get_or_create_namespace(root_node.uri.parsed.hostname) + else: + namespace = get_or_create_namespace("temp.namespace.com") + + root_node.namespace_id = namespace.id + + DBSession.add(root_node) + DBSession.flush() + DBSession.commit() + + +def downgrade(): + pass diff --git a/remarkbox/scripts/alembic/versions/2e00b92495e4_node_disabled_timestamp.py b/remarkbox/scripts/alembic/versions/2e00b92495e4_node_disabled_timestamp.py new file mode 100644 index 0000000..f986d72 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/2e00b92495e4_node_disabled_timestamp.py @@ -0,0 +1,42 @@ +"""Node disabled_timestamp + +Revision ID: 2e00b92495e4 +Revises: 7a3a4755b4a8 +Create Date: 2017-08-13 15:58:33.169624 + +""" + +# revision identifiers, used by Alembic. +revision = "2e00b92495e4" +down_revision = "7a3a4755b4a8" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from remarkbox.models import Node, now_timestamp + + +def upgrade(): + # add the new column. + op.add_column( + "Node", sa.Column("disabled_timestamp", sa.BigInteger(), nullable=True) + ) + + bind = op.get_bind() + DBSession = sa.orm.Session(bind=bind) + + # get all disabled nodes. + nodes = DBSession.query(Node).filter(Node.disabled == True) + + for node in nodes: + node.disabled_timestamp = now_timestamp() + DBSession.add(node) + + DBSession.flush() + DBSession.commit() + + +def downgrade(): + op.drop_column("Node", "disabled_timestamp") diff --git a/remarkbox/scripts/alembic/versions/3ee195d142fb_add_subscription_type_to_namespace.py b/remarkbox/scripts/alembic/versions/3ee195d142fb_add_subscription_type_to_namespace.py new file mode 100644 index 0000000..9a4427d --- /dev/null +++ b/remarkbox/scripts/alembic/versions/3ee195d142fb_add_subscription_type_to_namespace.py @@ -0,0 +1,32 @@ +"""add subscription_type to Namespace + +Revision ID: 3ee195d142fb +Revises: 771ef7c868de +Create Date: 2019-02-16 14:23:03.175524 + +""" + +# revision identifiers, used by Alembic. +revision = '3ee195d142fb' +down_revision = '771ef7c868de' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + 'rb_namespace', + sa.Column( + 'subscription_type', + sa.Enum('development', 'trial', 'production', name='subscription_type'), + nullable=False, + server_default='development' + ) + ) + + +def downgrade(): + pass diff --git a/remarkbox/scripts/alembic/versions/40ded1163f95_add_user_surrogate_columns.py b/remarkbox/scripts/alembic/versions/40ded1163f95_add_user_surrogate_columns.py new file mode 100644 index 0000000..e888bb9 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/40ded1163f95_add_user_surrogate_columns.py @@ -0,0 +1,25 @@ +"""add user surrogate columns + +Revision ID: 40ded1163f95 +Revises: c9793937e756 +Create Date: 2019-03-07 08:35:54.261514 + +""" + +# revision identifiers, used by Alembic. +revision = '40ded1163f95' +down_revision = 'c9793937e756' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from remarkbox.models import UUIDType + +def upgrade(): + op.add_column('rb_node', sa.Column('user_surrogate_id', UUIDType, nullable=True)) + + +def downgrade(): + pass diff --git a/remarkbox/scripts/alembic/versions/43c1e5a99d4e_new_namespace_column_3.py b/remarkbox/scripts/alembic/versions/43c1e5a99d4e_new_namespace_column_3.py new file mode 100644 index 0000000..a90c4c3 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/43c1e5a99d4e_new_namespace_column_3.py @@ -0,0 +1,27 @@ +"""new namespace column 3 + +Revision ID: 43c1e5a99d4e +Revises: a738d6859f18 +Create Date: 2017-11-11 10:35:40.930389 + +""" + +# revision identifiers, used by Alembic. +revision = "43c1e5a99d4e" +down_revision = "a738d6859f18" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "rb_namespace", + sa.Column("placeholder_text", sa.Unicode(length=256), nullable=True), + ) + + +def downgrade(): + op.drop_column("rb_namespace", "placeholder_text") diff --git a/remarkbox/scripts/alembic/versions/4aeda8e797ab_new_namespace_column_for_stylesheet_uris.py b/remarkbox/scripts/alembic/versions/4aeda8e797ab_new_namespace_column_for_stylesheet_uris.py new file mode 100644 index 0000000..4f84c57 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/4aeda8e797ab_new_namespace_column_for_stylesheet_uris.py @@ -0,0 +1,32 @@ +"""new namespace column for stylesheet uris + +Revision ID: 4aeda8e797ab +Revises: 43c1e5a99d4e +Create Date: 2018-01-28 16:11:09.507278 + +""" + +# revision identifiers, used by Alembic. +revision = "4aeda8e797ab" +down_revision = "43c1e5a99d4e" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "rb_namespace", + sa.Column("stylesheet_embed_uri", sa.Unicode(length=256), nullable=True), + ) + op.add_column( + "rb_namespace", + sa.Column("stylesheet_uri", sa.Unicode(length=256), nullable=True), + ) + + +def downgrade(): + op.drop_column("rb_namespace", "stylesheet_uri") + op.drop_column("rb_namespace", "stylesheet_embed_uri") diff --git a/remarkbox/scripts/alembic/versions/503a2cef2378_root_stats.py b/remarkbox/scripts/alembic/versions/503a2cef2378_root_stats.py new file mode 100644 index 0000000..a31c452 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/503a2cef2378_root_stats.py @@ -0,0 +1,48 @@ +"""root-stats + +Revision ID: 503a2cef2378 +Revises: aa2dfb4decda +Create Date: 2018-03-25 22:20:39.436208 + +""" + +# revision identifiers, used by Alembic. +revision = "503a2cef2378" +down_revision = "aa2dfb4decda" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +from sqlalchemy_utils import UUIDType as TempUUIDType + +UUIDType = TempUUIDType(binary=False) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "rb_root_cache", + sa.Column("id", UUIDType, nullable=False), + sa.Column("root_id", UUIDType, nullable=True), + sa.Column("valid", sa.Boolean(), nullable=True), + sa.Column("updated_timestamp", sa.BigInteger(), nullable=False), + sa.Column("json_stats", sa.UnicodeText(), nullable=True), + sa.ForeignKeyConstraint(["root_id"], ["rb_node.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rb_root_cache_id"), "rb_root_cache", ["id"], unique=False) + op.create_index( + op.f("ix_rb_root_cache_root_id"), "rb_root_cache", ["root_id"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_rb_root_cache_root_id"), table_name="rb_root_cache") + op.drop_index(op.f("ix_rb_root_cache_id"), table_name="rb_root_cache") + op.drop_table("rb_root_cache") + # ### end Alembic commands ### diff --git a/remarkbox/scripts/alembic/versions/591800942adf_node_data_html_column.py b/remarkbox/scripts/alembic/versions/591800942adf_node_data_html_column.py new file mode 100644 index 0000000..eb876fb --- /dev/null +++ b/remarkbox/scripts/alembic/versions/591800942adf_node_data_html_column.py @@ -0,0 +1,42 @@ +"""node data html column + +Revision ID: 591800942adf +Revises: c97ddfcbc406 +Create Date: 2017-09-01 21:42:39.429275 + +""" + +# revision identifiers, used by Alembic. +revision = "591800942adf" +down_revision = "c97ddfcbc406" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from remarkbox.models import Node + +from remarkbox.lib.render import markdown_to_html + + +def upgrade(): + + bind = op.get_bind() + DBSession = sa.orm.Session(bind=bind) + + op.add_column("Node", sa.Column("data_html", sa.UnicodeText(), nullable=True)) + + nodes = DBSession.query(Node).all() + + for node in nodes: + if node.data: + node.data_html = markdown_to_html(node.data, node.root.namespace) + DBSession.add(node) + + DBSession.flush() + DBSession.commit() + + +def downgrade(): + op.drop_column("Node", "data_html") diff --git a/remarkbox/scripts/alembic/versions/59592cf325fb_stylesheet_timestamp.py b/remarkbox/scripts/alembic/versions/59592cf325fb_stylesheet_timestamp.py new file mode 100644 index 0000000..18a192d --- /dev/null +++ b/remarkbox/scripts/alembic/versions/59592cf325fb_stylesheet_timestamp.py @@ -0,0 +1,26 @@ +"""stylesheet_timestamp + +Revision ID: 59592cf325fb +Revises: 256468780798 +Create Date: 2017-07-06 00:30:42.512137 + +""" + +# revision identifiers, used by Alembic. +revision = "59592cf325fb" +down_revision = "256468780798" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "Namespace", sa.Column("stylesheet_timestamp", sa.BigInteger(), nullable=True) + ) + + +def downgrade(): + op.drop_column("Namespace", "stylesheet_timestamp") diff --git a/remarkbox/scripts/alembic/versions/5a5d0b68db78_add_root_id_field.py b/remarkbox/scripts/alembic/versions/5a5d0b68db78_add_root_id_field.py new file mode 100644 index 0000000..86c8ab6 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/5a5d0b68db78_add_root_id_field.py @@ -0,0 +1,40 @@ +"""add root_id field + +Revision ID: 5a5d0b68db78 +Revises: 6bbc92ccc112 +Create Date: 2017-01-14 20:59:39.701946 + +""" + +# revision identifiers, used by Alembic. +revision = "5a5d0b68db78" +down_revision = "6bbc92ccc112" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from remarkbox.models import Node + + +def upgrade(): + + bind = op.get_bind() + DBSession = sa.orm.Session(bind=bind) + + op.add_column("Node", sa.Column("root_id", sa.Integer, default=None)) + + nodes = DBSession.query(Node).all() + + for node in nodes: + node.root_id = node.root.id + DBSession.add(node) + + DBSession.flush() + DBSession.commit() + + +def downgrade(): + # this will fail for sqlite3 databases... + op.drop_column("Node", "root_id") diff --git a/remarkbox/scripts/alembic/versions/6a440d3921d2_added_email_id_column_to_user_table.py b/remarkbox/scripts/alembic/versions/6a440d3921d2_added_email_id_column_to_user_table.py new file mode 100644 index 0000000..83e99aa --- /dev/null +++ b/remarkbox/scripts/alembic/versions/6a440d3921d2_added_email_id_column_to_user_table.py @@ -0,0 +1,48 @@ +"""Added email_id column to User table. + +Revision ID: 6a440d3921d2 +Revises: 5a5d0b68db78 +Create Date: 2017-05-16 16:22:51.161059 + +""" + +# revision identifiers, used by Alembic. +revision = "6a440d3921d2" +down_revision = "5a5d0b68db78" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from remarkbox.models import User +from remarkbox.models import now_timestamp + + +def upgrade(): + + bind = op.get_bind() + DBSession = sa.orm.Session(bind=bind) + + op.add_column("User", sa.Column("email_id", sa.Unicode)) + op.add_column( + "User", + sa.Column( + "password_timestamp", sa.BigInteger, server_default=str(now_timestamp()) + ), + ) + + users = DBSession.query(User).all() + + for user in users: + user.email_id = user._generate_raw_password(8) + DBSession.add(user) + + DBSession.flush() + DBSession.commit() + + +def downgrade(): + # this will fail for sqlite3 databases... + op.drop_column("User", "email_id") + op.drop_column("User", "password_timestamp") diff --git a/remarkbox/scripts/alembic/versions/6bbc92ccc112_add_changed_column_to_node_table.py b/remarkbox/scripts/alembic/versions/6bbc92ccc112_add_changed_column_to_node_table.py new file mode 100644 index 0000000..46bfafd --- /dev/null +++ b/remarkbox/scripts/alembic/versions/6bbc92ccc112_add_changed_column_to_node_table.py @@ -0,0 +1,31 @@ +"""add changed column to node table + +Revision ID: 6bbc92ccc112 +Revises: +Create Date: 2016-09-04 11:41:20.795668 + +""" + +# revision identifiers, used by Alembic. +revision = "6bbc92ccc112" +down_revision = None +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from time import time + + +def upgrade(): + now = int(time() * 1000) + op.add_column( + "Node", + sa.Column("changed", sa.BigInteger, nullable=False, server_default=str(now)), + ) + + +def downgrade(): + # this will fail for sqlite3 databases... + op.drop_column("Node", "changed") diff --git a/remarkbox/scripts/alembic/versions/6db39621f46c_namespace_group_conversations.py b/remarkbox/scripts/alembic/versions/6db39621f46c_namespace_group_conversations.py new file mode 100644 index 0000000..5c3cd70 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/6db39621f46c_namespace_group_conversations.py @@ -0,0 +1,24 @@ +"""namespace group conversations + +Revision ID: 6db39621f46c +Revises: f7b42b2024fb +Create Date: 2019-03-22 09:40:30.185446 + +""" + +# revision identifiers, used by Alembic. +revision = '6db39621f46c' +down_revision = 'f7b42b2024fb' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('rb_namespace', sa.Column('group_conversations', sa.Boolean(), nullable=True)) + + +def downgrade(): + pass diff --git a/remarkbox/scripts/alembic/versions/6fc1d0cf350c_notification_columns_to_rb_user_table.py b/remarkbox/scripts/alembic/versions/6fc1d0cf350c_notification_columns_to_rb_user_table.py new file mode 100644 index 0000000..3d62f4d --- /dev/null +++ b/remarkbox/scripts/alembic/versions/6fc1d0cf350c_notification_columns_to_rb_user_table.py @@ -0,0 +1,52 @@ +"""notification columns to rb_user table + +Revision ID: 6fc1d0cf350c +Revises: a26302bcfa25 +Create Date: 2018-05-27 21:04:17.704776 + +""" + +# revision identifiers, used by Alembic. +revision = "6fc1d0cf350c" +down_revision = "a26302bcfa25" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "rb_user", + sa.Column( + "auto_watch_threads_i_create", + sa.Boolean(), + nullable=False, + server_default="1", + ), + ) + op.add_column( + "rb_user", + sa.Column( + "auto_watch_threads_i_participate", + sa.Boolean(), + nullable=False, + server_default="0", + ), + ) + op.add_column( + "rb_user", + sa.Column( + "default_node_watcher_frequency", + sa.Enum("immediately", "daily", "weekly", name="frequency"), + nullable=False, + server_default="daily", + ), + ) + + +def downgrade(): + op.drop_column("rb_user", "default_node_watcher_frequency") + op.drop_column("rb_user", "auto_watch_threads_i_participate") + op.drop_column("rb_user", "auto_watch_threads_i_create") diff --git a/remarkbox/scripts/alembic/versions/76deff88cfd6_create_graph_depth_column_on_node_model.py b/remarkbox/scripts/alembic/versions/76deff88cfd6_create_graph_depth_column_on_node_model.py new file mode 100644 index 0000000..73a47af --- /dev/null +++ b/remarkbox/scripts/alembic/versions/76deff88cfd6_create_graph_depth_column_on_node_model.py @@ -0,0 +1,31 @@ +"""create graph_depth column on node model. + +Revision ID: 76deff88cfd6 +Revises: d1a0ab4f617f +Create Date: 2020-02-23 20:51:49.889183 + +""" + +# revision identifiers, used by Alembic. +revision = '76deff88cfd6' +down_revision = 'd1a0ab4f617f' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + 'rb_node', + sa.Column( + 'graph_depth', + sa.Integer(), + nullable=False, + server_default="-1", + ) + ) + +def downgrade(): + op.drop_column('rb_node', 'graph_depth') diff --git a/remarkbox/scripts/alembic/versions/771ef7c868de_added_created_timestamp_column_to_.py b/remarkbox/scripts/alembic/versions/771ef7c868de_added_created_timestamp_column_to_.py new file mode 100644 index 0000000..59d0491 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/771ef7c868de_added_created_timestamp_column_to_.py @@ -0,0 +1,31 @@ +"""Added created_timestamp column to namespace_request + +Revision ID: 771ef7c868de +Revises: 6fc1d0cf350c +Create Date: 2018-10-08 23:06:50.252729 + +""" + +# revision identifiers, used by Alembic. +revision = '771ef7c868de' +down_revision = '6fc1d0cf350c' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from time import time + +def now_timestamp(): + return int(time() * 1000) + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('rb_namespace_request', sa.Column('created_timestamp', sa.BigInteger(), nullable=False, server_default=str(now_timestamp()))) + op.create_index(op.f('ix_rb_namespace_request_created_timestamp'), 'rb_namespace_request', ['created_timestamp'], unique=False) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('rb_namespace_request', 'created_timestamp') diff --git a/remarkbox/scripts/alembic/versions/7a3a4755b4a8_add_hide_unless_approved_column.py b/remarkbox/scripts/alembic/versions/7a3a4755b4a8_add_hide_unless_approved_column.py new file mode 100644 index 0000000..1452b03 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/7a3a4755b4a8_add_hide_unless_approved_column.py @@ -0,0 +1,30 @@ +"""add hide_unless_approved column + +Revision ID: 7a3a4755b4a8 +Revises: 1040ee4836af +Create Date: 2017-08-09 21:53:01.995660 + +""" + +# revision identifiers, used by Alembic. +revision = "7a3a4755b4a8" +down_revision = "1040ee4836af" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "Namespace", sa.Column("hide_unless_approved", sa.Boolean(), nullable=True) + ) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column("Namespace", "hide_unless_approved") + ### end Alembic commands ### diff --git a/remarkbox/scripts/alembic/versions/87fbd2b4e06e_namespace_theme_embed_column.py b/remarkbox/scripts/alembic/versions/87fbd2b4e06e_namespace_theme_embed_column.py new file mode 100644 index 0000000..84ba8fc --- /dev/null +++ b/remarkbox/scripts/alembic/versions/87fbd2b4e06e_namespace_theme_embed_column.py @@ -0,0 +1,26 @@ +"""namespace theme embed column + +Revision ID: 87fbd2b4e06e +Revises: 591800942adf +Create Date: 2017-09-27 10:20:27.168937 + +""" + +# revision identifiers, used by Alembic. +revision = "87fbd2b4e06e" +down_revision = "591800942adf" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "Namespace", sa.Column("theme_embed", sa.Unicode(length=256), nullable=True) + ) + + +def downgrade(): + op.drop_column("Namespace", "theme_embed") diff --git a/remarkbox/scripts/alembic/versions/953d42406dbb_namespace_public_column.py b/remarkbox/scripts/alembic/versions/953d42406dbb_namespace_public_column.py new file mode 100644 index 0000000..c6a726e --- /dev/null +++ b/remarkbox/scripts/alembic/versions/953d42406dbb_namespace_public_column.py @@ -0,0 +1,24 @@ +"""namespace public column + +Revision ID: 953d42406dbb +Revises: 59592cf325fb +Create Date: 2017-07-08 15:48:41.261312 + +""" + +# revision identifiers, used by Alembic. +revision = "953d42406dbb" +down_revision = "59592cf325fb" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column("Namespace", sa.Column("public", sa.Boolean(), nullable=True)) + + +def downgrade(): + op.drop_column("Namespace", "public") diff --git a/remarkbox/scripts/alembic/versions/98a90d2add21_namespace_model.py b/remarkbox/scripts/alembic/versions/98a90d2add21_namespace_model.py new file mode 100644 index 0000000..69468f9 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/98a90d2add21_namespace_model.py @@ -0,0 +1,36 @@ +"""namespace model + +Revision ID: 98a90d2add21 +Revises: 04e52337756e +Create Date: 2017-07-03 16:13:09.160494 + +""" + +# revision identifiers, used by Alembic. +revision = "98a90d2add21" +down_revision = "04e52337756e" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from remarkbox.models import UUIDType + + +def upgrade(): + # we don't need to create the table. + # it will be created automatically during release. + # We do need to create the namespace_id column on the node table. + op.add_column(u"Node", sa.Column("namespace_id", UUIDType, nullable=True)) + + # this fails so I had to comment it out. + # op.create_foreign_key(None, 'Node', 'Namespace', ['namespace_id'], ['id']) + + +def downgrade(): + op.drop_constraint(None, "Node", type_="foreignkey") + op.drop_column(u"Node", "namespace_id") + op.drop_index(op.f("ix_Namespace_name"), table_name="Namespace") + op.drop_index(op.f("ix_Namespace_id"), table_name="Namespace") + op.drop_table("Namespace") diff --git a/remarkbox/scripts/alembic/versions/98ced5f6ff36_stripe_id_for_user.py b/remarkbox/scripts/alembic/versions/98ced5f6ff36_stripe_id_for_user.py new file mode 100644 index 0000000..fca35fb --- /dev/null +++ b/remarkbox/scripts/alembic/versions/98ced5f6ff36_stripe_id_for_user.py @@ -0,0 +1,26 @@ +"""stripe_id for User + +Revision ID: 98ced5f6ff36 +Revises: f5f88293428a +Create Date: 2017-10-19 10:23:30.236902 + +""" + +# revision identifiers, used by Alembic. +revision = "98ced5f6ff36" +down_revision = "f5f88293428a" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "rb_user", sa.Column("stripe_id", sa.Unicode(length=18), nullable=True) + ) + + +def downgrade(): + op.drop_column("rb_user", "stripe_id") diff --git a/remarkbox/scripts/alembic/versions/a26302bcfa25_add_created_timestamp_column_to_oauth_.py b/remarkbox/scripts/alembic/versions/a26302bcfa25_add_created_timestamp_column_to_oauth_.py new file mode 100644 index 0000000..8cf5709 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/a26302bcfa25_add_created_timestamp_column_to_oauth_.py @@ -0,0 +1,41 @@ +"""Add created_timestamp column to Oauth class, add email_notify enum to Namespace + +Revision ID: a26302bcfa25 +Revises: 00211ed642cc +Create Date: 2018-05-26 19:18:53.858014 + +""" + +# revision identifiers, used by Alembic. +revision = "a26302bcfa25" +down_revision = "00211ed642cc" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "rb_oauth", + sa.Column( + "created_timestamp", + sa.BigInteger(), + nullable=False, + server_default="1527378962438", + ), + ) + op.add_column( + "rb_namespace", + sa.Column( + "email_notify", + sa.Enum("immediately", "daily", "weekly", name="email_notify"), + nullable=True, + ), + ) + + +def downgrade(): + op.drop_column("rb_oauth", "created_timestamp") + op.drop_column("rb_namespace", "email_notify") diff --git a/remarkbox/scripts/alembic/versions/a38c12eeff2d_locked_rb_node.py b/remarkbox/scripts/alembic/versions/a38c12eeff2d_locked_rb_node.py new file mode 100644 index 0000000..2c35613 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/a38c12eeff2d_locked_rb_node.py @@ -0,0 +1,24 @@ +"""locked-rb-node + +Revision ID: a38c12eeff2d +Revises: 76deff88cfd6 +Create Date: 2020-04-20 10:22:43.611168 + +""" + +# revision identifiers, used by Alembic. +revision = 'a38c12eeff2d' +down_revision = '76deff88cfd6' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('rb_node', sa.Column('locked', sa.Boolean(), nullable=True)) + + +def downgrade(): + op.drop_column('rb_node', 'locked') diff --git a/remarkbox/scripts/alembic/versions/a738d6859f18_new_namespace_columns_2.py b/remarkbox/scripts/alembic/versions/a738d6859f18_new_namespace_columns_2.py new file mode 100644 index 0000000..bfb744f --- /dev/null +++ b/remarkbox/scripts/alembic/versions/a738d6859f18_new_namespace_columns_2.py @@ -0,0 +1,37 @@ +"""new namespace columns 2 + +Revision ID: a738d6859f18 +Revises: 98ced5f6ff36 +Create Date: 2017-11-06 00:05:09.284352 + +""" + +# revision identifiers, used by Alembic. +revision = "a738d6859f18" +down_revision = "98ced5f6ff36" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "rb_namespace", + sa.Column("google_analytics_id", sa.Unicode(length=18), nullable=True), + ) + op.add_column( + "rb_namespace", + sa.Column("no_nodes_text", sa.Unicode(length=256), nullable=True), + ) + op.add_column( + "rb_namespace", + sa.Column("owner_request_timestamp", sa.BigInteger(), nullable=True), + ) + + +def downgrade(): + op.drop_column("rb_namespace", "owner_request_timestamp") + op.drop_column("rb_namespace", "no_nodes_text") + op.drop_column("rb_namespace", "google_analytics_id") diff --git a/remarkbox/scripts/alembic/versions/aa2dfb4decda_hide_powered_by_column.py b/remarkbox/scripts/alembic/versions/aa2dfb4decda_hide_powered_by_column.py new file mode 100644 index 0000000..12db19a --- /dev/null +++ b/remarkbox/scripts/alembic/versions/aa2dfb4decda_hide_powered_by_column.py @@ -0,0 +1,26 @@ +"""hide_powered_by column + +Revision ID: aa2dfb4decda +Revises: 4aeda8e797ab +Create Date: 2018-03-15 15:44:31.591391 + +""" + +# revision identifiers, used by Alembic. +revision = "aa2dfb4decda" +down_revision = "4aeda8e797ab" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "rb_namespace", sa.Column("hide_powered_by", sa.Boolean(), nullable=True) + ) + + +def downgrade(): + op.drop_column("rb_namespace", "hide_powered_by") diff --git a/remarkbox/scripts/alembic/versions/b132ec968f0c_pay_what_you_can_table.py b/remarkbox/scripts/alembic/versions/b132ec968f0c_pay_what_you_can_table.py new file mode 100644 index 0000000..b834c3d --- /dev/null +++ b/remarkbox/scripts/alembic/versions/b132ec968f0c_pay_what_you_can_table.py @@ -0,0 +1,33 @@ +"""pay what you can table + +Revision ID: b132ec968f0c +Revises: 184a349cd589 +Create Date: 2021-01-31 09:47:47.220938 + +""" + +# revision identifiers, used by Alembic. +revision = 'b132ec968f0c' +down_revision = '184a349cd589' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from sqlalchemy_utils import UUIDType as TempUUIDType +UUIDType = TempUUIDType(binary=False) + + +def upgrade(): + op.create_table('rb_pay_what_you_can', + sa.Column('user_id', UUIDType, nullable=False), + sa.Column('frequency', sa.Enum('once', 'yearly', name='frequency'), nullable=False), + sa.Column('amount', sa.BigInteger(), nullable=False), + sa.Column('contributions', sa.BigInteger(), nullable=False), + sa.Column('created_timestamp', sa.BigInteger(), nullable=False), + sa.Column('updated_timestamp', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['rb_user.id'], ), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_index(op.f('ix_rb_pay_what_you_can_user_id'), 'rb_pay_what_you_can', ['user_id'], unique=False) diff --git a/remarkbox/scripts/alembic/versions/ba8e83a3b8fd_namespace_stylesheet_embed_column.py b/remarkbox/scripts/alembic/versions/ba8e83a3b8fd_namespace_stylesheet_embed_column.py new file mode 100644 index 0000000..f71a38e --- /dev/null +++ b/remarkbox/scripts/alembic/versions/ba8e83a3b8fd_namespace_stylesheet_embed_column.py @@ -0,0 +1,31 @@ +"""Namespace stylesheet_embed column + +Revision ID: ba8e83a3b8fd +Revises: 87fbd2b4e06e +Create Date: 2017-10-01 11:00:26.447925 + +""" + +# revision identifiers, used by Alembic. +revision = "ba8e83a3b8fd" +down_revision = "87fbd2b4e06e" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "Namespace", sa.Column("stylesheet_embed", sa.UnicodeText(), nullable=True) + ) + op.add_column( + "Namespace", + sa.Column("stylesheet_embed_timestamp", sa.BigInteger(), nullable=True), + ) + + +def downgrade(): + op.drop_column("Namespace", "stylesheet_embed_timestamp") + op.drop_column("Namespace", "stylesheet_embed") diff --git a/remarkbox/scripts/alembic/versions/c9793937e756_make_all_verified_namespace_request_.py b/remarkbox/scripts/alembic/versions/c9793937e756_make_all_verified_namespace_request_.py new file mode 100644 index 0000000..1a0f1e4 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/c9793937e756_make_all_verified_namespace_request_.py @@ -0,0 +1,38 @@ +"""add root_id field + +Revision ID: c9793937e756 +Revises: 051f8b810f26 +Create Date: 2017-01-14 20:59:39.701946 + +""" + +# revision identifiers, used by Alembic. +revision = "c9793937e756" +down_revision = "051f8b810f26" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + +from remarkbox.models import NamespaceRequest + + +def upgrade(): + + bind = op.get_bind() + DBSession = sa.orm.Session(bind=bind) + namespace_requests = DBSession.query(NamespaceRequest).all() + + for namespace_request in namespace_requests: + if namespace_request.verified: + namespace = namespace_request.namespace + namespace.set_role_for_user(namespace_request.user, "owner") + DBSession.add(namespace) + + DBSession.flush() + DBSession.commit() + + +def downgrade(): + pass diff --git a/remarkbox/scripts/alembic/versions/c97ddfcbc406_approve_column.py b/remarkbox/scripts/alembic/versions/c97ddfcbc406_approve_column.py new file mode 100644 index 0000000..f55f4be --- /dev/null +++ b/remarkbox/scripts/alembic/versions/c97ddfcbc406_approve_column.py @@ -0,0 +1,24 @@ +"""approve column + +Revision ID: c97ddfcbc406 +Revises: 2e00b92495e4 +Create Date: 2017-08-13 18:49:09.057118 + +""" + +# revision identifiers, used by Alembic. +revision = "c97ddfcbc406" +down_revision = "2e00b92495e4" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column("Node", sa.Column("approved", sa.Boolean(), nullable=True)) + + +def downgrade(): + op.drop_column("Node", "approved") diff --git a/remarkbox/scripts/alembic/versions/cbdf3cd4ccba_avatar_size_in_namespace.py b/remarkbox/scripts/alembic/versions/cbdf3cd4ccba_avatar_size_in_namespace.py new file mode 100644 index 0000000..c537358 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/cbdf3cd4ccba_avatar_size_in_namespace.py @@ -0,0 +1,24 @@ +"""avatar_size in namespace + +Revision ID: cbdf3cd4ccba +Revises: 953d42406dbb +Create Date: 2017-07-15 12:45:52.158320 + +""" + +# revision identifiers, used by Alembic. +revision = "cbdf3cd4ccba" +down_revision = "953d42406dbb" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column("Namespace", sa.Column("avatar_size", sa.Integer(), nullable=True)) + + +def downgrade(): + op.drop_column("Namespace", "avatar_size") diff --git a/remarkbox/scripts/alembic/versions/d1a0ab4f617f_add_has_uri_to_node.py b/remarkbox/scripts/alembic/versions/d1a0ab4f617f_add_has_uri_to_node.py new file mode 100644 index 0000000..9c191e0 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/d1a0ab4f617f_add_has_uri_to_node.py @@ -0,0 +1,24 @@ +"""add has_uri to Node + +Revision ID: d1a0ab4f617f +Revises: 6db39621f46c +Create Date: 2019-06-29 16:23:09.323221 + +""" + +# revision identifiers, used by Alembic. +revision = 'd1a0ab4f617f' +down_revision = '6db39621f46c' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('rb_node', sa.Column('has_uri', sa.Boolean(), nullable=False, server_default="0")) + + +def downgrade(): + op.drop_column('rb_node', 'has_uri') diff --git a/remarkbox/scripts/alembic/versions/f5f88293428a_add_mathjax_column_to_rb_namespace.py b/remarkbox/scripts/alembic/versions/f5f88293428a_add_mathjax_column_to_rb_namespace.py new file mode 100644 index 0000000..f712e23 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/f5f88293428a_add_mathjax_column_to_rb_namespace.py @@ -0,0 +1,26 @@ +"""add mathjax column to rb_namespace + +Revision ID: f5f88293428a +Revises: ba8e83a3b8fd +Create Date: 2017-10-14 23:13:46.584415 + +""" + +# revision identifiers, used by Alembic. +revision = "f5f88293428a" +down_revision = "ba8e83a3b8fd" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "rb_namespace", sa.Column("mathjax", sa.Boolean(), server_default=sa.false()) + ) + + +def downgrade(): + op.drop_column("rb_namespace", "mathjax") diff --git a/remarkbox/scripts/alembic/versions/f7b42b2024fb_namespace_reverse_node_order.py b/remarkbox/scripts/alembic/versions/f7b42b2024fb_namespace_reverse_node_order.py new file mode 100644 index 0000000..c489a3c --- /dev/null +++ b/remarkbox/scripts/alembic/versions/f7b42b2024fb_namespace_reverse_node_order.py @@ -0,0 +1,24 @@ +"""namespace reverse node order + +Revision ID: f7b42b2024fb +Revises: 40ded1163f95 +Create Date: 2019-03-19 11:17:31.427157 + +""" + +# revision identifiers, used by Alembic. +revision = 'f7b42b2024fb' +down_revision = '40ded1163f95' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('rb_namespace', sa.Column('reverse_order', sa.Boolean(), nullable=True)) + +def downgrade(): + pass diff --git a/remarkbox/scripts/alembic/versions/fa8402aa1a00_add_link_protection_column_to_namespace.py b/remarkbox/scripts/alembic/versions/fa8402aa1a00_add_link_protection_column_to_namespace.py new file mode 100644 index 0000000..8515499 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/fa8402aa1a00_add_link_protection_column_to_namespace.py @@ -0,0 +1,26 @@ +"""Add link_protection column to Namespace + +Revision ID: fa8402aa1a00 +Revises: 0208c10ea67f +Create Date: 2018-04-05 15:08:49.493735 + +""" + +# revision identifiers, used by Alembic. +revision = "fa8402aa1a00" +down_revision = "0208c10ea67f" +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column( + "rb_namespace", sa.Column("link_protection", sa.Boolean(), nullable=True) + ) + + +def downgrade(): + op.drop_column("rb_namespace", "link_protection") diff --git a/remarkbox/scripts/dump_sqlite3_to_csv.sh b/remarkbox/scripts/dump_sqlite3_to_csv.sh new file mode 100644 index 0000000..6ab7f0d --- /dev/null +++ b/remarkbox/scripts/dump_sqlite3_to_csv.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# obtains all data tables from database +TS=`sqlite3 $1 "SELECT tbl_name FROM sqlite_master WHERE type='table' and tbl_name not like 'sqlite_%';"` + +# exports each table to csv +for T in $TS; do + +sqlite3 $1 < [var=value]\n" + '(example: "%s development.ini")' % (cmd, cmd) + ) + sys.exit(1) + + +def main(argv=sys.argv): + if len(argv) < 2: + usage(argv) + config_uri = argv[1] + options = parse_vars(argv[2:]) + setup_logging(config_uri) + settings = get_appsettings(config_uri, options=options) + + engine = get_engine(settings) + Base.metadata.create_all(engine) + + session_factory = get_session_factory(engine) + + Base.metadata.create_all(engine) + + session_factory = get_session_factory(engine) + + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) diff --git a/remarkbox/scripts/invalidate_node_cache.py b/remarkbox/scripts/invalidate_node_cache.py new file mode 100644 index 0000000..d565464 --- /dev/null +++ b/remarkbox/scripts/invalidate_node_cache.py @@ -0,0 +1,32 @@ +from pyramid.paster import bootstrap, setup_logging + +from remarkbox.models import invalidate_all_node_cache_objects + +from . import base_parser + + +def main(): + parser = base_parser("Recompute denormalized fields and caches.") + args = parser.parse_args() + setup_logging(args.config) + + with bootstrap(args.config) as env, env["request"].tm as tm: + request = env["request"] + + # invalidate all NodeCache objs to force recomputation. + invalidate_all_node_cache_objects(request.dbsession) + + # get all Uri objects. + uris = m.uri.get_all_uris(request.dbsession) + + # interate over all Uri objects. + for uri in uris: + + if uri.node: + # modify the Uri's related Node. + uri.node.has_uri = True + # add the related Node object to the sqlalchemy session. + request.dbsession.add(uri.node) + + # flush / commit all changes stored the the sqlachemy session. + request.dbsession.flush() diff --git a/remarkbox/scripts/json_import.py b/remarkbox/scripts/json_import.py new file mode 100644 index 0000000..d2e7d46 --- /dev/null +++ b/remarkbox/scripts/json_import.py @@ -0,0 +1,155 @@ +import json + +from pyramid.paster import bootstrap, setup_logging + +from ..models import ( + User, + UserSurrogate, + generate_user_name, + get_user_by_email, + get_user_surrogate_by_name, + get_or_create_node_by_uri, + is_user_name_valid, + is_user_name_available, +) + +from miscutils import generate_password + +from . import base_parser + +try: + unicode("") +except: + from six import u as unicode + + +def get_arg_parser(): + parser = base_parser("import comments from JSON") + # parser.add_argument('--dry-run', action='store_true', default=False) + parser.add_argument('--user-surrogates', action='store_true', default=False) + parser.add_argument("--group") + parser.add_argument("json_path", help="path to JSON file") + return parser + + +def main(): + parser = get_arg_parser() + args = parser.parse_args() + setup_logging(args.config) + + # group = generate_password(3) + group = "" + if args.group: + group = args.group + + with open(args.json_path) as f: + pages = json.load(f) + + with bootstrap(args.config) as env, env["request"].tm as tm: + request = env["request"] + + users = {} + user_surrogates = {} + nodes = {} + + for slug, page_data in pages.items(): + + comments = page_data["comments"] + + if len(comments) == 0: + # skipping because this page has no comments. + continue + + uri = page_data["link"] + + print("creating page for {}".format(uri)) + root = get_or_create_node_by_uri(request.dbsession, uri) + request.dbsession.add(root) + request.dbsession.flush() + + for comment in comments: + + email = comment["email"] + + user = None + user_surrogate = None + + if not email: + if args.user_surrogates: + name = comment["author"] + if name not in user_surrogates: + user_surrogates[name] = UserSurrogate( + name, + root.namespace, + ) + user_surrogate = user_surrogates[name] + request.dbsession.add(user_surrogate) + request.dbsession.flush() + else: + # an email may not be empty or None. + continue + + elif email in users: + user = users[email] + else: + user = get_user_by_email(request.dbsession, email) + if user: + users[email] = user + print("found user for {} ({})".format(email, user.name)) + else: + desired_name = ( + comment["author"].replace(" ", "-").replace("_", "-") + ) + if group: + desired_name = desired_name + "-" + group + if not is_user_name_valid( + desired_name + ) or not is_user_name_available( + request.dbsession, desired_name + ): + desired_name = generate_user_name( + request.dbsession, desired_name + ) + user = User(email) + user.name = unicode(desired_name) + users[email] = user + print("created user for {} ({})".format(email, desired_name)) + + request.dbsession.add(user) + request.dbsession.flush() + + + node = root.new_child() + node.set_data(unicode(comment["content"])) + node.created = int(comment["timestamp"]) * 1000 + node.changed = int(comment["timestamp"]) * 1000 + + if user is not None: + node.user = user + elif user_surrogate is not None: + node.user_surrogate = user_surrogate + + node.verified = True + node.ip_address = comment.get("author_ip", None) + nodes[comment["id"]] = node + + request.dbsession.add(nodes[comment["id"]]) + request.dbsession.flush() + + for comment in comments: + if comment["parent_id"]: + print( + "id {}, parent_id {}".format( + comment["id"], comment["parent_id"] + ) + ) + if nodes.get(comment["parent_id"]): + nodes[comment["id"]].parent_id = nodes[comment["parent_id"]].id + request.dbsession.add(nodes[comment["id"]]) + request.dbsession.flush() + else: + print( + "parent_id ({}) is not found, skipping".format( + comment["parent_id"] + ) + ) diff --git a/remarkbox/scripts/load_csv_into_postgres.sh b/remarkbox/scripts/load_csv_into_postgres.sh new file mode 100644 index 0000000..69fb036 --- /dev/null +++ b/remarkbox/scripts/load_csv_into_postgres.sh @@ -0,0 +1,12 @@ +for file in $(ls *.csv) +do + table_name="${file%.*}" + column_names=$(head -n1 $file | sed -e 's/\r//g') + sql_statement="COPY $table_name ($column_names) FROM '/tmp/$file' WITH DELIMITER ',' CSV HEADER;" + # use this to echo out all the psql commands instead of running them. + echo "psql --dbname remarkbox -c \"$sql_statement\"" +done + + +# this is an example importing a single table from a CSV file. +#psql --dbname remarkbox -c "COPY rb_user (id,name,email,password,created,gravatar,verified,disabled,admin,email_id,password_timestamp) FROM 'rb_user.csv' WITH DELIMITER ',' CSV HEADER; diff --git a/remarkbox/scripts/merge_dupes.py b/remarkbox/scripts/merge_dupes.py new file mode 100644 index 0000000..8f1f2a2 --- /dev/null +++ b/remarkbox/scripts/merge_dupes.py @@ -0,0 +1,49 @@ +from collections import defaultdict + +from pyramid.paster import bootstrap, setup_logging + +from ..models import get_namespace_by_name, get_nodes_who_share_root + +from . import base_parser + + +def get_arg_parser(): + parser = base_parser("Given a namespace find dupe uri roots and merge them.") + parser.add_argument("namespace") + return parser + + +def main(): + parser = get_arg_parser() + args = parser.parse_args() + setup_logging(args.config) + + uris = defaultdict(list) + + with bootstrap(args.config) as env, env["request"].tm as tm: + request = env["request"] + + namespace = get_namespace_by_name(request.dbsession, args.namespace) + if namespace is not None: + + for root in namespace.roots: + if root.uri is None: + continue + uris[root.uri.data].append(root) + + for uri, roots in uris.items(): + if len(roots) == 2: + for child in roots[0].children: + child.parent_id = roots[1].id + request.dbsession.add(child) + nodes = get_nodes_who_share_root(request.dbsession, roots[0]) + for node in nodes: + node.root_id = roots[1].id + request.dbsession.add(node) + request.dbsession.flush() + request.dbsession.delete(roots[0]) + request.dbsession.delete(roots[0].uri) + request.dbsession.flush() + + else: + print("no such namespace ({})".format(args.namespace)) diff --git a/remarkbox/scripts/modify_namespace.py b/remarkbox/scripts/modify_namespace.py new file mode 100644 index 0000000..bc4c8fe --- /dev/null +++ b/remarkbox/scripts/modify_namespace.py @@ -0,0 +1,159 @@ +from __future__ import print_function + +from pyramid.paster import bootstrap, setup_logging + +from ..models import get_or_create_namespace, get_namespace_by_name, get_nodes_who_share_roots + +from ..models.meta import SUBSCRIPTION_TYPES + +from . import base_parser + + +toggle = lambda x: not x + + +def get_arg_parser(): + parser = base_parser("Modify a Namespace.") + parser.add_argument( + "--toggle-hide-unless-approved", action="store_true", default=False + ) + parser.add_argument("--create", action="store_true", default=False) + parser.add_argument("--delete", action="store_true", default=False) + parser.add_argument("--delete-children-nodes", action="store_true", default=False, help="this is useful if you mess up an import.") + parser.add_argument("--info", action="store_true", default=False) + parser.add_argument("--subscription-type", choices=SUBSCRIPTION_TYPES) + parser.add_argument("namespace") + return parser + + +def main(): + parser = get_arg_parser() + args = parser.parse_args() + setup_logging(args.config) + + with bootstrap(args.config) as env, env["request"].tm as tm: + sp = tm.savepoint() + request = env["request"] + + if args.create: + namespace = get_or_create_namespace(request.dbsession, args.namespace) + request.dbsession.add(namespace) + request.dbsession.flush() + else: + namespace = get_namespace_by_name(request.dbsession, args.namespace) + + if not namespace: + print("Namespace '{}' not found.".format(args.namespace)) + exit() + + if args.info: + from collections import OrderedDict + + namespace_dict = OrderedDict( + sorted(namespace.__dict__.items(), key=lambda t: t[0]) + ) + for key, value in namespace_dict.items(): + print("{}: ".format(key), end="") + print(value) + + print("owners:") + for user in namespace.owners: + print(" {}: {}".format(user.name, user.email)) + + print("moderators:") + for user in namespace.moderators: + print(" {}: {}".format(user.name, user.email)) + + exit() + + elif args.subscription_type: + namespace.subscription_type = args.subscription_type + request.dbsession.add(namespace) + request.dbsession.flush() + + elif args.toggle_hide_unless_approved: + namespace.hide_unless_approved = toggle(namespace.hide_unless_approved) + request.dbsession.add(namespace) + request.dbsession.flush() + + elif args.delete_children_nodes: + nodes = get_nodes_who_share_roots(request.dbsession, namespace.roots) + + # delete all children nodes. + for node in nodes: + if node.is_root: + # Skip root nodes to keep Uri and Watchers relations. + continue + request.dbsession.delete(node) + + if ( + raw_input("*** DANGER: Really delete Nodes forever? [yes, no]: ") + == "yes" + ): + request.dbsession.flush() + print("Flushed transaction to database, the Nodes were completely destroyed!") + else: + sp.rollback() + print("Rolled back transaction, sheepishly refused to delete anything!") + + elif args.delete: + + nodes = get_nodes_who_share_roots(request.dbsession, namespace.roots) + + # delete all nodes. + for node in nodes: + if node.events: + # delete all related NodeEvents. + for event in node.events: + request.dbsession.delete(event) + + # delete node. + request.dbsession.delete(node) + + for root in namespace.roots: + if root.uri: + # delete related Uri. + request.dbsession.delete(root.uri) + if root.cache: + # delete related NodeCache. + request.dbsession.delete(root.cache) + if root.watchers: + # delete all related NodeEventWatchers. + for watcher in root.watchers: + # delete all unsent NodeEventNotifications. + if watcher.unsent_notifications(): + for notification in watcher.unsent_notifications(): + request.dbsession.delete(notification) + request.dbsession.delete(watcher) + + if namespace.watchers: + for watcher in namespace.watchers: + # delete all unsent NodeEventNotifications. + if watcher.unsent_notifications(): + for notification in watcher.unsent_notifications(): + request.dbsession.delete(notification) + request.dbsession.delete(watcher) + + # disable all related NamespaceUser objects. + if namespace.namespace_users: + for nsu in namespace.namespace_users: + # delete related NamespaceUser. + request.dbsession.delete(nsu) + + # delete all related OauthRecords. + if namespace.oauth_records: + for oauth_record in namespace.oauth_records: + request.dbsession.delete(oauth_record) + + # finally delete Namespace. + request.dbsession.delete(namespace) + + if ( + raw_input("*** DANGER: Delete Namespace '{}' ({}) forever? [yes, no]: ".format(namespace.name, namespace.id)) + == "yes" + ): + request.dbsession.flush() + print("Flushed transaction to database, the Namespace was completely destroyed!") + else: + sp.rollback() + print("Rolled back transaction, sheepishly refused to delete anything!") diff --git a/remarkbox/scripts/modify_node.py b/remarkbox/scripts/modify_node.py new file mode 100644 index 0000000..25a79a7 --- /dev/null +++ b/remarkbox/scripts/modify_node.py @@ -0,0 +1,176 @@ +from pyramid.paster import bootstrap, setup_logging + +from ..models import get_node_by_uri, get_node_by_id + +from . import base_parser + +# edit node. +from os import environ +from tempfile import NamedTemporaryFile +from subprocess import call + +ANSI_ESCAPES = { + "HEADER": "\033[95m", + "OKBLUE": "\033[94m", + "OKGREEN": "\033[92m", + "WARNING": "\033[93m", + "FAIL": "\033[91m", + "DISABLED": "\033[37m", + "ENDC": "\033[0m", + "BOLD": "\033[1m", + "UNDERLINE": "\033[4m", +} + + +def ansi(msg, codes=None): + msg = str(msg) + if codes: + for code in codes: + msg = ANSI_ESCAPES[code] + msg + msg += ANSI_ESCAPES["ENDC"] + return msg + + +def color_node(node): + # if node.spam: + # return ansi(node.id, 'FAIL') + if node.disabled == True: + return ansi(node.id, codes=["DISABLED"]) + elif node.verified == False: + return ansi(node.id, codes=["WARNING"]) + else: + return ansi(node.id, codes=["OKGREEN"]) + + +def traverse(node): + """stolen from here: http://www.saltycrane.com/blog/2008/08/python-recursion-example-navigate-tree-data/""" + print(" " * traverse.level + str(color_node(node))) + for child in node.children: + traverse.level += 2 + traverse(child) + traverse.level -= 2 + + +def edit_node(node): + EDITOR = environ.get("EDITOR", "vim") + with NamedTemporaryFile(suffix=".md") as tf: + tf.write(node.data) + tf.flush() + call([EDITOR, tf.name]) + + # do the parsing with `tf` using regular File operations. + # for instance: + tf.seek(0) + new_node_data = tf.read() + + if new_node_data and new_node_data != node.data: + node.data = new_node_data + + +def info_node(node): + from collections import OrderedDict + + node_dict = OrderedDict(sorted(node.__dict__.items(), key=lambda t: t[0])) + for key, value in node_dict.items(): + print("{}: {}".format(key, value)) + if node.uri: + print(node.uri.data) + + +def get_arg_parser(): + parser = base_parser("Modify a Node.") + parser.add_argument("-u", "--uri", type=unicode, default=None) + parser.add_argument("-i", "--id", type=unicode, default=None) + parser.add_argument("--show", default=False, action="store_true") + parser.add_argument("--info", default=False, action="store_true") + parser.add_argument("--move", default=False, metavar="NEW-PARENT-ID") + parser.add_argument("--edit", default=False, action="store_true") + parser.add_argument("--disable", default=False, action="store_true") + parser.add_argument("--enable", default=False, action="store_true") + parser.add_argument("--delete", default=False, action="store_true") + parser.add_argument("--spam", default=False, action="store_true") + return parser + + +def main(): + parser = get_arg_parser() + args = parser.parse_args() + setup_logging(args.config) + + if args.uri and args.id: + print("do not pass both -u and -i flags.") + exit(1) + + action_flag_count = sum( + [ + args.show, + args.info, + bool(args.move), + args.disable, + args.enable, + args.spam, + args.delete, + args.edit, + ] + ) + if action_flag_count != 1: + if action_flag_count == 0: + parser.print_help() + else: + print("only pass one action flag.") + exit(1) + + with bootstrap(args.config) as env, env["request"].tm as tm: + request = env["request"] + + node = None + if args.uri is not None: + node = get_node_by_uri(request.dbsession, args.uri) + + elif args.id is not None: + node = get_node_by_id(request.dbsession, args.id) + + if node is None: + print("No such, node. Check the id or uri.") + exit(1) + + if args.show: + traverse.level = 1 + traverse(node) + + if args.info: + info_node(node) + + if args.delete: + if ( + raw_input("Delete node '{}' forever? [yes, no]: ".format(node.id)) + == "yes" + ): + request.dbsession.delete(node) + request.dbsession.flush() + else: + + if args.move: + parent = get_node_by_id(request.dbsession, args.move) + if parent is None: + print( + "The new parent id ({}) does not exist.".format(args.new_parent) + ) + exit(0) + node.parent_id = parent.id + node.root_id = parent.id + node.namespace = parent.namespace + if node.title: + node.title = "" + + if args.edit: + edit_node(node) + + if args.disable: + node.disable() + + if args.enable: + node.enable() + + request.dbsession.add(node) + request.dbsession.flush() diff --git a/remarkbox/scripts/modify_uris.py b/remarkbox/scripts/modify_uris.py new file mode 100644 index 0000000..e697c10 --- /dev/null +++ b/remarkbox/scripts/modify_uris.py @@ -0,0 +1,55 @@ +from pyramid.paster import bootstrap, setup_logging + +from ..models import get_namespace_by_name + +from . import base_parser + + +def get_arg_parser(): + parser = base_parser("Modify all uris which fall under namespace.") + parser.add_argument("namespace") + parser.add_argument("--add-trailing-slash", action="store_true", default=False) + parser.add_argument("--https", action="store_true", default=False) + parser.add_argument("--http", action="store_true", default=False) + parser.add_argument("--move-namespace-domain") + return parser + + +def main(): + parser = get_arg_parser() + args = parser.parse_args() + setup_logging(args.config) + + with bootstrap(args.config) as env, env["request"].tm as tm: + request = env["request"] + + namespace = get_namespace_by_name(request.dbsession, args.namespace) + if namespace is not None: + + for root in namespace.roots: + if root.uri is None: + continue + + # parsed miniuri object. + puri = root.uri.parsed + + if args.add_trailing_slash and not root.uri.data.endswith("/"): + print("adding trailing slash to: {}".format(root.uri.data)) + puri.path = puri.path + "/" + + if args.move_namespace_domain and "." in args.move_namespace_domain: + namespace.name = args.move_namespace_domain + puri.hostname = args.move_namespace_domain + + if args.http: + puri.scheme = "http" + + if args.https: + puri.scheme = "https" + + root.uri.data = puri.uri + request.dbsession.add(root.uri) + + request.dbsession.flush() + else: + print("no such namespace ({})".format(args.namespace)) diff --git a/remarkbox/scripts/modify_user.py b/remarkbox/scripts/modify_user.py new file mode 100644 index 0000000..1276d2c --- /dev/null +++ b/remarkbox/scripts/modify_user.py @@ -0,0 +1,83 @@ +from pyramid.paster import bootstrap, setup_logging + +from ..models import ( + get_user_by_email, + get_namespace_by_name, + get_namespace_request, + is_user_name_valid, + is_user_name_available, +) + +from . import base_parser + +from six import u as unicode + + +def get_arg_parser(): + parser = base_parser("Modify a user.") + parser.add_argument("-i", "--info", action="store_true", default=False) + parser.add_argument("email", type=unicode) + parser.add_argument("--namespace-remove", default=None, type=unicode) + parser.add_argument("--namespace-add", default=None, type=unicode) + parser.add_argument( + "--namespace-role", default=None, type=unicode, choices=[None, "owner"] + ) + parser.add_argument("--change-user-name", type=unicode) + return parser + + +def main(): + parser = get_arg_parser() + args = parser.parse_args() + setup_logging(args.config) + + with bootstrap(args.config) as env, env["request"].tm as tm: + request = env["request"] + + user = get_user_by_email(request.dbsession, args.email) + + if user is None: + print("No such user found by email: {}.".format(args.email)) + exit() + + if args.info: + from collections import OrderedDict + + user_dict = OrderedDict(sorted(user.__dict__.items(), key=lambda t: t[0])) + del user_dict["password"] + del user_dict["email_id"] + for key, value in user_dict.items(): + print("{}: {}".format(key, value)) + + print("namespaces:") + for namespace in user.namespaces: + print(" - {}".format(namespace.name)) + exit() + + if args.namespace_add: + namespace_to_add = get_namespace_by_name( + request.dbsession, args.namespace_add + ) + if namespace_to_add: + namespace_to_add.set_role_for_user(user, role=args.namespace_role) + if args.namespace_remove: + namespace_to_remove = get_namespace_by_name( + request.dbsession, args.namespace_remove + ) + if namespace_to_remove: + namespace_request_to_remove = get_namespace_request( + request.dbsession, user, namespace_to_remove + ) + if namespace_request_to_remove: + request.dbsession.delete(namespace_request_to_remove) + namespace_to_remove.clear_owner_request_timestamp() + namespace_to_remove.remove_user(user) + + if args.change_user_name: + if is_user_name_valid(args.change_user_name) and is_user_name_available( + request.dbsession, args.change_user_name + ): + user.name = args.change_user_name + + request.dbsession.add(user) + request.dbsession.flush() diff --git a/remarkbox/scripts/recompute_node_depths.py b/remarkbox/scripts/recompute_node_depths.py new file mode 100644 index 0000000..c902ff1 --- /dev/null +++ b/remarkbox/scripts/recompute_node_depths.py @@ -0,0 +1,21 @@ +from pyramid.paster import bootstrap, setup_logging + +from remarkbox.models import recompute_depth_on_all_node_objects + +from . import base_parser + + +def main(): + parser = base_parser( + "Recompute nested node.depth in relation their thread." + ) + parser.add_argument("--chunk", type=int, default=500,) + args = parser.parse_args() + setup_logging(args.config) + + with bootstrap(args.config) as env, env["request"].tm as tm: + request = env["request"] + + # Run node.recompute_depth() on all Node objects. + # Flush all changes stored in the sqlalchemy session to database. + recompute_depth_on_all_node_objects(request.dbsession, args.chunk) diff --git a/remarkbox/scripts/safe_approve_all_nodes.py b/remarkbox/scripts/safe_approve_all_nodes.py new file mode 100644 index 0000000..efcc3c7 --- /dev/null +++ b/remarkbox/scripts/safe_approve_all_nodes.py @@ -0,0 +1,29 @@ +from pyramid.paster import bootstrap, setup_logging + +from ..models import get_root_nodes, get_nodes_who_share_root + +from . import base_parser + + +def main(): + parser = base_parser( + "Safely approve all nodes, permitting namespace settings allow." + ) + args = parser.parse_args() + setup_logging(args.config) + + with bootstrap(args.config) as env, env["request"].tm as tm: + request = env["request"] + for root in get_root_nodes(request.dbsession): + if root.namespace is None: + # skip this root, it's namespace is None? + continue + if root.namespace.hide_unless_approved: + # skip this root, it's namespace is actively moderated! + continue + root.approved = True + request.dbsession.add(root) + for node in get_nodes_who_share_root(request.dbsession, root): + node.approved = True + request.dbsession.add(node) + request.dbsession.flush() diff --git a/remarkbox/scripts/send_node_digest_notifications.py b/remarkbox/scripts/send_node_digest_notifications.py new file mode 100644 index 0000000..4a6fa1b --- /dev/null +++ b/remarkbox/scripts/send_node_digest_notifications.py @@ -0,0 +1,19 @@ +from pyramid.paster import bootstrap, setup_logging + +from ..lib.notify import deliver_scheduled_notifications + +from . import base_parser + + +def get_arg_parser(): + parser = base_parser("Send Node Notification Digests.") + return parser + + +def main(): + parser = get_arg_parser() + args = parser.parse_args() + setup_logging(args.config) + + with bootstrap(args.config) as env: + deliver_scheduled_notifications(env["request"]) diff --git a/remarkbox/static/css/common.css b/remarkbox/static/css/common.css new file mode 100644 index 0000000..7f6c875 --- /dev/null +++ b/remarkbox/static/css/common.css @@ -0,0 +1,678 @@ +body { + overflow-x: hidden; + font-size: 1.0em; + line-height: 1.6; + font-weight: 400; + font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; + color: #222; +} + +h1, h2, h3, h4, h5, h6 { + margin-top: 0px; + margin-bottom: 0px; + font-size: 1.3em; + font-weight: 300; +} +h1 {font-size: 3.00rem;} +h2 {font-size: 2.50rem;} +h3 {font-size: 2.00rem;} +h4 {font-size: 1.75rem;} +h5 {font-size: 1.50rem;} +h6 {font-size: 1.25rem;} + +.node-data h1, +.node-data h2, +.node-data h3, +.node-data h4, +.node-data h5, +.node-data h6 { + font-size: 1.50rem; + margin-bottom: .3em; +} + +a, a:visited, a:hover { + color: #292929; + opacity: .75; + text-decoration: none; + font-weight: bold; +} + +a:hover { + opacity: 1.0; +} + +a.anchor { + display: block; + position: relative; + top: -150px; + visibility: hidden; +} + +a.mod::after { + content: " mod "; + background-color: #292929; + color: white; + border-radius: 8px; + margin-left: 4px; + margin-right: 4px; + padding: 1px; +} + +pre { + overflow: auto; + margin-top: 20px; + margin-bottom: 20px; + margin-left: 20px; + padding: 2%; + color: #000000; + font-size: 12px; + font-family: 'PT Mono', monospace; + line-height: 1.125em; + overflow-x: auto; + border-radius: 8px; + background-color: #FAFAFA; +} + +label.close { + margin-right: 16px; +} + +label { + display: block; + margin-bottom: .5rem; + font-weight: 600; +} + +code { + padding: 0.2em; + margin: 0; + background-color: rgba(27,31,35,0.05); + border-radius: 3px; +} + +blockquote { + font-style: italic; + margin-top: 10px; + margin-bottom: 10px; + margin-left: 15px; + padding-left: 15px; + border-left: 3px solid #ccc; +} + +button.link, +.button { + color: #292929; + text-align: left; + padding: 0; + opacity: .80; +} + +button, +button.link, +.button { + outline: none; +} + +button.link { + border: none; + background: none; +} + +button:hover, +button.link:hover, +.button:hover { + opacity: 1.0; +} + +span.avoidwrap { + display:inline-block; +} + +div.summary { + margin-top: 25px; + margin-bottom: 25px; +} + +p.whats-next { + margin-top: 15px; + margin-bottom: 15px; +} + +.node p { + margin-top: 0px; + margin-bottom: .5em; +} + +form.node-action { + display: inline; +} + +.node-actions { + margin-top: -.3em; +} + +.preview { + border-top: 1px dotted #bbbbbb; + border-bottom: 1px dotted #bbbbbb; + padding-top: 16px; + padding-bottom: 16px; + margin-top: 0px; + margin-bottom: 16px; + margin-right: 5px; + margin-left: 5px; + line-height: 1.6; +} + +.preview p { + margin-top: 15px; + margin-bottom: 15px; + line-height: 1.75; +} + +.preview-raw-markdown { + display: block; + white-space: pre-wrap; + background-color: unset; + border-radius: unset; + border: unset; + border-width: 0px; + + margin-top: 19px; + margin-bottom: 10px; +} + +.powered-by { + text-align: center; + margin-bottom: 0px; + margin-top: 8px; +} + +.nested-avatar { + margin-top: 6px; +} + +.avatar { + border-radius: 50%; + -moz-border-radius: 50%; + -webkit-border-radius: 50%; +} + +.user-settings-profile-pic { +} + +.focused { + animation: fade 4s forwards; + background-color: rgba(237, 237, 237, 1); +} + +@keyframes fade { + from {background-color:rgba(237, 237, 237, 1);} + to {background-color:rgba(237, 237, 237, 0);} +} + +/* ALERT START */ +#alerts { + z-index: 98; +} + +.alert { + opacity: .25; + display: grid; + grid-template-columns: 1fr 50px; + grid-gap: 10px; +} + +.alert-message { + text-align: center; + margin-top: 10px; + margin-bottom: 10px; +} + +.alert .close { + text-align: right; + margin-top: 10px; + margin-bottom: 10px; + margin-right: 15px; +} + +.alert-danger, .alert-error { + color: #721c24; + background-color: #f8d7da; + border-color: #f5c6cb; +} + +.alert-warning { + color: #856404; + background-color: #fff3cd; + border-color: #ffeeba; +} + +.alert-info, .alert-notice { + color: #0c5460; + background-color: #d1ecf1; + border-color: #bee5eb; +} + +.alert-success { + color: #155724; + background-color: #d4edda; + border-color: #c3e6cb; +} + +/* ALERT END */ + +.author-and-date, .status, .whats-next, .topic, .remark-count { + opacity: .50; + font-size: .80em; +} + +.date:before { + content: "\00b7"; +} + +.question-mark-circle:before +{ + content: '?'; + display: inline-block; + font-family: sans-serif; + font-weight: bold; + text-align: center; + width: 1.8ex; + height: 1.8ex; + font-size: 1.4ex; + line-height: 1.8ex; + border-radius: 1.2ex; + margin-right: 4px; + padding: 1px; + color: #cccccc; + background: white; + border: 1px solid #cccccc; + text-decoration: none; +} + +.question-mark-circle:hover:before +{ + color: white; + background: #cccccc; + text-decoration: none; +} + +.remark, .load-more, .action { + font-size: .75em; + margin-right: 8px; + font-weight: bold; + cursor: pointer; +} + +.center { + text-align: center; +} + +.load-more, .load-more:visited { + color: #336699; +} + +.button-right { + float: right; + margin-left: 8px; +} + +.button-max-width { + width: 100%; +} + +.opacity-20 {opacity: .2 !important;} +.opacity-40 {opacity: .4 !important;} +.opacity-60 {opacity: .6 !important;} +.opacity-80 {opacity: .8 !important;} + +.remark-box-div-main { + margin-bottom: 35px; +} + +.remark-box-div { + display: none; + /* this is needed to prevent "jumping" jquery bug. */ + overflow: hidden; + margin-top: 10px; +} + +.edit-box-div { + display: none; + /* this is needed to prevent "jumping" jquery bug. */ + overflow: hidden; + margin-top: 15px; +} + +.my-namespaces-div { + display: none; + position: absolute; + background-color: #ffffff; + z-index: 1; + /* this is needed to prevent "jumping" jquery bug. */ + overflow: hidden; + padding-top: 10px; + padding-bottom: 10px; +} + +#remarkbox-footer { + font-size: .80em; + font-weight: bold; +} + +#search { + display: inline; +} + +#keywords { + width: 100%; + line-height: 12px; + margin-bottom: 0px; + text-align: center; +} + +.common-text-input, .common-textarea { + width: 100%; + border-color: #bbbbbb; + border-style: solid; + border-width: 1px; + border-radius: 2px; + + margin-top: 7px; + + + /* This allows padding on inputs without causing scrollbars */ + padding: .5em; + box-sizing:border-box; + -moz-box-sizing:border-box; + -webkit-box-sizing:border-box; + -ms-box-sizing:border-box; + } + +.common-textarea { + height: 120px; +} + +.monospace { + font-family: monospace; +} + +#email_input {} + +#email2_input { + display: none; + margin-bottom: 0px; +} + +/* Stripe form */ +.StripeElement { + + border-color: #bbbbbb; + border-style: solid; + border-width: 1px; + border-radius: 2px; + background-color: white; + + width: 300px; + padding-top: 12px; + padding-bottom: 12px; + padding-left: 3px; + padding-right: 3px; +} + +.StripeElement--focus { + box-shadow: 0 1px 3px 0 #cfd7df; +} + +.StripeElement--invalid { + border-color: #fa755a; +} + +.StripeElement--webkit-autofill { + background-color: #fefde5 !important; +} + + +/* media viewports */ + +.shown-on-phone { + display: none; +} + +.shown-on-tablet { + display: none; +} + +.remarkbox-menu { + font-size: .80em; + + z-index: 98; + padding-top: 8px; + padding-bottom: 8px; + + display: grid; + grid-template-columns: 1fr; + grid-gap: 10px; +} + +.remarkbox-menu .user { + text-align: right; + padding: 8px; + padding-right: 48px; +} + +.remarkbox-menu .home { + padding: 8px; + padding-left: 48px; +} + +.remarkbox-sub-menu { + font-size: .80em; +} + +div.user-watching { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 10px; +} + +.call-to-action { + text-align: right; +} + +@media screen and (max-width:568px) { + h1, h2, h3, h4, h5, h6 {font-size: 1.5em !important;} + .hidden-on-phone {display: none} + .shown-on-phone {display: inline} + .remarkbox-menu { + padding-top: 0px; + } + .remarkbox-menu .search {display: none;} + .remarkbox-menu .home {padding: 0px; text-align: center;} + .remarkbox-menu .user {padding: 0px; text-align: center;} + .call-to-action {padding: 0px;} + .thread {padding-left: 40px;} +} + +@media screen and (min-width:568px) { + .navbar.hidden-on-tablet {display: none} + .hidden-on-tablet {display: none} + .shown-on-tablet {display: inline} + .remarkbox-menu {grid-template-columns: 1fr 1fr 1fr;} + .remarkbox-menu .home {padding: 8px; padding-left: 24px;} + .remarkbox-menu .user {padding: 8px; padding-right: 24px;} + + .call-to-action { + grid-column: 1 / span 3; + } +} + +@media screen and (min-width:750px) { + .remarkbox-menu {grid-template-columns: 1fr 1fr 1fr;} + .remarkbox-menu .home {padding: 8px; padding-left: 48px;} + .remarkbox-menu .user {padding: 8px; padding-right: 48px;} + + .call-to-action { + grid-column: 1 / span 3; + } +} + +/* + * + * burger phone menu + * + */ + +.burger-checkbox{ + position: fixed; + bottom: 20px; + right: 20px; + height: 20px; + opacity: 0; +} +.burger-label { + border-radius: 50%; + position: fixed; + bottom: 15px; + right: 20px; + height: 55px; + width: 55px; + z-index: 99; +} +.burger-label span { + position: absolute; + margin-left: 14px; + width: 26px; + height: 2px; + top: 50%; + margin-top: -1px; + left: 0; + display: block; + background: #020304; + transition: .5s; +} +.burger-label span:first-child { + top: 18px; +} +.burger-label span:last-child { + top: 38px; +} +.burger-label:hover { + cursor: pointer; +} +.burger-checkbox:checked + .burger-label span { + opacity: 0; + top: 50%; +} +.burger-checkbox:checked + .burger-label span:first-child { + opacity: 1; + transform: rotate(405deg); +} +.burger-checkbox:checked + .burger-label span:last-child { + opacity: 1; + transform: rotate(-405deg); +} +.burger-checkbox ~ .phone-menu { + background: white; + position: fixed; + bottom: 0; + right: 0; + width: 100%; + height: 0px; + z-index: 98; + transition: .25s; + transition-delay: .25s; + /*overflow-y: scroll;*/ +} +.burger-checkbox ~ .phone-menu > h5 { + margin-top: 15px; + margin-bottom: 15px; +} +.burger-checkbox ~ .phone-menu > ul, +.burger-checkbox ~ .phone-menu > h5 { + text-align: center; + left: 10%; + right: 10%; + margin-bottom: 0px; + margin-top: 20px; + padding-top: 0px; +} +.burger-checkbox ~ .phone-menu > ul { + margin: 0px; + padding: 0px; +} +.burger-checkbox ~ .phone-menu > ul > li, +.burger-checkbox ~ .phone-menu > h5 { + opacity: 0; + transition: .25s; + transition-delay: 0s; +} +.burger-checkbox ~ .phone-menu > ul > li > a { + text-decoration: none; + font-size: 11px; + font-weight: 700; + font-family: sans-serif; + display: block; + padding: 0px; +} +.burger-checkbox:checked ~ .phone-menu { + height: 100%; + transition-delay: 0s; +} +.burger-checkbox:checked ~ .phone-menu > ul > li, +.burger-checkbox:checked ~ .phone-menu > h5 { + opacity: 1; + transition-delay: .25s; +} +.phone-menu ul { + list-style-type: none; + padding-left: 0px; +} + +section.join-or-log-in { + margin-left: auto; + margin-right: auto; + max-width: 340px; +} + +/* + * + * funnel pages style + * + */ + +.funnel { + margin-top: 60px; + margin-left: auto; + margin-right: auto; + max-width: 340px; +} + +section.step-1, +section.step-2, +section.step-3, +section.step-4, +section.active-card, +section.new-card, +section.payment-card, +section.join-or-log-in, +section.well +{ + background-color: white; + padding: 10px 12px; + border-radius: 4px; + border: 1px solid transparent; + box-shadow: 0 1px 3px 0 #e6ebf1; + -webkit-transition: box-shadow 150ms ease; + transition: box-shadow 150ms ease; + text-align: center; +} + +section.step-4 { + text-align: left; +} + +input#namespace-domain { + margin-bottom: 10px; + text-align: center; +} diff --git a/remarkbox/static/css/embed.css b/remarkbox/static/css/embed.css new file mode 100644 index 0000000..6c26807 --- /dev/null +++ b/remarkbox/static/css/embed.css @@ -0,0 +1,38 @@ +body { + margin-left: 0px; + margin-top: 0px; + margin-right: 0px; + margin-bottom: 16px; + + /* This is a hack for iOS to prevent overflow on ipads and phones */ + width: 1px; + min-width: 100%; + *width: 100%; +} + +.remarkbox-menu { + grid-template-columns: 1fr; + font-size: 1em; +} + +.remarkbox-menu .home { + display: none; +} + +.remarkbox-menu .search { + display: none; +} + +.remarkbox-menu .user { + text-align: right; + padding: 0px; +} + +.call-to-action { + grid-column: 1; +} + +.avatar-nav .avatar { + margin-top: -4px; + margin-left: 6px; +} diff --git a/remarkbox/static/css/pygments.css b/remarkbox/static/css/pygments.css new file mode 100644 index 0000000..4e1f074 --- /dev/null +++ b/remarkbox/static/css/pygments.css @@ -0,0 +1,61 @@ +.hll { background-color: #ffffcc } +.c { color: #408080; font-style: italic } /* Comment */ +.err { border: 1px solid #FF0000 } /* Error */ +.k { color: #008000; font-weight: bold } /* Keyword */ +.o { color: #666666 } /* Operator */ +.cm { color: #408080; font-style: italic } /* Comment.Multiline */ +.cp { color: #BC7A00 } /* Comment.Preproc */ +.c1 { color: #408080; font-style: italic } /* Comment.Single */ +.cs { color: #408080; font-style: italic } /* Comment.Special */ +.gd { color: #A00000 } /* Generic.Deleted */ +.ge { font-style: italic } /* Generic.Emph */ +.gr { color: #FF0000 } /* Generic.Error */ +.gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.gi { color: #00A000 } /* Generic.Inserted */ +.go { color: #888888 } /* Generic.Output */ +.gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.gs { font-weight: bold } /* Generic.Strong */ +.gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.gt { color: #0044DD } /* Generic.Traceback */ +.kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.kp { color: #008000 } /* Keyword.Pseudo */ +.kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.kt { color: #B00040 } /* Keyword.Type */ +.m { color: #666666 } /* Literal.Number */ +.s { color: #BA2121 } /* Literal.String */ +.na { color: #7D9029 } /* Name.Attribute */ +.nb { color: #008000 } /* Name.Builtin */ +.nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.no { color: #880000 } /* Name.Constant */ +.nd { color: #AA22FF } /* Name.Decorator */ +.ni { color: #999999; font-weight: bold } /* Name.Entity */ +.ne { color: #D2413A; font-weight: bold } /* Name.Exception */ +.nf { color: #0000FF } /* Name.Function */ +.nl { color: #A0A000 } /* Name.Label */ +.nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.nt { color: #008000; font-weight: bold } /* Name.Tag */ +.nv { color: #19177C } /* Name.Variable */ +.ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.w { color: #bbbbbb } /* Text.Whitespace */ +.mf { color: #666666 } /* Literal.Number.Float */ +.mh { color: #666666 } /* Literal.Number.Hex */ +.mi { color: #666666 } /* Literal.Number.Integer */ +.mo { color: #666666 } /* Literal.Number.Oct */ +.sb { color: #BA2121 } /* Literal.String.Backtick */ +.sc { color: #BA2121 } /* Literal.String.Char */ +.sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.s2 { color: #BA2121 } /* Literal.String.Double */ +.se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ +.sh { color: #BA2121 } /* Literal.String.Heredoc */ +.si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ +.sx { color: #008000 } /* Literal.String.Other */ +.sr { color: #BB6688 } /* Literal.String.Regex */ +.s1 { color: #BA2121 } /* Literal.String.Single */ +.ss { color: #19177C } /* Literal.String.Symbol */ +.bp { color: #008000 } /* Name.Builtin.Pseudo */ +.vc { color: #19177C } /* Name.Variable.Class */ +.vg { color: #19177C } /* Name.Variable.Global */ +.vi { color: #19177C } /* Name.Variable.Instance */ +.il { color: #666666 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/remarkbox/static/favicon.ico b/remarkbox/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..524a91686952274227ef3a3f042effda56ec4248 GIT binary patch literal 5538 zcmeI0OKTKC6opS5Bn#09KEM}7f}sCF5CmQ5FK{FN1Gl=e+Dyi1f*~fFj0RsQ>bMa^ z(S_nFzCe6!#fYLCS3U;~8PDnN8hd7{XS#c0$Rd@TsjliT8&@JR8aT4vbU)?+g1aQagZBF1}}P zFKs>nr`YBkWh9nW-)=H*fyeCc8FiByrM%DVe}q!Z76l%9xAOWv(9ipKsMl19@>y)o z`?AZLObNKlzTQOil-HKae4xKc^8E%bvHnHGPArG18Xnr2Vc^)(^hDkv;A*7*D3h+m zb(@-W*fhk2HdVHdWZIXG$J+^=)^#33OgIz;$>m_!M=&RwLo)@govR*BR@>yYFKVpgYo8@|e z#T~9M>*YD030J6Is^R&SvM%fMMQ16U>l72|8Z|~8p|UciWw8u5=+kmU z^LCh$vb17|@%^pY!|2?3)4|?shoQ(m^VA9I zGG+ZG$;C8G8FH9|9LtYjOxll0+FKjTFu-ZqNI_3aWgw$E>1oW{7G=tJw@#pJJRG0;x-|2yS3($^U>{Rxd@_bK*fZfzos50P> XV+QcYRWZRhIe7j9m2xh~!}$CK!wa-k literal 0 HcmV?d00001 diff --git a/remarkbox/static/img/remarkbox-logo.png b/remarkbox/static/img/remarkbox-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..bc88595e1ce15f0d0d66cd3313776c97a5c3fb39 GIT binary patch literal 13177 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 literal 0 HcmV?d00001 diff --git a/remarkbox/static/img/remarkbox-minified.png b/remarkbox/static/img/remarkbox-minified.png new file mode 100644 index 0000000000000000000000000000000000000000..0eca2feceff544fd51a8f0d556e6d0ad5bcab6dd GIT binary patch literal 11978 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 literal 0 HcmV?d00001 diff --git a/remarkbox/static/js/custom.js b/remarkbox/static/js/custom.js new file mode 100644 index 0000000..ab28d8a --- /dev/null +++ b/remarkbox/static/js/custom.js @@ -0,0 +1,93 @@ +// post_preview +// previewTimer must live outside the functions. +var previewTimer = null; + +function previewAjax(textarea, div, show_raw = false, mathjax = false){ + // set div to raw textarea while waiting for remote Markdown rendering. + if (show_raw) { + // bust HTML tags like + diff --git a/remarkbox/templates/home.j2 b/remarkbox/templates/home.j2 new file mode 100644 index 0000000..983fa08 --- /dev/null +++ b/remarkbox/templates/home.j2 @@ -0,0 +1,121 @@ +{% extends request.base_template -%} +{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%} + +{% block call_to_action %} +{% if request.saas_home %} + +{% elif request.faq_home %} +new thread +{% endif %} +{% endblock %} + +{% block content -%} + +{% if request.user in request.namespace.users %} +{{ snippets.namespace_actions(request.namespace) }} +{% endif %} + +{% if not request.saas_home %} + +

{{ the_title}}

+{% if request.namespace.description %} +
{{ request.namespace.description }}
+{% endif %} + +{%- for node in nodes %} +
+ + {% if node.user %} + {{ snippets.user_link(node.user) }} + {% else %} + thread + {% endif %} + + {{ node.human_changed_timestamp }} + + +
+ {% if node.has_uri -%} + {{ node.uri.data }} + {% else -%} + {{ node.title }} + {% endif -%} +
+ + {{ node.stats["root"]["visible_count"] }} remarks + +
+{% endfor -%} + +{% elif request.saas_home %} + +{%- if request.user.authenticated %} + +{%- if request.user.unverified_nodes.count() > 0 %} + +
+
+
+

+You have unverified comments ({{ request.user.unverified_nodes.count() }}) pending review. +

+ +

+You should verify that they belong to you because only verified comments will receive reply notifications. +

+
+ +{%- endif %} + +{% if request.user.unverified_namespace_owner_requests %} +
+
+
+You should complete the setup on your pending Namespace. +

+For help, go to our frequently asked questions site! +

+
+
+{% endif %} + +
+
+{% if request.user.namespaces %} + +
    +{% for namespace in request.user.namespaces %} +
  • {{ snippets.namespace_home_uri(namespace) }} + {% if namespace.is_owner(request.user) %} + [settings] + {% else%} + [unlock settings]
  • + {% endif %} + +{% endfor %} +
+ +{% elif not request.user.unverified_namespace_owner_requests %} + +
+
+You should setup a Namespace to put comments on your site! +
+
+
+
+{% endif %} + +{% else %} +
+
+You should log in to use this dashboard. +
+
+
+
+{% endif %} + +{% endif %} + +{%- endblock -%} diff --git a/remarkbox/templates/join-or-log-in.j2 b/remarkbox/templates/join-or-log-in.j2 new file mode 100644 index 0000000..4a0d044 --- /dev/null +++ b/remarkbox/templates/join-or-log-in.j2 @@ -0,0 +1,34 @@ +{% extends request.base_funnel_template -%} +{% block title %}join or log in | {{ request.domain }}{% endblock -%} +{% block content %} + +
+

What's next? check your email to log in!

+ +{% endblock %} diff --git a/remarkbox/templates/list-namespace-owners.j2 b/remarkbox/templates/list-namespace-owners.j2 new file mode 100644 index 0000000..ad69abe --- /dev/null +++ b/remarkbox/templates/list-namespace-owners.j2 @@ -0,0 +1,27 @@ +{% extends request.base_template -%} + +{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%} +{% block content -%} + +

{{ the_title }}

+ +
    +{% for namespace in namespaces %} + +{% if namespace.owners %} +
  • + +{{ namespace.name }} + +{% for user in namespace.owners %} +
    +{{ user.name }} | {{ user.email }} +{% endfor %} + +
  • +{% endif %} + +{% endfor -%} +
+ +{%- endblock -%} diff --git a/remarkbox/templates/list-namespace-requests.j2 b/remarkbox/templates/list-namespace-requests.j2 new file mode 100644 index 0000000..f7357f5 --- /dev/null +++ b/remarkbox/templates/list-namespace-requests.j2 @@ -0,0 +1,29 @@ +{% extends request.base_template -%} + +{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%} +{% block content -%} + +

{{ the_title }}

+ +{% for namespace_request in namespace_requests %} + +
{% if namespace_request.verified %}☑{% else %}☐{% endif %} +{{ namespace.name }} | + +{% if namespace_request.target %} +{{ namespace_request.target }} +{% endif %} + +
+ +
+{{ namespace_request.namespace.owner_request_timestamp }} +{{ namespace_request.user.name }} | +{{ namespace_request.user.email }} +
+snippet +
+ +{% endfor %} + +{%- endblock -%} diff --git a/remarkbox/templates/list-nodes.j2 b/remarkbox/templates/list-nodes.j2 new file mode 100644 index 0000000..483a547 --- /dev/null +++ b/remarkbox/templates/list-nodes.j2 @@ -0,0 +1,59 @@ +{% extends request.base_template -%} + +{% import 'snippets/forms.j2' as forms with context %} + +{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%} +{% block content -%} + +{% if 'namespace' in request.matchdict %} + +{{ snippets.namespace_actions(request.namespace) }} + +{% elif request.user and request.user.name == request.matchdict.get('user_name') %} + +
+{{ snippets.node_list_sub_menu(request.user) }} +
+ +
+ +{% endif %} + +{% if request.page_number != 1 %} +
+page: {{ request.page_number }} +
+ +{% endif %} + +

{{ the_title }}

+ +{%- for node in nodes %} + +
+ + +
+ {{ snippets.node_avatar(node) }} + {{ snippets.author_and_date(node) }} + {{ snippets.status(node) }} + +
+ {{ node.data_html | safe }} +
+ + {{ snippets.permalinks(node) }} + {{ snippets.actions(node) }} +
+ +
+ {{ forms.edit(node) }} +
+ +
+ {{ forms.reply(node, node) }} +
+ +{% endfor -%} + +{%- endblock -%} diff --git a/remarkbox/templates/list-notifications.j2 b/remarkbox/templates/list-notifications.j2 new file mode 100644 index 0000000..e739cd6 --- /dev/null +++ b/remarkbox/templates/list-notifications.j2 @@ -0,0 +1,45 @@ +{% extends request.base_template -%} + +{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%} +{% block content -%} + +{{ the_title }} + +
+
+ +{% for s in ["unsent", "sent", "all"] %} + + {% if s == state %} + {{ s }}: {{ counts[s] }} + {% else %} + {{ s }}: {{ counts[s] }} + {% endif %} + +
+{% endfor %} + +
+ +{% for notification in notifications %} + +
{% if notification.sent %}☑{% else %}☐{% endif %} +{{ notification.node_event.user.name }} +{{ notification.node_event.action }} +(link) +| {{ notification.human_created_timestamp }} + +
+{% if topsecret %} +{{ notification.user.name }} | +{{ notification.user.email }} | +{{ notification.watcher.type }} | +{{ notification.method }} | +{{ notification.frequency }} +{% endif %} + +
+ +{% endfor %} + +{%- endblock -%} diff --git a/remarkbox/templates/mail_digest_html.j2 b/remarkbox/templates/mail_digest_html.j2 new file mode 100644 index 0000000..3236f6e --- /dev/null +++ b/remarkbox/templates/mail_digest_html.j2 @@ -0,0 +1,32 @@ +{%- import 'mail_snippet_html.j2' as mail_snippet with context -%} + + + + {{ subject }} | {{ request.app_domain }} + + +

Hey there, {{ user.name }}!

+ +

+ You received {{ subject }}! +

+ + {% for root, notifications in grouped_notifications.items() %} + +

+ {% if root.title_ascii %}{{ root.title_ascii }}{% else %}{{ root.uri.data_acsii }}{% endif %} +

+ +

    + {% for notification in notifications %} +
  • + {{ mail_snippet.human_action(notification) }} +
  • + {% endfor %} +
+ + {% endfor %} + + {{ mail_snippet.email_footer() }} + + diff --git a/remarkbox/templates/mail_digest_text.j2 b/remarkbox/templates/mail_digest_text.j2 new file mode 100644 index 0000000..dde3220 --- /dev/null +++ b/remarkbox/templates/mail_digest_text.j2 @@ -0,0 +1,15 @@ +{%- import 'mail_snippet_text.j2' as mail_snippet with context -%} +Hey there, {{ user.name }}! + +You received {{ subject }}! + +{% for node, notifications in grouped_notifications.items() %} + + {% if node.title_ascii %}{{ node.title_ascii }}{% else %}{{ node.uri.data_ascii }}{% endif %} + ------------------------------------------------------------------------- + {% for notification in notifications %} + * {{ mail_snippet.human_action(notification) }} + {% endfor %} +{% endfor %} + +{{ mail_snippet.email_footer() }} diff --git a/remarkbox/templates/mail_immediate_html.j2 b/remarkbox/templates/mail_immediate_html.j2 new file mode 100644 index 0000000..c97159d --- /dev/null +++ b/remarkbox/templates/mail_immediate_html.j2 @@ -0,0 +1,27 @@ +{%- import 'mail_snippet_html.j2' as mail_snippet with context -%} + + + + Activity Notification | {{ request.app_domain }} + + +

Hey there, {{ notification.user.name }}!

+ +

+ Just notifying you of new activity: +

+ +

+ {% if root.title_ascii %}{{ root.title_ascii }}{% else %}{{ root.uri.data_acsii }}{% endif %} +

+ + +

    +
  • + {{ mail_snippet.human_action(notification) }} +
  • +
+ + {{ mail_snippet.email_footer(notification) }} + + diff --git a/remarkbox/templates/mail_immediate_text.j2 b/remarkbox/templates/mail_immediate_text.j2 new file mode 100644 index 0000000..a6e995e --- /dev/null +++ b/remarkbox/templates/mail_immediate_text.j2 @@ -0,0 +1,8 @@ +{% import 'mail_snippet_text.j2' as mail_snippet with context %} +Hey there, {{ notification.user.name }}! + +Just notifying you of new activity: + + * {{ mail_snippet.human_action(notification) }} + +{{ mail_snippet.email_footer(notification) }} diff --git a/remarkbox/templates/mail_snippet_html.j2 b/remarkbox/templates/mail_snippet_html.j2 new file mode 100644 index 0000000..517cbea --- /dev/null +++ b/remarkbox/templates/mail_snippet_html.j2 @@ -0,0 +1,40 @@ +{% macro notification_link(notification, text) %} + {{ text }} +{% endmacro %} + + +{% macro human_action(notification) %} + {% if notification.node_event.node.verified %}{{ notification.node_event.user.name }} {% else %}an unverified user {% endif %} + {%- if notification.node_event.action == "created" -%} + created a new {{ notification_link(notification, "thread") }} + {%- elif notification.node_event.action == "commented" -%} + left a {{ notification_link(notification, "comment") }} + {%- elif notification.node_event.action == "edited" -%} + edited a {{ notification_link(notification, "comment") }} + {%- endif -%} +{% endmacro %} + + +{%- macro email_footer(notification=None) -%} + + {% if notification %} +

+ We sent this notification because you + + {%- if notification.watcher.type in ['node', 'namespace'] %} watch this {% if notification.watcher.type == 'node' %}thread{% else %}Namespace{% endif %}. + {%- else %} + subscribe to reply notifications. + {% endif -%} +

+ + {% else %} +

+ We sent this message because you currently subscribe to Email notifications. +

+ {% endif %} + +

+ To adjust your notification settings, go to {{ request.app_url }}/u/watching +

+ +{%- endmacro -%} diff --git a/remarkbox/templates/mail_snippet_text.j2 b/remarkbox/templates/mail_snippet_text.j2 new file mode 100644 index 0000000..ebca3d4 --- /dev/null +++ b/remarkbox/templates/mail_snippet_text.j2 @@ -0,0 +1,27 @@ +{%- macro notification_link(notification) -%} + {{ request.app_url }}/r/{{ notification.node_event.node.id }} +{%- endmacro -%} + +{%- macro human_action(notification) -%} + {%- if notification.node_event.node.verified %}{{ notification.node_event.user.name }} {% else %}an unverified user {% endif %} + {%- if notification.node_event.action == "created" -%} + created a new thread: + {{ notification_link(notification) }} + {%- elif notification.node_event.action == "commented" -%} + left a comment: + {{ notification_link(notification) }} + {%- endif -%} +{%- endmacro -%} + +{%- macro email_footer(notification=None) -%} + +{%- if notification %} +We sent this notification because you {% if notification.watcher.type in ['node', 'namespace'] %}watch this {% if notification.watcher.type == 'node' %}thread{% else %}Namespace{% endif %}{%- else %}subscribe to reply notifications{% endif %}. +{%- else %} +We sent this message because you currently subscribe to Email notifications. +{%- endif %} + +To adjust your notification settings, go to: + + * {{ request.app_url }}/u/watching +{%- endmacro -%} diff --git a/remarkbox/templates/namespace-settings.j2 b/remarkbox/templates/namespace-settings.j2 new file mode 100644 index 0000000..38dab30 --- /dev/null +++ b/remarkbox/templates/namespace-settings.j2 @@ -0,0 +1,269 @@ +{% extends request.base_template -%} + +{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%} +{% block content -%} + +{% set submit_button_classes = 'button-right' %} +{% set submit_button_value = 'save changes' %} + +

{{ the_title }}

+ +{% if request.namespace.subscription_type == "development" %} +
+
+Development Plan +

+Note: The options below only work for the Production plan.
You should contact me (russell.ballestrini@gmail.com) to upgrade. +
+
+
+{% endif %} + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +If checked, hide new comments until approved by a moderator. +
+
+ + +If checked, replace all links in comments with [link removed]. +
+
+ + +If checked, reverse the comment order so that newest comments appear first. Newest comments on top, oldest comments on bottom. +
+
+ + +If checked, disable infinite nesting and limit to only a depth of 2. +
+
+ + +If checked, both example.com/1 and example.com/1?a=2 will load the same thread because a=2 is ignored. +
+
+ + +If checked, allow beautiful math equations with MathJax (could introduce slowness) +
+$$E = mc^2$$ +
+
+ + +If checked, hide the Remarkbox Logo in footer.{% if request.namespace.hide_powered_by %}.. bummer.{% endif %} + +{% include 'snippets/submit.j2' %} + +
+
+ +
+ +

Embed Mode Settings

+ + +You can host your own CSS file and link it here. + + + + + + +
+
+ + + + +
+
+ +{% include 'snippets/submit.j2' %} + +
+
+ +
+ +
+ +

Stand-alone Mode Settings

+ + + + +
+
+ +{% include 'snippets/submit.j2' %} +{% include 'snippets/csrf.j2' %} +
+ + +
+ +
+ +

Notifications

+ + + + +Adjust Email Notifications in the user settings and +watching pages. + +
+
+ + + +Use any of these feeds with your RSS reader to get notified about new comments. +
+
+RSS threads +  +  +RSS comments +  +  +Atom Feed + + +
+
+ + + +Create the #remarks channel before adding Remarkbox to Slack. + +
+
+ +{% include 'snippets/add-to-slack.j2' %} + +
+
+ +{% if request.namespace.slack_oauth_records %} +Integrated Slack Teams and Channels: + +
+
+ +
    +{% for oauth in request.namespace.slack_oauth_records %} +
  • {{ oauth.user.name }} integrated this Namespace with the Slack Team {{ oauth.data["team_name"] }} #remarks
  • +
    + + + {% include 'snippets/csrf.j2' %} + {% set submit_button_classes = 'button-small' %} + {% set submit_button_value = 'delete' %} + {% include 'snippets/submit.j2' %} +
    +
    +
    +{% endfor %} +
+{% endif %} + +
+ +
+ +
+ +

API and Integrations

+ + + +Embed this snippet into your web page to enable comments: + +
+
+ +
    +{% for namespace_request in request.namespace.namespace_owner_requests %} +{% if namespace_request.user in request.namespace.users %} +
  • + {{ namespace_request.user.name }}'s snippet +
  • +{% endif %} + +{% endfor %} +
+ + + +This stats.json endpoint +returns various statistics about the threads in this Namespace. +
+Useful for displaying comment counts on summary pages. +
+
+ + +This dump.json endpoint +returns all threads and comments for this Namespace. +
+Useful for backing up your data. +
+
+ +
+ +
+ +
+ +

Very Important People

+ + +These people may approve, deny, edit, or delete any comment in this Namespace. +
    +{% for user in request.namespace.moderators %} +
  • {{ snippets.user_link(user) }}
  • +{% endfor %} +
+ + +These people may modify Namespace settings (this page). +
    +{% for user in request.namespace.owners %} +
  • {{ snippets.user_link(user) }}
  • +{% endfor %} +
+ +
+ +
+ +
+ +

Payment Preferences

+ + + +{%- endblock -%} diff --git a/remarkbox/templates/new.j2 b/remarkbox/templates/new.j2 new file mode 100644 index 0000000..a1d4985 --- /dev/null +++ b/remarkbox/templates/new.j2 @@ -0,0 +1,14 @@ +{% extends request.base_template -%} + +{% block title %}new thread | {{ request.domain }}{%- endblock -%} + +{% block content %} + +
+{% include 'snippets/create.j2' %} +
+ +
+
+ +{% endblock %} diff --git a/remarkbox/templates/rss.xml.j2 b/remarkbox/templates/rss.xml.j2 new file mode 100644 index 0000000..d2721ca --- /dev/null +++ b/remarkbox/templates/rss.xml.j2 @@ -0,0 +1,40 @@ + + + + + {% if nodes %} + {% set feed_type = "Nodes" %} + {% if nodes[0].is_root %}{% set feed_type = "Threads" %}{% endif %} + {{ feed_type }}: {{ request.namespace.name }} + https://{{ request.app_domain }}/ns/{{ request.namespace.name }} + {{ feed_type }} for {{ request.namespace.name }} + en-us + {{ nodes[0].changed_datetime }} + https://faq.remarkbox.com + 20 + + {% for node in nodes %} + + {% if node.title %} + {{ node.title }} + {% endif %} + + {% if node.is_root and node.title %} + {{ node.title }} + {% elif node.is_root and node.has_uri %} + {{ node.uri.data }} + {% else %} + {{ node.data }} + {% endif %} + + {{ node.changed_datetime }} + https://{{ request.app_domain }}/r/{{ node.id }} + https://{{ request.app_domain }}/r/{{ node.id }} + https://{{ request.app_domain }}/r/{{ node.id }} + {% if node.is_verified %}{{ node.user.name }}{% else %}unverified{% endif %} + + {% endfor %} + + {% endif %} + + diff --git a/remarkbox/templates/setup-namespace.j2 b/remarkbox/templates/setup-namespace.j2 new file mode 100644 index 0000000..871d502 --- /dev/null +++ b/remarkbox/templates/setup-namespace.j2 @@ -0,0 +1,165 @@ +{% extends request.base_funnel_template -%} +{%- import 'snippets/stripe.j2' as stripe with context -%} +{% import 'snippets/forms.j2' as forms with context %} + +{% block title %}Let's Rock | {{ request.domain }}{%- endblock -%} +{% block content -%} + +
+ +

Awesome, Let's Rock!

+ +

Follow 3 steps to put comments on your site.

+ + +
+ +
+ +
+ +
+ + + + + +{% include 'snippets/csrf.j2' %} + +{% set submit_button_classes = 'red-button button-max-width' %} +{% set submit_button_value = 'submit domain' %} +{% include 'snippets/submit.j2' %} + +
+ +
+ +
+
+
+❰ back +
+ + +{% if namespace_requests %} + +{% if unverified_namespace_requests %} +
+
+ +
+ + + + {% for namespace_request in unverified_namespace_requests %} + +
+ + {{ namespace_request.namespace.name }} +
+ + {% include 'snippets/csrf.j2' %} + {% set submit_button_classes = 'button-small' %} + {% set submit_button_value = 'cancel' %} + {% include 'snippets/submit.j2' %} + +
+ + {% endfor %} + +
+ +
+ +
+
+
+ +
+ + +Our business model depends on reciprocity and trust to succeed. We do not set price, but wish for contributions to maintain the product and service. + +
+
+ +There's no obligation to pay anything. : ) + +
+
+ +{{ forms.pay_what_you_can() }} + +
+ +
+{% if request.stripe_active_card %} + {{ stripe.active_card(change_card=True) }} +{% else %} + {{ stripe.new_card() }} +{% endif %} +
+ +
+ +Or PayPal @russellbal + + +
+
+ +Thank you so much! + +
+ + +
+ + +
+
+
+ +
+ +

+You should contact me, don't be to shy to say hi! I love hearing from all my friends, old and new! What are you up to? Any cool projects? Got an idea? + +

+

+russell.ballestrini@gmail.com +

+
+ +{% endif %} +{% endif %} + +
+
+
+ +{%- endblock -%} diff --git a/remarkbox/templates/show-count.j2 b/remarkbox/templates/show-count.j2 new file mode 100644 index 0000000..e2c1306 --- /dev/null +++ b/remarkbox/templates/show-count.j2 @@ -0,0 +1,15 @@ +{% set number = request.node.stats["root"]["visible_count"] %} + +{% if number == 0 %} +{% set msg = "No remarks" %} +{% elif number == 1 %} +{% set msg = "1 remark" %} +{% else %} +{% set msg = number ~ " remarks" %} +{% endif %} + +{% if request.node.uri %} +{{ msg }} +{% else %} +{{ msg }} +{% endif %} diff --git a/remarkbox/templates/show-node.j2 b/remarkbox/templates/show-node.j2 new file mode 100644 index 0000000..f984ce5 --- /dev/null +++ b/remarkbox/templates/show-node.j2 @@ -0,0 +1,189 @@ +{% extends request.base_template -%} + +{% import 'snippets/forms.j2' as forms with context %} + +{% block title %}{% if request.node.title is not none %}{{ request.node.title }} | {% endif %}{{ request.domain }}{% endblock -%} + +{% block call_to_action %} +{% if request.node.is_root and request.user.authenticated %} + {% if request.user.get_watcher_by_node_id(request.node.id) %} + {{ snippets.unwatch_node(node_id = request.node.id) }} + {% else%} + {{ snippets.watch_node(node_id = request.node.id) }} + {% endif %} + + {% if request.node.root.namespace.is_moderator(request.user) %} + {% if request.node.locked %} + {{ snippets.unlock_node(node_id = request.node.id) }} + {% else%} + {{ snippets.lock_node(node_id = request.node.id) }} + {% endif %} + {% endif %} +{% endif %} + +{%- if request.user.authenticated and request.mode == "embed" %} + + {{ snippets.user_avatar_link(request.user, 35, class="avatar-nav") }} + +{% else %} + join-or-log-in +{%- endif %} + + +{% endblock %} + +{% block content -%} + + +{% if request.mode != "embed" and request.node.root.uri %} +Topic: {{ request.node.root.uri.data }} +{% endif %} + + +{% if request.node.has_uri == false %} + +{% if request.node.title is not none %} +

{{ request.node.title }}

+{% endif %} + + +
+ {{ snippets.node_avatar_link(request.node) }} + {{ snippets.author_and_date(request.node) }} + {{ snippets.status(request.node) }} + +
+ {% if not request.namespace.can_see_node(request.node, request.user) %} +

{{ snippets.status(request.node) }}

+ {% else %} + {{ request.node.data_html | safe }} + {% endif %} +
+ + {% if not request.node.is_root %} + + root + + {% endif %} + +
+ {{ snippets.actions(request.node) }} +
+ +
+{% endif %} + +
+ {{ forms.edit(request.node) }} +
+ +
+ {{ forms.reply(request.node, request.root_node) }} +
+ + +{%- if request.node.id and request.node_graph[request.node.id] -%} + + +
+ +{#- prevent DetachedInstanceError from iterating over node not in database. #} +{%- set parent_ids = request.node_graph[request.node.id] -%} + +{%- for parent_id in parent_ids recursive -%} + + {%- set parent = request.node_id_map[parent_id] -%} + {%- set prev_parent = request.node_id_map[parent.parent_id] -%} + + {%- if request.namespace.group_conversations -%} + {%- set children_ids = request.conversation_graph[parent.id] -%} + {%- else -%} + {%- set children_ids = request.node_graph[parent.id] -%} + {%- endif -%} + + {%- set display_class = '' -%} + {%- if loop.depth > 4 -%} + {%- set display_class = 'hidden-on-tablet' -%} + {%- endif -%} + + {%- if request.namespace.can_see_node(parent, request.user) or (request.namespace.hide_unless_approved and parent.approved) %} + + {%- if loop.depth == 1 %} +
+ {%- endif -%} + + {%- if loop.depth > 1 -%} + {%- set avatar_size = request.avatar_size * 0.75 -%} + {%- else -%} + {%- set avatar_size = request.avatar_size -%} + {%- endif -%} + + +
+ + {{ snippets.node_avatar_link(parent, size=avatar_size) }} + {{ snippets.author_and_date(parent) }} + {{ snippets.status(parent) }} + +
+ {%- if not request.namespace.can_see_node(parent, request.user) %} +

{{ snippets.status(parent) }}

+ {%- else %} + {{ parent.data_html | safe }} + {%- endif %} +
+ +
+ {{ snippets.permalinks(parent, prev_parent, request.root_node) }} + {{ snippets.actions(parent) }} + {%- if children_ids %} + {{ snippets.button_collapse(parent) }} + {% endif -%} +
+ + {%- if loop.depth == 4 and children_ids -%} +
+ load more ({{children_ids | length}} remarks) + {% endif %} + +
+ {{ forms.edit(parent) }} +
+ +
+ {{ forms.reply(parent, request.root_node) }} +
+ + {#- nest children in this node's div for convo collapsing. -#} +
+ {%- if children_ids %} + {{ loop(children_ids) }} + {% endif -%} +
+ + {# close the class="node" div #} +
+ + {%- if loop.depth == 1 %} +
+ {%- endif -%} + + {%- endif -%} +{%- endfor -%} + + +
+ +{%- else -%} +
+
+
+{% if request.namespace.no_nodes_text %} +{{ request.namespace.no_nodes_text }} +{% else %} +Leave a comment to get the conversation going! +{% endif %} +
+{% endif %} + +{% endblock %} diff --git a/remarkbox/templates/show-node2.j2 b/remarkbox/templates/show-node2.j2 new file mode 100644 index 0000000..c0bb141 --- /dev/null +++ b/remarkbox/templates/show-node2.j2 @@ -0,0 +1,146 @@ +{% extends request.base_template -%} + +{% import 'snippets/forms.j2' as forms with context %} + +{% block title %}{% if request.node.title is not none %}{{ request.node.title }} | {% endif %}{{ request.domain }}{% endblock -%} + +{% block call_to_action %} +{% if request.node.is_root and request.user.authenticated %} + {% if request.user.get_watcher_by_node_id(request.node.id) %} + {{ snippets.unwatch_node(node_id = request.node.id) }} + {% else%} + {{ snippets.watch_node(node_id = request.node.id) }} + {% endif %} + + {% if request.node.root.namespace.is_moderator(request.user) %} + {% if request.node.locked %} + {{ snippets.unlock_node(node_id = request.node.id) }} + {% else%} + {{ snippets.lock_node(node_id = request.node.id) }} + {% endif %} + {% endif %} +{% endif %} + +{%- if request.user.authenticated and request.mode == "embed" %} + + {{ snippets.user_avatar_link(request.user, 35, class="avatar-nav") }} + +{% else %} + join-or-log-in +{%- endif %} + + +{% endblock %} + +{% block content -%} + + +{% if request.mode != "embed" and request.node.root.uri %} +Topic: {{ request.node.root.uri.data }} +{% endif %} + + +{% if request.node.has_uri == false %} + +{% if request.node.title is not none %} +

{{ request.node.title }}

+{% endif %} + + +
+ {{ snippets.node_avatar_link(request.node) }} + {{ snippets.author_and_date(request.node) }} + {{ snippets.status(request.node) }} + +
+ {% if not request.namespace.can_see_node(request.node, request.user) %} +

{{ snippets.status(request.node) }}

+ {% else %} + {{ request.node.data_html | safe }} + {% endif %} +
+ + {% if not request.node.is_root %} + + root + + {% endif %} + +
+ {{ snippets.actions(request.node) }} +
+ +
+{% endif %} + +
+ {{ forms.edit(request.node) }} +
+ +
+ {{ forms.reply(request.node, request.root) }} +
+ +{#- prevent DetachedInstanceError from iterating over node not in database. #} +{%- if request.node.id and request.node_graph[request.node.id] -%} + +{%- for node_id in request.node_flat_graph -%} + + {% set node = request.node_id_map[node_id] %} + + {%- if request.namespace.can_see_node(node, request.user) or (request.namespace.hide_unless_approved and node.approved) %} + + {%- if node.depth == 1 %} + {%- set avatar_size = request.avatar_size -%} + {%- else -%} + {%- set avatar_size = request.avatar_size * 0.75 -%} + {%- endif -%} + + +
+ + {{ snippets.node_avatar_link(node, size=avatar_size) }} + {{ snippets.author_and_date(node) }} + {{ snippets.status(node) }} + +
+ {%- if not request.namespace.can_see_node(node, request.user) %} +

{{ snippets.status(node) }}

+ {%- else %} + {{ node.data_html | safe }} + {%- endif %} +
+ +
+ {{ snippets.permalinks(node, node) }} + {{ snippets.actions(node) }} + {{ snippets.button_collapse(node) }} +
+ +
+ {{ forms.edit(node) }} +
+ +
+ {{ forms.reply(node, request.root) }} +
+ + {# close the class="node" div #} +
+ + {%- endif -%} +{%- endfor -%} + +{%- else -%} +
+
+
+{% if request.namespace.no_nodes_text %} +{{ request.namespace.no_nodes_text }} +{% else %} +Leave a comment to get the conversation going! +{% endif %} +
+{% endif %} + +{% endblock %} diff --git a/remarkbox/templates/sitemap.xml.j2 b/remarkbox/templates/sitemap.xml.j2 new file mode 100644 index 0000000..99dfe39 --- /dev/null +++ b/remarkbox/templates/sitemap.xml.j2 @@ -0,0 +1,11 @@ + + +{% for node in nodes %} + + {{ request.host_url }}{{ node.path }} + weekly + 0.8 + {{ node.changed_datetime.isoformat() }} + +{% endfor %} + diff --git a/remarkbox/templates/snippets/add-to-slack.j2 b/remarkbox/templates/snippets/add-to-slack.j2 new file mode 100644 index 0000000..cfe8ae4 --- /dev/null +++ b/remarkbox/templates/snippets/add-to-slack.j2 @@ -0,0 +1,7 @@ +{% if 'slack.public' in request.app %} + + + Add to Slack + + +{% endif %} diff --git a/remarkbox/templates/snippets/create.j2 b/remarkbox/templates/snippets/create.j2 new file mode 100644 index 0000000..a4cff25 --- /dev/null +++ b/remarkbox/templates/snippets/create.j2 @@ -0,0 +1,29 @@ +
+ + + + +
+ +{% include 'csrf.j2' %} + +{% set show_whats_next = true %} +{% include 'email.j2' %} + +{% set submit_button_classes = 'button-right' %} +{% set submit_button_value = 'create thread' %} +{% include 'submit.j2' %} + +
diff --git a/remarkbox/templates/snippets/csrf.j2 b/remarkbox/templates/snippets/csrf.j2 new file mode 100644 index 0000000..f28926c --- /dev/null +++ b/remarkbox/templates/snippets/csrf.j2 @@ -0,0 +1,3 @@ +{% if request.csrf_token %} + +{% endif %} diff --git a/remarkbox/templates/snippets/email.j2 b/remarkbox/templates/snippets/email.j2 new file mode 100644 index 0000000..8b1cc1a --- /dev/null +++ b/remarkbox/templates/snippets/email.j2 @@ -0,0 +1,31 @@ +{% if not request.user.authenticated %} + + + + {% if show_whats_next %} +
+

What's next? check your email to log in!

+
+ {% endif %} + + {% if show_whats_next_notifications %} +
+

What's next? verify your email address for reply notifications!

+
+ {% endif %} + +{% endif %} diff --git a/remarkbox/templates/snippets/flash-alerts.j2 b/remarkbox/templates/snippets/flash-alerts.j2 new file mode 100644 index 0000000..36d3c54 --- /dev/null +++ b/remarkbox/templates/snippets/flash-alerts.j2 @@ -0,0 +1,11 @@ +
+{%- if request.session.peek_flash() -%} +{%- for message, level in request.session.pop_flash() %} +
+

{{message|safe}}

+ +
+{%- endfor %} +{%- endif %} +
+ diff --git a/remarkbox/templates/snippets/forms.j2 b/remarkbox/templates/snippets/forms.j2 new file mode 100644 index 0000000..23e5af6 --- /dev/null +++ b/remarkbox/templates/snippets/forms.j2 @@ -0,0 +1,100 @@ +{% macro reply(node, root) %} + +{% if root.locked %} +
+

This thread was locked to prevent additional comments.

+{% else %} + +
+ +{# the first if statement gaurds the 2nd and is an optimization #} +{% if node.id == request.node.id %} +{% if node.has_uri %} + +{% endif %} +{% endif %} + + + +
+ hide preview +
+
+ +{% include 'csrf.j2' %} + +{% set show_whats_next_notifications = true %} +{% include 'email.j2' %} + +{% set submit_button_classes = 'button-right' %} +{% set submit_button_value = 'save message' %} +{% include 'submit.j2' %} +
+ +{% endif %} + +{% endmacro %} + +{% macro edit(node) %} + +
+ + {% if node.title %} + + {% endif %} + + + +{% include 'csrf.j2' %} + +{% set submit_button_classes = 'button-right' %} +{% set submit_button_value = 'save message' %} +{% include 'submit.j2' %} +
+ +{% endmacro %} + +{% macro pay_what_you_can() %} +
+
+ Pay What You Can + Safely adjust frequency or amount anytime. +
+
+ Once
+ Yearly
+
+ +
+
+ We charge cards on the 1st of each month to prevent double charges. +
+
+ Once is cumulative, for example let's say you configured amount to $15 and months later adjust to $25, on the 1st of the next month you will be charged $10. +
+
+ Yearly acts similar but also reoccurs each anniversary. +
+ {% include 'snippets/csrf.j2' %} + +
+
+{% endmacro %} diff --git a/remarkbox/templates/snippets/google-analytics.j2 b/remarkbox/templates/snippets/google-analytics.j2 new file mode 100644 index 0000000..610e2e3 --- /dev/null +++ b/remarkbox/templates/snippets/google-analytics.j2 @@ -0,0 +1,12 @@ +{% if 'google-analytics-id' in request.app %} + +{% endif %} diff --git a/remarkbox/templates/snippets/javascript-includes.j2 b/remarkbox/templates/snippets/javascript-includes.j2 new file mode 100644 index 0000000..4dd19bb --- /dev/null +++ b/remarkbox/templates/snippets/javascript-includes.j2 @@ -0,0 +1,29 @@ +{%- if request.stand_alone_mode == false and request.node and request.node.root.has_uri and request.namespace and request.namespace.powered_up %} + +{%- endif %} + + + + + + + +{%- if request.mode == "basic" and request.namespace and request.namespace.name == request.domain and request.namespace.google_analytics_id %} + + +{%- endif %} +{% if request.namespace and request.namespace.mathjax %} + + +{%- endif %} +{%- include 'google-analytics.j2' %} diff --git a/remarkbox/templates/snippets/owner-key-comment.j2 b/remarkbox/templates/snippets/owner-key-comment.j2 new file mode 100644 index 0000000..3c3c8d3 --- /dev/null +++ b/remarkbox/templates/snippets/owner-key-comment.j2 @@ -0,0 +1,6 @@ +{% if request.owner_key -%} + +{%- endif%} +{% if request.namespace %} + +{%- endif%} diff --git a/remarkbox/templates/snippets/phone-menu.j2 b/remarkbox/templates/snippets/phone-menu.j2 new file mode 100644 index 0000000..daa302b --- /dev/null +++ b/remarkbox/templates/snippets/phone-menu.j2 @@ -0,0 +1,41 @@ +{% if request.user and request.user.authenticated %} + + + + + +{% endif %} diff --git a/remarkbox/templates/snippets/powered-by.j2 b/remarkbox/templates/snippets/powered-by.j2 new file mode 100644 index 0000000..df1c3f5 --- /dev/null +++ b/remarkbox/templates/snippets/powered-by.j2 @@ -0,0 +1,9 @@ + {% if request.namespace.hide_powered_by %} + {% else %} +
+
+

+     Remarkbox +

+
+ {% endif %} diff --git a/remarkbox/templates/snippets/search.j2 b/remarkbox/templates/snippets/search.j2 new file mode 100644 index 0000000..8bf885b --- /dev/null +++ b/remarkbox/templates/snippets/search.j2 @@ -0,0 +1,7 @@ + diff --git a/remarkbox/templates/snippets/snippets.j2 b/remarkbox/templates/snippets/snippets.j2 new file mode 100644 index 0000000..46635f9 --- /dev/null +++ b/remarkbox/templates/snippets/snippets.j2 @@ -0,0 +1,272 @@ +{% macro node_avatar(node, size=request.avatar_size, align='left') %} + +{% endmacro %} + +{% macro user_avatar(user, size) %} + +{% endmacro %} + +{% macro node_avatar_link(node, size=request.avatar_size, anchor='', class='') %} + {% if node.user == request.user %} + {{ node_avatar(node, size) }} + {% elif node.user and node.verified %} + {{ node_avatar(node, size) }} + {% else %} + {{ node_avatar(node, size) }} + {% endif %} +{% endmacro %} + +{% macro user_avatar_link(user, size, anchor='', class='') %} + {% if user == request.user %} + {{ user_avatar(user, size) }} + {% else %} + {{ user_avatar(user, size) }} + {% endif %} +{% endmacro %} + +{% macro user_link(user, anchor='', class='') %} + {% if user == request.user %} + {{ user.name }} + {% else %} + {{ user.name }} + {% endif %} +{% endmacro %} + +{% macro author_link(node) %} + {% if node.user %} + {% set class = '' %} + {% if node.user in request.namespace.moderators %}{% set class = 'mod' %}{% endif %} + {{ user_link(node.user, anchor=node.id, class=class ) }} + {% else %} + {{ node.user_surrogate.name }} + {% endif %} +{% endmacro %} + +{% macro author_and_date(node) %} + + {% if node.disabled %} + node was disabled + {% else %} + {% if node.verified %} + {{ author_link(node) }} + {% else %} + unverified + {% endif %} + + {{ node.human_created_timestamp }} + + {% if node.was_edited %} + + [edited] + + {% endif %} + {% endif %} + +{% endmacro %} + +{% macro status(node) %} + +{% if node.disabled %} +(waiting for deletion) +{% elif request.namespace.hide_unless_approved and not node.approved %} +(hidden: waiting for approval) +{% endif %} + +{% endmacro %} + +{% macro button_remark(node, root_node) %} + {% if root_node and not root_node.locked %} + remark + {% endif %} +{% endmacro %} + +{% macro button_permalink(node, link_text='link', link_title='move to the permalink of this node') %} + {% set share_link_target = '_self' %} + {% if request.mode == 'embed' %} + {% set share_link_target = '_top' %} + {% endif %} + {{ link_text }} +{% endmacro %} + +{% macro button_collapse(node) %} + +{% endmacro %} + +{% macro permalinks(node, parent_node=None, root_node=None) %} + {% if not node.disabled and node != request.node %} + {{ button_remark(node, root_node) }} + {{ button_permalink(node) }} + {% if parent_node != request.node %} + {{ button_permalink(parent_node, link_text='parent', link_title='move to the permalink of the parent node') }} + {% endif %} + {% endif %} +{% endmacro %} + +{% macro namespace_home_uri(namespace) %} + {% if namespace.name == request.domain %} + {{ request.domain }} + {% else %} + {{ namespace.name }} + {% endif %} +{% endmacro %} + +{% macro node_list_sub_menu(user, divider="|") %} +{% set user_unverified_node_count = user.unverified_nodes.count() %} +{% set user_disabled_node_count = user.disabled_nodes.count() %} +my nodes +{{ divider }} +my unverified nodes ({{ user_unverified_node_count }}) +{{ divider }} +my disabled nodes ({{ user_disabled_node_count }}) +{{ divider }} +notifications ({{ user.unsent_node_notifications.count() }}) +{% endmacro %} + +{% macro watch_node(node_id) %} +
+ {% include 'csrf.j2' %} + + +
+{% endmacro %} + +{% macro unwatch_node(node_id="", watcher_id="") %} +
+ {% include 'csrf.j2' %} + + + +
+{% endmacro %} + +{% macro lock_node(node_id) %} +
+ {% include 'csrf.j2' %} + + +
+{% endmacro %} + +{% macro unlock_node(node_id) %} +
+ {% include 'csrf.j2' %} + + +
+{% endmacro %} + + +{% macro disable_node(node) %} +
+ {% include 'csrf.j2' %} + +
+{% endmacro %} + +{% macro enable_node(node) %} +
+ {% include 'csrf.j2' %} + +
+{% endmacro %} + +{% macro verify_node(node) %} +
+ {% include 'csrf.j2' %} + +
+{% endmacro %} + +{% macro approve_node(node) %} +
+ {% include 'csrf.j2' %} + +
+{% endmacro %} + +{% macro deny_node(node) %} +
+ {% include 'csrf.j2' %} + +
+{% endmacro %} + +{% macro actions(node) %} + + {% if request.namespace.can_alter_node(node, request.user) %} + + {% if node.disabled %} + {{ enable_node(node=node) }}   + {% else %} + edit + + {{ disable_node(node=node) }}   + + {% endif %} + {% endif %} + + {% if node.verified == False and node.user == request.user %} + {{ verify_node(node=node) }}   + {% endif %} + + {% if node.enabled and request.namespace.hide_unless_approved and request.namespace.is_moderator(request.user) %} + + {% if node.approved is none %} + {{ approve_node(node=node) }}   + {{ deny_node(node=node) }}   + {% elif node.approved %} + {{ deny_node(node=node) }}   + {% else %} + {{ approve_node(node=node) }}   + {% endif %} + + {% endif %} +{% endmacro %} + +{% macro namespace_actions(namespace) %} + {% if request.mode != "embed" %} + + {% set namespace_thread_count = namespace.visible_roots.count() %} + {% set namespace_all_node_count = namespace.nodes.count() %} + {% set namespace_unapproved_node_count = namespace.unapproved_nodes.count() %} + {% set namespace_approved_node_count = namespace.approved_nodes.count() %} + {% set namespace_disabled_node_count = namespace.disabled_nodes.count() %} + +
+ + {% if request.namespace.name == request.domain %} + threads ({{ namespace_thread_count }}) + {% else %} + threads ({{ namespace_thread_count }}) + {% endif %} | + + {% if namespace.hide_unless_approved %} + pending nodes ({{ namespace_unapproved_node_count }}) | + approved nodes ({{ namespace_approved_node_count }}) | + {% else %} + nodes ({{ namespace_all_node_count }}) | + {% endif %} + disabled nodes ({{ namespace_disabled_node_count }}) + + {% if request.user in namespace.owners %} + | namespace settings + {% endif %} + +
+ + {% endif %} +{% endmacro %} diff --git a/remarkbox/templates/snippets/stripe.j2 b/remarkbox/templates/snippets/stripe.j2 new file mode 100644 index 0000000..a7b5bc5 --- /dev/null +++ b/remarkbox/templates/snippets/stripe.j2 @@ -0,0 +1,133 @@ +{% macro display_card(card, actions=True, change_card=False) %} + {% + set brand_logos = { + "Visa" : "https://js.stripe.com/v3/fingerprinted/img/visa-d6c6e0a636f7373e06d5fb896ad49475.svg", + "MasterCard" : "https://js.stripe.com/v3/fingerprinted/img/mastercard-a96ee3841a5e1e28d05ed3f0f4da62b8.svg", + "American Express" : "https://js.stripe.com/v3/fingerprinted/img/amex-edf6011de255d8a4c22904795c9d8770.svg", + "Discover" : "https://js.stripe.com/v3/fingerprinted/img/discover-8f3d8fc6ef836da1fcac12c095ee6fb8.svg", + "Diners Club" : "https://js.stripe.com/v3/fingerprinted/img/diners-fced9e136fd8c25f40a3e7b37a51dc1d.svg", + "JCB" : "https://js.stripe.com/v3/fingerprinted/img/jcb-1b12d588a1e9465d4d9fb84a610f9136.svg", + "UnionPay" : "https://js.stripe.com/v3/fingerprinted/img/unionpay-en-099cb6671310a54f640ac16d5f2a825c.svg", + } + %} + +
+ + {{ card.brand }} + ending in + {{ card.last4 }} +
+ + expiring {{ card.exp_month }}/{{ card.exp_year }} + +
+
+
+ {% if actions %} + + + {% endif %} + {% if change_card %} + + {% endif %} +
+
+{% endmacro %} + +{% macro active_card(change_card=False) %} + {% if request.stripe_active_card %} + {{ display_card(request.stripe_active_card, actions=False, change_card=change_card) }} + {% endif %} +{% endmacro %} + +{% macro saved_cards() %} + {% if request.stripe_saved_cards|length > 1 %} + + {% for card in request.stripe_saved_cards %} + {% if card != request.stripe_active_card %} + {{ display_card(card) }} +
+ {% endif %} + {% endfor %} +
+ {% endif %} +{% endmacro %} + +{% macro new_card() %} + +
+ {% include 'snippets/csrf.j2' %} + +
+ +
+
+ +
+
+ + +
+ +
+
+ + + + +
+ +
+
+ + +{% endmacro %} diff --git a/remarkbox/templates/snippets/stylesheet-includes.j2 b/remarkbox/templates/snippets/stylesheet-includes.j2 new file mode 100644 index 0000000..94413a9 --- /dev/null +++ b/remarkbox/templates/snippets/stylesheet-includes.j2 @@ -0,0 +1,18 @@ + + + + +{%- if request.stylesheet_uri %} + +{%- endif %} + +{%- if request.mode == 'embed' %} + + {% if request.stylesheet_embed %} + + {% endif %} +{%- else %} + {% if request.stylesheet %} + + {% endif %} +{%- endif %} diff --git a/remarkbox/templates/snippets/submit.j2 b/remarkbox/templates/snippets/submit.j2 new file mode 100644 index 0000000..9849e0d --- /dev/null +++ b/remarkbox/templates/snippets/submit.j2 @@ -0,0 +1 @@ + diff --git a/remarkbox/templates/update-card.j2 b/remarkbox/templates/update-card.j2 new file mode 100644 index 0000000..48d6ac1 --- /dev/null +++ b/remarkbox/templates/update-card.j2 @@ -0,0 +1,23 @@ +{% extends request.base_funnel_template -%} +{%- import 'snippets/stripe.j2' as stripe with context -%} +{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%} +{% block content -%} +

{{ the_title }}

+ + + +{{ stripe.display_card(card, actions=False) }} + +
+ +
+
+ {% include 'snippets/csrf.j2' %} + + + + +
+
+ +{%- endblock -%} diff --git a/remarkbox/templates/user-settings.j2 b/remarkbox/templates/user-settings.j2 new file mode 100644 index 0000000..e37930d --- /dev/null +++ b/remarkbox/templates/user-settings.j2 @@ -0,0 +1,92 @@ +{% extends request.base_template -%} +{%- import 'snippets/stripe.j2' as stripe with context -%} +{% block title %}{{ the_title }} | {{ request.domain }}{%- endblock -%} +{% block content -%} + +{% if request.mode != "embed" %} +
+{{ snippets.node_list_sub_menu(request.user) }} +
+
+{% endif %} + + +log out + +

User Settings

+ +
+ + +
+
+ +{{ snippets.user_avatar_link(request.user, 130, class="user-settings-profile-pic") }} +
+
+ + enable gravatar? + +
+
+
+ + + + + +When my comments get a reply, notify me + +via +{{ request.user.reply_watcher.notification_method }}. + +
+
+ + + +View and adjust your watched thread settings. +
+ +By default, when I watch a thread, notify me + of changes. + + +
+
+ + + +View and adjust your Namespace notification settings. +
+ +{% include 'snippets/csrf.j2' %} +{% set submit_button_value = 'save changes' %} +{% set submit_button_classes = 'button-right' %} +{% include 'snippets/submit.j2' %} + +
+ +{%- endblock -%} diff --git a/remarkbox/templates/user-watching.j2 b/remarkbox/templates/user-watching.j2 new file mode 100644 index 0000000..dfe5877 --- /dev/null +++ b/remarkbox/templates/user-watching.j2 @@ -0,0 +1,75 @@ +{% extends request.base_template -%} + +{% block content -%} + +
+ +
+ +
+
Namespaces
+ {% for namespace_watcher in request.user.namespace_watchers %} +
+ {{ namespace_watcher.namespace.name }} +
+ + Notify + + via {{ namespace_watcher.notification_method }} + +
+
+ {% endfor %} + +
+ + {% include 'snippets/csrf.j2' %} + {% set submit_button_value = 'save changes' %} + {% include 'snippets/submit.j2' %} + +
+ +
+
Threads
+ {% for node_watcher in request.user.node_watchers %} +
+ + {% if node_watcher.node.title %} + {{ node_watcher.node.title }} + {% elif node_watcher.node %} + {{ node_watcher.node.uri.data }} + {% endif %} + + +
+ Notify + + via {{ node_watcher.notification_method }} +
+
+ {% endfor %} + +
+ + {% include 'snippets/csrf.j2' %} + {% set submit_button_value = 'save changes' %} + {% include 'snippets/submit.j2' %} + +
+ +
+ + +
+ +{% endblock %} diff --git a/remarkbox/tests/test_models.py b/remarkbox/tests/test_models.py new file mode 100644 index 0000000..bc64f42 --- /dev/null +++ b/remarkbox/tests/test_models.py @@ -0,0 +1,350 @@ +import unittest + +import mock + +from remarkbox.models.user import User, is_user_name_valid + +from remarkbox.models.node import ( + Node, + get_or_create_node_by_uri, + get_graph_from_nodes, + get_group_conversation_ids, + get_conversation_graph_from_nodes, + flatten_graph, +) + +from remarkbox.models.namespace import Namespace + +from remarkbox.models.namespace_request import NamespaceRequest + +from remarkbox.models.vote import Vote + +mock_always_none = mock.Mock(return_value=None) +mock_always_true = mock.Mock(return_value=True) +mock_false_then_true = mock.Mock(side_effect=[False, False, True]) + + +class TestUser(unittest.TestCase): + + @mock.patch("remarkbox.models.user.is_user_name_available", mock_always_true) + def setUp(self): + self.user = User("russell@ballestrini.net") + + def test_created_timestamp_set(self): + self.assertGreater(self.user.created, 100000) + + def test_email_set(self): + self.assertEqual(self.user.email, "russell@ballestrini.net") + + def test_new_password(self): + raw_password = self.user.new_password() + self.assertGreater(len(raw_password), 30) + + def test_check_password_success(self): + raw_password = self.user.new_password() + self.assertTrue(self.user.check_password(raw_password)) + + def test_check_password_failure(self): + raw_password = self.user.new_password() + self.assertFalse(self.user.check_password("fake password")) + + def test_is_user_name_valid(self): + self.assertTrue(is_user_name_valid("validusername")) + self.assertTrue(is_user_name_valid("validusername2")) + self.assertTrue(is_user_name_valid("ValidUsername")) + self.assertTrue(is_user_name_valid("valid-username")) + + self.assertFalse(is_user_name_valid("invalid username")) + self.assertFalse(is_user_name_valid("invalid!!!")) + self.assertFalse(is_user_name_valid("")) + + @mock.patch("remarkbox.models.user.is_user_name_available", mock_always_true) + def test_generate_user_name_no_dash_prefix(self): + u = User("tim@example.com") + self.assertFalse(u.name.startswith("-")) + + +class TestNode(unittest.TestCase): + + def setUp(self): + self.node = Node() + self.node.id = 21 + self.node.data = u"taco" + self.node.title = u"Taco, Nacho, and Burrito" + + def test_equality(self): + self.assertEqual(self.node, self.node) + cloned_node = Node() + cloned_node.id = 21 + self.assertEqual(self.node, cloned_node) + + def test_inequality(self): + another_node = Node() + another_node.id = 22 + self.assertNotEqual(self.node, another_node) + + def test_expected_in_slug(self): + for item in ("taco", "nacho", "burrito", "-"): + self.assertIn(item, self.node.slug) + + def test_unexpected_not_in_slug(self): + for item in (",", " ", "T"): + self.assertNotIn(item, self.node.slug) + + def test_path_with_slug(self): + self.assertEqual(self.node.path, "/21/taco-nacho-and-burrito") + + def test_path_without_slug(self): + n = Node() + n.id = 45 + n.title = None + self.assertEqual(n.path, "/45") + + def test_uri_is_none(self): + self.assertEqual(self.node.uri, None) + + @mock.patch("remarkbox.models.namespace.get_namespace_by_name", mock_always_none) + @mock.patch("remarkbox.models.uri.get_uri_by_uri", mock_always_none) + # @mock.patch('remarkbox.models.namespace.Namespace.commit', mock_always_none) + def test_embed_parent_title_data_is_none(self): + dbsession = mock.MagicMock() + node = get_or_create_node_by_uri( + dbsession, "http://russell.ballestrini.net/about" + ) + self.assertTrue(node.uri) + self.assertEqual(node.uri.data, "http://russell.ballestrini.net/about") + self.assertEqual(node.title, None) + self.assertFalse(node.title) + + def test_created_timestamp_set(self): + self.assertGreater(self.node.created, 100000) + + def test_short_id(self): + import uuid + + n = Node() + n.id = uuid.UUID(hex="75b1de48-414b-11e7-aecf-9c4e369c7158") + self.assertEqual(n.short_id, "dbHeSEFLEeeuz5xONpxxWA") + + +class TestNamespace(unittest.TestCase): + + # @mock.patch("remarkbox.models.user.is_user_name_available", mock_always_true) + def setUp(self): + # self.user = User("russell@ballestrini.net") + self.namespace = Namespace("russell.ballestrini.net") + + def test_namespace_production_custom_settings(self): + self.namespace.subscription_type = "production" + self.namespace.link_protection = True + self.namespace.hide_powered_by = True + self.namespace.mathjax = True + self.assertTrue(self.namespace.link_protection) + self.assertTrue(self.namespace.hide_powered_by) + self.assertTrue(self.namespace.mathjax) + self.assertFalse(self.namespace.memoized_attr_protection) + + def test_namespace_development_custom_settings(self): + self.namespace.subscription_type = "development" + self.namespace.link_protection = True + self.namespace.hide_powered_by = True + self.namespace.mathjax = True + self.assertFalse(self.namespace.link_protection) + self.assertFalse(self.namespace.hide_powered_by) + self.assertFalse(self.namespace.mathjax) + self.assertTrue(self.namespace.memoized_attr_protection) + + def test_namespace_simulate_expired_subscription(self): + # first we have a production namespace with custom settings. + self.namespace.subscription_type = "production" + self.namespace.link_protection = True + self.namespace.hide_powered_by = True + self.namespace.mathjax = True + self.assertTrue(self.namespace.link_protection) + self.assertTrue(self.namespace.hide_powered_by) + self.assertTrue(self.namespace.mathjax) + + # next the namespace subscription expires with custom settings. + self.namespace.subscription_type = "development" + self.namespace.reload_memoized_attr_protection() + self.assertTrue(self.namespace.memoized_attr_protection) + self.assertFalse(self.namespace.link_protection) + self.assertFalse(self.namespace.hide_powered_by) + self.assertFalse(self.namespace.mathjax) + + # finally the namespace subscription is renewed with custom settings. + self.namespace.subscription_type = "production" + self.namespace.reload_memoized_attr_protection() + self.assertFalse(self.namespace.memoized_attr_protection) + self.assertTrue(self.namespace.link_protection) + self.assertTrue(self.namespace.hide_powered_by) + self.assertTrue(self.namespace.mathjax) + +class TestNamespaceRequest(unittest.TestCase): + + @mock.patch("remarkbox.models.user.is_user_name_available", mock_always_true) + def setUp(self): + self.user = User("russell@ballestrini.net") + self.namespace = Namespace("russell.ballestrini.net") + self.namespace_request = NamespaceRequest(self.user, self.namespace) + # self.namespace_request.user = self.user + # self.namespace_request.namespace = self.namespace + + self.target = "http://russell.ballestrini.net/so-you-are-planning-a-beta-test/" + + @mock.patch("remarkbox.models.NamespaceRequest.scrape_target", mock_always_true) + def test_namespace_request_id_found(self): + self.namespace_request.verify_target(self.target) + self.assertEqual(self.target, self.namespace_request.target) + self.assertTrue(self.namespace_request.verified) + + @mock.patch('remarkbox.models.NamespaceRequest.scrape_target', mock_always_none) + def test_namespace_request_id_not_found(self): + self.namespace_request.verify_target(self.target) + self.assertEqual(self.target, self.namespace_request.target) + self.assertFalse(self.namespace_request.verified) + + +class TestNodeTree(unittest.TestCase): + + def setUp(self): + self.root = Node() + self.root.id = 21 + self.root.data = u"taco" + self.root.title = u"Taco, Nacho, and Burrito" + + # create some children. + self.child1 = self.root.new_child() + self.child1.id = 24 + self.child1.parent_id = 21 + self.child1.root_id = 21 + self.child1.data = u"nacho" + self.root.children.append(self.child1) + + self.child2 = self.root.new_child() + self.child2.id = 27 + self.child2.parent_id = 21 + self.child2.root_id = 21 + self.child2.data = u"burrito" + self.root.children.append(self.child2) + + self.grandchild1 = self.child1.new_child() + self.grandchild1.id = 25 + self.grandchild1.parent_id = 24 + self.grandchild1.root_id = 21 + self.grandchild1.data = u"nacho-cheese" + self.child1.children.append(self.grandchild1) + + self.great_grandchild1 = self.grandchild1.new_child() + self.great_grandchild1.id = 38 + self.great_grandchild1.parent_id = 25 + self.great_grandchild1.root_id = 21 + self.great_grandchild1.data = u"tortilla" + self.grandchild1.children.append(self.great_grandchild1) + + self.nodes = [ + self.root, + self.child1, + self.child2, + self.grandchild1, + self.great_grandchild1, + ] + + self.graph = get_graph_from_nodes(self.nodes) + + def test_parent_child_relationship(self): + self.assertEqual(self.child1.parent.data, u"taco") + self.assertEqual(self.child2.parent.id, 21) + + def test_root2_node(self): + # TODO: delete this test and the root2 attribute/property from Node. + self.assertEqual(self.grandchild1.root2.id, self.root.id) + self.assertEqual(self.child2.root2.id, self.root.id) + + def test_root_is_root(self): + # TODO: delete this test and the root2 attribute/property from Node. + self.assertEqual(self.root.id, self.root.root2.id) + + def test_path_to_root(self): + expected_path = [self.grandchild1, self.child1, self.root] + self.assertEqual(self.grandchild1.path_to_root, expected_path) + + def test_path_from_root(self): + expected_path = [self.root, self.child1, self.grandchild1] + self.assertEqual(self.grandchild1.path_from_root, expected_path) + + def test_get_graph_from_nodes(self): + expected_graph = {21: [24, 27], 24: [25], 25: [38], 27: [], 38: []} + self.assertEqual(get_graph_from_nodes(self.nodes), expected_graph) + + def test_get_group_conversation_ids(self): + expected_ids = [24, 27] + self.assertEqual(get_group_conversation_ids(self.nodes), expected_ids) + + def test_flatten_graph(self): + expected_ids = [25, 38] + self.assertEqual( + flatten_graph(24, self.graph), + expected_ids, + ) + + def test_get_conversation_graph_from_nodes(self): + expected_graph = {24: [25, 38], 27: []} + self.assertEqual( + get_conversation_graph_from_nodes(self.nodes), + expected_graph, + ) + + def test_get_conversation_graph_from_nodes2(self): + expected_graph = {24: [25, 38], 27: []} + self.assertEqual( + get_conversation_graph_from_nodes(self.nodes, self.graph), + expected_graph, + ) + + +class TestIntegration(unittest.TestCase): + + @mock.patch("remarkbox.models.user.is_user_name_available", mock_always_true) + def setUp(self): + self.user = User("russell@ballestrini.net") + + self.parent = Node() + self.parent.data = u"taco" + + self.child = Node() + self.child.data = u"nacho" + self.child.parent = self.parent + + self.user.nodes.append(self.parent) + self.user.nodes.append(self.child) + + self.vote1 = Vote() + self.vote1.user = self.user + self.vote1.node = self.parent + self.vote1.value = 1 + + self.vote2 = Vote() + self.vote2.user = self.user + self.vote2.node = self.child + self.vote2.value = -1 + + def test_user_vote_count(self): + self.assertEqual(len(self.user.votes), 2) + + def test_user_node_count(self): + self.assertEqual(len(list(self.user.nodes)), 2) + + def test_user_nodes_vote_count(self): + for node in self.user.nodes: + self.assertEqual(len(node.votes), 1) + + def test_node_score(self): + self.assertEqual(self.parent.score(), 1) + self.assertEqual(self.child.score(), -1) + + def test_node_new_child(self): + new_child = self.parent.new_child() + self.assertEqual(new_child.parent, self.parent) + self.assertEqual(new_child.parent.data, "taco") + self.assertEqual(new_child.parent.user.email, "russell@ballestrini.net") diff --git a/remarkbox/tests/test_render.py b/remarkbox/tests/test_render.py new file mode 100644 index 0000000..0091618 --- /dev/null +++ b/remarkbox/tests/test_render.py @@ -0,0 +1,120 @@ +import unittest + +from remarkbox.models import Namespace, User + +from remarkbox.lib.render import ( + markdown_to_raw_html, + clean_raw_html, + make_cleaner_from_namespace, +) + + +SAMPLE_MARKDOWN = """ +Hi +=== + +* this +* is +* a + +Bullet. + +[internal relative](/about/) + +[internal absolute](https://russell.ballestrini.net/about/) + +[external](https://www.remarkbox.com/) + + + + +""" + + +class TestRenderMarkdown(unittest.TestCase): + + def test_markdown_to_raw_html(self): + self.assertEqual( + markdown_to_raw_html(SAMPLE_MARKDOWN), + """

Hi

\n
    \n
  • this
  • \n
  • is
  • \n
  • a
  • \n
\n

Bullet.

\n

internal relative

\n

internal absolute

\n

external

\n\n\n""", + ) + + def test_make_cleaner_from_default_namespace(self): + namespace = Namespace("russell.ballestrini.net") + cleaner = make_cleaner_from_namespace(namespace) + self.assertFalse(cleaner.link_protection) + self.assertEqual("russell.ballestrini.net", cleaner.absolute_domain) + self.assertIn("russell.ballestrini.net", cleaner.whitelist_domains) + + def test_make_cleaner_from_custom_namespace(self): + namespace = Namespace("russell.ballestrini.net") + namespace.subscription_type = "production" + namespace.mathjax = True + namespace.link_protection = True + cleaner = make_cleaner_from_namespace(namespace) + self.assertTrue(cleaner.link_protection) + self.assertIn("script", cleaner.tags) + self.assertEqual("russell.ballestrini.net", cleaner.absolute_domain) + self.assertIn("russell.ballestrini.net", cleaner.whitelist_domains) + + def test_mathjax_enabled_and_disabled(self): + raw_html = markdown_to_raw_html(SAMPLE_MARKDOWN) + namespace = Namespace("russell.ballestrini.net") + namespace.subscription_type = "production" + + # test mathjax enabled. + namespace.mathjax = True + cleaner = make_cleaner_from_namespace(namespace) + self.assertTrue(cleaner.mathjax) + clean_html = clean_raw_html(raw_html, cleaner) + self.assertIn( + '', clean_html + ) + + # test mathjax disabled. + namespace.mathjax = False + cleaner = make_cleaner_from_namespace(namespace) + self.assertFalse(cleaner.mathjax) + clean_html = clean_raw_html(raw_html, cleaner) + self.assertNotIn( + '', clean_html + ) + + def test_link_protection_enabled(self): + raw_html = markdown_to_raw_html(SAMPLE_MARKDOWN) + namespace = Namespace("russell.ballestrini.net") + namespace.subscription_type = "production" + namespace.link_protection = True + cleaner = make_cleaner_from_namespace(namespace) + clean_html = clean_raw_html(raw_html, cleaner) + + self.assertIn( + 'internal relative', + clean_html, + ) + self.assertIn( + 'internal absolute', + clean_html, + ) + self.assertIn("

[link removed]

", clean_html) + self.assertNotIn( + 'external', + clean_html, + ) + + def test_link_protection_enabled_in_development_mode(self): + """ + When a Namespace moves from production to development, we stop + loading custom settings, and fall back to defaults attributes. + """ + raw_html = markdown_to_raw_html(SAMPLE_MARKDOWN) + namespace = Namespace("russell.ballestrini.net") + namespace.subscription_type = "development" + namespace.link_protection = True + self.assertFalse(namespace.link_protection) + cleaner = make_cleaner_from_namespace(namespace) + clean_html = clean_raw_html(raw_html, cleaner) + self.assertNotIn("

[link removed]

", clean_html) + self.assertIn( + '', clean_html + ) diff --git a/remarkbox/tests/test_views.py b/remarkbox/tests/test_views.py new file mode 100644 index 0000000..b678963 --- /dev/null +++ b/remarkbox/tests/test_views.py @@ -0,0 +1,479 @@ +import transaction +import unittest +import webtest +import stripe + +from remarkbox.models import ( + Node, + get_tm_session, + get_or_create_user_by_email, + get_user_by_email, + NodeEventNotification, +) + +from remarkbox.models.meta import Base + +from remarkbox.lib.notify import deliver_scheduled_notifications + +from pyramid.paster import get_appsettings + +import mock +from mock import patch, call +import re + +try: + unicode("") +except: + from six import u as unicode + + +# todo we should pick a new file to put test helpers. +mock_always_true = mock.Mock(return_value=True) + + +# todo we should pick a new file to put test helpers. +class FunctionalTests(unittest.TestCase, object): + + @classmethod + def setUpClass(cls): + from remarkbox import main + + cls.settings = get_appsettings("test.ini") + + cls.app = main({}, **cls.settings) + cls.testapp = webtest.TestApp(cls.app) + + cls.session_factory = cls.app.registry["dbsession_factory"] + cls.engine = cls.session_factory.kw["bind"] + Base.metadata.create_all(bind=cls.engine) + + cls.tm = transaction.manager + cls.dbsession = get_tm_session(cls.session_factory, cls.tm) + + @classmethod + def tearDownClass(cls): + # drop all tables in database. + cls.dbsession.close() + Base.metadata.drop_all(bind=cls.engine) + + def tearDown(self): + # log out test_user. + self.testapp.get("/log-out") + + +class UnauthenticatedFunctionalTests(FunctionalTests): + + def test_root_home_page(self): + res = self.testapp.get("/", status=200) + self.assertTrue(b"Faq" in res.body) + self.assertTrue(b"Meta" in res.body) + self.assertTrue(b"About" in res.body) + self.assertTrue(b"Demo" in res.body) + self.assertTrue(b"Plans" in res.body) + self.assertTrue(b"join-or-log-in" in res.body) + + def test_top_secret_redirects(self): + redirect_res = self.testapp.get("/topsecret", status=302) + res = redirect_res.follow() + self.assertTrue(b"You must be a super fly admin to access that." in res.body) + + def test_billing_redirects(self): + redirect_res = self.testapp.get("/billing", status=302) + res = redirect_res.follow() + self.assertTrue(b"Thank you for helping us out, Please verify your email in the form below!" in res.body) + + def test_user_settings_redirects(self): + redirect_res = self.testapp.get("/u/settings", status=302) + res = redirect_res.follow() + self.assertTrue(b"You must log in to access that area." in res.body) + + @patch("smtplib.SMTP") + def test_new_thread(self, mock_smtp): + redirect_res1 = self.testapp.post( + "/new", + { + "thread_title": "test title", + "thread_data": "test data", + "email": "test@example.com", + }, + status=302, + ) + redirect_res2 = redirect_res1.follow() + self.assertIn(b"The resource was found at", redirect_res2.body) + + res = redirect_res2.follow() + self.assertIn(b"test title", res.body) + self.assertIn(b"test data", res.body) + + def test_new_thread_without_email(self): + redirect_res = self.testapp.post( + "/new", + { + "thread_title": "test title", + "thread_data": "test data", + }, + status=302, + ) + res = redirect_res.follow() + self.assertTrue(b"Press the back button to fix your email address" in res.body) + + def _get_nodes_by_ip(self, ip): + from sqlalchemy.orm.exc import NoResultFound + + try: + return ( + self.dbsession.query(Node).filter(Node.ip_address == unicode(ip)).all() + ) + + except NoResultFound: + return None + + def test_ip_addr(self): + # this mocks the inbound request ip address. + test_addr = "127.0.0.2" + self.testapp.extra_environ.update({"REMOTE_ADDR": test_addr}) + + response = self.testapp.post( + "/new", + { + "thread_title": "test title", + "thread_data": "test data", + "email": "test@remarkbox.com", + }, + ) + self.assertTrue(bool(response)) + + nodes = self._get_nodes_by_ip(test_addr) + self.assertTrue(bool(nodes)) + + +class AuthenticatedFunctionalTests(FunctionalTests): + + @classmethod + def setUpClass(cls): + try: + # Python 2. + FunctionalTests.setUpClass.im_func(cls) + except AttributeError: + # Python 3. + FunctionalTests.setUpClass.__func__(cls) + + stripe.api_key = cls.settings["app.stripe.secret"] + + def setUp(self): + # create test_user1 + self.test_user1 = get_or_create_user_by_email( + self.dbsession, "test1@remarkbox.com" + ) + self.raw_otp1 = self.test_user1.new_password() + + # create test_user2 + self.test_user2 = get_or_create_user_by_email( + self.dbsession, "test2@remarkbox.com" + ) + self.raw_otp2 = self.test_user2.new_password() + + # add to transaction + self.dbsession.add(self.test_user1) + self.dbsession.add(self.test_user2) + + # flush to database. + self.dbsession.flush() + + # commit the transaction. + self.tm.commit() + + # requery to avoid detached instance error. + self.test_user1 = get_or_create_user_by_email( + self.dbsession, "test1@remarkbox.com" + ) + self.test_user2 = get_or_create_user_by_email( + self.dbsession, "test2@remarkbox.com" + ) + + self.test_creds1 = ("test1@remarkbox.com", self.raw_otp1) + self.test_creds2 = ("test2@remarkbox.com", self.raw_otp2) + + def _clean_up_test_user(self, user): + if user.stripe_id: + # delete remote test Customer object on Stripe's test API. + stripe.Customer.retrieve(user.stripe_id).delete() + self.dbsession.delete(user) + + def tearDown(self): + # log out and delete the test_users in between tests. + super(AuthenticatedFunctionalTests, self).tearDown() + self._clean_up_test_user(self.test_user1) + self._clean_up_test_user(self.test_user2) + self.dbsession.flush() + self.tm.commit() + + def _log_in_test_user(self, test_creds): + # log in user. + res_login = self.testapp.get( + "/join-or-log-in?email={}&raw-otp={}".format(*test_creds) + ) + # attach csrf to class. + res_csrf = self.testapp.get("/") + self.csrf = res_csrf.form.fields["csrf_token"][0].value + return res_login + + def test_log_in(self): + redirect_res1 = self._log_in_test_user(self.test_creds1) + redirect_res2 = redirect_res1.follow() + redirect_res3 = redirect_res2.follow() + self.assertIn(self.test_user1.name.encode("utf-8"), redirect_res3.body) + + def test_log_in_or_join_creates_default_watcher(self): + """This tests makes sure a default watcher is created for new users.""" + + # a brand new user has no reply_watchers. + self.assertEqual(self.test_user1.reply_watchers.count(), 0) + + # after successful authentication, the user will get a default reply_watcher. + self._log_in_test_user(self.test_creds1) + + # expire self.test_user1 so next time it is accessed it is reloaded. + self.dbsession.expire(self.test_user1) + + self.assertEqual(self.test_user1.reply_watchers.count(), 1) + + # log the user out and then log back in again. + self.testapp.get("/log-out") + self._log_in_test_user(self.test_creds1) + + # expire self.test_user1 so next time it is accessed it is reloaded. + self.dbsession.expire(self.test_user1) + + # after a subsequent re-authentication, the user will still have a + # default reply_watcher. reply_watcher count does not increase. + self.assertEqual(self.test_user1.reply_watchers.count(), 1) + + @patch("smtplib.SMTP") + @patch("remarkbox.models.NamespaceRequest.scrape_target", mock_always_true) + def test_namespace_request_and_verification(self, mock_smtp): + """This tests steps through the process of setting up a new Namespace.""" + + target_namespace_domain = "www.example.com" + + self._log_in_test_user(self.test_creds1) + + self.assertEqual(self.test_user1.namespace_owner_requests.count(), 0) + + setup_step_1_response = self.testapp.post( + "/setup", + { + "namespace-domain": target_namespace_domain, + "csrf_token": self.csrf, + }, + ) + + self.assertTrue(b"Great work, on to Step 2!" in setup_step_1_response.body) + + self.dbsession.refresh(self.test_user1) + + self.assertEqual(self.test_user1.namespace_owner_requests.count(), 1) + + namespace_request = self.test_user1.namespace_owner_requests[0] + + self.assertFalse(namespace_request.verified) + self.assertEqual(namespace_request.namespace.name, target_namespace_domain) + + embed_thread_uri = "/embed?rb_owner_key={}&thread_uri={}/test.html" + verify_response = self.testapp.get( + embed_thread_uri.format(namespace_request.id, target_namespace_domain) + ) + + self.assertTrue(b"Namespace owner was verified!" in verify_response.body) + + self.dbsession.refresh(namespace_request) + self.assertTrue(namespace_request.verified) + + # the only reason we need to patch is because of temporary operator email. + @patch("smtplib.SMTP") + def test_billing(self, mock_smtp): + + self._log_in_test_user(self.test_creds1) + + billing_response = self.testapp.post( + "/billing/add-card", + { + "email": "test@remarkbox.com", + "csrf_token": self.csrf, + "stripeToken": "tok_visa", + }, + ) + + self.dbsession.refresh(self.test_user1) + customer = stripe.Customer.retrieve(self.test_user1.stripe_id) + self.assertEqual( + customer.sources.retrieve(customer.default_source).brand, "Visa" + ) + + self.testapp.post( + "/billing/add-card", + { + "email": "test@remarkbox.com", + "csrf_token": self.csrf, + "stripeToken": "tok_amex", + }, + ) + + for source in customer.sources.list(): + if source.brand == "Visa": + visa = source + if source.brand == "American Express": + amex = source + + self.testapp.post( + "/billing/update-card", + { + "email": "test@remarkbox.com", + "csrf_token": self.csrf, + "action": "make-card-active", + "card_id": amex.id, + }, + ) + + customer = stripe.Customer.retrieve(self.test_user1.stripe_id) + self.assertEqual( + customer.sources.retrieve(customer.default_source).brand, "American Express" + ) + + self.testapp.post( + "/billing/update-card", + { + "email": "test@remarkbox.com", + "csrf_token": self.csrf, + "action": "delete-card", + "card_id": amex.id, + }, + ) + + customer = stripe.Customer.retrieve(self.test_user1.stripe_id) + self.assertEqual( + customer.sources.retrieve(customer.default_source).brand, "Visa" + ) + + @patch("smtplib.SMTP") + def test_notifications(self, mock_smtp): + """ + In this test we create 2 test users. + + The first test user logs in, configures notification settings, and creates a new thread. This test user logs out. + + The next test user logs in, configures notification settings, and replies to the original thread. + + We test that only 1 notification is created for the first user. + + + """ + + self._log_in_test_user(self.test_creds1) + + # test for immediate notifications + + self.testapp.post( + "/u/settings", + { + "csrf_token": self.csrf, + "default-node-watcher-frequency": "immediately", + "reply-watcher-frequency": "immediately", + }, + ) + + new_resp = self.testapp.post( + "/new", + { + "thread_title": "test title", + "thread_data": "test data", + "csrf_token": self.csrf, + }, + ) + + thread_url = new_resp.headers["location"] + root_id = re.search("\/([^/]*)\/[^/]*$", thread_url).group(1) + + self.testapp.post("/watch", {"csrf_token": self.csrf, "root-id": root_id}) + + self.testapp.get("/log-out") + self._log_in_test_user(self.test_creds2) + + self.testapp.post( + "/u/settings", + { + "csrf_token": self.csrf, + "default-node-watcher-frequency": "immediately", + "reply-watcher-frequency": "immediately", + }, + ) + + self.testapp.post( + "/{}/reply".format(root_id), + {"csrf_token": self.csrf, "thread_data": "test reply data"}, + ) + + notifications = list(self.dbsession.query(NodeEventNotification).all()) + self.assertEqual(len(notifications), 1) + + self.assertEqual(self.test_user1.id, notifications[0].user_id) + + instance = mock_smtp.return_value + self.assertTrue(instance.sendmail.called) + + # log back in as the thread creator and rewatch thread as daily. + self.testapp.get("/log-out") + self._log_in_test_user(self.test_creds1) + + self.testapp.post("/unwatch", {"csrf_token": self.csrf, "root-id": root_id}) + + self.testapp.post( + "/u/settings", + { + "csrf_token": self.csrf, + "default-node-watcher-frequency": "daily", + "reply-watcher-frequency": "daily", + }, + ) + + self.testapp.post("/watch", {"csrf_token": self.csrf, "root-id": root_id}) + + # log back in as 2nd test user and post another reply. + self.testapp.get("/log-out") + self._log_in_test_user(self.test_creds2) + + self.testapp.post( + "/{}/reply".format(root_id), + {"csrf_token": self.csrf, "thread_data": "test reply data"}, + ) + + # prove that there is 1 notification waiting to go out. + notifications = list( + self.dbsession.query(NodeEventNotification).filter( + NodeEventNotification.sent == False + ) + ) + self.assertEqual(len(notifications), 1) + + from time import sleep + + # simulating waiting a "day" of time. + # deliver_scheduled_notifications invokes as console script via cron. + sleep(1) + deliver_scheduled_notifications() + self.assertTrue(instance.sendmail.called) + + # prove that the notification was sent. + notifications = list( + self.dbsession.query(NodeEventNotification).filter( + NodeEventNotification.sent == False + ) + ) + self.assertEqual(len(notifications), 0) + + notifications = list(self.dbsession.query(NodeEventNotification).all()) + self.assertEqual(len(notifications), 1) + + # make sure our daily notification was sent. + self.assertEqual(notifications[0].frequency, "daily") + self.assertTrue(notifications[0].sent) diff --git a/remarkbox/views/__init__.py b/remarkbox/views/__init__.py new file mode 100644 index 0000000..249becf --- /dev/null +++ b/remarkbox/views/__init__.py @@ -0,0 +1,128 @@ +from remarkbox.models import get_node_by_id + +from pyramid.httpexceptions import HTTPFound + +try: + unicode("") +except: + from six import u as unicode + + +# view decorator. +def user_required( + flash_msg="You must log in to access that area.", + flash_level="error", + return_to_route_name="", +): + """This view requires that the request has a user.""" + + def wrapped(fn): + + def inner(request): + if request.user and request.user.authenticated: + return fn(request) + request.session.flash((flash_msg, flash_level)) + return_to = "" + if return_to_route_name: + return_to = request.route_url(return_to_route_name) + return HTTPFound(get_join_or_log_in_route_uri(request, return_to)) + + return inner + + return wrapped + + +# view decorator. +def super_fly_required(fn): + """This view requires that the request has a super fly admin.""" + # TODO: don't rely on this hack for super fly admin. + def wrapped(request): + if ( + request.user + and request.user.authenticated + and (request.user.name == "Remarkbox" or request.user.name == "russell") + ): + return fn(request) + request.session.flash( + ("You must be a super fly admin to access that.", "error") + ) + return HTTPFound(get_referer_or_home(request)) + + return wrapped + + +# view decorator. +def reject_stand_alone(fn): + """Reject access to view if app is running in stand_alone_mode.""" + + def wrapped(request): + if request.stand_alone_mode: + request.session.flash( + ( + b"You may not access this page if stand-alone mode is enabled.", + b"error", + ) + ) + return HTTPFound(get_referer_or_home(request)) + return fn(request) + + return wrapped + + +def get_referer_or_home(request): + """return referer or safe_redirect or '/'""" + return ( + request.referer + if request.referer is not None + else request.app.get("safe_redirect", "/") + ) + + +def get_node_route_uri(request, node, anchor=""): + """return full URI for given node.""" + kwargs = {"node_id": node.id, "_anchor": anchor} + route_name = "basic-show-node" + if node.slug: + route_name = "basic-show-node2" + kwargs["slug"] = node.slug + return request.route_url(route_name, **kwargs) + + +def get_embed_route_uri(request, external_uri, anchor=""): + return request.route_url( + "embed-show-node", _query={"thread_uri": external_uri}, _anchor=anchor + ) + + +def get_join_or_log_in_route_uri(request, return_to=""): + query = None + if return_to: + query = {"return-to":return_to} + return request.route_url( + "{}-join-or-log-in".format(request.mode), + _query=query, + namespace=request.namespace, + ) + + +nodes_pending_verify = lambda request: request.session.get("nodes_pending_verify", []) + + +def set_node_to_pending_in_session(request, node): + """Update session to make node go into pending_verify state.""" + nodes_pending_verify(request).append(str(node.id)) + + +def verify_pending_nodes_in_session(request, user): + """Attempt to verify all nodes pending verification in session.""" + still_pending_verify = list(nodes_pending_verify(request)) + for node_id in nodes_pending_verify(request): + node = get_node_by_id(request.dbsession, node_id) + if node is not None: + # make sure these values match to prevent spoofing. + if user == node.user: + node.verified = True + request.dbsession.add(node) + still_pending_verify.remove(str(node.id)) + request.dbsession.flush() + request.session["nodes_pending_verify"] = still_pending_verify diff --git a/remarkbox/views/authenticated/__init__.py b/remarkbox/views/authenticated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/remarkbox/views/authenticated/authenticated.py b/remarkbox/views/authenticated/authenticated.py new file mode 100644 index 0000000..2c25197 --- /dev/null +++ b/remarkbox/views/authenticated/authenticated.py @@ -0,0 +1,403 @@ +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound + +from pyramid.response import Response + +from remarkbox.models.meta import NOTIFICATION_FREQUENCIES + +from remarkbox.models import ( + get_or_create_namespace, + get_or_create_namespace_request, + get_namespace_request_by_id, + is_user_name_available, + is_user_name_valid, +) + +from remarkbox.views import get_referer_or_home, user_required, reject_stand_alone + +from string import digits + +try: + # Python 2. + from string import lowercase +except ImportError: + # Python 3. + from string import ascii_lowercase as lowercase + + +domain_name_set = set(lowercase + digits + ".-") + + +def checkbox_to_bool(checkbox): + return True if checkbox == "on" else False + + +@view_config(route_name="basic-namespace-settings", renderer="namespace-settings.j2") +@view_config(route_name="embed-namespace-settings", renderer="namespace-settings.j2") +@user_required() +def namespace_settings(request): + + if not request.user in request.namespace.owners: + request.session.flash(("You do not own that Namespace.", "error")) + return HTTPFound(get_referer_or_home(request)) + + # temporarily set attribute protection to False + # so that an unpaid Namespace still displays the set values. + request.namespace.memoized_attr_protection = False + + if request.method == "POST": + p = request.params + stylesheet_embed = p.get("stylesheet-embed", request.namespace.stylesheet_embed) + stylesheet_embed_uri = p.get( + "stylesheet-embed-uri", request.namespace.stylesheet_embed_uri + ) + description = p.get("description", request.namespace.description) + no_nodes_text = p.get("no-nodes-text", request.namespace.no_nodes_text) + placeholder_text = p.get("placeholder-text", request.namespace.placeholder_text) + google_analytics_id = p.get( + "google-analytics-id", request.namespace.google_analytics_id + ) + + hide_unless_approved_checkbox = p.get("hide-unless-approved-checkbox", "off") + link_protection_checkbox = p.get("link-protection-checkbox", "off") + reverse_order_checkbox = p.get("reverse-order-checkbox", "off") + group_conversations_checkbox = p.get("group-conversations-checkbox", "off") + mathjax_checkbox = p.get("mathjax-checkbox", "off") + ignore_query_string_checkbox = p.get("ignore-query-string-checkbox", "off") + hide_powered_by_checkbox = p.get("hide-powered-by-checkbox", "off") + + hide_unless_approved = checkbox_to_bool(hide_unless_approved_checkbox) + link_protection = checkbox_to_bool(link_protection_checkbox) + reverse_order = checkbox_to_bool(reverse_order_checkbox) + group_conversations = checkbox_to_bool(group_conversations_checkbox) + mathjax = checkbox_to_bool(mathjax_checkbox) + ignore_query_string = checkbox_to_bool(ignore_query_string_checkbox) + hide_powered_by = checkbox_to_bool(hide_powered_by_checkbox) + + if stylesheet_embed != request.namespace.stylesheet_embed and ( + stylesheet_embed or request.namespace.stylesheet_embed + ): + request.namespace.set_stylesheet_embed(stylesheet_embed) + request.session.flash(("Success, you changed the stylesheet", "success")) + + if stylesheet_embed_uri != request.namespace.stylesheet_embed_uri and ( + stylesheet_embed_uri or request.namespace.stylesheet_embed_uri + ): + request.namespace.stylesheet_embed_uri = stylesheet_embed_uri + request.session.flash( + ("Success, you changed the stylesheet URI", "success") + ) + + if description != request.namespace.description and ( + description or request.namespace.description + ): + request.namespace.description = description + request.session.flash( + ( + "Success, you changed the description for {}".format( + request.namespace.name + ), + "success", + ) + ) + + if no_nodes_text != request.namespace.no_nodes_text and ( + no_nodes_text or request.namespace.no_nodes_text + ): + request.namespace.no_nodes_text = no_nodes_text + request.session.flash( + ( + "Success, you changed the text when there are no nodes in the thread.", + "success", + ) + ) + + if placeholder_text != request.namespace.placeholder_text and ( + placeholder_text or request.namespace.placeholder_text + ): + request.namespace.placeholder_text = placeholder_text + request.session.flash( + ( + "Success, you changed the placeholder textarea text for new nodes.", + "success", + ) + ) + + if google_analytics_id != request.namespace.google_analytics_id and ( + google_analytics_id or request.namespace.google_analytics_id + ): + request.namespace.google_analytics_id = google_analytics_id + request.session.flash( + ( + "Success, you changed the Google Analytics ID for stand alone mode.", + "success", + ) + ) + + if hide_unless_approved != request.namespace.hide_unless_approved: + request.namespace.hide_unless_approved = hide_unless_approved + request.session.flash( + ( + "You turned {} hide_unless_approved".format( + hide_unless_approved_checkbox + ), + "success", + ) + ) + + if link_protection != request.namespace.link_protection: + request.namespace.link_protection = link_protection + request.session.flash( + ( + "You turned {} link_protection".format(link_protection_checkbox), + "success", + ) + ) + + if reverse_order != request.namespace.reverse_order: + request.namespace.reverse_order = reverse_order + request.session.flash( + ( + "You turned {} reverse_order".format(reverse_order_checkbox), + "success", + ) + ) + + if group_conversations != request.namespace.group_conversations: + request.namespace.group_conversations = group_conversations + request.session.flash( + ( + "You turned {} group_conversations".format( + group_conversations_checkbox + ), + "success", + ) + ) + + if ignore_query_string != request.namespace.ignore_query_string: + request.namespace.ignore_query_string = ignore_query_string + request.session.flash( + ( + "You turned {} ignore_query_string".format( + ignore_query_string_checkbox + ), + "success", + ) + ) + + if hide_powered_by != request.namespace.hide_powered_by: + request.namespace.hide_powered_by = hide_powered_by + request.session.flash( + ( + "You turned {} hide_powered_by".format(hide_powered_by_checkbox), + "success", + ) + ) + + if mathjax != request.namespace.mathjax: + request.namespace.mathjax = mathjax + request.session.flash( + ("You turned {} MathJax".format(mathjax_checkbox), "success") + ) + + request.dbsession.add(request.namespace) + request.dbsession.flush() + + return {"the_title": "Namespace settings for {}".format(request.namespace.name)} + + +@view_config(route_name="embed-user-settings", renderer="user-settings.j2") +@view_config(route_name="basic-user-settings", renderer="user-settings.j2") +@user_required() +def user_settings(request): + if request.method == "POST": + _msg = "{} was turned {}" + display_name = request.params.get("display-name", request.user.name) + + gravatar_checkbox = request.params.get("gravatar-checkbox", "off") + auto_watch_create_checkbox = request.params.get( + "auto-watch-threads-i-create-checkbox", "off" + ) + auto_watch_participate_checkbox = request.params.get( + "auto-watch-threads-i-participate-checkbox", "off" + ) + + gravatar = checkbox_to_bool(gravatar_checkbox) + auto_watch_create = checkbox_to_bool(auto_watch_create_checkbox) + auto_watch_participate = checkbox_to_bool(auto_watch_participate_checkbox) + + reply_watcher_frequency = request.params["reply-watcher-frequency"] + default_node_watcher_frequency = request.params[ + "default-node-watcher-frequency" + ] + + if display_name != request.user.name: + if is_user_name_valid(display_name) == False: + request.session.flash( + ("Sorry, display name must be alpha numeric", "error") + ) + elif is_user_name_available(request.dbsession, display_name) == False: + request.session.flash( + ('Sorry, "{}" is already in use'.format(display_name), "error") + ) + else: + request.user.name = display_name + request.session.flash( + ( + "Nice to meet you {}! (preferred display name saved!)".format( + request.user.name + ), + "success", + ) + ) + + if gravatar != request.user.gravatar: + request.user.gravatar = gravatar + request.session.flash( + (_msg.format("gravatar", gravatar_checkbox), "success") + ) + + """ + if auto_watch_create != request.user.auto_watch_threads_i_create: + request.user.auto_watch_threads_i_create = auto_watch_create + request.session.flash( + ( + _msg.format( + "auto_watch_threads_i_create" + auto_watch_create_checkbox, + ), + "success", + ) + ) + + if auto_watch_participate != request.user.auto_watch_threads_i_participate: + request.user.auto_watch_threads_i_participate = auto_watch_participate + request.session.flash( + ( + _msg.format( + "auto_watch_threads_i_participate", + auto_watch_participate_checkbox, + ), + "success", + ) + ) + """ + + if ( + default_node_watcher_frequency + != request.user.default_node_watcher_frequency + ): + if default_node_watcher_frequency in NOTIFICATION_FREQUENCIES: + request.user.default_node_watcher_frequency = ( + default_node_watcher_frequency + ) + request.session.flash( + ( + "By default, when you watch a thread, we will notify you {}".format( + default_node_watcher_frequency + ), + "success", + ) + ) + else: + request.session.flash( + ("Invalid Enum value for default_node_watcher_frequency.", "error") + ) + + if reply_watcher_frequency != request.user.reply_watcher.notification_frequency: + if reply_watcher_frequency in NOTIFICATION_FREQUENCIES: + request.user.reply_watcher.notification_frequency = ( + reply_watcher_frequency + ) + request.session.flash( + ( + "When any of your comments get a reply, we will notify you {} via {}".format( + request.user.reply_watcher.notification_frequency, + request.user.reply_watcher.notification_method, + ), + "success", + ) + ) + else: + request.session.flash( + ("Invalid Enum value for reply_watcher_frequency.", "error") + ) + + request.dbsession.add(request.user) + request.dbsession.flush() + + return {"the_title": "{}'s settings".format(request.user.name)} + + +@view_config(route_name="setup", renderer="setup-namespace.j2") +@user_required( + flash_msg="Verify your email to get started.", + flash_level="info", + return_to_route_name="setup", +) +@reject_stand_alone +def setup_namespace(request): + if request.method == "POST": + # domains are case insensitive (make lowercase). + namespace_domain = request.params.get("namespace-domain", "").lower() + if "." not in namespace_domain: + request.session.flash(('A domain needs at least one "."', "error")) + elif namespace_domain.startswith("-") or namespace_domain.startswith("."): + request.session.flash(('A domain cannot start with "." or "-"', "error")) + elif not set(namespace_domain).issubset(domain_name_set): + request.session.flash( + ('A domain has only letters, digits, "." and "-"', "error") + ) + elif namespace_domain: + namespace = get_or_create_namespace(request.dbsession, namespace_domain) + namespace_request = get_or_create_namespace_request( + request.dbsession, request.user, namespace + ) + request.dbsession.add(namespace_request) + request.dbsession.flush() + request.session.flash(("Great work, on to Step 2!", "success")) + else: + request.session.flash(("Please enter a domain.", "error")) + + return { + "namespaces": request.user.namespaces, + "namespace_requests": request.user.namespace_owner_requests, + "unverified_namespace_requests": request.user.unverified_namespace_owner_requests, + } + + +@view_config(route_name="setup-namespace-request-cancel") +@user_required() +@reject_stand_alone +def namespace_request_cancel(request): + if request.method == "POST": + namespace_request_id = request.params.get("namespace-request-id") + if namespace_request_id: + namespace_request = get_namespace_request_by_id( + request.dbsession, namespace_request_id + ) + if namespace_request and namespace_request.user == request.user: + request.dbsession.delete(namespace_request) + request.dbsession.flush() + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="namespace-dump-to-json") +@user_required() +@reject_stand_alone +def namespace_dump_to_json(request): + + if not request.user in request.namespace.owners: + request.session.flash(("You do not own that Namespace.", "error")) + return HTTPFound(get_referer_or_home(request)) + + from json import dumps + + response = Response(body=dumps(request.namespace.dict_dump)) + response.headerlist = [] + # allow Javascript from other domains to download this resource. + response.headerlist.extend( + (("Access-Control-Allow-Origin", "*"), ("Content-Type", "application/json")) + ) + return response diff --git a/remarkbox/views/authenticated/lock.py b/remarkbox/views/authenticated/lock.py new file mode 100644 index 0000000..7ca5971 --- /dev/null +++ b/remarkbox/views/authenticated/lock.py @@ -0,0 +1,95 @@ +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound + +from remarkbox.models import get_node_by_id + +from remarkbox.views import get_referer_or_home, user_required + +import logging + +log = logging.getLogger(__name__) + + +@view_config(route_name="lock", request_method=("POST", "PUT")) +@user_required() +def lock(request): + root_id = request.params.get("root-id", None) + root = get_node_by_id(request.dbsession, root_id) + + if not root: + request.session.flash( + ( + "Thread does not exist (root-id={}).".format(root_id), + "error", + ) + ) + elif root.namespace.is_moderator(request.user): + root.locked = True + request.dbsession.add(root) + request.dbsession.flush() + request.session.flash( + ( + "You locked this thread to prevent new comments.", + "success", + ) + ) + log.info( + "event={} user={} root={} namespace={}".format( + "locked-thread", + request.user.id, + root.id, + root.namespace.id, + ) + ) + else: + request.session.flash( + ( + "You may
    not
lock this thread. Namespace moderator role needed. ({}).".format(root.namespace.name), + "error", + ) + ) + + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="unlock", request_method=("POST", "PUT")) +@user_required() +def unlock(request): + root_id = request.params.get("root-id", None) + root = get_node_by_id(request.dbsession, root_id) + + if not root: + request.session.flash( + ( + "Thread does not exist (root-id={}).".format(root_id), + "error", + ) + ) + elif root.namespace.is_moderator(request.user): + root.locked = False + request.dbsession.add(root) + request.dbsession.flush() + request.session.flash( + ( + "You unlocked this thread to allow new comments.", + "success", + ) + ) + log.info( + "event={} user={} root={} namespace={}".format( + "unlocked-thread", + request.user.id, + root.id, + root.namespace.id, + ) + ) + else: + request.session.flash( + ( + "You may
    not
unlock this thread. Namespace moderator role needed. ({}).".format(root.namespace.name), + "error", + ) + ) + + return HTTPFound(get_referer_or_home(request)) diff --git a/remarkbox/views/authenticated/notifications.py b/remarkbox/views/authenticated/notifications.py new file mode 100644 index 0000000..8fa5d6a --- /dev/null +++ b/remarkbox/views/authenticated/notifications.py @@ -0,0 +1,44 @@ +from pyramid.view import view_config + +from remarkbox.views import user_required + +from remarkbox.models.notification import NodeEventNotification + + +@view_config(route_name="basic-user-notifications", renderer="list-notifications.j2") +@view_config(route_name="embed-user-notifications", renderer="list-notifications.j2") +@user_required() +def user_notifications(request): + if "all" in request.params: + state = "all" + notifications = request.user.node_notifications + elif "sent" in request.params: + state = "sent" + notifications = request.user.sent_node_notifications + else: + state = "unsent" + notifications = request.user.unsent_node_notifications + + paginated_notifications = ( + notifications + .order_by( + NodeEventNotification.created_timestamp.desc(), + NodeEventNotification.user_id, + ) + .offset(request.page_offset) + .limit(request.page_size) + ) + + notification_counts = { + "all": request.user.node_notifications.count(), + "sent": request.user.sent_node_notifications.count(), + "unsent": request.user.unsent_node_notifications.count(), + } + + return { + "notifications": paginated_notifications, + "the_title": "Notifications", + "topsecret": False, + "state": state, + "counts": notification_counts, + } diff --git a/remarkbox/views/authenticated/oauth.py b/remarkbox/views/authenticated/oauth.py new file mode 100644 index 0000000..4e2ddd5 --- /dev/null +++ b/remarkbox/views/authenticated/oauth.py @@ -0,0 +1,125 @@ +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound + +from remarkbox.views import ( + get_referer_or_home, + get_join_or_log_in_route_uri, + user_required, +) + +from remarkbox.models import get_oauth_by_id + + +def get_namespace_settings_route(request, anchor=None): + return request.route_url( + "basic-namespace-settings", namespace=request.namespace.name, _anchor=anchor + ) + + +@view_config(route_name="oauth-slack-delete") +@user_required() +def oauth_slack_delete(request): + oauth_id = request.params.get("oauth-id", "") + oauth_record = get_oauth_by_id(request.dbsession, oauth_id) + + if oauth_record: + request.dbsession.delete(oauth_record) + request.session.flash( + ("You deleted that Remarkbox side of that Slack integration.", "success") + ) + request.session.flash( + ( + "Please remember to delete the Slack Team side of the integration.", + "info", + ) + ) + request.dbsession.flush() + else: + request.session.flash(("Invalid OAuth Id.", "error")) + + return HTTPFound(get_namespace_settings_route(request, "notifications")) + + +@view_config(route_name="oauth-slack") +@user_required() +def oauth_slack(request): + + if not request.user in request.namespace.owners: + request.session.flash(("You do not own that Namespace.", "error")) + return HTTPFound(get_join_or_log_in_route_uri(request)) + + oauth_error = request.params.get("error", "") + oauth_code = request.params.get("code", "") + + if oauth_error: + if oauth_error == "access_denied": + request.session.flash( + ("You declined to grant Remarkbox access to Slack", "info") + ) + else: + request.session.flash((oauth_error, "error")) + return HTTPFound(get_namespace_settings_route(request, "notifications")) + + from slacker import Slacker + + # when doing the oauth dance, the first time + # we connect we don't need a token. + slack = Slacker("") + + # Request the auth tokens from Slack + oauth_response = slack.oauth.access( + client_id=request.app.get("slack.public"), + client_secret=request.app.get("slack.secret"), + code=oauth_code, + redirect_uri=request.route_url( + "oauth-slack", _query={"namespace": request.namespace.name} + ), + ) + + if oauth_response.successful: + + #access_token = oauth_response.body["bot"]["bot_access_token"] + access_token = oauth_response.body["access_token"] + request.namespace.add_oauth_record( + user=request.user, + service="slack", + token=access_token, + data=oauth_response.body + ) + request.dbsession.add(request.namespace) + request.dbsession.flush() + request.session.flash( + ( + "Success, you integrated Remarkbox with {} (Slack Team)".format( + oauth_response.body["team_name"] + ), + "success", + ) + ) + request.session.flash( + ( + "Please create a channel in {} called #remarks".format( + oauth_response.body["team_name"] + ), + "info", + ) + ) + + # send a test message to #remark channel! + msg1 = "*Success!* {} integrated this Slack Team with a Remarkbox Namespace (`{}`)".format( + request.user.name, + request.namespace.name, + ) + msg2 = "Please create the `#remarks` channel." + slack = Slacker(access_token) + slack.chat.post_message( + "#general", + msg1, + ) + slack.chat.post_message( + "#general", + msg2, + ) + + return HTTPFound(get_namespace_settings_route(request, "notifications")) diff --git a/remarkbox/views/authenticated/stripe.py b/remarkbox/views/authenticated/stripe.py new file mode 100644 index 0000000..66fa01d --- /dev/null +++ b/remarkbox/views/authenticated/stripe.py @@ -0,0 +1,94 @@ +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound, HTTPBadRequest + +from remarkbox.views import user_required + +from remarkbox.lib.mail import send_operator_email + +from remarkbox.models import PayWhatYouCan + + +@view_config(route_name="billing", renderer="billing.j2") +@user_required( + flash_msg="Thank you for helping us out, Please verify your email in the form below!", + flash_level="info", + return_to_route_name="billing" +) +def billing(request): + return {"the_title": "Payment Preferences"} + + +@view_config(route_name="add-card") +@user_required() +def add_card(request): + # try to get the return_to uri from posted parameters. + return_to = request.params.get("return-to", "/billing") + try: + source = request.stripe_customer.sources.create( + source=request.params.get("stripeToken") + ) + request.stripe_customer.default_source = source + request.session.flash(("You saved a new card.", "success")) + # TODO: this is very temporary just as a stop gap for me to stay on top of new customers. + send_operator_email( + request, + "A user (hopefully a new one!) added a new card to their Stripe customer account. Log into Stripe and follow up to close the sale!", + ) + except request.stripe.error.CardError as e: + body = e.json_body + err = body.get("error", {}) + request.session.flash((err.get("message"), "error")) + request.stripe_customer.save() + return HTTPFound(return_to) + + +@view_config(route_name="confirm-update-card", renderer="update-card.j2") +@user_required() +def confirm_update_card(request): + card_id = request.matchdict.get("card_id") + card = request.stripe_customer.sources.retrieve(card_id) + action = request.matchdict.get("action") + action_human = action.replace("-", " ") + button_class = "red-button" if action == "delete-card" else "blue-button" + return { + "card": card, + "card_id": card_id, + "action": action, + "action_human": action_human, + "button_class": button_class, + "the_title": action_human.title(), + } + + +@view_config(route_name="update-card") +@user_required() +def update_card(request): + action = request.params.get("action", None) + card_id = request.params.get("card_id") + if action not in ["make-card-active", "delete-card"]: + return HTTPBadRequest + if "delete-card" == action: + request.stripe_customer.sources.retrieve(card_id).delete() + request.session.flash(("You deleted that card.", "success")) + if "make-card-active" == action: + request.stripe_customer.default_source = card_id + request.session.flash(("You set the active card.", "success")) + request.stripe_customer.save() + return HTTPFound("/billing") + + +@view_config(route_name="pay-what-you-can") +@user_required() +def pay_what_you_can(request): + frequency = request.params.get("frequency", None) + amount = request.params.get("amount", None) + if frequency is None or amount is None: + request.session.flash(("You must put a value for both frequency and amount.", "error")) + elif request.user.pay_what_you_can: + request.user.pay_what_you_can.update(frequency, amount) + request.session.flash(("You updated your contribution preferences.", "success")) + else: + request.user.pay_what_you_can = PayWhatYouCan(request.user, frequency, amount) + request.session.flash(("You updated your contribution preferences.", "success")) + return HTTPFound("/billing") diff --git a/remarkbox/views/authenticated/topsecret.py b/remarkbox/views/authenticated/topsecret.py new file mode 100644 index 0000000..680d58d --- /dev/null +++ b/remarkbox/views/authenticated/topsecret.py @@ -0,0 +1,80 @@ +from pyramid.view import view_config + +from remarkbox.models import ( + get_topsecret_namespace_requests, + get_topsecret_notifications, + get_topsecret_namespaces, + get_topsecret_roots, + get_topsecret_nodes, +) + +from remarkbox.views import super_fly_required + + +@view_config(route_name="topsecret-namespaces", renderer="list-namespaces.j2") +@super_fly_required +def topsecret_namespaces(request): + return { + "namespaces": get_topsecret_namespaces(request.dbsession), + "the_title": "topsecret namespaces!", + } + + +@view_config( + route_name="topsecret-namespace-requests", renderer="list-namespace-requests.j2" +) +@super_fly_required +def topsecret_namespace_requests(request): + limit = request.params.get("limit", 100) + page = request.params.get("page", 0) + return { + "namespace_requests": get_topsecret_namespace_requests( + request.dbsession, + limit=limit, + offset=page, + ), + "the_title": "topsecret namespace requests!", + } + + +@view_config( + route_name="topsecret-namespace-owners", renderer="list-namespace-owners.j2" +) +@super_fly_required +def topsecret_namespace_owners(request): + return { + "namespaces": get_topsecret_namespaces(request.dbsession), + "the_title": "topsecret namespace owners!", + } + + +@view_config(route_name="topsecret-notifications", renderer="list-notifications.j2") +@super_fly_required +def topsecret_notifications(request): + return { + "notifications": get_topsecret_notifications( + request.dbsession, limit=request.page_size, offset=request.page_offset + ), + "the_title": "topsecret notifications!", + "topsecret": True, + } + + +@view_config(route_name="topsecret", renderer="home.j2") +@super_fly_required +def topsecret_roots(request): + return { + "nodes": get_topsecret_roots(request.dbsession), + "the_title": "topsecret root nodes!", + } + + +@view_config(route_name="topsecret-nodes", renderer="list-nodes.j2") +@super_fly_required +def topsecret_nodes(request): + return { + "nodes": get_topsecret_nodes( + request.dbsession, limit=request.page_size, offset=request.page_offset + ), + "the_title": "topsecret activity on everything!", + } diff --git a/remarkbox/views/authenticated/watch.py b/remarkbox/views/authenticated/watch.py new file mode 100644 index 0000000..1a685cb --- /dev/null +++ b/remarkbox/views/authenticated/watch.py @@ -0,0 +1,94 @@ +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound + +from remarkbox.models import get_node_by_id + +from remarkbox.views import get_referer_or_home, user_required + + +# TODO: maybe use this when javascript is enabled. +# @view_config(route_name="watch", renderer="json", request_method=("POST", "PUT"), xhr=True) +# otherwise fallback to this when javascript is disabled: +@view_config(route_name="watch", request_method=("POST", "PUT")) +@user_required() +def watch(request): + root_id = request.params.get("root-id", None) + if root_id: + root = get_node_by_id(request.dbsession, root_id) + if root: + watcher = request.user.watch_node(root) + request.session.flash( + ( + "You watched this thread. We will notify you of changes {}.".format( + watcher.notification_frequency + ), + "success", + ) + ) + else: + request.session.flash( + ("You may not watch an empty thread.", "error") + ) + + return HTTPFound(get_referer_or_home(request)) + + +# TODO: maybe use this when javascript is enabled. +# @view_config(route_name="watch", renderer="json", request_method=("POST", "PUT"), xhr=True) +# otherwise fallback to this when javascript is disabled: +@view_config(route_name="unwatch", request_method=("POST", "PUT")) +@user_required() +def unwatch(request): + root_id = request.params.get("root-id", None) + watcher_id = request.params.get("watcher-id", None) + + if watcher_id: + watcher = request.user.get_watcher_by_id(watcher_id) + elif root_id: + watcher = request.user.get_watcher_by_node_id(root_id) + + if watcher: + request.dbsession.delete(watcher) + request.session.flash( + ( + "You unwatched this thread. We will not notify you of changes.", + "success", + ) + ) + return HTTPFound(get_referer_or_home(request)) + + +@view_config(route_name="embed-user-watching", renderer="user-watching.j2") +@view_config(route_name="basic-user-watching", renderer="user-watching.j2") +@user_required() +def watching(request): + return {"the_title": "Watching"} + + +@view_config(route_name="update-watching", request_method=("POST", "PUT")) +@user_required() +def update_watching(request): + changed = False + watchers = request.user.node_watchers.all() + request.user.namespace_watchers.all() + for watcher in watchers: + watcher_dom_id = "watcher-{}".format(watcher.id) + new_notification_frequency = request.params.get(watcher_dom_id) + if new_notification_frequency: + if watcher.notification_frequency == new_notification_frequency: + continue + elif new_notification_frequency == "never" and watcher.type == "node": + request.dbsession.delete(watcher) + else: + watcher.notification_frequency = new_notification_frequency + request.dbsession.add(watcher) + changed = True + + request.dbsession.flush() + + if changed: + request.session.flash( + ("You updated how we notify you of changes.", "success") + ) + + return HTTPFound(get_referer_or_home(request)) diff --git a/remarkbox/views/authentication.py b/remarkbox/views/authentication.py new file mode 100644 index 0000000..13e2d54 --- /dev/null +++ b/remarkbox/views/authentication.py @@ -0,0 +1,130 @@ +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound + +from remarkbox.models import get_node_by_uri, get_node_by_id + +from remarkbox.lib.mail import send_otp_email + +from . import get_referer_or_home, get_embed_route_uri, verify_pending_nodes_in_session + + +@view_config(route_name="log-out") +@view_config(route_name="embed-log-out") +def log_out(request): + """log out the user, redirect to back to referer.""" + uri = get_referer_or_home(request) + request.session["authenticated_user_id"] = None + return HTTPFound(uri) + + +# disable CSRF checking for iframe embedded version of this view. +# If a client has 3rd party cookies disabled this security feature causes +# more trouble then it helps, essentially blocking unauthenticated users. +# disable CSRF checking for basic mode since we only set the +# CSRF token for logged in users. +@view_config(route_name="basic-join-or-log-in", renderer="join-or-log-in.j2", require_csrf=False) +@view_config(route_name="embed-join-or-log-in", renderer="join-or-log-in.j2", require_csrf=False) +def join_or_log_in(request): + """ + This view handles user registration, verification, and log in. + It uses "password-less" authentication by sending OTP (one-time-password) + links to the user's email address. + """ + # get the return_to uri from posted parameters. + return_to = request.params.get("return-to", "") + + # get the return_to uri from posted parameters. + thread_uri = request.params.get("thread_uri", "") + + # get the raw OTP (one-time-password) from posted parameters. + raw_otp = request.params.get("raw-otp", "") + + # get the email_id from posted parameters. + email_id = request.params.get("email-id", "") + + if request.spam: + return request.spam + + if request.user is not None: + + if request.user.authenticated: + # user already authenticated, return early. + if return_to: + return HTTPFound(return_to) + return HTTPFound(get_referer_or_home(request)) + + user = request.user + + if raw_otp and user.check_password(raw_otp): + # success: the user was verified. + user.verified = True + msg = ("Welcome {}".format(user.name), "success") + request.session["authenticated_user_id"] = str(user.id) + request.session.flash(msg) + + # attempt to verify all nodes_pending_verify in user's session. + verify_pending_nodes_in_session(request, user) + + # Idempotent operation. Make certain a user has at least one reply_watcher. + user.create_default_reply_watcher() + + request.dbsession.add(user) + request.dbsession.flush() + + return HTTPFound(return_to) + + if user.throttle_password(): + msg = ( + "We already sent a link to {}. Click it to log in.".format(user.email), + "info", + ) + + else: + # generate a new one-time-password and save to database + raw_otp = user.new_password() + request.dbsession.add(user) + request.dbsession.flush() + + email_return_to = return_to + + if thread_uri: + if "#" not in thread_uri: + thread_uri = thread_uri + "#remarkbox-div" + email_return_to = thread_uri + + # email user the one-time-password and flash message. + send_otp_email(request, user.email, raw_otp, email_return_to) + + msg = ( + "We just sent a link to {}. Click it to log in.".format(user.email), + "info", + ) + + request.session.flash(msg) + + if request.mode == "basic": + return HTTPFound(return_to) + + if request.mode == "embed": + # determine if fragment is a valid node.id. + if "#" in return_to: + node = get_node_by_id(request.dbsession, return_to.split("#")[-1]) + if node is not None: + if node.root.uri: + return HTTPFound( + get_embed_route_uri(request, node.root.uri.data, node.id) + ) + + # determine if return_to is a valid uri in database. + root = get_node_by_uri(request.dbsession, return_to.split("#")[0]) + if root is not None: + # embeded external site, uses embed routes. + return HTTPFound(get_embed_route_uri(request, root.uri.data)) + + return { + # 'the_title' : 'join or log in', + "title": "join or log in", + "return_to": return_to, + "thread_uri": thread_uri, + } diff --git a/remarkbox/views/list_nodes.py b/remarkbox/views/list_nodes.py new file mode 100644 index 0000000..677c995 --- /dev/null +++ b/remarkbox/views/list_nodes.py @@ -0,0 +1,206 @@ +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound, HTTPForbidden + +from remarkbox.models import ( + get_user_by_name, + get_root_nodes_by_keywords, +) + +from . import ( + get_referer_or_home, + get_node_route_uri, + get_embed_route_uri, + get_join_or_log_in_route_uri, +) + +try: + unicode("") +except: + from six import u as unicode + + +@view_config(route_name="sitemap", renderer="sitemap.xml.j2") +@view_config(route_name="sitemap2", renderer="sitemap.xml.j2") +@view_config(route_name="basic-namespace-threads-rss", renderer="rss.xml.j2") +def rss_xml(request): + request.response.content_type = "text/xml" + return {"nodes": request.namespace.visible_roots.all()} + + +@view_config(route_name="basic-namespace-nodes-rss", renderer="rss.xml.j2") +def rss_nodes_xml(request): + request.response.content_type = "text/xml" + if request.namespace.hide_unless_approved: + nodes = request.namespace.approved_nodes + else: + nodes = request.namespace.page_nodes( + limit=request.page_size, offset=request.page_offset + ) + request.response.content_type = "text/xml" + return {"nodes": nodes} + + +@view_config(route_name="basic-namespace-pending-rss", renderer="rss.xml.j2") +def rss_pending_xml(request): + request.response.content_type = "text/xml" + nodes = request.namespace.unapproved_nodes + if request.namespace.hide_unless_approved: + feed_key = request.matchdict.get("feed_key", None) + # should we bcrypt this password? + if feed_key != request.namespace.feed_key: + return HTTPForbidden("The feed password was invalid!") + return {"nodes": nodes} + + +@view_config(route_name="basic-namespace", renderer="home.j2") +@view_config(route_name="embed-namespace", renderer="home.j2") +@view_config(route_name="home", renderer="home.j2") +def namespace(request): + return { + "nodes": request.namespace.visible_roots, + "the_title": request.namespace.name, + } + + +@view_config(route_name="basic-namespace-nodes", renderer="list-nodes.j2") +@view_config(route_name="embed-namespace-nodes", renderer="list-nodes.j2") +def namespace_nodes(request): + # TODO: the way I'm currently protecting views (or in this case certain urls) + # is causing a lot of copy and paste and even confusing conditional logic. + if request.namespace.hide_unless_approved: + if "disabled" in request.params or "pending" in request.params: + # TODO: maybe dry this out and turn it into a decorator? + if not request.user or not request.user.authenticated: + request.session.flash(("You must log in to access that area.", "error")) + return HTTPFound(get_join_or_log_in_route_uri(request)) + + # TODO: maybe dry this out and turn it into a decorator? + if request.user not in request.namespace.moderators: + request.session.flash( + ("You must be a moderator in to access that area.", "error") + ) + return HTTPFound(get_join_or_log_in_route_uri(request)) + + if "disabled" in request.params: + # TODO: pagination. + state = "disabled" + nodes = request.namespace.disabled_nodes + elif "pending" in request.params: + # TODO: pagination. + state = "pending" + nodes = request.namespace.unapproved_nodes + else: + state = "active" + nodes = request.namespace.page_nodes( + limit=request.page_size, offset=request.page_offset + ) + else: + if "disabled" in request.params: + # TODO: pagination. + state = "disabled" + nodes = request.namespace.disabled_nodes + else: + state = "active" + nodes = request.namespace.page_nodes( + limit=request.page_size, offset=request.page_offset + ) + return { + "nodes": nodes, + "the_title": "{} - {} nodes".format(request.namespace.name, state), + } + + +@view_config(route_name="basic-user", renderer="list-nodes.j2") +@view_config(route_name="embed-user", renderer="list-nodes.j2") +def user_nodes(request): + subject_user_name = request.matchdict.get("user_name", None) + subject_user = get_user_by_name(request.dbsession, subject_user_name) + if subject_user is None: + return HTTPFound(get_referer_or_home(request)) + if "disabled" in request.params: + # TODO: pagination. + state = "disabled" + nodes = subject_user.disabled_nodes + elif "pending" in request.params: + # TODO: pagination. + state = "pending" + nodes = subject_user.unverified_nodes + else: + state = "active" + nodes = subject_user.page_nodes( + limit=request.page_size, offset=request.page_offset + ) + return { + "nodes": nodes, + "the_title": "{} - {} nodes".format(subject_user.name, state), + } + + +@view_config(route_name="search", renderer="home.j2") +def search(request): + # TODO: maybe we should maintain a search table where we have: root_id, data + # where data is the concatenated data from all nodes in a tree. + # whenever a new node is added to a tree, it would recompute this value. + keywords = request.params.get("keywords", None) + + if not keywords: + return HTTPFound(get_referer_or_home(request)) + + # Note: a search from topsecret page does not pass a namespace. + nodes = get_root_nodes_by_keywords( + request.dbsession, + keywords.split(" "), + request.namespace + ) + + if len(nodes) == 1: + # redirect to only match, the title slugified node uri. + return HTTPFound(nodes[0].path) + + return { + "nodes": nodes, + "the_title": '"{}" on {}'.format(keywords, request.namespace.name), + } + + +def verify_namespace_request(request): + """check if namespace_request is present on the target.""" + if request.namespace_request: + user = request.namespace_request.user + request.namespace_request.verify_target(request.node.root.uri.data) + request.namespace.update_owner_request_timestamp() + if request.namespace_request.verified and user not in request.namespace.owners: + request.namespace.set_role_for_user(user, unicode("owner")) + request.session.flash( + ( + "Namespace owner was verified! ({})".format(request.namespace.name), + "Success" + ) + ) + request.dbsession.add(request.namespace_request) + request.dbsession.add(request.namespace) + request.dbsession.flush() + + +#@view_config(route_name="basic-show-node", renderer="show-node2.j2") +#@view_config(route_name="basic-show-node2", renderer="show-node2.j2") +@view_config(route_name="basic-show-node", renderer="show-node.j2") +@view_config(route_name="basic-show-node2", renderer="show-node.j2") +@view_config(route_name="embed-show-node", renderer="show-node.j2") +@view_config(route_name="embed-show-node2", renderer="show-node.j2") +@view_config(route_name="embed-show-node3", renderer="show-node.j2") +def show_node(request): + """Show a node.""" + if request.node is None: + # unable to find node by id or thread_uri, or create by thread_uri. + return HTTPFound(get_referer_or_home(request)) + + if request.node.has_uri == False and request.node.slug is not None and "slug" not in request.matchdict: + # redirect to the title slugified node uri. + return HTTPFound(get_node_route_uri(request, request.node)) + + # logic to magically verify an owner of a namespace. + verify_namespace_request(request) + + return {} diff --git a/remarkbox/views/misc.py b/remarkbox/views/misc.py new file mode 100644 index 0000000..3133f5d --- /dev/null +++ b/remarkbox/views/misc.py @@ -0,0 +1,127 @@ +# needed for favicon +import os +from pyramid.response import FileResponse, Response + +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound, HTTPNotFound + +from pyramid.renderers import render_to_response + +from remarkbox.lib.render import markdown_to_html + +from . import get_node_route_uri + +from uuid import uuid1 + +DEFAULT_ROBOTS_DOT_TXT = """ +User-agent: * +Disallow: +""" + + +@view_config(route_name="basic-namespace-stats-json") +def namespace_stats_json(request): + from json import dumps + response = Response(body=dumps(request.namespace.visible_root_stats)) + response.headerlist = [] + # allow Javascript from other domains to download this resource. + response.headerlist.extend( + ( + ('Access-Control-Allow-Origin', '*'), + ('Content-Type', 'application/json') + ) + ) + return response + + +@view_config(route_name="redirect-to-root") +def redirect_to_root(request): + """ + Given a node id, redirect to it's root uri. + This significantly speeds up page loads for pages without a subject node. + """ + if request.node.root.uri: + return HTTPFound("{}#{}".format(request.node.root.uri.data, request.node.id)) + + return HTTPFound( + get_node_route_uri(request, request.node.root, anchor=request.node.id) + ) + + +# I set require_csfr to false because new browsers +# don't save 3rd party cookies anymore. +# This ajax endpoint should work whether a user is authenticated or not. +@view_config(route_name="preview-post", renderer="string", xhr=True, require_csrf=False) +def preview_post(request): + """AJAJ: Accept MarkDown data param, return HTML""" + try: + return markdown_to_html(request.params["data"], request.namespace) + except: + return "we could not create markdown to html preview." + + +@view_config(route_name="favicon") +def favicon_view(request): + here = os.path.dirname(__file__) + icon = os.path.join(here, "..", "static", "favicon.ico") + return FileResponse(icon, request=request) + + +@view_config(route_name="robots") +def robots_view(request): + """Load and return either default robots.txt or version from .ini""" + response = Response( + body=request.app.get( + "robots_dot_txt", + DEFAULT_ROBOTS_DOT_TXT + ).lstrip() + ) + response.content_type = "text/plain" + return response + + +@view_config(route_name="embed-show-count", renderer="show-count.j2") +@view_config(route_name="basic-show-count", renderer="show-count.j2") +@view_config(route_name="basic-show-count2", renderer="show-count.j2") +def show_count(request): + return { "stats" : request.namespace.visible_root_stats } + + +@view_config(route_name="embed-iframe") +@view_config(route_name="embed-iframe-min") +def embed_iframe(request): + """Jinja render a text file and serve it as plain text.""" + context = {"rb_owner_key": request.params.get("rb_owner_key", "none")} + response = render_to_response("embed-iframe.txt.j2", context, request=request) + response.content_type = "text/plain" + if request.matched_route.name == "embed-iframe-min": + response.text = " ".join(response.text.split()) + return response + + +@view_config(route_name="basic-namespace-stylesheet") +@view_config(route_name="embed-namespace-stylesheet") +def namespace_stylesheet(request): + """serve a namespaces css, expire cache after a month.""" + response = Response(body=request.stylesheet) + response.content_type = "text/css" + response.cache_expires(seconds=2592000) + return response + + +@view_config(route_name="dynamic-remarkbox-css") +def remarkbox_dynamic_stylesheet(request): + """ + Jinja render a css file and serve it as text/css. + Serve a dynamic css, expire cache after a month. + """ + context = {"request": request} + response = render_to_response( + "dynamic-remarkbox.css.j2", + context, + request=request, + ) + response.content_type = "text/css" + response.cache_expires(seconds=2592000) + return response diff --git a/remarkbox/views/modify_node.py b/remarkbox/views/modify_node.py new file mode 100644 index 0000000..c5bd80a --- /dev/null +++ b/remarkbox/views/modify_node.py @@ -0,0 +1,100 @@ +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound + +from . import get_referer_or_home, get_node_route_uri, get_embed_route_uri + + +@view_config(route_name="embed-edit", renderer="edit-node.j2") +@view_config(route_name="basic-edit", renderer="edit-node.j2") +@view_config(route_name="basic-edit2", renderer="edit-node.j2") +def edit_node(request): + """handle posting of edit form from show-node pages.""" + thread_data = request.params.get("thread_data", "") + thread_title = request.params.get("thread_title", "") + + if request.user is None: + request.session.flash(("You must log in to edit your messages.", "error")) + return HTTPFound(get_referer_or_home(request)) + + if not request.namespace.can_alter_node(request.node, request.user): + request.session.flash(("You do not own this message.", "error")) + return HTTPFound(get_referer_or_home(request)) + + if thread_data or thread_title: + if thread_title: + request.node.title = thread_title + request.node.edit(thread_data) + + # set return_to URI. + return_to = get_node_route_uri(request, request.node.root, request.node.id) + if request.mode == "embed": + return_to = get_embed_route_uri( + request, request.node.root.uri.data, request.node.id + ) + + return HTTPFound(return_to) + + return {} + + +def _node_route_uri(request): + """accept a request, return the request's node route uri""" + if request.mode == "embed": + return get_embed_route_uri(request, request.node.root.uri.data, request.node.id) + return get_node_route_uri(request, request.node.root, request.node.id) + + +@view_config(route_name="embed-disable", request_method=("POST", "PUT")) +@view_config(route_name="basic-disable", request_method=("POST", "PUT")) +def disable_node(request): + """disable node if user allowed.""" + if request.namespace.can_alter_node(request.node, request.user): + request.node.disable() + request.dbsession.add(request.node) + request.dbsession.flush() + return HTTPFound(_node_route_uri(request)) + + +@view_config(route_name="embed-enable", request_method=("POST", "PUT")) +@view_config(route_name="basic-enable", request_method=("POST", "PUT")) +def enable_node(request): + """enable node if user allowed.""" + if request.namespace.can_alter_node(request.node, request.user): + request.node.enable() + request.dbsession.add(request.node) + request.dbsession.flush() + return HTTPFound(_node_route_uri(request)) + + +@view_config(route_name="embed-verify", request_method=("POST", "PUT")) +@view_config(route_name="basic-verify", request_method=("POST", "PUT")) +def verify_node(request): + """only the node creator may verify ownership of a node.""" + if request.node.user == request.user: + request.node.verify() + request.dbsession.add(request.node) + request.dbsession.flush() + return HTTPFound(_node_route_uri(request)) + + +@view_config(route_name="embed-approve", request_method=("POST", "PUT")) +@view_config(route_name="basic-approve", request_method=("POST", "PUT")) +def approve_node(request): + """approve node if user allowed.""" + if request.namespace.is_moderator(request.user): + request.node.approve() + request.dbsession.add(request.node) + request.dbsession.flush() + return HTTPFound(_node_route_uri(request)) + + +@view_config(route_name="embed-deny", request_method=("POST", "PUT")) +@view_config(route_name="basic-deny", request_method=("POST", "PUT")) +def deny_node(request): + """deny node if user allowed.""" + if request.namespace.is_moderator(request.user): + request.node.deny() + request.dbsession.add(request.node) + request.dbsession.flush() + return HTTPFound(_node_route_uri(request)) diff --git a/remarkbox/views/new_thread.py b/remarkbox/views/new_thread.py new file mode 100644 index 0000000..d7c9a2c --- /dev/null +++ b/remarkbox/views/new_thread.py @@ -0,0 +1,93 @@ +from pyramid.view import view_config + +from pyramid.csrf import check_csrf_token + +from pyramid.httpexceptions import HTTPFound + +from remarkbox.models import create_root_node + +from . import get_referer_or_home, get_node_route_uri, set_node_to_pending_in_session + +from remarkbox.lib.notify import schedule_notifications + +try: + unicode("") +except: + from six import u as unicode + + +@view_config(route_name="new", renderer="new.j2", require_csrf=False) +def new_thread(request): + """Display new page and handle posting of form.""" + thread_title = request.params.get("thread_title", "") + thread_data = request.params.get("thread_data", "") + + if request.spam: + return request.spam + + # check CSRF only if user is authenticated. + if request.method == "POST" and request.csrf_token: + check_csrf_token(request) + + if thread_title and thread_data: + # handle the submitted form new/create form. + + if request.user is None: + request.session.flash( + ("Press the back button to fix your email address", "error") + ) + return HTTPFound(get_referer_or_home(request)) + + else: + # create a new root node. + node = create_root_node() + + node.user = request.user + node.verified = request.user.authenticated + node.namespace = request.namespace + node.ip_address = unicode(request.client_addr) + + node.title = thread_title + node.set_data(thread_data) + + node_event = node.new_event(request.user, "created") + + request.dbsession.add(node) + request.dbsession.add(node_event) + request.dbsession.add(request.user) + request.dbsession.add(node.namespace) + request.dbsession.flush() + + # TODO: schedule_notification expects the request to have a node. + request.node = node + + schedule_notifications(request, node_event) + + msg = ("Your post was successful!", "success") + request.session.flash(msg) + + # set return_to to the node's URI. + return_to = get_node_route_uri(request, node) + + if node.verified == True: + # Redirect to new node if verified. + return HTTPFound(return_to) + + set_node_to_pending_in_session(request, node) + + # Redirect to join-or-log-in, posting email and submit. + uri = request.route_url( + route_name="basic-join-or-log-in", + _query={ + "email": request.user.email, + "return-to": return_to, + "submit": True, + }, + ) + return HTTPFound(uri) + + return { + "title": "Create a new thread", + "thread_title": thread_title, + "thread_data": thread_data, + } diff --git a/remarkbox/views/reply_node.py b/remarkbox/views/reply_node.py new file mode 100644 index 0000000..80767d1 --- /dev/null +++ b/remarkbox/views/reply_node.py @@ -0,0 +1,133 @@ +from pyramid.view import view_config + +from pyramid.csrf import check_csrf_token + +from pyramid.httpexceptions import HTTPFound + +from . import ( + get_referer_or_home, + get_embed_route_uri, + get_node_route_uri, + set_node_to_pending_in_session, +) + +from remarkbox.lib.notify import schedule_notifications + +try: + unicode("") +except: + from six import u as unicode + + +# check CSRF only if user is authenticated. +@view_config(route_name="embed-reply", require_csrf=False) +@view_config(route_name="embed-reply2", require_csrf=False) +@view_config(route_name="basic-reply", require_csrf=False) +@view_config(route_name="basic-reply2", require_csrf=False) +def reply_node(request): + """handle posting of reply form from show-node pages.""" + thread_data = request.params.get("thread_data", "") + + # return early if spam attribute is truthy. + if request.spam: + return request.spam + + # return early if node (parent) not not exist. + if request.node is None: + # redirect to the referer or home. No node found in database. + return HTTPFound(get_referer_or_home(request)) + + # return early if node (parent) is disabled. + if request.node.disabled is True: + # redirect to the referer or home. + request.session.flash(("No remarks for the disabled.", "error")) + return HTTPFound(get_referer_or_home(request)) + + # flash error and return early if user is None. + if request.user is None: + request.session.flash( + ("Press the back button to fix your email address", "error") + ) + return HTTPFound(get_referer_or_home(request)) + + # return early if thread (root node) is locked. + if request.node.root.locked: + request.session.flash( + ("You may not comment, this thread is locked.", "error") + ) + return HTTPFound(get_referer_or_home(request)) + + + # check CSRF only if user is authenticated. + if request.method == "POST" and request.csrf_token: + check_csrf_token(request) + + # flash error and return early if data is empty. + if thread_data == "": + request.session.flash(("Your message was empty", "error")) + return HTTPFound(get_referer_or_home(request)) + + # STEP 1: get a parent node. + parent = request.node + + # STEP 2: attach a brand new child node to parent node. + child = parent.new_child() + child.user = request.user + child.ip_address = unicode(request.client_addr) + child.verified = request.user.authenticated + child.set_data(thread_data, namespace=request.namespace) + child_event = child.new_event(request.user, "commented") + + if request.namespace.hide_unless_approved: + # by default comments are approved, unless Namespace hide_unless_approved + # is enabled, moderators nodes are always auto approved. + child.approved = request.namespace.is_moderator(request.user) + + # STEP 3: update root's changed timestamp. + # TODO: maybe we should find a better way to "bump" a thread. + parent.root.changed = child.changed + # invalidate root cache to force recomputation. + parent._invalidate_cache() + + # STEP 4: commit to database. + request.dbsession.add(request.user) + request.dbsession.add(child) + request.dbsession.add(child_event) + request.dbsession.add(parent) + request.dbsession.add(parent.root) + request.dbsession.flush() + + schedule_notifications(request, child_event) + + msg = ("Your post was successful!", "success") + request.session.flash(msg) + + ### TODO: everything below this is pretty much crap code... + # and likely deserves a flowchart... + + # set return_to URI. + if request.mode == "embed": + return_to = get_embed_route_uri(request, child.root.uri.data, child.id) + else: + return_to = get_node_route_uri(request, child.root, child.id) + + if child.verified == True: + # Redirect to new node if user and new node is verified. + return HTTPFound(return_to) + + set_node_to_pending_in_session(request, child) + + if request.mode == "embed": + # set return_to to the parent iframe URI. + return_to = "{}#{}".format(child.root.uri.data, child.id) + + # Redirect to join-or-log-in, posting email and submit. + # Per the docs: " Extra replacement names are ignored." + # In this case "namespace" is extra when mode is basic. + # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.route_url + uri = request.route_url( + route_name=request.mode + "-join-or-log-in", + namespace=request.namespace.name, + _query={"email": request.user.email, "return-to": return_to, "submit": True}, + ) + return HTTPFound(uri) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..7dca248 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +# bpython shell and teach pyramid about it. +bpython +pyramid_bpython + +# dev web application server. +waitress diff --git a/requirements-prod.txt b/requirements-prod.txt new file mode 100644 index 0000000..07418e7 --- /dev/null +++ b/requirements-prod.txt @@ -0,0 +1,5 @@ +# prod web application server. +uwsgi + +# prod db is PostgreSQL, psycopg2 is a database driver. +psycopg2 diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..0a36b85 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,6 @@ +# testing. +pytest +nose +mock +webtest +fakeredis diff --git a/requirements.py3.txt b/requirements.py3.txt new file mode 100644 index 0000000..c012eed --- /dev/null +++ b/requirements.py3.txt @@ -0,0 +1,74 @@ +# python 2 -> 3 compat. +six + +# Pyramid. +plaster_pastedeploy +pyramid +pyramid_jinja2 +pyramid_debugtoolbar +pyramid_retry +pyramid_tm + +# Model. +SQLAlchemy +SQLAlchemy-Utils +transaction +zope.sqlalchemy +alembic + +# PostgreSQL database connections. +# requires postgresql-devel on Fedora for `pg_config` binary. +psycopg2-binary + +# Template renderer. +#jinja2 +jinja2==2.11.1 + +# MarkDown is a popular and familair WYSIWYM markup language. +markdown +python-markdown-math +html5lib +BeautifulSoup4 + +# Bleach sanitizes MarkDown (removes HTML/Javascript) to prevent XSS. +bleach>=2.1.4 +bleach-allowlist + +# codehilite. +pygments + +# Human readable time deltas. +ago + +# light weight URI attribute parser. +miniuri + +# my miscutils checked out via git as an editable package. +#-e git+git@github.com:russellballestrini/miscutils.git#egg=miscutils +git+https://github.com/russellballestrini/miscutils.git#egg=miscutils + +# the official Remarkbox "meta" theme. +git+https://github.com/russellballestrini/remarkbox-theme-meta.git#egg-remarkbox-theme-meta + +# another example theme. +git+https://github.com/russellballestrini/remarkbox-westworld.git#egg-remarkbox-westworld + +# Create URI slugs from titles. +python-slugify + +# Future proof, computationally complex password hashes. +bcrypt + +# reach out to verify public rb_owner_key. +requests[security] +# request library pins this dependency too low. +# idna==2.6 + +# credit card storage and processing. +stripe + +# slack: bot notifications. +slacker + +# DKIM Signed Email from Python. +dkimpy diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5b76c5b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,79 @@ +# python 2 -> 3 compat. +six + +# Pyramid. +plaster_pastedeploy +pyramid +pyramid_jinja2 +pyramid_debugtoolbar +pyramid_retry +pyramid_tm + +# python 2. +venusian==2.1.0 + +# python 2. +soupsieve==1.9.5 + +# Model. +SQLAlchemy==1.3.24 +SQLAlchemy-Utils==0.36.3 +transaction +zope.sqlalchemy +alembic + +# PostgreSQL database connections. +# requires postgresql-devel on Fedora for `pg_config` binary. +psycopg2-binary + +# Template renderer. +# python 3. +#jinja2 +# python 2. +jinja2==2.11.1 + +# MarkDown is a popular and familair WYSIWYM markup language. +# python 2. +markdown==2.6.11 +python-markdown-math +html5lib +BeautifulSoup4 + +# Bleach sanitizes MarkDown (removes HTML/Javascript) to prevent XSS. +bleach>=2.1.4 +bleach-allowlist + +# codehilite. +pygments + +# Human readable time deltas. +ago + +# light weight URI attribute parser. +miniuri + +# my miscutils checked out via git as an editable package. +#-e git+git@github.com:russellballestrini/miscutils.git#egg=miscutils +git+https://github.com/russellballestrini/miscutils.git#egg=miscutils + +# Create URI slugs from titles. +python-slugify + +# Future proof, computationally complex password hashes. +bcrypt + +# reach out to verify public rb_owner_key. +requests[security] +# request library pins this dependency too low. +# idna==2.6 + +# credit card storage and processing. +stripe + +# slack: bot notifications. +slacker + +# DKIM Signed Email from Python. +#dkimpy +# python 2. +dkimpy==1.0.2 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..eecc989 --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +import os + +from setuptools import setup, find_packages + +here = os.path.abspath(os.path.dirname(__file__)) +readme_path = os.path.join(here, "README.rst") +with open(readme_path, "r") as f: + README = f.read() + +setup( + name="remarkbox", + version="0.2.01", + description="remarkbox", + long_description=README, + classifiers=[ + "Programming Language :: Python", + "Framework :: Pyramid", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", + ], + author="Russell Ballestrini", + author_email="russell@ballestrini.net", + url="https://russell.ballestrini.net", + keywords="remarkbox question answer forum embed comments reviews", + include_package_data=True, + packages=find_packages(), + package_data={ + "remarkbox": ["scripts/alembic/*.py", "scripts/alembic/versions/*.py"] + }, + zip_safe=False, + test_suite="remarkbox", + entry_points={ + "paste.app_factory": ["main = remarkbox:main"], + "console_scripts": [ + "remarkbox_init_db = remarkbox.scripts.init_db:main", + "remarkbox_modify_node = remarkbox.scripts.modify_node:main", + "remarkbox_modify_user = remarkbox.scripts.modify_user:main", + "remarkbox_modify_uris = remarkbox.scripts.modify_uris:main", + "remarkbox_modify_namespace = remarkbox.scripts.modify_namespace:main", + "remarkbox_json_import = remarkbox.scripts.json_import:main", + "remarkbox_json_import2 = remarkbox.scripts.json_import2:main", + "remarkbox_merge_dupes = remarkbox.scripts.merge_dupes:main", + "remarkbox_invalidate_node_cache = remarkbox.scripts.invalidate_node_cache:main", + "remarkbox_recompute_node_depths = remarkbox.scripts.recompute_node_depths:main", + "remarkbox_safe_approve_all_nodes = remarkbox.scripts.safe_approve_all_nodes:main", + "remarkbox_send_node_digest_notifications = remarkbox.scripts.send_node_digest_notifications:main", + ], + }, +) diff --git a/test.ini b/test.ini new file mode 100644 index 0000000..a2a3e7c --- /dev/null +++ b/test.ini @@ -0,0 +1,140 @@ +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html + +[alembic] +script_location = remarkbox:scripts/alembic +sqlalchemy.url = sqlite:///%(here)s/test-remarkbox.sqlite + +[app:main] +use = egg:remarkbox + +sqlalchemy.url = sqlite:///%(here)s/test-remarkbox.sqlite +#sqlalchemy.url = postgres://localhost/remarkbox_test + +### +# Pyramid configuration. +### +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_tm + +# By default, the toolbar only appears for clients from IP addresses +# '127.0.0.1' and '::1'. +# debugtoolbar.hosts = 127.0.0.1 ::1 + +### +# session / cookie configuration. +# http://docs.pylonsproject.org/docs/pyramid/en/latest/api/session.html +### +session.hashalg = sha512 +session.secret = test-test-secret +session.timeout = 31104000 +session.max_age = 31104000 +session.reissue_time = 15552000 + +### +# custom app configuration. +### +app.avatar.size = 35 + +# If set we force all links related to `join or log in` to this base url +# and use this url for links in email notifications. +# Also useful when multiple subdomains point at the same Remarkbox cluster. +# In prod I set this to: https://my.remarkbox.com (default = request.host_url) +#app.app_url = "" + +# if your marketing / main landing page base url is different then app url. +# In prod I set this to: https://www.remarkbox.com (default = request.app_url) +#app.marketing_url = "" + +# if Node is None, we can redirect to this uri. +#app.safe_redirect = https://unturf.com + +app.email.relay = localhost +app.email.sender = no-reply@remarkbox.com + +# set this to the path of your private DKIM key. +# reference: https://russell.ballestrini.net/quickstart-to-dkim-signed-email-with-python/ +#app.email.dkim_private_key_path = "" +#app.email.dkim_selector = "" + +# The app name in the email subject (default = request.app_domain) +# In production I set this to "Remarkbox". +#app.email.subject_postfix + +# optional theme plugin name. +#app.theme = westworld +app.theme = meta + +# optional theme plugin name for embed mode. +#app.theme_embed = meta + +# stand-alone mode disables a bunch of views and templates +# related to billing and registering Namespaces. Defaults to False +#app.stand_alone_mode = enabled + +# stripe: credit card storage and processing. +# This syntax will automatically expand an ENV var of the same name. +app.stripe.secret = ${REMARKBOX_APP_STRIPE_SECRET} +app.stripe.public = ${REMARKBOX_APP_STRIPE_PUBLIC} + +# slack: bot notifications. +app.slack.secret = ${REMARKBOX_APP_SLACK_SECRET} +app.slack.public = ${REMARKBOX_APP_SLACK_PUBLIC} +app.slack.oauth_scope = channels:read,chat:write:bot + +# feature flag for email notifications. +#app.deliver_email_notifications = true + +### +# wsgi server configuration +### +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### +[loggers] +keys = root, remarkbox, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_remarkbox] +level = DEBUG +handlers = +qualname = remarkbox + +[logger_sqlalchemy] +level = INFO +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s + +[pshell] +m = remarkbox.models