From e980ab3bda4eeec80170ab040e088ef6bde72e1f Mon Sep 17 00:00:00 2001 From: Deimos Date: Tue, 17 Jul 2018 13:39:29 -0600 Subject: [PATCH] Initial open-source release --- .gitignore | 23 + CONTRIBUTING.md | 49 + LICENSE.md | 660 +++++++++ README.md | 15 + Vagrantfile | 27 + git_hooks/pre-commit | 7 + git_hooks/pre-push | 8 + salt/minion | 19 + salt/pillar/dev.sls | 5 + salt/pillar/monitoring-secrets.example.sls | 8 + salt/pillar/monitoring.sls | 5 + salt/pillar/prod.sls | 6 + salt/pillar/top.sls | 8 + salt/salt/boussole.service.jinja2 | 13 + salt/salt/boussole.sls | 32 + salt/salt/cmark-gfm.sls | 25 + salt/salt/common.jinja2 | 13 + salt/salt/consumers/init.sls | 11 + .../topic_metadata_generator.service.jinja2 | 16 + salt/salt/cronjobs.sls | 7 + salt/salt/development.sls | 21 + salt/salt/final-setup.sls | 17 + salt/salt/grafana/grafana.conf.jinja2 | 19 + salt/salt/grafana/grafana.ini.jinja2 | 17 + salt/salt/grafana/init.sls | 61 + salt/salt/gunicorn/gunicorn.conf | 1 + salt/salt/gunicorn/gunicorn.service.jinja2 | 22 + salt/salt/gunicorn/gunicorn.socket | 11 + salt/salt/gunicorn/init.sls | 41 + salt/salt/nginx/init.sls | 25 + salt/salt/nginx/nginx.conf.jinja2 | 89 ++ salt/salt/nginx/site-config.sls | 16 + salt/salt/nginx/static-sites-config.sls | 16 + .../nginx/tildes-static-sites.conf.jinja2 | 35 + salt/salt/nginx/tildes.conf.jinja2 | 97 ++ salt/salt/postgresql/init.sls | 56 + salt/salt/postgresql/pg_hba.conf.jinja2 | 6 + salt/salt/postgresql/pgbouncer.ini.jinja2 | 21 + salt/salt/postgresql/pgbouncer.sls | 25 + salt/salt/postgresql/site-db.sls | 57 + salt/salt/postgresql/test-db.sls | 44 + .../prometheus/exporters/node_exporter.sls | 28 + .../exporters/postgres_exporter.sls | 28 + .../prometheus_node_exporter.service | 14 + .../prometheus_postgres_exporter.service | 15 + .../prometheus_redis_exporter.service | 14 + .../prometheus/exporters/redis_exporter.sls | 27 + salt/salt/prometheus/init.sls | 57 + salt/salt/prometheus/prometheus.conf.jinja2 | 21 + salt/salt/prometheus/prometheus.service | 14 + salt/salt/prometheus/prometheus.yml | 23 + salt/salt/prometheus/user.sls | 7 + salt/salt/python.sls | 58 + salt/salt/rabbitmq/definitions.json | 1 + salt/salt/rabbitmq/init.sls | 82 ++ salt/salt/rabbitmq/pg-amqp-bridge.service | 14 + salt/salt/rabbitmq/rabbitmq.config | 5 + salt/salt/raven.sls | 6 + salt/salt/redis/init.sls | 144 ++ salt/salt/redis/modules/rebloom.sls | 14 + salt/salt/redis/modules/redis-cell.sls | 20 + salt/salt/redis/redis.conf.jinja2 | 1297 +++++++++++++++++ salt/salt/redis/redis.service | 14 + salt/salt/redis/redis_breached_passwords.conf | 24 + .../redis/redis_breached_passwords.service | 14 + salt/salt/redis/transparent_hugepage.service | 11 + salt/salt/scripts/activate.sh.jinja2 | 7 + .../scripts/generate-site-icons.sh.jinja2 | 5 + salt/salt/scripts/init.sls | 7 + salt/salt/self-signed-cert.sls | 17 + salt/salt/sentry/common.jinja2 | 4 + salt/salt/sentry/config.yml.jinja2 | 64 + salt/salt/sentry/init.sls | 143 ++ salt/salt/sentry/sentry-cron.service.jinja2 | 15 + salt/salt/sentry/sentry-web.service.jinja2 | 17 + salt/salt/sentry/sentry-worker.service.jinja2 | 15 + salt/salt/sentry/sentry.conf.jinja2 | 21 + salt/salt/sentry/sentry.conf.py | 135 ++ salt/salt/site-icons-spriter.sls | 42 + salt/salt/top.sls | 40 + salt/salt/webassets.service.jinja2 | 12 + salt/salt/webassets.sls | 17 + tildes/alembic.ini | 38 + tildes/alembic/env.py | 80 + tildes/alembic/script.py.mako | 24 + tildes/boussole.yaml | 6 + tildes/consumers/topic_metadata_generator.py | 80 + tildes/development.ini | 49 + tildes/gunicorn_config.py | 14 + tildes/mypy.ini | 4 + tildes/production.ini.example | 37 + tildes/pylama.ini | 54 + tildes/pytest.ini | 3 + tildes/requirements-to-freeze.txt | 38 + tildes/requirements.txt | 105 ++ tildes/scripts/__init__.py | 1 + tildes/scripts/breached_passwords.py | 177 +++ tildes/scripts/clean_private_data.py | 124 ++ tildes/scripts/initialize_db.py | 81 + .../site-icons-spriter/css_template.jinja2 | 22 + tildes/scss/_base.scss | 208 +++ tildes/scss/_layout.scss | 88 ++ tildes/scss/_mixins.scss | 18 + tildes/scss/_spectre_variables.scss | 20 + tildes/scss/_themes.scss | 280 ++++ tildes/scss/_variables.scss | 53 + tildes/scss/modules/_btn.scss | 121 ++ tildes/scss/modules/_comment.scss | 131 ++ tildes/scss/modules/_divider.scss | 3 + tildes/scss/modules/_empty.scss | 4 + tildes/scss/modules/_form.scss | 75 + tildes/scss/modules/_group.scss | 42 + tildes/scss/modules/_heading.scss | 3 + tildes/scss/modules/_input.scss | 3 + tildes/scss/modules/_label.scss | 16 + tildes/scss/modules/_link.scss | 5 + tildes/scss/modules/_listing.scss | 12 + tildes/scss/modules/_logged-in-user.scss | 24 + tildes/scss/modules/_message.scss | 38 + tildes/scss/modules/_nav.scss | 25 + tildes/scss/modules/_pagination.scss | 5 + tildes/scss/modules/_post-buttons.scss | 38 + tildes/scss/modules/_post.scss | 43 + tildes/scss/modules/_settings.scss | 7 + tildes/scss/modules/_sidebar.scss | 45 + tildes/scss/modules/_site-footer.scss | 32 + tildes/scss/modules/_site-header.scss | 44 + tildes/scss/modules/_tab.scss | 26 + tildes/scss/modules/_text.scss | 4 + tildes/scss/modules/_time.scss | 17 + tildes/scss/modules/_toast.scss | 16 + tildes/scss/modules/_topic.scss | 272 ++++ tildes/scss/spectre-0.5.1/_accordions.scss | 38 + tildes/scss/spectre-0.5.1/_animations.scss | 20 + tildes/scss/spectre-0.5.1/_asian.scss | 33 + tildes/scss/spectre-0.5.1/_autocomplete.scss | 47 + tildes/scss/spectre-0.5.1/_avatars.scss | 77 + tildes/scss/spectre-0.5.1/_badges.scss | 70 + tildes/scss/spectre-0.5.1/_bars.scss | 71 + tildes/scss/spectre-0.5.1/_base.scss | 40 + tildes/scss/spectre-0.5.1/_breadcrumbs.scss | 29 + tildes/scss/spectre-0.5.1/_buttons.scss | 191 +++ tildes/scss/spectre-0.5.1/_calendars.scss | 203 +++ tildes/scss/spectre-0.5.1/_cards.scss | 39 + tildes/scss/spectre-0.5.1/_carousels.scss | 126 ++ tildes/scss/spectre-0.5.1/_chips.scss | 26 + tildes/scss/spectre-0.5.1/_codes.scss | 31 + .../spectre-0.5.1/_comparison-sliders.scss | 115 ++ tildes/scss/spectre-0.5.1/_dropdowns.scss | 36 + tildes/scss/spectre-0.5.1/_empty.scss | 21 + tildes/scss/spectre-0.5.1/_filters.scss | 37 + tildes/scss/spectre-0.5.1/_forms.scss | 527 +++++++ tildes/scss/spectre-0.5.1/_icons.scss | 5 + tildes/scss/spectre-0.5.1/_labels.scss | 34 + tildes/scss/spectre-0.5.1/_layout.scss | 424 ++++++ tildes/scss/spectre-0.5.1/_media.scss | 75 + tildes/scss/spectre-0.5.1/_menus.scss | 62 + tildes/scss/spectre-0.5.1/_meters.scss | 57 + tildes/scss/spectre-0.5.1/_mixins.scss | 11 + tildes/scss/spectre-0.5.1/_modals.scss | 81 + tildes/scss/spectre-0.5.1/_navbar.scss | 29 + tildes/scss/spectre-0.5.1/_navs.scss | 34 + tildes/scss/spectre-0.5.1/_normalize.scss | 446 ++++++ tildes/scss/spectre-0.5.1/_off-canvas.scss | 91 ++ tildes/scss/spectre-0.5.1/_pagination.scss | 61 + tildes/scss/spectre-0.5.1/_panels.scss | 23 + tildes/scss/spectre-0.5.1/_parallax.scss | 135 ++ tildes/scss/spectre-0.5.1/_popovers.scss | 69 + tildes/scss/spectre-0.5.1/_progress.scss | 45 + tildes/scss/spectre-0.5.1/_sliders.scss | 99 ++ tildes/scss/spectre-0.5.1/_steps.scss | 70 + tildes/scss/spectre-0.5.1/_tables.scss | 57 + tildes/scss/spectre-0.5.1/_tabs.scss | 66 + tildes/scss/spectre-0.5.1/_tiles.scss | 38 + tildes/scss/spectre-0.5.1/_timelines.scss | 54 + tildes/scss/spectre-0.5.1/_toasts.scss | 42 + tildes/scss/spectre-0.5.1/_tooltips.scss | 79 + tildes/scss/spectre-0.5.1/_typography.scss | 128 ++ tildes/scss/spectre-0.5.1/_utilities.scss | 8 + tildes/scss/spectre-0.5.1/_variables.scss | 117 ++ .../spectre-0.5.1/icons/_icons-action.scss | 316 ++++ .../scss/spectre-0.5.1/icons/_icons-core.scss | 53 + .../icons/_icons-navigation.scss | 133 ++ .../spectre-0.5.1/icons/_icons-object.scss | 176 +++ tildes/scss/spectre-0.5.1/mixins/_avatar.scss | 6 + tildes/scss/spectre-0.5.1/mixins/_button.scss | 54 + .../scss/spectre-0.5.1/mixins/_clearfix.scss | 8 + tildes/scss/spectre-0.5.1/mixins/_color.scss | 24 + tildes/scss/spectre-0.5.1/mixins/_label.scss | 11 + .../scss/spectre-0.5.1/mixins/_position.scss | 65 + tildes/scss/spectre-0.5.1/mixins/_shadow.scss | 9 + tildes/scss/spectre-0.5.1/mixins/_text.scss | 6 + tildes/scss/spectre-0.5.1/mixins/_toast.scss | 5 + .../spectre-0.5.1/mixins/_transition.scss | 4 + tildes/scss/spectre-0.5.1/spectre-exp.scss | 17 + tildes/scss/spectre-0.5.1/spectre-icons.scss | 10 + tildes/scss/spectre-0.5.1/spectre.scss | 48 + .../scss/spectre-0.5.1/utilities/_colors.scss | 29 + .../spectre-0.5.1/utilities/_cursors.scss | 24 + .../spectre-0.5.1/utilities/_display.scss | 44 + .../spectre-0.5.1/utilities/_divider.scss | 50 + .../spectre-0.5.1/utilities/_loading.scss | 34 + .../spectre-0.5.1/utilities/_position.scss | 50 + .../scss/spectre-0.5.1/utilities/_shapes.scss | 8 + .../scss/spectre-0.5.1/utilities/_text.scss | 64 + tildes/scss/styles.scss | 35 + tildes/setup.py | 14 + tildes/sql/init/insert_base_data.sql | 4 + tildes/sql/init/rabbitmq_functions.sql | 3 + .../triggers/comment_notifications/users.sql | 42 + .../init/triggers/comment_votes/comments.sql | 21 + .../comments/comment_notifications.sql | 15 + .../sql/init/triggers/comments/comments.sql | 14 + .../init/triggers/comments/topic_visits.sql | 35 + tildes/sql/init/triggers/comments/topics.sql | 83 ++ .../triggers/group_subscriptions/groups.sql | 21 + .../triggers/message_conversations/users.sql | 37 + .../message_replies/message_conversations.sql | 37 + .../sql/init/triggers/topic_votes/topics.sql | 21 + tildes/sql/init/triggers/topics/rabbitmq.sql | 31 + tildes/sql/init/triggers/topics/topics.sql | 14 + tildes/static/android-chrome-192x192.png | Bin 0 -> 845 bytes tildes/static/android-chrome-512x512.png | Bin 0 -> 2813 bytes tildes/static/apple-touch-icon.png | Bin 0 -> 1330 bytes tildes/static/browserconfig.xml | 9 + tildes/static/favicon-16x16.png | Bin 0 -> 298 bytes tildes/static/favicon-32x32.png | Bin 0 -> 500 bytes tildes/static/favicon.ico | Bin 0 -> 2734 bytes tildes/static/images/mark-new-comments.png | Bin 0 -> 6532 bytes tildes/static/js/behaviors/auto-focus.js | 8 + .../static/js/behaviors/autoselect-input.js | 5 + .../js/behaviors/autosubmit-on-change.js | 5 + tildes/static/js/behaviors/cancel-button.js | 25 + .../js/behaviors/comment-collapse-button.js | 16 + .../js/behaviors/comment-parent-button.js | 21 + .../js/behaviors/comment-reply-button.js | 82 ++ .../static/js/behaviors/comment-tag-button.js | 82 ++ .../behaviors/confirm-leave-page-unsaved.js | 11 + .../js/behaviors/ctrl-enter-submit-form.js | 8 + .../js/behaviors/fadeout-parent-on-success.js | 5 + .../js/behaviors/hide-sidebar-if-open.js | 9 + .../js/behaviors/prevent-double-submit.js | 16 + tildes/static/js/behaviors/remove-on-click.js | 5 + .../static/js/behaviors/remove-on-success.js | 5 + tildes/static/js/behaviors/sidebar-toggle.js | 8 + tildes/static/js/behaviors/theme-selector.js | 26 + .../static/js/behaviors/time-period-select.js | 32 + tildes/static/js/scripts.js | 69 + .../static/js/third_party/areyousure-1.9.0.js | 192 +++ .../js/third_party/intercooler-1.0.3.min.js | 2 + .../static/js/third_party/jquery-3.1.1.min.js | 4 + tildes/static/js/third_party/onmount-1.3.0.js | 417 ++++++ tildes/static/manifest.json | 18 + tildes/static/mstile-150x150.png | Bin 0 -> 923 bytes tildes/static/robots.txt | 0 tildes/static/safari-pinned-tab.svg | 21 + tildes/tests/conftest.py | 219 +++ tildes/tests/test_comment.py | 248 ++++ tildes/tests/test_datetime.py | 59 + tildes/tests/test_group.py | 84 ++ tildes/tests/test_hash.py | 17 + tildes/tests/test_id.py | 51 + tildes/tests/test_markdown.py | 331 +++++ tildes/tests/test_markdown_field.py | 64 + tildes/tests/test_messages.py | 101 ++ tildes/tests/test_metrics.py | 12 + tildes/tests/test_ratelimit.py | 239 +++ tildes/tests/test_simplestring_field.py | 86 ++ tildes/tests/test_string.py | 129 ++ tildes/tests/test_title.py | 63 + tildes/tests/test_topic.py | 230 +++ tildes/tests/test_topic_tags.py | 53 + tildes/tests/test_url.py | 45 + tildes/tests/test_user.py | 115 ++ tildes/tests/test_username.py | 59 + tildes/tests/test_webassets.py | 22 + tildes/tests/webtests/test_user_page.py | 13 + tildes/tildes/__init__.py | 182 +++ tildes/tildes/api.py | 31 + tildes/tildes/auth.py | 135 ++ tildes/tildes/database.py | 99 ++ tildes/tildes/enums.py | 86 ++ tildes/tildes/jinja.py | 60 + tildes/tildes/json.py | 43 + tildes/tildes/lib/__init__.py | 9 + tildes/tildes/lib/amqp.py | 78 + tildes/tildes/lib/database.py | 140 ++ tildes/tildes/lib/datetime.py | 118 ++ tildes/tildes/lib/hash.py | 29 + tildes/tildes/lib/id.py | 41 + tildes/tildes/lib/markdown.py | 390 +++++ tildes/tildes/lib/message.py | 48 + tildes/tildes/lib/password.py | 26 + tildes/tildes/lib/ratelimit.py | 302 ++++ tildes/tildes/lib/string.py | 197 +++ tildes/tildes/lib/url.py | 16 + tildes/tildes/metrics.py | 89 ++ tildes/tildes/models/__init__.py | 4 + tildes/tildes/models/comment/__init__.py | 9 + tildes/tildes/models/comment/comment.py | 210 +++ .../models/comment/comment_notification.py | 74 + .../comment/comment_notification_query.py | 54 + tildes/tildes/models/comment/comment_query.py | 55 + tildes/tildes/models/comment/comment_tag.py | 58 + tildes/tildes/models/comment/comment_tree.py | 166 +++ tildes/tildes/models/comment/comment_vote.py | 53 + tildes/tildes/models/database_model.py | 138 ++ tildes/tildes/models/group/__init__.py | 5 + tildes/tildes/models/group/group.py | 94 ++ tildes/tildes/models/group/group_query.py | 55 + .../tildes/models/group/group_subscription.py | 53 + tildes/tildes/models/log/__init__.py | 3 + tildes/tildes/models/log/log.py | 222 +++ tildes/tildes/models/message/__init__.py | 3 + tildes/tildes/models/message/message.py | 263 ++++ tildes/tildes/models/model_query.py | 140 ++ tildes/tildes/models/pagination.py | 212 +++ tildes/tildes/models/topic/__init__.py | 6 + tildes/tildes/models/topic/topic.py | 327 +++++ tildes/tildes/models/topic/topic_query.py | 143 ++ tildes/tildes/models/topic/topic_visit.py | 73 + tildes/tildes/models/topic/topic_vote.py | 53 + tildes/tildes/models/user/__init__.py | 5 + tildes/tildes/models/user/user.py | 181 +++ .../tildes/models/user/user_group_settings.py | 36 + tildes/tildes/models/user/user_invite_code.py | 89 ++ tildes/tildes/resources/__init__.py | 31 + tildes/tildes/resources/comment.py | 21 + tildes/tildes/resources/group.py | 32 + tildes/tildes/resources/message.py | 26 + tildes/tildes/resources/topic.py | 35 + tildes/tildes/resources/user.py | 19 + tildes/tildes/routes.py | 200 +++ tildes/tildes/schemas/__init__.py | 15 + tildes/tildes/schemas/comment.py | 29 + tildes/tildes/schemas/fields.py | 167 +++ tildes/tildes/schemas/group.py | 78 + tildes/tildes/schemas/message.py | 38 + tildes/tildes/schemas/topic.py | 139 ++ tildes/tildes/schemas/topic_listing.py | 45 + tildes/tildes/schemas/user.py | 129 ++ tildes/tildes/templates/base.jinja2 | 97 ++ .../tildes/templates/base_no_sidebar.jinja2 | 9 + tildes/tildes/templates/donate_stripe.jinja2 | 19 + tildes/tildes/templates/error_403.jinja2 | 35 + tildes/tildes/templates/groups.jinja2 | 34 + tildes/tildes/templates/home.jinja2 | 50 + .../includes/password_restrictions.jinja2 | 8 + .../intercooler/comment_contents.jinja2 | 3 + .../templates/intercooler/comment_edit.jinja2 | 30 + .../intercooler/group_subscription_box.jinja2 | 3 + .../templates/intercooler/invite_code.jinja2 | 12 + .../intercooler/single_comment.jinja2 | 3 + .../intercooler/single_message.jinja2 | 3 + .../intercooler/topic_contents.jinja2 | 1 + .../templates/intercooler/topic_edit.jinja2 | 25 + .../intercooler/topic_group_edit.jinja2 | 15 + .../templates/intercooler/topic_tags.jinja2 | 5 + .../intercooler/topic_tags_edit.jinja2 | 17 + .../intercooler/topic_title_edit.jinja2 | 16 + .../templates/intercooler/topic_voting.jinja2 | 3 + tildes/tildes/templates/invite.jinja2 | 39 + tildes/tildes/templates/login.jinja2 | 32 + .../tildes/templates/macros/comments.jinja2 | 183 +++ .../tildes/templates/macros/datetime.jinja2 | 16 + tildes/tildes/templates/macros/forms.jinja2 | 31 + tildes/tildes/templates/macros/groups.jinja2 | 33 + tildes/tildes/templates/macros/links.jinja2 | 7 + .../tildes/templates/macros/messages.jinja2 | 18 + tildes/tildes/templates/macros/topics.jinja2 | 137 ++ tildes/tildes/templates/macros/user.jinja2 | 27 + .../templates/message_conversation.jinja2 | 35 + tildes/tildes/templates/messages.jinja2 | 36 + tildes/tildes/templates/messages_sent.jinja2 | 5 + .../tildes/templates/messages_unread.jinja2 | 5 + tildes/tildes/templates/new_message.jinja2 | 37 + tildes/tildes/templates/new_topic.jinja2 | 58 + tildes/tildes/templates/notifications.jinja2 | 10 + .../templates/notifications_unread.jinja2 | 41 + tildes/tildes/templates/register.jinja2 | 66 + tildes/tildes/templates/settings.jinja2 | 53 + .../settings_account_recovery.jinja2 | 60 + .../templates/settings_comment_visits.jinja2 | 43 + .../tildes/templates/settings_filters.jinja2 | 27 + .../templates/settings_password_change.jinja2 | 41 + tildes/tildes/templates/topic.jinja2 | 265 ++++ tildes/tildes/templates/topic_listing.jinja2 | 198 +++ tildes/tildes/templates/user.jinja2 | 86 ++ tildes/tildes/views/__init__.py | 13 + tildes/tildes/views/api/__init__.py | 1 + tildes/tildes/views/api/v0/__init__.py | 1 + tildes/tildes/views/api/v0/group.py | 15 + tildes/tildes/views/api/v0/topic.py | 19 + tildes/tildes/views/api/v0/user.py | 15 + tildes/tildes/views/api/web/__init__.py | 1 + tildes/tildes/views/api/web/comment.py | 327 +++++ tildes/tildes/views/api/web/exceptions.py | 102 ++ tildes/tildes/views/api/web/group.py | 118 ++ tildes/tildes/views/api/web/message.py | 38 + tildes/tildes/views/api/web/topic.py | 312 ++++ tildes/tildes/views/api/web/user.py | 213 +++ tildes/tildes/views/decorators.py | 64 + tildes/tildes/views/donate.py | 52 + tildes/tildes/views/exceptions.py | 11 + tildes/tildes/views/group.py | 14 + tildes/tildes/views/login.py | 89 ++ tildes/tildes/views/message.py | 143 ++ tildes/tildes/views/metrics.py | 26 + tildes/tildes/views/notifications.py | 61 + tildes/tildes/views/register.py | 152 ++ tildes/tildes/views/settings.py | 91 ++ tildes/tildes/views/topic.py | 304 ++++ tildes/tildes/views/user.py | 89 ++ tildes/webassets.yaml | 28 + 414 files changed, 26238 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 Vagrantfile create mode 100755 git_hooks/pre-commit create mode 100755 git_hooks/pre-push create mode 100644 salt/minion create mode 100644 salt/pillar/dev.sls create mode 100644 salt/pillar/monitoring-secrets.example.sls create mode 100644 salt/pillar/monitoring.sls create mode 100644 salt/pillar/prod.sls create mode 100644 salt/pillar/top.sls create mode 100644 salt/salt/boussole.service.jinja2 create mode 100644 salt/salt/boussole.sls create mode 100644 salt/salt/cmark-gfm.sls create mode 100644 salt/salt/common.jinja2 create mode 100644 salt/salt/consumers/init.sls create mode 100644 salt/salt/consumers/topic_metadata_generator.service.jinja2 create mode 100644 salt/salt/cronjobs.sls create mode 100644 salt/salt/development.sls create mode 100644 salt/salt/final-setup.sls create mode 100644 salt/salt/grafana/grafana.conf.jinja2 create mode 100644 salt/salt/grafana/grafana.ini.jinja2 create mode 100644 salt/salt/grafana/init.sls create mode 100644 salt/salt/gunicorn/gunicorn.conf create mode 100644 salt/salt/gunicorn/gunicorn.service.jinja2 create mode 100644 salt/salt/gunicorn/gunicorn.socket create mode 100644 salt/salt/gunicorn/init.sls create mode 100644 salt/salt/nginx/init.sls create mode 100644 salt/salt/nginx/nginx.conf.jinja2 create mode 100644 salt/salt/nginx/site-config.sls create mode 100644 salt/salt/nginx/static-sites-config.sls create mode 100644 salt/salt/nginx/tildes-static-sites.conf.jinja2 create mode 100644 salt/salt/nginx/tildes.conf.jinja2 create mode 100644 salt/salt/postgresql/init.sls create mode 100644 salt/salt/postgresql/pg_hba.conf.jinja2 create mode 100644 salt/salt/postgresql/pgbouncer.ini.jinja2 create mode 100644 salt/salt/postgresql/pgbouncer.sls create mode 100644 salt/salt/postgresql/site-db.sls create mode 100644 salt/salt/postgresql/test-db.sls create mode 100644 salt/salt/prometheus/exporters/node_exporter.sls create mode 100644 salt/salt/prometheus/exporters/postgres_exporter.sls create mode 100644 salt/salt/prometheus/exporters/prometheus_node_exporter.service create mode 100644 salt/salt/prometheus/exporters/prometheus_postgres_exporter.service create mode 100644 salt/salt/prometheus/exporters/prometheus_redis_exporter.service create mode 100644 salt/salt/prometheus/exporters/redis_exporter.sls create mode 100644 salt/salt/prometheus/init.sls create mode 100644 salt/salt/prometheus/prometheus.conf.jinja2 create mode 100644 salt/salt/prometheus/prometheus.service create mode 100644 salt/salt/prometheus/prometheus.yml create mode 100644 salt/salt/prometheus/user.sls create mode 100644 salt/salt/python.sls create mode 100644 salt/salt/rabbitmq/definitions.json create mode 100644 salt/salt/rabbitmq/init.sls create mode 100644 salt/salt/rabbitmq/pg-amqp-bridge.service create mode 100644 salt/salt/rabbitmq/rabbitmq.config create mode 100644 salt/salt/raven.sls create mode 100644 salt/salt/redis/init.sls create mode 100644 salt/salt/redis/modules/rebloom.sls create mode 100644 salt/salt/redis/modules/redis-cell.sls create mode 100644 salt/salt/redis/redis.conf.jinja2 create mode 100644 salt/salt/redis/redis.service create mode 100644 salt/salt/redis/redis_breached_passwords.conf create mode 100644 salt/salt/redis/redis_breached_passwords.service create mode 100644 salt/salt/redis/transparent_hugepage.service create mode 100644 salt/salt/scripts/activate.sh.jinja2 create mode 100644 salt/salt/scripts/generate-site-icons.sh.jinja2 create mode 100644 salt/salt/scripts/init.sls create mode 100644 salt/salt/self-signed-cert.sls create mode 100644 salt/salt/sentry/common.jinja2 create mode 100644 salt/salt/sentry/config.yml.jinja2 create mode 100644 salt/salt/sentry/init.sls create mode 100644 salt/salt/sentry/sentry-cron.service.jinja2 create mode 100644 salt/salt/sentry/sentry-web.service.jinja2 create mode 100644 salt/salt/sentry/sentry-worker.service.jinja2 create mode 100644 salt/salt/sentry/sentry.conf.jinja2 create mode 100644 salt/salt/sentry/sentry.conf.py create mode 100644 salt/salt/site-icons-spriter.sls create mode 100644 salt/salt/top.sls create mode 100644 salt/salt/webassets.service.jinja2 create mode 100644 salt/salt/webassets.sls create mode 100644 tildes/alembic.ini create mode 100644 tildes/alembic/env.py create mode 100644 tildes/alembic/script.py.mako create mode 100644 tildes/boussole.yaml create mode 100644 tildes/consumers/topic_metadata_generator.py create mode 100644 tildes/development.ini create mode 100644 tildes/gunicorn_config.py create mode 100644 tildes/mypy.ini create mode 100644 tildes/production.ini.example create mode 100644 tildes/pylama.ini create mode 100644 tildes/pytest.ini create mode 100644 tildes/requirements-to-freeze.txt create mode 100644 tildes/requirements.txt create mode 100644 tildes/scripts/__init__.py create mode 100644 tildes/scripts/breached_passwords.py create mode 100644 tildes/scripts/clean_private_data.py create mode 100644 tildes/scripts/initialize_db.py create mode 100644 tildes/scripts/site-icons-spriter/css_template.jinja2 create mode 100644 tildes/scss/_base.scss create mode 100644 tildes/scss/_layout.scss create mode 100644 tildes/scss/_mixins.scss create mode 100644 tildes/scss/_spectre_variables.scss create mode 100644 tildes/scss/_themes.scss create mode 100644 tildes/scss/_variables.scss create mode 100644 tildes/scss/modules/_btn.scss create mode 100644 tildes/scss/modules/_comment.scss create mode 100644 tildes/scss/modules/_divider.scss create mode 100644 tildes/scss/modules/_empty.scss create mode 100644 tildes/scss/modules/_form.scss create mode 100644 tildes/scss/modules/_group.scss create mode 100644 tildes/scss/modules/_heading.scss create mode 100644 tildes/scss/modules/_input.scss create mode 100644 tildes/scss/modules/_label.scss create mode 100644 tildes/scss/modules/_link.scss create mode 100644 tildes/scss/modules/_listing.scss create mode 100644 tildes/scss/modules/_logged-in-user.scss create mode 100644 tildes/scss/modules/_message.scss create mode 100644 tildes/scss/modules/_nav.scss create mode 100644 tildes/scss/modules/_pagination.scss create mode 100644 tildes/scss/modules/_post-buttons.scss create mode 100644 tildes/scss/modules/_post.scss create mode 100644 tildes/scss/modules/_settings.scss create mode 100644 tildes/scss/modules/_sidebar.scss create mode 100644 tildes/scss/modules/_site-footer.scss create mode 100644 tildes/scss/modules/_site-header.scss create mode 100644 tildes/scss/modules/_tab.scss create mode 100644 tildes/scss/modules/_text.scss create mode 100644 tildes/scss/modules/_time.scss create mode 100644 tildes/scss/modules/_toast.scss create mode 100644 tildes/scss/modules/_topic.scss create mode 100644 tildes/scss/spectre-0.5.1/_accordions.scss create mode 100644 tildes/scss/spectre-0.5.1/_animations.scss create mode 100644 tildes/scss/spectre-0.5.1/_asian.scss create mode 100644 tildes/scss/spectre-0.5.1/_autocomplete.scss create mode 100644 tildes/scss/spectre-0.5.1/_avatars.scss create mode 100644 tildes/scss/spectre-0.5.1/_badges.scss create mode 100644 tildes/scss/spectre-0.5.1/_bars.scss create mode 100644 tildes/scss/spectre-0.5.1/_base.scss create mode 100644 tildes/scss/spectre-0.5.1/_breadcrumbs.scss create mode 100644 tildes/scss/spectre-0.5.1/_buttons.scss create mode 100644 tildes/scss/spectre-0.5.1/_calendars.scss create mode 100644 tildes/scss/spectre-0.5.1/_cards.scss create mode 100644 tildes/scss/spectre-0.5.1/_carousels.scss create mode 100644 tildes/scss/spectre-0.5.1/_chips.scss create mode 100644 tildes/scss/spectre-0.5.1/_codes.scss create mode 100644 tildes/scss/spectre-0.5.1/_comparison-sliders.scss create mode 100644 tildes/scss/spectre-0.5.1/_dropdowns.scss create mode 100644 tildes/scss/spectre-0.5.1/_empty.scss create mode 100644 tildes/scss/spectre-0.5.1/_filters.scss create mode 100644 tildes/scss/spectre-0.5.1/_forms.scss create mode 100644 tildes/scss/spectre-0.5.1/_icons.scss create mode 100644 tildes/scss/spectre-0.5.1/_labels.scss create mode 100644 tildes/scss/spectre-0.5.1/_layout.scss create mode 100644 tildes/scss/spectre-0.5.1/_media.scss create mode 100644 tildes/scss/spectre-0.5.1/_menus.scss create mode 100644 tildes/scss/spectre-0.5.1/_meters.scss create mode 100644 tildes/scss/spectre-0.5.1/_mixins.scss create mode 100644 tildes/scss/spectre-0.5.1/_modals.scss create mode 100755 tildes/scss/spectre-0.5.1/_navbar.scss create mode 100644 tildes/scss/spectre-0.5.1/_navs.scss create mode 100644 tildes/scss/spectre-0.5.1/_normalize.scss create mode 100644 tildes/scss/spectre-0.5.1/_off-canvas.scss create mode 100644 tildes/scss/spectre-0.5.1/_pagination.scss create mode 100644 tildes/scss/spectre-0.5.1/_panels.scss create mode 100644 tildes/scss/spectre-0.5.1/_parallax.scss create mode 100644 tildes/scss/spectre-0.5.1/_popovers.scss create mode 100644 tildes/scss/spectre-0.5.1/_progress.scss create mode 100644 tildes/scss/spectre-0.5.1/_sliders.scss create mode 100644 tildes/scss/spectre-0.5.1/_steps.scss create mode 100644 tildes/scss/spectre-0.5.1/_tables.scss create mode 100644 tildes/scss/spectre-0.5.1/_tabs.scss create mode 100644 tildes/scss/spectre-0.5.1/_tiles.scss create mode 100644 tildes/scss/spectre-0.5.1/_timelines.scss create mode 100644 tildes/scss/spectre-0.5.1/_toasts.scss create mode 100644 tildes/scss/spectre-0.5.1/_tooltips.scss create mode 100644 tildes/scss/spectre-0.5.1/_typography.scss create mode 100644 tildes/scss/spectre-0.5.1/_utilities.scss create mode 100644 tildes/scss/spectre-0.5.1/_variables.scss create mode 100644 tildes/scss/spectre-0.5.1/icons/_icons-action.scss create mode 100644 tildes/scss/spectre-0.5.1/icons/_icons-core.scss create mode 100644 tildes/scss/spectre-0.5.1/icons/_icons-navigation.scss create mode 100644 tildes/scss/spectre-0.5.1/icons/_icons-object.scss create mode 100644 tildes/scss/spectre-0.5.1/mixins/_avatar.scss create mode 100644 tildes/scss/spectre-0.5.1/mixins/_button.scss create mode 100644 tildes/scss/spectre-0.5.1/mixins/_clearfix.scss create mode 100644 tildes/scss/spectre-0.5.1/mixins/_color.scss create mode 100644 tildes/scss/spectre-0.5.1/mixins/_label.scss create mode 100644 tildes/scss/spectre-0.5.1/mixins/_position.scss create mode 100644 tildes/scss/spectre-0.5.1/mixins/_shadow.scss create mode 100644 tildes/scss/spectre-0.5.1/mixins/_text.scss create mode 100644 tildes/scss/spectre-0.5.1/mixins/_toast.scss create mode 100644 tildes/scss/spectre-0.5.1/mixins/_transition.scss create mode 100644 tildes/scss/spectre-0.5.1/spectre-exp.scss create mode 100644 tildes/scss/spectre-0.5.1/spectre-icons.scss create mode 100644 tildes/scss/spectre-0.5.1/spectre.scss create mode 100644 tildes/scss/spectre-0.5.1/utilities/_colors.scss create mode 100644 tildes/scss/spectre-0.5.1/utilities/_cursors.scss create mode 100644 tildes/scss/spectre-0.5.1/utilities/_display.scss create mode 100644 tildes/scss/spectre-0.5.1/utilities/_divider.scss create mode 100644 tildes/scss/spectre-0.5.1/utilities/_loading.scss create mode 100644 tildes/scss/spectre-0.5.1/utilities/_position.scss create mode 100644 tildes/scss/spectre-0.5.1/utilities/_shapes.scss create mode 100644 tildes/scss/spectre-0.5.1/utilities/_text.scss create mode 100644 tildes/scss/styles.scss create mode 100644 tildes/setup.py create mode 100644 tildes/sql/init/insert_base_data.sql create mode 100644 tildes/sql/init/rabbitmq_functions.sql create mode 100644 tildes/sql/init/triggers/comment_notifications/users.sql create mode 100644 tildes/sql/init/triggers/comment_votes/comments.sql create mode 100644 tildes/sql/init/triggers/comments/comment_notifications.sql create mode 100644 tildes/sql/init/triggers/comments/comments.sql create mode 100644 tildes/sql/init/triggers/comments/topic_visits.sql create mode 100644 tildes/sql/init/triggers/comments/topics.sql create mode 100644 tildes/sql/init/triggers/group_subscriptions/groups.sql create mode 100644 tildes/sql/init/triggers/message_conversations/users.sql create mode 100644 tildes/sql/init/triggers/message_replies/message_conversations.sql create mode 100644 tildes/sql/init/triggers/topic_votes/topics.sql create mode 100644 tildes/sql/init/triggers/topics/rabbitmq.sql create mode 100644 tildes/sql/init/triggers/topics/topics.sql create mode 100644 tildes/static/android-chrome-192x192.png create mode 100644 tildes/static/android-chrome-512x512.png create mode 100644 tildes/static/apple-touch-icon.png create mode 100644 tildes/static/browserconfig.xml create mode 100644 tildes/static/favicon-16x16.png create mode 100644 tildes/static/favicon-32x32.png create mode 100644 tildes/static/favicon.ico create mode 100644 tildes/static/images/mark-new-comments.png create mode 100644 tildes/static/js/behaviors/auto-focus.js create mode 100644 tildes/static/js/behaviors/autoselect-input.js create mode 100644 tildes/static/js/behaviors/autosubmit-on-change.js create mode 100644 tildes/static/js/behaviors/cancel-button.js create mode 100644 tildes/static/js/behaviors/comment-collapse-button.js create mode 100644 tildes/static/js/behaviors/comment-parent-button.js create mode 100644 tildes/static/js/behaviors/comment-reply-button.js create mode 100644 tildes/static/js/behaviors/comment-tag-button.js create mode 100644 tildes/static/js/behaviors/confirm-leave-page-unsaved.js create mode 100644 tildes/static/js/behaviors/ctrl-enter-submit-form.js create mode 100644 tildes/static/js/behaviors/fadeout-parent-on-success.js create mode 100644 tildes/static/js/behaviors/hide-sidebar-if-open.js create mode 100644 tildes/static/js/behaviors/prevent-double-submit.js create mode 100644 tildes/static/js/behaviors/remove-on-click.js create mode 100644 tildes/static/js/behaviors/remove-on-success.js create mode 100644 tildes/static/js/behaviors/sidebar-toggle.js create mode 100644 tildes/static/js/behaviors/theme-selector.js create mode 100644 tildes/static/js/behaviors/time-period-select.js create mode 100644 tildes/static/js/scripts.js create mode 100644 tildes/static/js/third_party/areyousure-1.9.0.js create mode 100644 tildes/static/js/third_party/intercooler-1.0.3.min.js create mode 100644 tildes/static/js/third_party/jquery-3.1.1.min.js create mode 100644 tildes/static/js/third_party/onmount-1.3.0.js create mode 100644 tildes/static/manifest.json create mode 100644 tildes/static/mstile-150x150.png create mode 100644 tildes/static/robots.txt create mode 100644 tildes/static/safari-pinned-tab.svg create mode 100644 tildes/tests/conftest.py create mode 100644 tildes/tests/test_comment.py create mode 100644 tildes/tests/test_datetime.py create mode 100644 tildes/tests/test_group.py create mode 100644 tildes/tests/test_hash.py create mode 100644 tildes/tests/test_id.py create mode 100644 tildes/tests/test_markdown.py create mode 100644 tildes/tests/test_markdown_field.py create mode 100644 tildes/tests/test_messages.py create mode 100644 tildes/tests/test_metrics.py create mode 100644 tildes/tests/test_ratelimit.py create mode 100644 tildes/tests/test_simplestring_field.py create mode 100644 tildes/tests/test_string.py create mode 100644 tildes/tests/test_title.py create mode 100644 tildes/tests/test_topic.py create mode 100644 tildes/tests/test_topic_tags.py create mode 100644 tildes/tests/test_url.py create mode 100644 tildes/tests/test_user.py create mode 100644 tildes/tests/test_username.py create mode 100644 tildes/tests/test_webassets.py create mode 100644 tildes/tests/webtests/test_user_page.py create mode 100644 tildes/tildes/__init__.py create mode 100644 tildes/tildes/api.py create mode 100644 tildes/tildes/auth.py create mode 100644 tildes/tildes/database.py create mode 100644 tildes/tildes/enums.py create mode 100644 tildes/tildes/jinja.py create mode 100644 tildes/tildes/json.py create mode 100644 tildes/tildes/lib/__init__.py create mode 100644 tildes/tildes/lib/amqp.py create mode 100644 tildes/tildes/lib/database.py create mode 100644 tildes/tildes/lib/datetime.py create mode 100644 tildes/tildes/lib/hash.py create mode 100644 tildes/tildes/lib/id.py create mode 100644 tildes/tildes/lib/markdown.py create mode 100644 tildes/tildes/lib/message.py create mode 100644 tildes/tildes/lib/password.py create mode 100644 tildes/tildes/lib/ratelimit.py create mode 100644 tildes/tildes/lib/string.py create mode 100644 tildes/tildes/lib/url.py create mode 100644 tildes/tildes/metrics.py create mode 100644 tildes/tildes/models/__init__.py create mode 100644 tildes/tildes/models/comment/__init__.py create mode 100644 tildes/tildes/models/comment/comment.py create mode 100644 tildes/tildes/models/comment/comment_notification.py create mode 100644 tildes/tildes/models/comment/comment_notification_query.py create mode 100644 tildes/tildes/models/comment/comment_query.py create mode 100644 tildes/tildes/models/comment/comment_tag.py create mode 100644 tildes/tildes/models/comment/comment_tree.py create mode 100644 tildes/tildes/models/comment/comment_vote.py create mode 100644 tildes/tildes/models/database_model.py create mode 100644 tildes/tildes/models/group/__init__.py create mode 100644 tildes/tildes/models/group/group.py create mode 100644 tildes/tildes/models/group/group_query.py create mode 100644 tildes/tildes/models/group/group_subscription.py create mode 100644 tildes/tildes/models/log/__init__.py create mode 100644 tildes/tildes/models/log/log.py create mode 100644 tildes/tildes/models/message/__init__.py create mode 100644 tildes/tildes/models/message/message.py create mode 100644 tildes/tildes/models/model_query.py create mode 100644 tildes/tildes/models/pagination.py create mode 100644 tildes/tildes/models/topic/__init__.py create mode 100644 tildes/tildes/models/topic/topic.py create mode 100644 tildes/tildes/models/topic/topic_query.py create mode 100644 tildes/tildes/models/topic/topic_visit.py create mode 100644 tildes/tildes/models/topic/topic_vote.py create mode 100644 tildes/tildes/models/user/__init__.py create mode 100644 tildes/tildes/models/user/user.py create mode 100644 tildes/tildes/models/user/user_group_settings.py create mode 100644 tildes/tildes/models/user/user_invite_code.py create mode 100644 tildes/tildes/resources/__init__.py create mode 100644 tildes/tildes/resources/comment.py create mode 100644 tildes/tildes/resources/group.py create mode 100644 tildes/tildes/resources/message.py create mode 100644 tildes/tildes/resources/topic.py create mode 100644 tildes/tildes/resources/user.py create mode 100644 tildes/tildes/routes.py create mode 100644 tildes/tildes/schemas/__init__.py create mode 100644 tildes/tildes/schemas/comment.py create mode 100644 tildes/tildes/schemas/fields.py create mode 100644 tildes/tildes/schemas/group.py create mode 100644 tildes/tildes/schemas/message.py create mode 100644 tildes/tildes/schemas/topic.py create mode 100644 tildes/tildes/schemas/topic_listing.py create mode 100644 tildes/tildes/schemas/user.py create mode 100644 tildes/tildes/templates/base.jinja2 create mode 100644 tildes/tildes/templates/base_no_sidebar.jinja2 create mode 100644 tildes/tildes/templates/donate_stripe.jinja2 create mode 100644 tildes/tildes/templates/error_403.jinja2 create mode 100644 tildes/tildes/templates/groups.jinja2 create mode 100644 tildes/tildes/templates/home.jinja2 create mode 100644 tildes/tildes/templates/includes/password_restrictions.jinja2 create mode 100644 tildes/tildes/templates/intercooler/comment_contents.jinja2 create mode 100644 tildes/tildes/templates/intercooler/comment_edit.jinja2 create mode 100644 tildes/tildes/templates/intercooler/group_subscription_box.jinja2 create mode 100644 tildes/tildes/templates/intercooler/invite_code.jinja2 create mode 100644 tildes/tildes/templates/intercooler/single_comment.jinja2 create mode 100644 tildes/tildes/templates/intercooler/single_message.jinja2 create mode 100644 tildes/tildes/templates/intercooler/topic_contents.jinja2 create mode 100644 tildes/tildes/templates/intercooler/topic_edit.jinja2 create mode 100644 tildes/tildes/templates/intercooler/topic_group_edit.jinja2 create mode 100644 tildes/tildes/templates/intercooler/topic_tags.jinja2 create mode 100644 tildes/tildes/templates/intercooler/topic_tags_edit.jinja2 create mode 100644 tildes/tildes/templates/intercooler/topic_title_edit.jinja2 create mode 100644 tildes/tildes/templates/intercooler/topic_voting.jinja2 create mode 100644 tildes/tildes/templates/invite.jinja2 create mode 100644 tildes/tildes/templates/login.jinja2 create mode 100644 tildes/tildes/templates/macros/comments.jinja2 create mode 100644 tildes/tildes/templates/macros/datetime.jinja2 create mode 100644 tildes/tildes/templates/macros/forms.jinja2 create mode 100644 tildes/tildes/templates/macros/groups.jinja2 create mode 100644 tildes/tildes/templates/macros/links.jinja2 create mode 100644 tildes/tildes/templates/macros/messages.jinja2 create mode 100644 tildes/tildes/templates/macros/topics.jinja2 create mode 100644 tildes/tildes/templates/macros/user.jinja2 create mode 100644 tildes/tildes/templates/message_conversation.jinja2 create mode 100644 tildes/tildes/templates/messages.jinja2 create mode 100644 tildes/tildes/templates/messages_sent.jinja2 create mode 100644 tildes/tildes/templates/messages_unread.jinja2 create mode 100644 tildes/tildes/templates/new_message.jinja2 create mode 100644 tildes/tildes/templates/new_topic.jinja2 create mode 100644 tildes/tildes/templates/notifications.jinja2 create mode 100644 tildes/tildes/templates/notifications_unread.jinja2 create mode 100644 tildes/tildes/templates/register.jinja2 create mode 100644 tildes/tildes/templates/settings.jinja2 create mode 100644 tildes/tildes/templates/settings_account_recovery.jinja2 create mode 100644 tildes/tildes/templates/settings_comment_visits.jinja2 create mode 100644 tildes/tildes/templates/settings_filters.jinja2 create mode 100644 tildes/tildes/templates/settings_password_change.jinja2 create mode 100644 tildes/tildes/templates/topic.jinja2 create mode 100644 tildes/tildes/templates/topic_listing.jinja2 create mode 100644 tildes/tildes/templates/user.jinja2 create mode 100644 tildes/tildes/views/__init__.py create mode 100644 tildes/tildes/views/api/__init__.py create mode 100644 tildes/tildes/views/api/v0/__init__.py create mode 100644 tildes/tildes/views/api/v0/group.py create mode 100644 tildes/tildes/views/api/v0/topic.py create mode 100644 tildes/tildes/views/api/v0/user.py create mode 100644 tildes/tildes/views/api/web/__init__.py create mode 100644 tildes/tildes/views/api/web/comment.py create mode 100644 tildes/tildes/views/api/web/exceptions.py create mode 100644 tildes/tildes/views/api/web/group.py create mode 100644 tildes/tildes/views/api/web/message.py create mode 100644 tildes/tildes/views/api/web/topic.py create mode 100644 tildes/tildes/views/api/web/user.py create mode 100644 tildes/tildes/views/decorators.py create mode 100644 tildes/tildes/views/donate.py create mode 100644 tildes/tildes/views/exceptions.py create mode 100644 tildes/tildes/views/group.py create mode 100644 tildes/tildes/views/login.py create mode 100644 tildes/tildes/views/message.py create mode 100644 tildes/tildes/views/metrics.py create mode 100644 tildes/tildes/views/notifications.py create mode 100644 tildes/tildes/views/register.py create mode 100644 tildes/tildes/views/settings.py create mode 100644 tildes/tildes/views/topic.py create mode 100644 tildes/tildes/views/user.py create mode 100644 tildes/webassets.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..906dbbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +.vagrant +Customfile + +__pycache__ +*.py[co] + +*.egg-info/ + +.cache/ +.mypy_cache/ +.webassets-cache/ +.webassets-manifest + +*.gz +*.log + +# don't track the built versions of CSS and JS +tildes/static/css/* +tildes/static/js/third_party.js +tildes/static/js/tildes.js + +# don't track the site-icons spritesheet(s) +tildes/static/images/site-icons* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2ddac82 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing to Tildes development + +Outside of actual code changes, there are several other ways to contribute to Tildes development: + +* Known issues and plans for upcoming changes are [tracked on GitLab](https://gitlab.com/tildes/tildes/issues). If you've found a bug/problem on the site or have a suggestion, please feel free to submit an issue for it (if one doesn't already exist). If you have a Tildes account, you can also post in [the ~tildes group](https://tildes.net/~tildes) with issues, suggestions, or questions. +* The [Tildes Docs site](https://docs.tildes.net) is also open-source and accepts contributions. It has a separate repository, also on GitLab: https://gitlab.com/tildes/tildes-static-sites +* [Donating to Tildes](https://docs.tildes.net/donate) supports its development directly by enabling Deimos to continue working on it full-time. Tildes is a non-profit with no investors and no advertising. Its operation and development relies on donations. + +## License + +Please take note that Tildes uses [the AGPLv3 license](https://www.gnu.org/licenses/why-affero-gpl.html). This means that if you use the Tildes code to run your own instance of the site, you *must* also open-source the code for your site, including any changes that you've made. If you are not able to open-source the code for your instance, you can not use the Tildes code to run it. + +## Setting up a development version + +Please see this page on the Tildes Docs for instructions to set up a development version: https://docs.tildes.net/development-setup + +## General development information + +This page on the Tildes docs contains information about many aspects of Tildes development: https://docs.tildes.net/development + +## Contributing code + +**Please do not work on significant changes or features without first ensuring that the changes are desired.** If there isn't already an issue related to the change you're intending to make, please [create one first](https://gitlab.com/tildes/tildes/issues/new) to discuss your plans. Similarly, even when there *is* already an issue, if the specifics of how it should work aren't clear, please try to sort that out first (by posting comments on the issue) before doing the work. + +In general, anything beyond straightforward fixes and adjustments should have an associated issue. This is for everyone's benefit—it ensures that you don't waste effort working on changes that won't be accepted, and makes it easier for other contributors to see what's already being worked on. + +### Choosing what to work on + +If you look at [the list of issues](https://gitlab.com/tildes/tildes/issues), there are a few indicators you can use to find a good contribution to work on: + +* Make sure the issue isn't already being worked on by someone else. Check the comments on it, and whether it's set as "assigned to" someone. If someone else was working on it but there hasn't been any activity in a while, please post a comment first to check if they're still making progress. +* Issues with the "high priority" label are ones that are needed soon, and contributions for these will probably be most appreciated. However, they may also be more complex than many other issues, and might not be the best place for a newer contributor to start. +* Check the "Weight" field on the issue. If set, this is an estimate of how complex it will be to address, ranging from 1 (extremely simple, very small changes) to 9 (major changes, possibly requiring days or even weeks of work). + +Once you've selected an issue to work on, please leave a comment saying so. This will allow other people to see that they shouldn't also start on that issue. + +### Before submitting a merge request + +After you've finished making your changes, there are a few things to check before proposing your changes as a merge request. + +First, ensure that all the checks (tests, mypy and code-style) pass successfully. Merge requests that fail any of the checks will not be accepted. For more information, see this section of the development docs: https://docs.tildes.net/development#running-checks-on-your-code + +Squash your changes into logical commits. For many changes there should only be a single commit, but some can be broken into multiple logical sections. The commit messages should follow [the formatting described in this article](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html), with the summary line written in the imperative form. The summary line should make sense if you were to use it in a sentence like "If applied, this commit will \_\_\_\_\_\_\_\_". For example, "Add a new X", "Fix a bug with Y", and so on. + +### Merge request and code review + +Once your code is ready, you can [submit a new merge request on GitLab](https://gitlab.com/tildes/tildes/merge_requests/new). + +After creating the merge request, if you need to make any further changes to your code (whether in response to code review or not), please add the changes as new commits. Do not modify the existing commits, this makes it far simpler for other people to follow what changes you're making. Once the merge request is approved, a final pass can be done to squash the commits down, make updates to commit messages, etc. before it's actually merged. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..cba6f6a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,660 @@ +### GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains +free software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing +under this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS + +#### 0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public +License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +#### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +#### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +#### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +#### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +#### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +#### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +#### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +#### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +#### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +#### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +#### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +#### 13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Affero General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +#### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +#### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +#### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +### How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + +To do so, attach the following notices to the program. It is safest to +attach them to the start of each source file to most effectively state +the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper +mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for +the specific requirements. + +You should also get your employer (if you work as a programmer) or +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. For more information on this, and how to apply and follow +the GNU AGPL, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bcb564 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Tildes + +This is the code behind [Tildes](https://tildes.net), a non-profit community site. The official repository is located on GitLab at https://gitlab.com/tildes/tildes + +For general information about Tildes and its goals, please see [the announcement blog post](https://blog.tildes.net/announcing-tildes) and [the Tildes Docs site](https://docs.tildes.net). + +## Issue tracker / plans + +Known issues and plans for upcoming changes are tracked on GitLab: https://gitlab.com/tildes/tildes/issues + +The "board" view is useful as an overview: https://gitlab.com/tildes/tildes/boards + +## Contributing to Tildes development + +Please see [the Contributing doc](CONTRIBUTING.md) for more detailed information about setting up a development version of Tildes and how to contribute to development. diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..7d8c34b --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,27 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +VAGRANT_CONFIG_VERSION = "2" + +Vagrant.configure(VAGRANT_CONFIG_VERSION) do |config| + config.vm.box = "ubuntu/xenial64" + + # Main application folder + config.vm.synced_folder "tildes/", "/opt/tildes/" + + # Mount the salt file root and pillar root + config.vm.synced_folder "salt/salt/", "/srv/salt/" + config.vm.synced_folder "salt/pillar/", "/srv/pillar/" + + config.vm.network "forwarded_port", guest: 443, host: 4443 + config.vm.network "forwarded_port", guest: 9090, host: 9090 + + # Masterless salt provisioning + config.vm.provision :salt do |salt| + salt.masterless = true + salt.minion_config = "salt/minion" + salt.run_highstate = true + salt.verbose = true + salt.log_level = "info" + end +end diff --git a/git_hooks/pre-commit b/git_hooks/pre-commit new file mode 100755 index 0000000..1aa452c --- /dev/null +++ b/git_hooks/pre-commit @@ -0,0 +1,7 @@ +#!/bin/sh +# +# Pre-commit hook script that ensures mypy checks and tests pass + +vagrant ssh -c ". activate \ + && echo 'Checking mypy type annotations...' && mypy . \ + && echo -n 'Running tests: ' && pytest -q" diff --git a/git_hooks/pre-push b/git_hooks/pre-push new file mode 100755 index 0000000..dafcae3 --- /dev/null +++ b/git_hooks/pre-push @@ -0,0 +1,8 @@ +#!/bin/sh +# +# Pre-push hook script that ensures mypy checks, style checks, and tests pass + +vagrant ssh -c ". activate \ + && echo 'Checking mypy type annotations...' && mypy . \ + && echo -n 'Running tests: ' && pytest -q \ + && echo 'Checking code style (takes a while)...' && pylama" diff --git a/salt/minion b/salt/minion new file mode 100644 index 0000000..fe4e094 --- /dev/null +++ b/salt/minion @@ -0,0 +1,19 @@ +# look for files on the minion, not the master +file_client: local + +# set a specific minion ID for use in top file / pillars +# options: +# - "dev" for the vagrant setup +# - "prod" for (single-server) production +# - "monitoring" for the monitoring server +id: dev + +# base path for SSL certificates +ca.cert_base_path: '/etc/pki' + +state_verbose: False + +# enable new module.run state syntax +# https://docs.saltstack.com/en/develop/topics/releases/2017.7.0.html#state-module-changes +use_superseded: + - module.run diff --git a/salt/pillar/dev.sls b/salt/pillar/dev.sls new file mode 100644 index 0000000..cd6c80b --- /dev/null +++ b/salt/pillar/dev.sls @@ -0,0 +1,5 @@ +ini_file: development.ini +ssl_cert_path: /etc/pki/tls/certs/localhost.crt +ssl_private_key_path: /etc/pki/tls/certs/localhost.key +nginx_worker_processes: 1 +postgresql_version: 10 diff --git a/salt/pillar/monitoring-secrets.example.sls b/salt/pillar/monitoring-secrets.example.sls new file mode 100644 index 0000000..c505701 --- /dev/null +++ b/salt/pillar/monitoring-secrets.example.sls @@ -0,0 +1,8 @@ +sentry_secret: 'sentry_secret_token' +sentry_email: 'sentry_superuser@email.address' +sentry_password: 'passwordforsentrysuperuser' +sentry_server_name: 'sentry.example.com' +developer_ips: ['127.0.0.1'] +prometheus_server_name: 'prom.example.com' +grafana_server_name: 'grafana.example.com' +grafana_admin_password: 'passwordforgrafanaadmin' diff --git a/salt/pillar/monitoring.sls b/salt/pillar/monitoring.sls new file mode 100644 index 0000000..d7de940 --- /dev/null +++ b/salt/pillar/monitoring.sls @@ -0,0 +1,5 @@ +ssl_cert_path: /etc/pki/tls/certs/localhost.crt +ssl_private_key_path: /etc/pki/tls/certs/localhost.key +hsts_max_age: 60 +nginx_worker_processes: auto +postgresql_version: 9.6 diff --git a/salt/pillar/prod.sls b/salt/pillar/prod.sls new file mode 100644 index 0000000..466c029 --- /dev/null +++ b/salt/pillar/prod.sls @@ -0,0 +1,6 @@ +ini_file: production.ini +ssl_cert_path: /etc/letsencrypt/live/tildes.net/fullchain.pem +ssl_private_key_path: /etc/letsencrypt/live/tildes.net/privkey.pem +hsts_max_age: 63072000 +nginx_worker_processes: auto +postgresql_version: 10 diff --git a/salt/pillar/top.sls b/salt/pillar/top.sls new file mode 100644 index 0000000..91f58d9 --- /dev/null +++ b/salt/pillar/top.sls @@ -0,0 +1,8 @@ +base: + 'dev': + - dev + 'prod': + - prod + 'monitoring': + - monitoring + - monitoring-secrets diff --git a/salt/salt/boussole.service.jinja2 b/salt/salt/boussole.service.jinja2 new file mode 100644 index 0000000..96132ea --- /dev/null +++ b/salt/salt/boussole.service.jinja2 @@ -0,0 +1,13 @@ +{% from 'common.jinja2' import app_dir, bin_dir -%} +[Unit] +Description=Boussole - auto-compile SCSS files on change + +[Service] +WorkingDirectory={{ app_dir }} +Environment="LC_ALL=C.UTF-8" "LANG=C.UTF-8" +ExecStart={{ bin_dir }}/boussole watch --backend=yaml --config=boussole.yaml --poll +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/boussole.sls b/salt/salt/boussole.sls new file mode 100644 index 0000000..28d7c69 --- /dev/null +++ b/salt/salt/boussole.sls @@ -0,0 +1,32 @@ +{% from 'common.jinja2' import app_dir, bin_dir %} + +/etc/systemd/system/boussole.service: + file.managed: + - source: salt://boussole.service.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + - require_in: + - service: boussole.service + +boussole.service: + service.running: + - enable: True + - require: + - pip: pip-installs + +create-css-directory: + file.directory: + - name: {{ app_dir }}/static/css + +initial-boussole-run: + cmd.run: + - name: {{ bin_dir }}/boussole compile --backend=yaml --config=boussole.yaml + - cwd: {{ app_dir }} + - env: + - LC_ALL: C.UTF-8 + - LANG: C.UTF-8 + - require: + - file: create-css-directory + - unless: ls {{ app_dir }}/static/css/*.css diff --git a/salt/salt/cmark-gfm.sls b/salt/salt/cmark-gfm.sls new file mode 100644 index 0000000..11a0a89 --- /dev/null +++ b/salt/salt/cmark-gfm.sls @@ -0,0 +1,25 @@ +unpack-cmark-gfm: + archive.extracted: + - name: /tmp/cmark-gfm + - source: + - salt://cmark-gfm.tar.gz + - https://github.com/github/cmark/archive/0.28.0.gfm.11.tar.gz + - source_hash: sha256=a95ee221c3f6d718bbb38bede95f05f05e07827f8f3c29ed6cb09ddb7d05c2cd + - if_missing: /usr/local/lib/libcmark-gfm.so + - options: --strip-components=1 + - enforce_toplevel: False + +install-cmark-build-deps: + pkg.installed: + - name: cmake + +install-cmark-gfm: + cmd.run: + - cwd: /tmp/cmark-gfm/ + - names: + - make + - make install + - onchanges: + - archive: unpack-cmark-gfm + - require: + - pkg: install-cmark-build-deps diff --git a/salt/salt/common.jinja2 b/salt/salt/common.jinja2 new file mode 100644 index 0000000..163d8e5 --- /dev/null +++ b/salt/salt/common.jinja2 @@ -0,0 +1,13 @@ +{% set python_version = '3.6.5' %} + +{% set app_dir = '/opt/tildes' %} +{% set static_sites_dir = '/opt/tildes-static-sites' %} + +{% set venv_dir = '/opt/venvs/tildes' %} +{% set bin_dir = venv_dir + '/bin' %} + +{% if grains['id'] == 'dev' %} + {% set app_username = 'vagrant' %} +{% else %} + {% set app_username = 'tildes' %} +{% endif %} diff --git a/salt/salt/consumers/init.sls b/salt/salt/consumers/init.sls new file mode 100644 index 0000000..73437ae --- /dev/null +++ b/salt/salt/consumers/init.sls @@ -0,0 +1,11 @@ +/etc/systemd/system/consumer-topic_metadata_generator.service: + file.managed: + - source: salt://consumers/topic_metadata_generator.service.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + +consumer-topic_metadata_generator.service: + service.running: + - enable: True diff --git a/salt/salt/consumers/topic_metadata_generator.service.jinja2 b/salt/salt/consumers/topic_metadata_generator.service.jinja2 new file mode 100644 index 0000000..1025c03 --- /dev/null +++ b/salt/salt/consumers/topic_metadata_generator.service.jinja2 @@ -0,0 +1,16 @@ +{% from 'common.jinja2' import app_dir, bin_dir -%} +[Unit] +Description=Topic Metadata Generator (Queue Consumer) +Requires=rabbitmq-server.service +After=rabbitmq-server.service +PartOf=rabbitmq-server.service + +[Service] +WorkingDirectory={{ app_dir }}/consumers +Environment="INI_FILE={{ app_dir }}/{{ pillar['ini_file'] }}" +ExecStart={{ bin_dir }}/python topic_metadata_generator.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/cronjobs.sls b/salt/salt/cronjobs.sls new file mode 100644 index 0000000..929a9b0 --- /dev/null +++ b/salt/salt/cronjobs.sls @@ -0,0 +1,7 @@ +{% from 'common.jinja2' import app_dir, bin_dir %} + +data-cleanup-cronjob: + cron.present: + - name: {{ bin_dir }}/python -c "from scripts.clean_private_data import clean_all_data; clean_all_data('{{ app_dir }}/{{ pillar['ini_file'] }}')" + - hour: 4 + - minute: 10 diff --git a/salt/salt/development.sls b/salt/salt/development.sls new file mode 100644 index 0000000..7ea2ffb --- /dev/null +++ b/salt/salt/development.sls @@ -0,0 +1,21 @@ +{% from 'common.jinja2' import app_username, bin_dir %} + +{% set profile_dir = '/home/' + app_username + '/.ipython/profile_default' %} + +ipython-profile: + cmd.run: + - name: {{ bin_dir }}/ipython profile create + - runas: {{ app_username }} + - creates: {{ profile_dir }} + file.managed: + - name: {{ profile_dir }}/ipython_config.py + - contents: + - c.InteractiveShellApp.extensions = ['autoreload'] + - c.InteractiveShellApp.exec_lines = ['%autoreload 2'] + require: + - pip: pip-installs + +automatic-activate: + file.append: + - name: '/home/{{ app_username }}/.bashrc' + - text: 'source activate' diff --git a/salt/salt/final-setup.sls b/salt/salt/final-setup.sls new file mode 100644 index 0000000..aa3ec61 --- /dev/null +++ b/salt/salt/final-setup.sls @@ -0,0 +1,17 @@ +{% from 'common.jinja2' import app_dir, bin_dir %} + +initialize-db: + cmd.run: + - name: {{ bin_dir }}/python -c "from scripts.initialize_db import initialize_db; initialize_db('{{ app_dir }}/{{ pillar['ini_file'] }}')" + - cwd: {{ app_dir }} + - onchanges: + - postgres_database: tildes + +{% if grains['id'] == 'dev' %} +insert-dev-data: + cmd.run: + - name: {{ bin_dir }}/python -c "from scripts.initialize_db import insert_dev_data; insert_dev_data('{{ app_dir }}/{{ pillar['ini_file'] }}')" + - cwd: {{ app_dir }} + - onchanges: + - cmd: initialize-db +{% endif %} diff --git a/salt/salt/grafana/grafana.conf.jinja2 b/salt/salt/grafana/grafana.conf.jinja2 new file mode 100644 index 0000000..480d59a --- /dev/null +++ b/salt/salt/grafana/grafana.conf.jinja2 @@ -0,0 +1,19 @@ +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + + {% for ip in pillar['developer_ips'] %} + allow {{ ip }}; + {% endfor %} + deny all; + + add_header Strict-Transport-Security "max-age={{ pillar['hsts_max_age'] }}; includeSubDomains; preload" always; + + server_name {{ pillar['grafana_server_name'] }}; + + location / { + proxy_set_header Host $host; + proxy_redirect off; + proxy_pass http://localhost:3000; + } +} diff --git a/salt/salt/grafana/grafana.ini.jinja2 b/salt/salt/grafana/grafana.ini.jinja2 new file mode 100644 index 0000000..db942d8 --- /dev/null +++ b/salt/salt/grafana/grafana.ini.jinja2 @@ -0,0 +1,17 @@ +[server] +domain = {{ pillar['grafana_server_name'] }} +root_url = https://{{ pillar['grafana_server_name'] }} + +[analytics] +reporting_enabled = false + +[security] +admin_password = """{{ pillar['grafana_admin_password'] }}""" +disable_gravatar = true + +[snapshots] +external_enabled = false + +[users] +allow_sign_up = false +allow_org_create = false diff --git a/salt/salt/grafana/init.sls b/salt/salt/grafana/init.sls new file mode 100644 index 0000000..ccbfab8 --- /dev/null +++ b/salt/salt/grafana/init.sls @@ -0,0 +1,61 @@ +grafana: + pkgrepo.managed: + - name: deb https://packagecloud.io/grafana/stable/debian/ jessie main + - dist: jessie + - file: /etc/apt/sources.list.d/grafana.list + - key_url: https://packagecloud.io/gpg.key + - require_in: + - pkg: grafana + pkg.installed: + - name: grafana + - refresh: True + # note: this file must be set up before the server is started for the first + # time, otherwise the admin password will not be set correctly + file.managed: + - name: /etc/grafana/grafana.ini + - source: salt://grafana/grafana.ini.jinja2 + - template: jinja + - user: root + - group: grafana + - mode: 640 + service.running: + - name: grafana-server + - enable: True + - require: + - pkg: grafana + - file: /etc/grafana/grafana.ini + - watch: + - file: /etc/grafana/grafana.ini + http.query: + - name: http://localhost:3000/api/datasources + - method: POST + - username: admin + - password: {{ pillar['grafana_admin_password'] }} + - data: | + {"name": "Prometheus", + "type": "prometheus", + "url": "http://localhost:9090", + "access": "proxy", + "isDefault": true} + - header_list: + - 'Content-Type: application/json' + - status: 200 + - unless: + - curl -f http://admin:{{ pillar['grafana_admin_password'] }}@localhost:3000/api/datasources/name/Prometheus + +/etc/nginx/sites-available/grafana.conf: + file.managed: + - source: salt://grafana/grafana.conf.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + - makedirs: True + +/etc/nginx/sites-enabled/grafana.conf: + file.symlink: + - target: /etc/nginx/sites-available/grafana.conf + - makedirs: True + - user: root + - group: root + - mode: 644 diff --git a/salt/salt/gunicorn/gunicorn.conf b/salt/salt/gunicorn/gunicorn.conf new file mode 100644 index 0000000..1b0a1a7 --- /dev/null +++ b/salt/salt/gunicorn/gunicorn.conf @@ -0,0 +1 @@ +d /run/gunicorn 0755 gunicorn gunicorn - diff --git a/salt/salt/gunicorn/gunicorn.service.jinja2 b/salt/salt/gunicorn/gunicorn.service.jinja2 new file mode 100644 index 0000000..3747d60 --- /dev/null +++ b/salt/salt/gunicorn/gunicorn.service.jinja2 @@ -0,0 +1,22 @@ +{% from 'common.jinja2' import app_dir, bin_dir -%} +[Unit] +Description=gunicorn daemon +Requires=gunicorn.socket +After=network.target + +[Service] +PIDFile=/run/gunicorn/pid +User=gunicorn +Group=gunicorn +RuntimeDirectory=gunicorn +WorkingDirectory={{ app_dir }} +ExecStart={{ bin_dir }}/gunicorn --paste {{ pillar['ini_file'] }} --config {{ app_dir }}/gunicorn_config.py +ExecReload=/bin/kill -s HUP $MAINPID +ExecStop=/bin/kill -s TERM $MAINPID +PrivateTmp=true +Environment=prometheus_multiproc_dir=/tmp +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/gunicorn/gunicorn.socket b/salt/salt/gunicorn/gunicorn.socket new file mode 100644 index 0000000..f1cee38 --- /dev/null +++ b/salt/salt/gunicorn/gunicorn.socket @@ -0,0 +1,11 @@ +[Unit] +Description=gunicorn socket +PartOf=gunicorn.service + +[Socket] +ListenStream=/run/gunicorn/socket +SocketUser=gunicorn +SocketGroup=gunicorn + +[Install] +WantedBy=sockets.target diff --git a/salt/salt/gunicorn/init.sls b/salt/salt/gunicorn/init.sls new file mode 100644 index 0000000..f24ea3d --- /dev/null +++ b/salt/salt/gunicorn/init.sls @@ -0,0 +1,41 @@ +gunicorn: + group.present: + - name: gunicorn + user.present: + - name: gunicorn + - groups: [gunicorn] + - createhome: False + +/etc/systemd/system/gunicorn.service: + file.managed: + - source: salt://gunicorn/gunicorn.service.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + - require_in: + - service: gunicorn.socket + +/etc/systemd/system/gunicorn.socket: + file.managed: + - source: salt://gunicorn/gunicorn.socket + - user: root + - group: root + - mode: 644 + - require_in: + - service: gunicorn.socket + +/usr/lib/tmpfiles.d/gunicorn.conf: + file.managed: + - source: salt://gunicorn/gunicorn.conf + - user: root + - group: root + - mode: 644 + - require_in: + - service: gunicorn.socket + +gunicorn.socket: + service.running: + - enable: True + - require: + - user: gunicorn diff --git a/salt/salt/nginx/init.sls b/salt/salt/nginx/init.sls new file mode 100644 index 0000000..c617e73 --- /dev/null +++ b/salt/salt/nginx/init.sls @@ -0,0 +1,25 @@ +nginx: + pkgrepo.managed: + - name: deb http://nginx.org/packages/ubuntu/ xenial nginx + - dist: xenial + - file: /etc/apt/sources.list.d/nginx.list + - key_url: https://nginx.org/keys/nginx_signing.key + - require_in: + - pkg: nginx + pkg.installed: + - name: nginx + - refresh: True + service.running: + - require: + - pkg: nginx + - reload: True + - watch: + - file: /etc/nginx/* + +/etc/nginx/nginx.conf: + file.managed: + - source: salt://nginx/nginx.conf.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 diff --git a/salt/salt/nginx/nginx.conf.jinja2 b/salt/salt/nginx/nginx.conf.jinja2 new file mode 100644 index 0000000..f938a58 --- /dev/null +++ b/salt/salt/nginx/nginx.conf.jinja2 @@ -0,0 +1,89 @@ +user nginx; +worker_processes {{ pillar['nginx_worker_processes'] }}; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server_tokens off; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + {% if grains['id'] == 'dev' %} + # have to disable sendfile for vagrant due to a virtualbox bug + sendfile off; + {% else %} + sendfile on; + {% endif %} + + keepalive_timeout 65; + + # redirect non-https accesses to the https version + server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + return 301 https://$host$request_uri; + } + + gzip on; + gzip_min_length 1000; + gzip_comp_level 6; + gzip_vary on; + gzip_proxied any; + + # type list from https://github.com/h5bp/server-configs-nginx + gzip_types + application/atom+xml + application/javascript + application/json + application/ld+json + application/manifest+json + application/rss+xml + application/vnd.geo+json + application/vnd.ms-fontobject + application/x-font-ttf + application/x-web-app-manifest+json + application/xhtml+xml + application/xml + font/opentype + image/bmp + image/svg+xml + image/x-icon + text/cache-manifest + text/css + text/plain + text/vcard + text/vnd.rim.location.xloc + text/vtt + text/x-component + text/x-cross-domain-policy; + + ssl_certificate {{ pillar['ssl_cert_path'] }}; + ssl_certificate_key {{ pillar['ssl_private_key_path'] }}; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_session_tickets off; + + # "modern" configuration from Mozilla guidelines + ssl_protocols TLSv1.2; + ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256'; + ssl_prefer_server_ciphers on; + + include /etc/nginx/sites-enabled/*.conf; +} + diff --git a/salt/salt/nginx/site-config.sls b/salt/salt/nginx/site-config.sls new file mode 100644 index 0000000..e42e420 --- /dev/null +++ b/salt/salt/nginx/site-config.sls @@ -0,0 +1,16 @@ +/etc/nginx/sites-available/tildes.conf: + file.managed: + - source: salt://nginx/tildes.conf.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + - makedirs: True + +/etc/nginx/sites-enabled/tildes.conf: + file.symlink: + - target: /etc/nginx/sites-available/tildes.conf + - makedirs: True + - user: root + - group: root + - mode: 644 diff --git a/salt/salt/nginx/static-sites-config.sls b/salt/salt/nginx/static-sites-config.sls new file mode 100644 index 0000000..2d034b6 --- /dev/null +++ b/salt/salt/nginx/static-sites-config.sls @@ -0,0 +1,16 @@ +/etc/nginx/sites-available/tildes-static-sites.conf: + file.managed: + - source: salt://nginx/tildes-static-sites.conf.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + - makedirs: True + +/etc/nginx/sites-enabled/tildes-static-sites.conf: + file.symlink: + - target: /etc/nginx/sites-available/tildes-static-sites.conf + - makedirs: True + - user: root + - group: root + - mode: 644 diff --git a/salt/salt/nginx/tildes-static-sites.conf.jinja2 b/salt/salt/nginx/tildes-static-sites.conf.jinja2 new file mode 100644 index 0000000..f17c0ff --- /dev/null +++ b/salt/salt/nginx/tildes-static-sites.conf.jinja2 @@ -0,0 +1,35 @@ +{% from 'common.jinja2' import static_sites_dir -%} + +{% for subdomain in ('blog', 'docs') %} +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + + add_header Strict-Transport-Security "max-age={{ pillar['hsts_max_age'] }}; includeSubDomains; preload" always; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Xss-Protection "1; mode=block" always; + add_header Referrer-Policy "same-origin" always; + + server_name {{ subdomain }}.tildes.net; + + keepalive_timeout 5; + gzip_static on; + + location /favicon.ico { + root /opt/tildes-static-sites/theme/images; + try_files $uri =404; + } + + location /theme { + root {{ static_sites_dir }}; + try_files $uri =404; + } + + location / { + root {{ static_sites_dir }}/{{ subdomain }}; + try_files $uri $uri.html $uri/index.html =404; + } +} +{% endfor %} diff --git a/salt/salt/nginx/tildes.conf.jinja2 b/salt/salt/nginx/tildes.conf.jinja2 new file mode 100644 index 0000000..a5bfaed --- /dev/null +++ b/salt/salt/nginx/tildes.conf.jinja2 @@ -0,0 +1,97 @@ +{% from 'common.jinja2' import app_dir -%} +upstream app_server { + server unix:/run/gunicorn/socket fail_timeout=0; +} + +# define map to set Expires+Cache-Control headers for files based on type +map $sent_http_content_type $expires_type_map { + default off; + text/css max; + application/javascript max; + image/x-icon 1d; + ~image/ max; +} + +# redirect www. to base domain +server { + listen 80; + listen 443 ssl http2; + listen [::]:443 ssl http2; + + server_name www.tildes.net; + return 301 https://tildes.net$request_uri; +} + +server { + # remove trailing slash from addresses, the $port thing is a hack for + # development in Vagrant, so the port forwarding from the host is kept + set $port ''; + if ($http_host ~ :(\d+)$) { + set $port :$1; + } + rewrite ^/(.*)/$ https://$host$port/$1 permanent; + + # redirect /u/username to /user/username + rewrite ^/u/(.*)$ https://$host$port/user/$1; + + listen 443 ssl http2; + listen [::]:443 ssl http2; + + {% if grains['id'] != 'dev' %} + add_header Strict-Transport-Security "max-age={{ pillar['hsts_max_age'] }}; includeSubDomains; preload" always; + {% endif %} + + # Content Security Policy: + # - "img-src data:" is needed for Spectre.css icons + set $csp_value "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"; + + {% if grains['id'] == 'dev' %} + add_header Content-Security-Policy-Report-Only $csp_value always; + {% else %} + add_header Content-Security-Policy $csp_value always; + {% endif %} + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-Xss-Protection "1; mode=block" always; + add_header Referrer-Policy "same-origin" always; + + server_name tildes.net localhost; + + keepalive_timeout 5; + + root {{ app_dir }}/static; + + # Block access to /metrics except from localhost (for Prometheus) + location /metrics { + allow 127.0.0.1; + deny all; + + # try_files is unnecessary here, but I don't know the "proper" way + try_files $uri @proxy_to_app; + } + + # add Expires+Cache-Control headers from the mime-type map defined above + expires $expires_type_map; + + location / { + # checks for static file, if not found proxy to app + try_files $uri @proxy_to_app; + gzip_static on; + } + + location @proxy_to_app { + # Cornice adds the X-Content-Type-Options header, so it will end up + # being duplicated since nginx is also configured to send it (above). + # It's better to drop the header coming from Gunicorn here than to + # stop sending it in nginx, since if I ever stop using Cornice I would + # lose that header (and probably not realize). + proxy_hide_header X-Content-Type-Options; + + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_pass http://app_server; + } +} diff --git a/salt/salt/postgresql/init.sls b/salt/salt/postgresql/init.sls new file mode 100644 index 0000000..c020713 --- /dev/null +++ b/salt/salt/postgresql/init.sls @@ -0,0 +1,56 @@ +postgresql: + pkgrepo.managed: + - name: deb http://apt.postgresql.org/pub/repos/apt/ xenial-pgdg main + - dist: xenial-pgdg + - file: /etc/apt/sources.list.d/psql.list + - key_url: https://www.postgresql.org/media/keys/ACCC4CF8.asc + - require_in: + - pkg: postgresql + pkg.installed: + - name: postgresql-{{ pillar['postgresql_version'] }} + - refresh: True + service.running: + - require: + - pkg: postgresql + - reload: True + - watch: + - file: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/*.conf + +set-lock-timeout: + file.replace: + - name: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/postgresql.conf + - pattern: '^#?lock_timeout = (?!5000).*$' + - repl: 'lock_timeout = 5000' + +set-statement-timeout: + file.replace: + - name: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/postgresql.conf + - pattern: '^#?statement_timeout = (?!5000).*$' + - repl: 'statement_timeout = 5000' + +set-idle-in-transaction-timeout: + file.replace: + - name: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/postgresql.conf + - pattern: '^#?idle_in_transaction_session_timeout = (?!600000).*$' + - repl: 'idle_in_transaction_session_timeout = 600000' + +enable-pg-stat-statements: + file.replace: + - name: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/postgresql.conf + - pattern: '^#?shared_preload_libraries = .*$' + - repl: "shared_preload_libraries = 'pg_stat_statements'" + - watch_in: + - module: enable-pg-stat-statements + module.wait: + - service.restart: + - name: postgresql.service + +/etc/postgresql/{{ pillar['postgresql_version'] }}/main/pg_hba.conf: + file.managed: + - source: salt://postgresql/pg_hba.conf.jinja2 + - template: jinja + - user: postgres + - group: postgres + - mode: 640 + require: + - service: postgresql diff --git a/salt/salt/postgresql/pg_hba.conf.jinja2 b/salt/salt/postgresql/pg_hba.conf.jinja2 new file mode 100644 index 0000000..45e7ba9 --- /dev/null +++ b/salt/salt/postgresql/pg_hba.conf.jinja2 @@ -0,0 +1,6 @@ +{% for line in accumulator['pg_hba_lines'] -%} +{{ line }} +{% endfor %} + +# Required: allow the database superuser to connect via socket +local all postgres peer diff --git a/salt/salt/postgresql/pgbouncer.ini.jinja2 b/salt/salt/postgresql/pgbouncer.ini.jinja2 new file mode 100644 index 0000000..9b89c8b --- /dev/null +++ b/salt/salt/postgresql/pgbouncer.ini.jinja2 @@ -0,0 +1,21 @@ +[databases] +{% for line in accumulator['pgbouncer_db_lines'] -%} +{{ line }} +{% endfor %} + +[pgbouncer] +logfile = /var/log/postgresql/pgbouncer.log +log_connections = 0 +log_disconnections = 0 + +pidfile = /var/run/postgresql/pgbouncer.pid + +listen_port = 6432 + +unix_socket_dir = /var/run/postgresql + +auth_type = hba +auth_file = /etc/pgbouncer/userlist.txt +auth_hba_file = /etc/postgresql/{{ pillar['postgresql_version'] }}/main/pg_hba.conf + +pool_mode = transaction diff --git a/salt/salt/postgresql/pgbouncer.sls b/salt/salt/postgresql/pgbouncer.sls new file mode 100644 index 0000000..21d7453 --- /dev/null +++ b/salt/salt/postgresql/pgbouncer.sls @@ -0,0 +1,25 @@ +install-pgbouncer: + pkg.installed: + - name: pgbouncer + +/etc/pgbouncer/pgbouncer.ini: + file.managed: + - source: salt://postgresql/pgbouncer.ini.jinja2 + - template: jinja + - user: postgres + - group: postgres + - mode: 640 + +/etc/pgbouncer/userlist.txt: + file.managed: + - contents: '"tildes" ""' + - user: postgres + - group: postgres + - mode: 640 + +pgbouncer.service: + service.running: + - enable: True + - reload: True + - watch: + - file: /etc/pgbouncer/pgbouncer.ini diff --git a/salt/salt/postgresql/site-db.sls b/salt/salt/postgresql/site-db.sls new file mode 100644 index 0000000..5f73a1e --- /dev/null +++ b/salt/salt/postgresql/site-db.sls @@ -0,0 +1,57 @@ +site-db-user: + postgres_user.present: + - name: tildes + - require: + - service: postgresql + +site-db-database: + postgres_database.present: + - name: tildes + - owner: tildes + - require: + - postgres_user: tildes + +site-db-enable-citext: + postgres_extension.present: + - name: citext + - maintenance_db: tildes + - require: + - postgres_database: tildes + +site-db-enable-ltree: + postgres_extension.present: + - name: ltree + - maintenance_db: tildes + - require: + - postgres_database: tildes + +site-db-enable-intarray: + postgres_extension.present: + - name: intarray + - maintenance_db: tildes + - require: + - postgres_database: tildes + +site-db-enable-pg_stat_statements: + postgres_extension.present: + - name: pg_stat_statements + - maintenance_db: tildes + - require: + - postgres_database: tildes + +site-db-pg_hba-lines: + file.accumulated: + - name: pg_hba_lines + - filename: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/pg_hba.conf + - text: + - 'local sameuser tildes trust' + - require_in: + - file: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/pg_hba.conf + +site-db-pgbouncer-lines: + file.accumulated: + - name: pgbouncer_db_lines + - filename: /etc/pgbouncer/pgbouncer.ini + - text: 'tildes =' + - require_in: + - file: /etc/pgbouncer/pgbouncer.ini diff --git a/salt/salt/postgresql/test-db.sls b/salt/salt/postgresql/test-db.sls new file mode 100644 index 0000000..813f73d --- /dev/null +++ b/salt/salt/postgresql/test-db.sls @@ -0,0 +1,44 @@ +test-db-database: + postgres_database.present: + - name: tildes_test + - owner: tildes + - require: + - postgres_user: tildes + +test-db-enable-citext: + postgres_extension.present: + - name: citext + - maintenance_db: tildes_test + - require: + - postgres_database: tildes_test + +test-db-enable-ltree: + postgres_extension.present: + - name: ltree + - maintenance_db: tildes_test + - require: + - postgres_database: tildes_test + +test-db-enable-intarray: + postgres_extension.present: + - name: intarray + - maintenance_db: tildes_test + - require: + - postgres_database: tildes_test + +test-db-pg_hba-lines: + file.accumulated: + - name: pg_hba_lines + - filename: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/pg_hba.conf + - text: + - 'local tildes_test tildes trust' + - require_in: + - file: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/pg_hba.conf + +test-db-pgbouncer-lines: + file.accumulated: + - name: pgbouncer_db_lines + - filename: /etc/pgbouncer/pgbouncer.ini + - text: 'tildes_test =' + - require_in: + - file: /etc/pgbouncer/pgbouncer.ini diff --git a/salt/salt/prometheus/exporters/node_exporter.sls b/salt/salt/prometheus/exporters/node_exporter.sls new file mode 100644 index 0000000..06d41b8 --- /dev/null +++ b/salt/salt/prometheus/exporters/node_exporter.sls @@ -0,0 +1,28 @@ +# Download/extract and set up the node exporter (hardware/OS metrics) +include: + - prometheus.user + +unpack-node-exporter: + archive.extracted: + - name: /opt/prometheus_node_exporter + - source: + - salt://prometheus/exporters/node_exporter-0.13.0.linux-amd64.tar.gz + - https://github.com/prometheus/node_exporter/releases/download/v0.13.0/node_exporter-0.13.0.linux-amd64.tar.gz + - source_hash: sha256=2de5d1e51330c41588ed4c88bc531a3d2dccf6b4d7b99d5782d95cff27a3c049 + - if_missing: /opt/prometheus_node_exporter + - user: prometheus + - group: prometheus + - options: --strip-components=1 + - enforce_toplevel: False + +/etc/systemd/system/prometheus_node_exporter.service: + file.managed: + - source: salt://prometheus/exporters/prometheus_node_exporter.service + - user: root + - group: root + - mode: 644 + +prometheus-node-exporter-service: + service.running: + - name: prometheus_node_exporter + - enable: True diff --git a/salt/salt/prometheus/exporters/postgres_exporter.sls b/salt/salt/prometheus/exporters/postgres_exporter.sls new file mode 100644 index 0000000..92dc162 --- /dev/null +++ b/salt/salt/prometheus/exporters/postgres_exporter.sls @@ -0,0 +1,28 @@ +# Download and set up the postgres exporter +include: + - prometheus.user + +postgres-exporter: + file.managed: + - name: /opt/prometheus_postgres_exporter/postgres_exporter + - source: + - salt://prometheus/exporters/postgres_exporter + - https://github.com/wrouesnel/postgres_exporter/releases/download/v0.3.0/postgres_exporter + - source_hash: sha256=44654860e3122acf183e8cad504bddc3bf9dd717910cddc99b589a3463d2ec6f + - user: postgres + - group: postgres + - mode: 774 + - makedirs: True + - unless: ls /opt/prometheus_postgres_exporter/postgres_exporter + +/etc/systemd/system/prometheus_postgres_exporter.service: + file.managed: + - source: salt://prometheus/exporters/prometheus_postgres_exporter.service + - user: root + - group: root + - mode: 644 + +prometheus-postgres-exporter-service: + service.running: + - name: prometheus_postgres_exporter + - enable: True diff --git a/salt/salt/prometheus/exporters/prometheus_node_exporter.service b/salt/salt/prometheus/exporters/prometheus_node_exporter.service new file mode 100644 index 0000000..661533d --- /dev/null +++ b/salt/salt/prometheus/exporters/prometheus_node_exporter.service @@ -0,0 +1,14 @@ +[Unit] +Description=Prometheus Node Exporter +After=syslog.target network.target + +[Service] +Type=simple +RemainAfterExit=no +WorkingDirectory=/opt/prometheus_node_exporter +User=prometheus +Group=prometheus +ExecStart=/opt/prometheus_node_exporter/node_exporter -log.level info + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/prometheus/exporters/prometheus_postgres_exporter.service b/salt/salt/prometheus/exporters/prometheus_postgres_exporter.service new file mode 100644 index 0000000..7d7b861 --- /dev/null +++ b/salt/salt/prometheus/exporters/prometheus_postgres_exporter.service @@ -0,0 +1,15 @@ +[Unit] +Description=Prometheus Postgres Exporter +After=syslog.target network.target + +[Service] +Type=simple +RemainAfterExit=no +WorkingDirectory=/opt/prometheus_postgres_exporter +User=postgres +Group=postgres +Environment="DATA_SOURCE_NAME=user=postgres host=/run/postgresql/ sslmode=disable" +ExecStart=/opt/prometheus_postgres_exporter/postgres_exporter + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/prometheus/exporters/prometheus_redis_exporter.service b/salt/salt/prometheus/exporters/prometheus_redis_exporter.service new file mode 100644 index 0000000..f18f650 --- /dev/null +++ b/salt/salt/prometheus/exporters/prometheus_redis_exporter.service @@ -0,0 +1,14 @@ +[Unit] +Description=Prometheus Redis Exporter +After=syslog.target network.target + +[Service] +Type=simple +RemainAfterExit=no +WorkingDirectory=/opt/prometheus_redis_exporter +User=prometheus +Group=prometheus +ExecStart=/opt/prometheus_redis_exporter/redis_exporter + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/prometheus/exporters/redis_exporter.sls b/salt/salt/prometheus/exporters/redis_exporter.sls new file mode 100644 index 0000000..c4c99ce --- /dev/null +++ b/salt/salt/prometheus/exporters/redis_exporter.sls @@ -0,0 +1,27 @@ +# Download/extract and set up the redis exporter +include: + - prometheus.user + +unpack-redis-exporter: + archive.extracted: + - name: /opt/prometheus_redis_exporter + - source: + - salt://prometheus/exporters/redis_exporter-v0.10.7.linux-amd64.tar.gz + - https://github.com/oliver006/redis_exporter/releases/download/v0.10.7/redis_exporter-v0.10.7.linux-amd64.tar.gz + - source_hash: sha256=b9b48f321a201f3b424f1710d2cac1bca03272d67001812d8b2fb6305099fb09 + - if_missing: /opt/prometheus_redis_exporter + - user: prometheus + - group: prometheus + - enforce_toplevel: False + +/etc/systemd/system/prometheus_redis_exporter.service: + file.managed: + - source: salt://prometheus/exporters/prometheus_redis_exporter.service + - user: root + - group: root + - mode: 644 + +prometheus-redis-exporter-service: + service.running: + - name: prometheus_redis_exporter + - enable: True diff --git a/salt/salt/prometheus/init.sls b/salt/salt/prometheus/init.sls new file mode 100644 index 0000000..05e74f1 --- /dev/null +++ b/salt/salt/prometheus/init.sls @@ -0,0 +1,57 @@ +include: + - prometheus.user + +unpack-prometheus: + archive.extracted: + - name: /opt/prometheus + - source: + - salt://prometheus/prometheus-2.0.0.linux-amd64.tar.gz + - https://github.com/prometheus/prometheus/releases/download/v2.0.0/prometheus-2.0.0.linux-amd64.tar.gz + - source_hash: sha256=e12917b25b32980daee0e9cf879d9ec197e2893924bd1574604eb0f550034d46 + - if_missing: /opt/prometheus + - user: prometheus + - group: prometheus + - options: --strip-components=1 + - enforce_toplevel: False + +/etc/systemd/system/prometheus.service: + file.managed: + - source: salt://prometheus/prometheus.service + - user: root + - group: root + - mode: 644 + +prometheus-service: + service.running: + - name: prometheus + - enable: True + - watch: + - file: /opt/prometheus/prometheus.yml + + +/opt/prometheus/prometheus.yml: + file.managed: + - source: salt://prometheus/prometheus.yml + - user: prometheus + - group: prometheus + - mode: 664 + +{# Set up nginx to reverse-proxy to prometheus in production #} +{% if grains['id'] == 'prod' %} +/etc/nginx/sites-available/prometheus.conf: + file.managed: + - source: salt://prometheus/prometheus.conf.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + - makedirs: True + +/etc/nginx/sites-enabled/prometheus.conf: + file.symlink: + - target: /etc/nginx/sites-available/prometheus.conf + - makedirs: True + - user: root + - group: root + - mode: 644 +{% endif %} diff --git a/salt/salt/prometheus/prometheus.conf.jinja2 b/salt/salt/prometheus/prometheus.conf.jinja2 new file mode 100644 index 0000000..3b4134f --- /dev/null +++ b/salt/salt/prometheus/prometheus.conf.jinja2 @@ -0,0 +1,21 @@ +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + + {% for ip in pillar['developer_ips'] %} + allow {{ ip }}; + {% endfor %} + deny all; + + add_header Strict-Transport-Security "max-age={{ pillar['hsts_max_age'] }}; includeSubDomains; preload" always; + + server_name {{ pillar['prometheus_server_name'] }}; + + location / { + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_pass http://localhost:9090; + } +} diff --git a/salt/salt/prometheus/prometheus.service b/salt/salt/prometheus/prometheus.service new file mode 100644 index 0000000..87bacf6 --- /dev/null +++ b/salt/salt/prometheus/prometheus.service @@ -0,0 +1,14 @@ +[Unit] +Description=Prometheus Server +After=syslog.target network.target + +[Service] +Type=simple +RemainAfterExit=no +WorkingDirectory=/opt/prometheus +User=prometheus +Group=prometheus +ExecStart=/opt/prometheus/prometheus --config.file=/opt/prometheus/prometheus.yml --log.level info + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/prometheus/prometheus.yml b/salt/salt/prometheus/prometheus.yml new file mode 100644 index 0000000..49c0bae --- /dev/null +++ b/salt/salt/prometheus/prometheus.yml @@ -0,0 +1,23 @@ +global: + scrape_interval: 30s + evaluation_interval: 30s + +scrape_configs: + - job_name: "node" + static_configs: + - targets: ['localhost:9100'] + + - job_name: "redis" + static_configs: + - targets: ['localhost:9121'] + + - job_name: "postgres" + static_configs: + - targets: ['localhost:9187'] + + - job_name: "tildes" + scheme: https + static_configs: + - targets: ['localhost:443'] + tls_config: + insecure_skip_verify: true diff --git a/salt/salt/prometheus/user.sls b/salt/salt/prometheus/user.sls new file mode 100644 index 0000000..46dcf72 --- /dev/null +++ b/salt/salt/prometheus/user.sls @@ -0,0 +1,7 @@ +prometheus-user: + group.present: + - name: prometheus + user.present: + - name: prometheus + - groups: [prometheus] + - createhome: False diff --git a/salt/salt/python.sls b/salt/salt/python.sls new file mode 100644 index 0000000..e4a6947 --- /dev/null +++ b/salt/salt/python.sls @@ -0,0 +1,58 @@ +{% from 'common.jinja2' import app_dir, bin_dir, python_version, venv_dir %} + +pyenv-deps: + pkg.installed: + - pkgs: + - build-essential + - curl + - libbz2-dev + - libncurses5-dev + - libncursesw5-dev + - libreadline-dev + - libsqlite3-dev + - libssl-dev + - llvm + - make + - wget + - xz-utils + - zlib1g-dev + +python-3.6: + pyenv.installed: + - name: {{ python_version }} + - default: True + - require: + - pkg: pyenv-deps + +delete-obsolete-venv: + file.absent: + - name: {{ venv_dir }} + - unless: {{ bin_dir }}/python --version | grep {{ python_version }} + +# Salt seems to use the deprecated pyvenv script, manual for now +venv-setup: + pkg.installed: + - name: python3-venv + cmd.run: + - name: /usr/local/pyenv/versions/{{ python_version }}/bin/python -m venv {{ venv_dir }} + - creates: {{ venv_dir }} + - require: + - pkg: python3-venv + - pyenv: {{ python_version }} + +pip-installs: + pip.installed: + - requirements: {{ app_dir }}/requirements.txt + - bin_env: {{ venv_dir }} + require: + - cmd: venv-setup + - pkg: pip-deps + +self-install: + pip.installed: + - bin_env: {{ venv_dir }} + - editable: + - {{ app_dir }} + - require: + - cmd: venv-setup + - unless: ls {{ venv_dir }}/lib/python3.6/site-packages/tildes.egg-link diff --git a/salt/salt/rabbitmq/definitions.json b/salt/salt/rabbitmq/definitions.json new file mode 100644 index 0000000..c7530be --- /dev/null +++ b/salt/salt/rabbitmq/definitions.json @@ -0,0 +1 @@ +{"exchanges":[{"name":"pgsql_events","vhost":"/","type":"topic","durable":true,"auto_delete":false,"internal":false,"arguments":{}}]} diff --git a/salt/salt/rabbitmq/init.sls b/salt/salt/rabbitmq/init.sls new file mode 100644 index 0000000..ef9eaa5 --- /dev/null +++ b/salt/salt/rabbitmq/init.sls @@ -0,0 +1,82 @@ +erlang: + pkgrepo.managed: + - name: deb http://packages.erlang-solutions.com/ubuntu/ xenial contrib + - dist: xenial + - file: /etc/apt/sources.list.d/erlang.list + - key_url: https://packages.erlang-solutions.com/ubuntu/erlang_solutions.asc + - require_in: + - pkg: rabbitmq-server + file.managed: + - name: /etc/apt/preferences.d/erlang + - mode: 755 + - contents: | + Package: erlang* + Pin: version 1:20.3-1 + Pin-Priority: 1000 + - require_in: + - pkg: rabbitmq-server + +rabbitmq: + pkgrepo.managed: + - name: deb http://www.rabbitmq.com/debian/ testing main + - dist: testing + - file: /etc/apt/sources.list.d/rabbitmq.list + - key_url: https://www.rabbitmq.com/rabbitmq-release-signing-key.asc + - require_in: + - pkg: rabbitmq-server + pkg.installed: + - name: rabbitmq-server + - refresh: True + +rabbitmq-server.service: + service.running: + - enable: True + - watch: + - file: /etc/rabbitmq/rabbitmq.config + - file: /etc/rabbitmq/definitions.json + +rabbitmq-management: + cmd.run: + - name: rabbitmq-plugins enable rabbitmq_management + - unless: 'rabbitmq-plugins list | grep \\[E.*rabbitmq_management' + +/usr/local/bin/rabbitmqadmin: + cmd.run: + - name: wget http://localhost:15672/cli/rabbitmqadmin -O /usr/local/bin/rabbitmqadmin + - creates: /usr/local/bin/rabbitmqadmin + file.managed: + - mode: 755 + +/etc/rabbitmq/rabbitmq.config: + file.managed: + - source: salt://rabbitmq/rabbitmq.config + - group: rabbitmq + - mode: 644 + +/etc/rabbitmq/definitions.json: + file.managed: + - source: salt://rabbitmq/definitions.json + - group: rabbitmq + - mode: 644 + +install-pg-amqp-bridge: + archive.extracted: + - name: /usr/local/bin + - source: + - https://github.com/subzerocloud/pg-amqp-bridge/releases/download/0.0.5/pg-amqp-bridge-0.0.5-x86_64-unknown-linux-gnu.tar.gz + - source_hash: sha256=8194c3307fe7954a0ef1ba66d2f51e14647756d0f87ddd468ef0dc3fbc8476fe + - unless: ls /usr/local/bin/pg-amqp-bridge + - enforce_toplevel: False + +/etc/systemd/system/pg-amqp-bridge.service: + file.managed: + - source: salt://rabbitmq/pg-amqp-bridge.service + - user: root + - group: root + - mode: 644 + - require_in: + - pg-amqp-bridge.service + +pg-amqp-bridge.service: + service.running: + - enable: True diff --git a/salt/salt/rabbitmq/pg-amqp-bridge.service b/salt/salt/rabbitmq/pg-amqp-bridge.service new file mode 100644 index 0000000..b9f7d4d --- /dev/null +++ b/salt/salt/rabbitmq/pg-amqp-bridge.service @@ -0,0 +1,14 @@ +[Unit] +Description=pg-amqp-bridge - send pgsql NOTIFY to rabbitmq +Requires=rabbitmq-server.service +After=rabbitmq-server.service +PartOf=rabbitmq-server.service + +[Service] +Environment="POSTGRESQL_URI=postgres://tildes@%2Frun%2Fpostgresql/tildes" "AMQP_URI=amqp://localhost//" "BRIDGE_CHANNELS=pgsql_events:pgsql_events" "DELIVERY_MODE=PERSISTENT" +ExecStart=/usr/local/bin/pg-amqp-bridge +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/rabbitmq/rabbitmq.config b/salt/salt/rabbitmq/rabbitmq.config new file mode 100644 index 0000000..4b96baa --- /dev/null +++ b/salt/salt/rabbitmq/rabbitmq.config @@ -0,0 +1,5 @@ +[ +{rabbitmq_management, [ + {load_definitions, "/etc/rabbitmq/definitions.json"} + ]} +]. diff --git a/salt/salt/raven.sls b/salt/salt/raven.sls new file mode 100644 index 0000000..e2183d1 --- /dev/null +++ b/salt/salt/raven.sls @@ -0,0 +1,6 @@ +{% from 'common.jinja2' import app_dir, venv_dir %} + +raven-pip-install: + pip.installed: + - name: raven + - bin_env: {{ venv_dir }} diff --git a/salt/salt/redis/init.sls b/salt/salt/redis/init.sls new file mode 100644 index 0000000..245f601 --- /dev/null +++ b/salt/salt/redis/init.sls @@ -0,0 +1,144 @@ +{% set redis_version = '4.0.9' %} + +unpack-redis: + archive.extracted: + - name: /tmp/redis-{{ redis_version }} + - source: + - salt://redis/{{ redis_version }}.tar.gz + - https://github.com/antirez/redis/archive/{{ redis_version }}.tar.gz + - source_hash: sha256=e18eebc08a4ccf48ac28aed692c69cf7b03f188d890803e7ccc6889c049f10b4 + - unless: /usr/local/bin/redis-server --version | grep v={{ redis_version }} + - options: --strip-components=1 + - enforce_toplevel: False + +install-redis: + pkg.installed: + - pkgs: + - build-essential + cmd.run: + - cwd: /tmp/redis-{{ redis_version }}/ + - names: + - make + - make install + - onchanges: + - archive: unpack-redis + +redis-user: + group.present: + - name: redis + user.present: + - name: redis + - groups: [redis] + - createhome: False + +/run/redis: + file.directory: + - user: redis + - group: redis + - mode: 755 + - require: + - user: redis-user + +/var/lib/redis: + file.directory: + - user: redis + - group: redis + - mode: 700 + - require: + - user: redis-user + +/var/log/redis: + file.directory: + - user: redis + - group: redis + - mode: 744 + - require: + - user: redis-user + +/etc/redis.conf: + file.managed: + - source: salt://redis/redis.conf.jinja2 + - template: jinja + - user: redis + - group: redis + - mode: 600 + - require: + - user: redis-user + +/etc/systemd/system/redis.service: + file.managed: + - source: salt://redis/redis.service + - user: root + - group: root + - mode: 644 + - require_in: + - service: redis.service + +# add the service file for disabling transparent hugepage +/etc/systemd/system/transparent_hugepage.service: + file.managed: + - source: salt://redis/transparent_hugepage.service + - user: root + - group: root + - mode: 644 + - require_in: + - service: disable-transparent-hugepage + +# enable the "disable transparent hugepage" service, and run it + restart redis if necessary +disable-transparent-hugepage: + service.enabled: + - name: transparent_hugepage.service + cmd.run: + - name: systemctl start transparent_hugepage.service && systemctl restart redis.service + - unless: 'cat /sys/kernel/mm/transparent_hugepage/enabled | grep \\[never\\]' + +# Set kernel overcommit mode (recommended for Redis) +overcommit-memory: + # will take effect immediately + cmd.run: + - name: sysctl vm.overcommit_memory=1 + - unless: sysctl -n vm.overcommit_memory | grep 1 + + # makes the setting permanent but requires a restart + file.append: + - name: /etc/sysctl.conf + - text: 'vm.overcommit_memory = 1' + +redis.service: + service.running: + - enable: True + - watch: + - file: /etc/redis.conf + - require: + - user: redis-user + - cmd: install-redis + +/run/redis_breached_passwords: + file.directory: + - user: redis + - group: redis + - mode: 755 + - require: + - user: redis-user + +/etc/redis_breached_passwords.conf: + file.managed: + - source: salt://redis/redis_breached_passwords.conf + - user: redis + - group: redis + - mode: 600 + +/etc/systemd/system/redis_breached_passwords.service: + file.managed: + - source: salt://redis/redis_breached_passwords.service + - user: root + - group: root + - mode: 644 + - require_in: + - service: redis_breached_passwords.service + +redis_breached_passwords.service: + service.running: + - enable: True + - watch: + - file: /etc/redis_breached_passwords.conf diff --git a/salt/salt/redis/modules/rebloom.sls b/salt/salt/redis/modules/rebloom.sls new file mode 100644 index 0000000..1e1787c --- /dev/null +++ b/salt/salt/redis/modules/rebloom.sls @@ -0,0 +1,14 @@ +rebloom-clone: + git.latest: + - name: https://github.com/RedisLabsModules/rebloom + - rev: 4947c9a75838688df56fc818729b93bf36588400 + - target: /opt/rebloom + +rebloom-make: + cmd.run: + - name: make + - cwd: /opt/rebloom + - onchanges: + - git: rebloom-clone + - require_in: + - service: redis_breached_passwords.service diff --git a/salt/salt/redis/modules/redis-cell.sls b/salt/salt/redis/modules/redis-cell.sls new file mode 100644 index 0000000..6951b32 --- /dev/null +++ b/salt/salt/redis/modules/redis-cell.sls @@ -0,0 +1,20 @@ +redis-cell-unpack: + archive.extracted: + - name: /opt/redis-cell + - source: + - salt://redis/modules/redis-cell-v0.2.1-x86_64-unknown-linux-gnu.tar.gz + - https://github.com/brandur/redis-cell/releases/download/v0.2.1/redis-cell-v0.2.1-x86_64-unknown-linux-gnu.tar.gz + - source_hash: sha256=9427fb100f4cada817f30f854ead7f233de32948a0ec644f15988c275a2ed1cb + - if_missing: /opt/redis-cell + - enforce_toplevel: False + - require_in: + - service: redis.service + +redis-cell-loadmodule-line: + file.accumulated: + - name: redis_loadmodule_lines + - filename: /etc/redis.conf + - text: + - 'loadmodule /opt/redis-cell/libredis_cell.so' + - require_in: + - file: /etc/redis.conf diff --git a/salt/salt/redis/redis.conf.jinja2 b/salt/salt/redis/redis.conf.jinja2 new file mode 100644 index 0000000..21cb4a1 --- /dev/null +++ b/salt/salt/redis/redis.conf.jinja2 @@ -0,0 +1,1297 @@ +# Redis configuration file example. +# +# Note that in order to read the configuration file, Redis must be +# started with the file path as first argument: +# +# ./redis-server /path/to/redis.conf + +# Note on units: when memory size is needed, it is possible to specify +# it in the usual form of 1k 5GB 4M and so forth: +# +# 1k => 1000 bytes +# 1kb => 1024 bytes +# 1m => 1000000 bytes +# 1mb => 1024*1024 bytes +# 1g => 1000000000 bytes +# 1gb => 1024*1024*1024 bytes +# +# units are case insensitive so 1GB 1Gb 1gB are all the same. + +################################## INCLUDES ################################### + +# Include one or more other config files here. This is useful if you +# have a standard template that goes to all Redis servers but also need +# to customize a few per-server settings. Include files can include +# other files, so use this wisely. +# +# Notice option "include" won't be rewritten by command "CONFIG REWRITE" +# from admin or Redis Sentinel. Since Redis always uses the last processed +# line as value of a configuration directive, you'd better put includes +# at the beginning of this file to avoid overwriting config change at runtime. +# +# If instead you are interested in using includes to override configuration +# options, it is better to use include as the last line. +# +# include /path/to/local.conf +# include /path/to/other.conf + +################################## MODULES ##################################### + +# Load modules at startup. If the server is not able to load modules +# it will abort. It is possible to use multiple loadmodule directives. +# +# loadmodule /path/to/my_module.so +# loadmodule /path/to/other_module.so + +{% for line in accumulator['redis_loadmodule_lines'] -%} +{{ line }} +{% endfor %} + +################################## NETWORK ##################################### + +# By default, if no "bind" configuration directive is specified, Redis listens +# for connections from all the network interfaces available on the server. +# It is possible to listen to just one or multiple selected interfaces using +# the "bind" configuration directive, followed by one or more IP addresses. +# +# Examples: +# +# bind 192.168.1.100 10.0.0.1 +# bind 127.0.0.1 ::1 +# +# ~~~ WARNING ~~~ If the computer running Redis is directly exposed to the +# internet, binding to all the interfaces is dangerous and will expose the +# instance to everybody on the internet. So by default we uncomment the +# following bind directive, that will force Redis to listen only into +# the IPv4 lookback interface address (this means Redis will be able to +# accept connections only from clients running into the same computer it +# is running). +# +# IF YOU ARE SURE YOU WANT YOUR INSTANCE TO LISTEN TO ALL THE INTERFACES +# JUST COMMENT THE FOLLOWING LINE. +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +bind 127.0.0.1 + +# Protected mode is a layer of security protection, in order to avoid that +# Redis instances left open on the internet are accessed and exploited. +# +# When protected mode is on and if: +# +# 1) The server is not binding explicitly to a set of addresses using the +# "bind" directive. +# 2) No password is configured. +# +# The server only accepts connections from clients connecting from the +# IPv4 and IPv6 loopback addresses 127.0.0.1 and ::1, and from Unix domain +# sockets. +# +# By default protected mode is enabled. You should disable it only if +# you are sure you want clients from other hosts to connect to Redis +# even if no authentication is configured, nor a specific set of interfaces +# are explicitly listed using the "bind" directive. +protected-mode yes + +# Accept connections on the specified port, default is 6379 (IANA #815344). +# If port 0 is specified Redis will not listen on a TCP socket. +port 6379 + +# TCP listen() backlog. +# +# In high requests-per-second environments you need an high backlog in order +# to avoid slow clients connections issues. Note that the Linux kernel +# will silently truncate it to the value of /proc/sys/net/core/somaxconn so +# make sure to raise both the value of somaxconn and tcp_max_syn_backlog +# in order to get the desired effect. +tcp-backlog 511 + +# Unix socket. +# +# Specify the path for the Unix socket that will be used to listen for +# incoming connections. There is no default, so Redis will not listen +# on a unix socket when not specified. +# +unixsocket /run/redis/socket +unixsocketperm 777 + +# Close the connection after a client is idle for N seconds (0 to disable) +timeout 0 + +# TCP keepalive. +# +# If non-zero, use SO_KEEPALIVE to send TCP ACKs to clients in absence +# of communication. This is useful for two reasons: +# +# 1) Detect dead peers. +# 2) Take the connection alive from the point of view of network +# equipment in the middle. +# +# On Linux, the specified value (in seconds) is the period used to send ACKs. +# Note that to close the connection the double of the time is needed. +# On other kernels the period depends on the kernel configuration. +# +# A reasonable value for this option is 300 seconds, which is the new +# Redis default starting with Redis 3.2.1. +tcp-keepalive 300 + +################################# GENERAL ##################################### + +# By default Redis does not run as a daemon. Use 'yes' if you need it. +# Note that Redis will write a pid file in /var/run/redis.pid when daemonized. +daemonize no + +# If you run Redis from upstart or systemd, Redis can interact with your +# supervision tree. Options: +# supervised no - no supervision interaction +# supervised upstart - signal upstart by putting Redis into SIGSTOP mode +# supervised systemd - signal systemd by writing READY=1 to $NOTIFY_SOCKET +# supervised auto - detect upstart or systemd method based on +# UPSTART_JOB or NOTIFY_SOCKET environment variables +# Note: these supervision methods only signal "process is ready." +# They do not enable continuous liveness pings back to your supervisor. +supervised systemd + +# If a pid file is specified, Redis writes it where specified at startup +# and removes it at exit. +# +# When the server runs non daemonized, no pid file is created if none is +# specified in the configuration. When the server is daemonized, the pid file +# is used even if not specified, defaulting to "/var/run/redis.pid". +# +# Creating a pid file is best effort: if Redis is not able to create it +# nothing bad happens, the server will start and run normally. +pidfile /run/redis/pid + +# Specify the server verbosity level. +# This can be one of: +# debug (a lot of information, useful for development/testing) +# verbose (many rarely useful info, but not a mess like the debug level) +# notice (moderately verbose, what you want in production probably) +# warning (only very important / critical messages are logged) +loglevel notice + +# Specify the log file name. Also the empty string can be used to force +# Redis to log on the standard output. Note that if you use standard +# output for logging but daemonize, logs will be sent to /dev/null +logfile "" + +# To enable logging to the system logger, just set 'syslog-enabled' to yes, +# and optionally update the other syslog parameters to suit your needs. +# syslog-enabled no + +# Specify the syslog identity. +# syslog-ident redis + +# Specify the syslog facility. Must be USER or between LOCAL0-LOCAL7. +# syslog-facility local0 + +# Set the number of databases. The default database is DB 0, you can select +# a different one on a per-connection basis using SELECT where +# dbid is a number between 0 and 'databases'-1 +databases 16 + +# By default Redis shows an ASCII art logo only when started to log to the +# standard output and if the standard output is a TTY. Basically this means +# that normally a logo is displayed only in interactive sessions. +# +# However it is possible to force the pre-4.0 behavior and always show a +# ASCII art logo in startup logs by setting the following option to yes. +always-show-logo yes + +################################ SNAPSHOTTING ################################ +# +# Save the DB on disk: +# +# save +# +# Will save the DB if both the given number of seconds and the given +# number of write operations against the DB occurred. +# +# In the example below the behaviour will be to save: +# after 900 sec (15 min) if at least 1 key changed +# after 300 sec (5 min) if at least 10 keys changed +# after 60 sec if at least 10000 keys changed +# +# Note: you can disable saving completely by commenting out all "save" lines. +# +# It is also possible to remove all the previously configured save +# points by adding a save directive with a single empty string argument +# like in the following example: +# +# save "" + +save 900 1 +save 300 10 +save 60 10000 + +# By default Redis will stop accepting writes if RDB snapshots are enabled +# (at least one save point) and the latest background save failed. +# This will make the user aware (in a hard way) that data is not persisting +# on disk properly, otherwise chances are that no one will notice and some +# disaster will happen. +# +# If the background saving process will start working again Redis will +# automatically allow writes again. +# +# However if you have setup your proper monitoring of the Redis server +# and persistence, you may want to disable this feature so that Redis will +# continue to work as usual even if there are problems with disk, +# permissions, and so forth. +stop-writes-on-bgsave-error yes + +# Compress string objects using LZF when dump .rdb databases? +# For default that's set to 'yes' as it's almost always a win. +# If you want to save some CPU in the saving child set it to 'no' but +# the dataset will likely be bigger if you have compressible values or keys. +rdbcompression yes + +# Since version 5 of RDB a CRC64 checksum is placed at the end of the file. +# This makes the format more resistant to corruption but there is a performance +# hit to pay (around 10%) when saving and loading RDB files, so you can disable it +# for maximum performances. +# +# RDB files created with checksum disabled have a checksum of zero that will +# tell the loading code to skip the check. +rdbchecksum yes + +# The filename where to dump the DB +dbfilename dump.rdb + +# The working directory. +# +# The DB will be written inside this directory, with the filename specified +# above using the 'dbfilename' configuration directive. +# +# The Append Only File will also be created inside this directory. +# +# Note that you must specify a directory here, not a file name. +dir /var/lib/redis + +################################# REPLICATION ################################# + +# Master-Slave replication. Use slaveof to make a Redis instance a copy of +# another Redis server. A few things to understand ASAP about Redis replication. +# +# 1) Redis replication is asynchronous, but you can configure a master to +# stop accepting writes if it appears to be not connected with at least +# a given number of slaves. +# 2) Redis slaves are able to perform a partial resynchronization with the +# master if the replication link is lost for a relatively small amount of +# time. You may want to configure the replication backlog size (see the next +# sections of this file) with a sensible value depending on your needs. +# 3) Replication is automatic and does not need user intervention. After a +# network partition slaves automatically try to reconnect to masters +# and resynchronize with them. +# +# slaveof + +# If the master is password protected (using the "requirepass" configuration +# directive below) it is possible to tell the slave to authenticate before +# starting the replication synchronization process, otherwise the master will +# refuse the slave request. +# +# masterauth + +# When a slave loses its connection with the master, or when the replication +# is still in progress, the slave can act in two different ways: +# +# 1) if slave-serve-stale-data is set to 'yes' (the default) the slave will +# still reply to client requests, possibly with out of date data, or the +# data set may just be empty if this is the first synchronization. +# +# 2) if slave-serve-stale-data is set to 'no' the slave will reply with +# an error "SYNC with master in progress" to all the kind of commands +# but to INFO and SLAVEOF. +# +slave-serve-stale-data yes + +# You can configure a slave instance to accept writes or not. Writing against +# a slave instance may be useful to store some ephemeral data (because data +# written on a slave will be easily deleted after resync with the master) but +# may also cause problems if clients are writing to it because of a +# misconfiguration. +# +# Since Redis 2.6 by default slaves are read-only. +# +# Note: read only slaves are not designed to be exposed to untrusted clients +# on the internet. It's just a protection layer against misuse of the instance. +# Still a read only slave exports by default all the administrative commands +# such as CONFIG, DEBUG, and so forth. To a limited extent you can improve +# security of read only slaves using 'rename-command' to shadow all the +# administrative / dangerous commands. +slave-read-only yes + +# Replication SYNC strategy: disk or socket. +# +# ------------------------------------------------------- +# WARNING: DISKLESS REPLICATION IS EXPERIMENTAL CURRENTLY +# ------------------------------------------------------- +# +# New slaves and reconnecting slaves that are not able to continue the replication +# process just receiving differences, need to do what is called a "full +# synchronization". An RDB file is transmitted from the master to the slaves. +# The transmission can happen in two different ways: +# +# 1) Disk-backed: The Redis master creates a new process that writes the RDB +# file on disk. Later the file is transferred by the parent +# process to the slaves incrementally. +# 2) Diskless: The Redis master creates a new process that directly writes the +# RDB file to slave sockets, without touching the disk at all. +# +# With disk-backed replication, while the RDB file is generated, more slaves +# can be queued and served with the RDB file as soon as the current child producing +# the RDB file finishes its work. With diskless replication instead once +# the transfer starts, new slaves arriving will be queued and a new transfer +# will start when the current one terminates. +# +# When diskless replication is used, the master waits a configurable amount of +# time (in seconds) before starting the transfer in the hope that multiple slaves +# will arrive and the transfer can be parallelized. +# +# With slow disks and fast (large bandwidth) networks, diskless replication +# works better. +repl-diskless-sync no + +# When diskless replication is enabled, it is possible to configure the delay +# the server waits in order to spawn the child that transfers the RDB via socket +# to the slaves. +# +# This is important since once the transfer starts, it is not possible to serve +# new slaves arriving, that will be queued for the next RDB transfer, so the server +# waits a delay in order to let more slaves arrive. +# +# The delay is specified in seconds, and by default is 5 seconds. To disable +# it entirely just set it to 0 seconds and the transfer will start ASAP. +repl-diskless-sync-delay 5 + +# Slaves send PINGs to server in a predefined interval. It's possible to change +# this interval with the repl_ping_slave_period option. The default value is 10 +# seconds. +# +# repl-ping-slave-period 10 + +# The following option sets the replication timeout for: +# +# 1) Bulk transfer I/O during SYNC, from the point of view of slave. +# 2) Master timeout from the point of view of slaves (data, pings). +# 3) Slave timeout from the point of view of masters (REPLCONF ACK pings). +# +# It is important to make sure that this value is greater than the value +# specified for repl-ping-slave-period otherwise a timeout will be detected +# every time there is low traffic between the master and the slave. +# +# repl-timeout 60 + +# Disable TCP_NODELAY on the slave socket after SYNC? +# +# If you select "yes" Redis will use a smaller number of TCP packets and +# less bandwidth to send data to slaves. But this can add a delay for +# the data to appear on the slave side, up to 40 milliseconds with +# Linux kernels using a default configuration. +# +# If you select "no" the delay for data to appear on the slave side will +# be reduced but more bandwidth will be used for replication. +# +# By default we optimize for low latency, but in very high traffic conditions +# or when the master and slaves are many hops away, turning this to "yes" may +# be a good idea. +repl-disable-tcp-nodelay no + +# Set the replication backlog size. The backlog is a buffer that accumulates +# slave data when slaves are disconnected for some time, so that when a slave +# wants to reconnect again, often a full resync is not needed, but a partial +# resync is enough, just passing the portion of data the slave missed while +# disconnected. +# +# The bigger the replication backlog, the longer the time the slave can be +# disconnected and later be able to perform a partial resynchronization. +# +# The backlog is only allocated once there is at least a slave connected. +# +# repl-backlog-size 1mb + +# After a master has no longer connected slaves for some time, the backlog +# will be freed. The following option configures the amount of seconds that +# need to elapse, starting from the time the last slave disconnected, for +# the backlog buffer to be freed. +# +# Note that slaves never free the backlog for timeout, since they may be +# promoted to masters later, and should be able to correctly "partially +# resynchronize" with the slaves: hence they should always accumulate backlog. +# +# A value of 0 means to never release the backlog. +# +# repl-backlog-ttl 3600 + +# The slave priority is an integer number published by Redis in the INFO output. +# It is used by Redis Sentinel in order to select a slave to promote into a +# master if the master is no longer working correctly. +# +# A slave with a low priority number is considered better for promotion, so +# for instance if there are three slaves with priority 10, 100, 25 Sentinel will +# pick the one with priority 10, that is the lowest. +# +# However a special priority of 0 marks the slave as not able to perform the +# role of master, so a slave with priority of 0 will never be selected by +# Redis Sentinel for promotion. +# +# By default the priority is 100. +slave-priority 100 + +# It is possible for a master to stop accepting writes if there are less than +# N slaves connected, having a lag less or equal than M seconds. +# +# The N slaves need to be in "online" state. +# +# The lag in seconds, that must be <= the specified value, is calculated from +# the last ping received from the slave, that is usually sent every second. +# +# This option does not GUARANTEE that N replicas will accept the write, but +# will limit the window of exposure for lost writes in case not enough slaves +# are available, to the specified number of seconds. +# +# For example to require at least 3 slaves with a lag <= 10 seconds use: +# +# min-slaves-to-write 3 +# min-slaves-max-lag 10 +# +# Setting one or the other to 0 disables the feature. +# +# By default min-slaves-to-write is set to 0 (feature disabled) and +# min-slaves-max-lag is set to 10. + +# A Redis master is able to list the address and port of the attached +# slaves in different ways. For example the "INFO replication" section +# offers this information, which is used, among other tools, by +# Redis Sentinel in order to discover slave instances. +# Another place where this info is available is in the output of the +# "ROLE" command of a master. +# +# The listed IP and address normally reported by a slave is obtained +# in the following way: +# +# IP: The address is auto detected by checking the peer address +# of the socket used by the slave to connect with the master. +# +# Port: The port is communicated by the slave during the replication +# handshake, and is normally the port that the slave is using to +# list for connections. +# +# However when port forwarding or Network Address Translation (NAT) is +# used, the slave may be actually reachable via different IP and port +# pairs. The following two options can be used by a slave in order to +# report to its master a specific set of IP and port, so that both INFO +# and ROLE will report those values. +# +# There is no need to use both the options if you need to override just +# the port or the IP address. +# +# slave-announce-ip 5.5.5.5 +# slave-announce-port 1234 + +################################## SECURITY ################################### + +# Require clients to issue AUTH before processing any other +# commands. This might be useful in environments in which you do not trust +# others with access to the host running redis-server. +# +# This should stay commented out for backward compatibility and because most +# people do not need auth (e.g. they run their own servers). +# +# Warning: since Redis is pretty fast an outside user can try up to +# 150k passwords per second against a good box. This means that you should +# use a very strong password otherwise it will be very easy to break. +# +# requirepass foobared + +# Command renaming. +# +# It is possible to change the name of dangerous commands in a shared +# environment. For instance the CONFIG command may be renamed into something +# hard to guess so that it will still be available for internal-use tools +# but not available for general clients. +# +# Example: +# +# rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52 +# +# It is also possible to completely kill a command by renaming it into +# an empty string: +# +# rename-command CONFIG "" +# +# Please note that changing the name of commands that are logged into the +# AOF file or transmitted to slaves may cause problems. + +################################### CLIENTS #################################### + +# Set the max number of connected clients at the same time. By default +# this limit is set to 10000 clients, however if the Redis server is not +# able to configure the process file limit to allow for the specified limit +# the max number of allowed clients is set to the current file limit +# minus 32 (as Redis reserves a few file descriptors for internal uses). +# +# Once the limit is reached Redis will close all the new connections sending +# an error 'max number of clients reached'. +# +# maxclients 10000 + +############################## MEMORY MANAGEMENT ################################ + +# Set a memory usage limit to the specified amount of bytes. +# When the memory limit is reached Redis will try to remove keys +# according to the eviction policy selected (see maxmemory-policy). +# +# If Redis can't remove keys according to the policy, or if the policy is +# set to 'noeviction', Redis will start to reply with errors to commands +# that would use more memory, like SET, LPUSH, and so on, and will continue +# to reply to read-only commands like GET. +# +# This option is usually useful when using Redis as an LRU or LFU cache, or to +# set a hard memory limit for an instance (using the 'noeviction' policy). +# +# WARNING: If you have slaves attached to an instance with maxmemory on, +# the size of the output buffers needed to feed the slaves are subtracted +# from the used memory count, so that network problems / resyncs will +# not trigger a loop where keys are evicted, and in turn the output +# buffer of slaves is full with DELs of keys evicted triggering the deletion +# of more keys, and so forth until the database is completely emptied. +# +# In short... if you have slaves attached it is suggested that you set a lower +# limit for maxmemory so that there is some free RAM on the system for slave +# output buffers (but this is not needed if the policy is 'noeviction'). +# +maxmemory 4GB + +# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory +# is reached. You can select among five behaviors: +# +# volatile-lru -> Evict using approximated LRU among the keys with an expire set. +# allkeys-lru -> Evict any key using approximated LRU. +# volatile-lfu -> Evict using approximated LFU among the keys with an expire set. +# allkeys-lfu -> Evict any key using approximated LFU. +# volatile-random -> Remove a random key among the ones with an expire set. +# allkeys-random -> Remove a random key, any key. +# volatile-ttl -> Remove the key with the nearest expire time (minor TTL) +# noeviction -> Don't evict anything, just return an error on write operations. +# +# LRU means Least Recently Used +# LFU means Least Frequently Used +# +# Both LRU, LFU and volatile-ttl are implemented using approximated +# randomized algorithms. +# +# Note: with any of the above policies, Redis will return an error on write +# operations, when there are no suitable keys for eviction. +# +# At the date of writing these commands are: set setnx setex append +# incr decr rpush lpush rpushx lpushx linsert lset rpoplpush sadd +# sinter sinterstore sunion sunionstore sdiff sdiffstore zadd zincrby +# zunionstore zinterstore hset hsetnx hmset hincrby incrby decrby +# getset mset msetnx exec sort +# +# The default is: +# +maxmemory-policy allkeys-lfu + +# LRU, LFU and minimal TTL algorithms are not precise algorithms but approximated +# algorithms (in order to save memory), so you can tune it for speed or +# accuracy. For default Redis will check five keys and pick the one that was +# used less recently, you can change the sample size using the following +# configuration directive. +# +# The default of 5 produces good enough results. 10 Approximates very closely +# true LRU but costs more CPU. 3 is faster but not very accurate. +# +maxmemory-samples 5 + +############################# LAZY FREEING #################################### + +# Redis has two primitives to delete keys. One is called DEL and is a blocking +# deletion of the object. It means that the server stops processing new commands +# in order to reclaim all the memory associated with an object in a synchronous +# way. If the key deleted is associated with a small object, the time needed +# in order to execute th DEL command is very small and comparable to most other +# O(1) or O(log_N) commands in Redis. However if the key is associated with an +# aggregated value containing millions of elements, the server can block for +# a long time (even seconds) in order to complete the operation. +# +# For the above reasons Redis also offers non blocking deletion primitives +# such as UNLINK (non blocking DEL) and the ASYNC option of FLUSHALL and +# FLUSHDB commands, in order to reclaim memory in background. Those commands +# are executed in constant time. Another thread will incrementally free the +# object in the background as fast as possible. +# +# DEL, UNLINK and ASYNC option of FLUSHALL and FLUSHDB are user-controlled. +# It's up to the design of the application to understand when it is a good +# idea to use one or the other. However the Redis server sometimes has to +# delete keys or flush the whole database as a side effect of other operations. +# Specifically Redis deletes objects independently of an user call in the +# following scenarios: +# +# 1) On eviction, because of the maxmemory and maxmemory policy configurations, +# in order to make room for new data, without going over the specified +# memory limit. +# 2) Because of expire: when a key with an associated time to live (see the +# EXPIRE command) must be deleted from memory. +# 3) Because of a side effect of a command that stores data on a key that may +# already exist. For example the RENAME command may delete the old key +# content when it is replaced with another one. Similarly SUNIONSTORE +# or SORT with STORE option may delete existing keys. The SET command +# itself removes any old content of the specified key in order to replace +# it with the specified string. +# 4) During replication, when a slave performs a full resynchronization with +# its master, the content of the whole database is removed in order to +# load the RDB file just transfered. +# +# In all the above cases the default is to delete objects in a blocking way, +# like if DEL was called. However you can configure each case specifically +# in order to instead release memory in a non-blocking way like if UNLINK +# was called, using the following configuration directives: + +lazyfree-lazy-eviction no +lazyfree-lazy-expire no +lazyfree-lazy-server-del no +slave-lazy-flush no + +############################## APPEND ONLY MODE ############################### + +# By default Redis asynchronously dumps the dataset on disk. This mode is +# good enough in many applications, but an issue with the Redis process or +# a power outage may result into a few minutes of writes lost (depending on +# the configured save points). +# +# The Append Only File is an alternative persistence mode that provides +# much better durability. For instance using the default data fsync policy +# (see later in the config file) Redis can lose just one second of writes in a +# dramatic event like a server power outage, or a single write if something +# wrong with the Redis process itself happens, but the operating system is +# still running correctly. +# +# AOF and RDB persistence can be enabled at the same time without problems. +# If the AOF is enabled on startup Redis will load the AOF, that is the file +# with the better durability guarantees. +# +# Please check http://redis.io/topics/persistence for more information. + +appendonly no + +# The name of the append only file (default: "appendonly.aof") + +appendfilename "appendonly.aof" + +# The fsync() call tells the Operating System to actually write data on disk +# instead of waiting for more data in the output buffer. Some OS will really flush +# data on disk, some other OS will just try to do it ASAP. +# +# Redis supports three different modes: +# +# no: don't fsync, just let the OS flush the data when it wants. Faster. +# always: fsync after every write to the append only log. Slow, Safest. +# everysec: fsync only one time every second. Compromise. +# +# The default is "everysec", as that's usually the right compromise between +# speed and data safety. It's up to you to understand if you can relax this to +# "no" that will let the operating system flush the output buffer when +# it wants, for better performances (but if you can live with the idea of +# some data loss consider the default persistence mode that's snapshotting), +# or on the contrary, use "always" that's very slow but a bit safer than +# everysec. +# +# More details please check the following article: +# http://antirez.com/post/redis-persistence-demystified.html +# +# If unsure, use "everysec". + +# appendfsync always +appendfsync everysec +# appendfsync no + +# When the AOF fsync policy is set to always or everysec, and a background +# saving process (a background save or AOF log background rewriting) is +# performing a lot of I/O against the disk, in some Linux configurations +# Redis may block too long on the fsync() call. Note that there is no fix for +# this currently, as even performing fsync in a different thread will block +# our synchronous write(2) call. +# +# In order to mitigate this problem it's possible to use the following option +# that will prevent fsync() from being called in the main process while a +# BGSAVE or BGREWRITEAOF is in progress. +# +# This means that while another child is saving, the durability of Redis is +# the same as "appendfsync none". In practical terms, this means that it is +# possible to lose up to 30 seconds of log in the worst scenario (with the +# default Linux settings). +# +# If you have latency problems turn this to "yes". Otherwise leave it as +# "no" that is the safest pick from the point of view of durability. + +no-appendfsync-on-rewrite no + +# Automatic rewrite of the append only file. +# Redis is able to automatically rewrite the log file implicitly calling +# BGREWRITEAOF when the AOF log size grows by the specified percentage. +# +# This is how it works: Redis remembers the size of the AOF file after the +# latest rewrite (if no rewrite has happened since the restart, the size of +# the AOF at startup is used). +# +# This base size is compared to the current size. If the current size is +# bigger than the specified percentage, the rewrite is triggered. Also +# you need to specify a minimal size for the AOF file to be rewritten, this +# is useful to avoid rewriting the AOF file even if the percentage increase +# is reached but it is still pretty small. +# +# Specify a percentage of zero in order to disable the automatic AOF +# rewrite feature. + +auto-aof-rewrite-percentage 100 +auto-aof-rewrite-min-size 64mb + +# An AOF file may be found to be truncated at the end during the Redis +# startup process, when the AOF data gets loaded back into memory. +# This may happen when the system where Redis is running +# crashes, especially when an ext4 filesystem is mounted without the +# data=ordered option (however this can't happen when Redis itself +# crashes or aborts but the operating system still works correctly). +# +# Redis can either exit with an error when this happens, or load as much +# data as possible (the default now) and start if the AOF file is found +# to be truncated at the end. The following option controls this behavior. +# +# If aof-load-truncated is set to yes, a truncated AOF file is loaded and +# the Redis server starts emitting a log to inform the user of the event. +# Otherwise if the option is set to no, the server aborts with an error +# and refuses to start. When the option is set to no, the user requires +# to fix the AOF file using the "redis-check-aof" utility before to restart +# the server. +# +# Note that if the AOF file will be found to be corrupted in the middle +# the server will still exit with an error. This option only applies when +# Redis will try to read more data from the AOF file but not enough bytes +# will be found. +aof-load-truncated yes + +# When rewriting the AOF file, Redis is able to use an RDB preamble in the +# AOF file for faster rewrites and recoveries. When this option is turned +# on the rewritten AOF file is composed of two different stanzas: +# +# [RDB file][AOF tail] +# +# When loading Redis recognizes that the AOF file starts with the "REDIS" +# string and loads the prefixed RDB file, and continues loading the AOF +# tail. +# +# This is currently turned off by default in order to avoid the surprise +# of a format change, but will at some point be used as the default. +aof-use-rdb-preamble no + +################################ LUA SCRIPTING ############################### + +# Max execution time of a Lua script in milliseconds. +# +# If the maximum execution time is reached Redis will log that a script is +# still in execution after the maximum allowed time and will start to +# reply to queries with an error. +# +# When a long running script exceeds the maximum execution time only the +# SCRIPT KILL and SHUTDOWN NOSAVE commands are available. The first can be +# used to stop a script that did not yet called write commands. The second +# is the only way to shut down the server in the case a write command was +# already issued by the script but the user doesn't want to wait for the natural +# termination of the script. +# +# Set it to 0 or a negative value for unlimited execution without warnings. +lua-time-limit 5000 + +################################ REDIS CLUSTER ############################### +# +# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# WARNING EXPERIMENTAL: Redis Cluster is considered to be stable code, however +# in order to mark it as "mature" we need to wait for a non trivial percentage +# of users to deploy it in production. +# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +# +# Normal Redis instances can't be part of a Redis Cluster; only nodes that are +# started as cluster nodes can. In order to start a Redis instance as a +# cluster node enable the cluster support uncommenting the following: +# +# cluster-enabled yes + +# Every cluster node has a cluster configuration file. This file is not +# intended to be edited by hand. It is created and updated by Redis nodes. +# Every Redis Cluster node requires a different cluster configuration file. +# Make sure that instances running in the same system do not have +# overlapping cluster configuration file names. +# +# cluster-config-file nodes-6379.conf + +# Cluster node timeout is the amount of milliseconds a node must be unreachable +# for it to be considered in failure state. +# Most other internal time limits are multiple of the node timeout. +# +# cluster-node-timeout 15000 + +# A slave of a failing master will avoid to start a failover if its data +# looks too old. +# +# There is no simple way for a slave to actually have an exact measure of +# its "data age", so the following two checks are performed: +# +# 1) If there are multiple slaves able to failover, they exchange messages +# in order to try to give an advantage to the slave with the best +# replication offset (more data from the master processed). +# Slaves will try to get their rank by offset, and apply to the start +# of the failover a delay proportional to their rank. +# +# 2) Every single slave computes the time of the last interaction with +# its master. This can be the last ping or command received (if the master +# is still in the "connected" state), or the time that elapsed since the +# disconnection with the master (if the replication link is currently down). +# If the last interaction is too old, the slave will not try to failover +# at all. +# +# The point "2" can be tuned by user. Specifically a slave will not perform +# the failover if, since the last interaction with the master, the time +# elapsed is greater than: +# +# (node-timeout * slave-validity-factor) + repl-ping-slave-period +# +# So for example if node-timeout is 30 seconds, and the slave-validity-factor +# is 10, and assuming a default repl-ping-slave-period of 10 seconds, the +# slave will not try to failover if it was not able to talk with the master +# for longer than 310 seconds. +# +# A large slave-validity-factor may allow slaves with too old data to failover +# a master, while a too small value may prevent the cluster from being able to +# elect a slave at all. +# +# For maximum availability, it is possible to set the slave-validity-factor +# to a value of 0, which means, that slaves will always try to failover the +# master regardless of the last time they interacted with the master. +# (However they'll always try to apply a delay proportional to their +# offset rank). +# +# Zero is the only value able to guarantee that when all the partitions heal +# the cluster will always be able to continue. +# +# cluster-slave-validity-factor 10 + +# Cluster slaves are able to migrate to orphaned masters, that are masters +# that are left without working slaves. This improves the cluster ability +# to resist to failures as otherwise an orphaned master can't be failed over +# in case of failure if it has no working slaves. +# +# Slaves migrate to orphaned masters only if there are still at least a +# given number of other working slaves for their old master. This number +# is the "migration barrier". A migration barrier of 1 means that a slave +# will migrate only if there is at least 1 other working slave for its master +# and so forth. It usually reflects the number of slaves you want for every +# master in your cluster. +# +# Default is 1 (slaves migrate only if their masters remain with at least +# one slave). To disable migration just set it to a very large value. +# A value of 0 can be set but is useful only for debugging and dangerous +# in production. +# +# cluster-migration-barrier 1 + +# By default Redis Cluster nodes stop accepting queries if they detect there +# is at least an hash slot uncovered (no available node is serving it). +# This way if the cluster is partially down (for example a range of hash slots +# are no longer covered) all the cluster becomes, eventually, unavailable. +# It automatically returns available as soon as all the slots are covered again. +# +# However sometimes you want the subset of the cluster which is working, +# to continue to accept queries for the part of the key space that is still +# covered. In order to do so, just set the cluster-require-full-coverage +# option to no. +# +# cluster-require-full-coverage yes + +# In order to setup your cluster make sure to read the documentation +# available at http://redis.io web site. + +########################## CLUSTER DOCKER/NAT support ######################## + +# In certain deployments, Redis Cluster nodes address discovery fails, because +# addresses are NAT-ted or because ports are forwarded (the typical case is +# Docker and other containers). +# +# In order to make Redis Cluster working in such environments, a static +# configuration where each node known its public address is needed. The +# following two options are used for this scope, and are: +# +# * cluster-announce-ip +# * cluster-announce-port +# * cluster-announce-bus-port +# +# Each instruct the node about its address, client port, and cluster message +# bus port. The information is then published in the header of the bus packets +# so that other nodes will be able to correctly map the address of the node +# publishing the information. +# +# If the above options are not used, the normal Redis Cluster auto-detection +# will be used instead. +# +# Note that when remapped, the bus port may not be at the fixed offset of +# clients port + 10000, so you can specify any port and bus-port depending +# on how they get remapped. If the bus-port is not set, a fixed offset of +# 10000 will be used as usually. +# +# Example: +# +# cluster-announce-ip 10.1.1.5 +# cluster-announce-port 6379 +# cluster-announce-bus-port 6380 + +################################## SLOW LOG ################################### + +# The Redis Slow Log is a system to log queries that exceeded a specified +# execution time. The execution time does not include the I/O operations +# like talking with the client, sending the reply and so forth, +# but just the time needed to actually execute the command (this is the only +# stage of command execution where the thread is blocked and can not serve +# other requests in the meantime). +# +# You can configure the slow log with two parameters: one tells Redis +# what is the execution time, in microseconds, to exceed in order for the +# command to get logged, and the other parameter is the length of the +# slow log. When a new command is logged the oldest one is removed from the +# queue of logged commands. + +# The following time is expressed in microseconds, so 1000000 is equivalent +# to one second. Note that a negative number disables the slow log, while +# a value of zero forces the logging of every command. +slowlog-log-slower-than 10000 + +# There is no limit to this length. Just be aware that it will consume memory. +# You can reclaim memory used by the slow log with SLOWLOG RESET. +slowlog-max-len 128 + +################################ LATENCY MONITOR ############################## + +# The Redis latency monitoring subsystem samples different operations +# at runtime in order to collect data related to possible sources of +# latency of a Redis instance. +# +# Via the LATENCY command this information is available to the user that can +# print graphs and obtain reports. +# +# The system only logs operations that were performed in a time equal or +# greater than the amount of milliseconds specified via the +# latency-monitor-threshold configuration directive. When its value is set +# to zero, the latency monitor is turned off. +# +# By default latency monitoring is disabled since it is mostly not needed +# if you don't have latency issues, and collecting data has a performance +# impact, that while very small, can be measured under big load. Latency +# monitoring can easily be enabled at runtime using the command +# "CONFIG SET latency-monitor-threshold " if needed. +latency-monitor-threshold 0 + +############################# EVENT NOTIFICATION ############################## + +# Redis can notify Pub/Sub clients about events happening in the key space. +# This feature is documented at http://redis.io/topics/notifications +# +# For instance if keyspace events notification is enabled, and a client +# performs a DEL operation on key "foo" stored in the Database 0, two +# messages will be published via Pub/Sub: +# +# PUBLISH __keyspace@0__:foo del +# PUBLISH __keyevent@0__:del foo +# +# It is possible to select the events that Redis will notify among a set +# of classes. Every class is identified by a single character: +# +# K Keyspace events, published with __keyspace@__ prefix. +# E Keyevent events, published with __keyevent@__ prefix. +# g Generic commands (non-type specific) like DEL, EXPIRE, RENAME, ... +# $ String commands +# l List commands +# s Set commands +# h Hash commands +# z Sorted set commands +# x Expired events (events generated every time a key expires) +# e Evicted events (events generated when a key is evicted for maxmemory) +# A Alias for g$lshzxe, so that the "AKE" string means all the events. +# +# The "notify-keyspace-events" takes as argument a string that is composed +# of zero or multiple characters. The empty string means that notifications +# are disabled. +# +# Example: to enable list and generic events, from the point of view of the +# event name, use: +# +# notify-keyspace-events Elg +# +# Example 2: to get the stream of the expired keys subscribing to channel +# name __keyevent@0__:expired use: +# +# notify-keyspace-events Ex +# +# By default all notifications are disabled because most users don't need +# this feature and the feature has some overhead. Note that if you don't +# specify at least one of K or E, no events will be delivered. +notify-keyspace-events "" + +############################### ADVANCED CONFIG ############################### + +# Hashes are encoded using a memory efficient data structure when they have a +# small number of entries, and the biggest entry does not exceed a given +# threshold. These thresholds can be configured using the following directives. +hash-max-ziplist-entries 512 +hash-max-ziplist-value 64 + +# Lists are also encoded in a special way to save a lot of space. +# The number of entries allowed per internal list node can be specified +# as a fixed maximum size or a maximum number of elements. +# For a fixed maximum size, use -5 through -1, meaning: +# -5: max size: 64 Kb <-- not recommended for normal workloads +# -4: max size: 32 Kb <-- not recommended +# -3: max size: 16 Kb <-- probably not recommended +# -2: max size: 8 Kb <-- good +# -1: max size: 4 Kb <-- good +# Positive numbers mean store up to _exactly_ that number of elements +# per list node. +# The highest performing option is usually -2 (8 Kb size) or -1 (4 Kb size), +# but if your use case is unique, adjust the settings as necessary. +list-max-ziplist-size -2 + +# Lists may also be compressed. +# Compress depth is the number of quicklist ziplist nodes from *each* side of +# the list to *exclude* from compression. The head and tail of the list +# are always uncompressed for fast push/pop operations. Settings are: +# 0: disable all list compression +# 1: depth 1 means "don't start compressing until after 1 node into the list, +# going from either the head or tail" +# So: [head]->node->node->...->node->[tail] +# [head], [tail] will always be uncompressed; inner nodes will compress. +# 2: [head]->[next]->node->node->...->node->[prev]->[tail] +# 2 here means: don't compress head or head->next or tail->prev or tail, +# but compress all nodes between them. +# 3: [head]->[next]->[next]->node->node->...->node->[prev]->[prev]->[tail] +# etc. +list-compress-depth 0 + +# Sets have a special encoding in just one case: when a set is composed +# of just strings that happen to be integers in radix 10 in the range +# of 64 bit signed integers. +# The following configuration setting sets the limit in the size of the +# set in order to use this special memory saving encoding. +set-max-intset-entries 512 + +# Similarly to hashes and lists, sorted sets are also specially encoded in +# order to save a lot of space. This encoding is only used when the length and +# elements of a sorted set are below the following limits: +zset-max-ziplist-entries 128 +zset-max-ziplist-value 64 + +# HyperLogLog sparse representation bytes limit. The limit includes the +# 16 bytes header. When an HyperLogLog using the sparse representation crosses +# this limit, it is converted into the dense representation. +# +# A value greater than 16000 is totally useless, since at that point the +# dense representation is more memory efficient. +# +# The suggested value is ~ 3000 in order to have the benefits of +# the space efficient encoding without slowing down too much PFADD, +# which is O(N) with the sparse encoding. The value can be raised to +# ~ 10000 when CPU is not a concern, but space is, and the data set is +# composed of many HyperLogLogs with cardinality in the 0 - 15000 range. +hll-sparse-max-bytes 3000 + +# Active rehashing uses 1 millisecond every 100 milliseconds of CPU time in +# order to help rehashing the main Redis hash table (the one mapping top-level +# keys to values). The hash table implementation Redis uses (see dict.c) +# performs a lazy rehashing: the more operation you run into a hash table +# that is rehashing, the more rehashing "steps" are performed, so if the +# server is idle the rehashing is never complete and some more memory is used +# by the hash table. +# +# The default is to use this millisecond 10 times every second in order to +# actively rehash the main dictionaries, freeing memory when possible. +# +# If unsure: +# use "activerehashing no" if you have hard latency requirements and it is +# not a good thing in your environment that Redis can reply from time to time +# to queries with 2 milliseconds delay. +# +# use "activerehashing yes" if you don't have such hard requirements but +# want to free memory asap when possible. +activerehashing yes + +# The client output buffer limits can be used to force disconnection of clients +# that are not reading data from the server fast enough for some reason (a +# common reason is that a Pub/Sub client can't consume messages as fast as the +# publisher can produce them). +# +# The limit can be set differently for the three different classes of clients: +# +# normal -> normal clients including MONITOR clients +# slave -> slave clients +# pubsub -> clients subscribed to at least one pubsub channel or pattern +# +# The syntax of every client-output-buffer-limit directive is the following: +# +# client-output-buffer-limit +# +# A client is immediately disconnected once the hard limit is reached, or if +# the soft limit is reached and remains reached for the specified number of +# seconds (continuously). +# So for instance if the hard limit is 32 megabytes and the soft limit is +# 16 megabytes / 10 seconds, the client will get disconnected immediately +# if the size of the output buffers reach 32 megabytes, but will also get +# disconnected if the client reaches 16 megabytes and continuously overcomes +# the limit for 10 seconds. +# +# By default normal clients are not limited because they don't receive data +# without asking (in a push way), but just after a request, so only +# asynchronous clients may create a scenario where data is requested faster +# than it can read. +# +# Instead there is a default limit for pubsub and slave clients, since +# subscribers and slaves receive data in a push fashion. +# +# Both the hard or the soft limit can be disabled by setting them to zero. +client-output-buffer-limit normal 0 0 0 +client-output-buffer-limit slave 256mb 64mb 60 +client-output-buffer-limit pubsub 32mb 8mb 60 + +# Redis calls an internal function to perform many background tasks, like +# closing connections of clients in timeout, purging expired keys that are +# never requested, and so forth. +# +# Not all tasks are performed with the same frequency, but Redis checks for +# tasks to perform according to the specified "hz" value. +# +# By default "hz" is set to 10. Raising the value will use more CPU when +# Redis is idle, but at the same time will make Redis more responsive when +# there are many keys expiring at the same time, and timeouts may be +# handled with more precision. +# +# The range is between 1 and 500, however a value over 100 is usually not +# a good idea. Most users should use the default of 10 and raise this up to +# 100 only in environments where very low latency is required. +hz 10 + +# When a child rewrites the AOF file, if the following option is enabled +# the file will be fsync-ed every 32 MB of data generated. This is useful +# in order to commit the file to the disk more incrementally and avoid +# big latency spikes. +aof-rewrite-incremental-fsync yes + +# Redis LFU eviction (see maxmemory setting) can be tuned. However it is a good +# idea to start with the default settings and only change them after investigating +# how to improve the performances and how the keys LFU change over time, which +# is possible to inspect via the OBJECT FREQ command. +# +# There are two tunable parameters in the Redis LFU implementation: the +# counter logarithm factor and the counter decay time. It is important to +# understand what the two parameters mean before changing them. +# +# The LFU counter is just 8 bits per key, it's maximum value is 255, so Redis +# uses a probabilistic increment with logarithmic behavior. Given the value +# of the old counter, when a key is accessed, the counter is incremented in +# this way: +# +# 1. A random number R between 0 and 1 is extracted. +# 2. A probability P is calculated as 1/(old_value*lfu_log_factor+1). +# 3. The counter is incremented only if R < P. +# +# The default lfu-log-factor is 10. This is a table of how the frequency +# counter changes with a different number of accesses with different +# logarithmic factors: +# +# +--------+------------+------------+------------+------------+------------+ +# | factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits | +# +--------+------------+------------+------------+------------+------------+ +# | 0 | 104 | 255 | 255 | 255 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# | 1 | 18 | 49 | 255 | 255 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# | 10 | 10 | 18 | 142 | 255 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# | 100 | 8 | 11 | 49 | 143 | 255 | +# +--------+------------+------------+------------+------------+------------+ +# +# NOTE: The above table was obtained by running the following commands: +# +# redis-benchmark -n 1000000 incr foo +# redis-cli object freq foo +# +# NOTE 2: The counter initial value is 5 in order to give new objects a chance +# to accumulate hits. +# +# The counter decay time is the time, in minutes, that must elapse in order +# for the key counter to be divided by two (or decremented if it has a value +# less <= 10). +# +# The default value for the lfu-decay-time is 1. A Special value of 0 means to +# decay the counter every time it happens to be scanned. +# +# lfu-log-factor 10 +# lfu-decay-time 1 + +########################### ACTIVE DEFRAGMENTATION ####################### +# +# WARNING THIS FEATURE IS EXPERIMENTAL. However it was stress tested +# even in production and manually tested by multiple engineers for some +# time. +# +# What is active defragmentation? +# ------------------------------- +# +# Active (online) defragmentation allows a Redis server to compact the +# spaces left between small allocations and deallocations of data in memory, +# thus allowing to reclaim back memory. +# +# Fragmentation is a natural process that happens with every allocator (but +# less so with Jemalloc, fortunately) and certain workloads. Normally a server +# restart is needed in order to lower the fragmentation, or at least to flush +# away all the data and create it again. However thanks to this feature +# implemented by Oran Agra for Redis 4.0 this process can happen at runtime +# in an "hot" way, while the server is running. +# +# Basically when the fragmentation is over a certain level (see the +# configuration options below) Redis will start to create new copies of the +# values in contiguous memory regions by exploiting certain specific Jemalloc +# features (in order to understand if an allocation is causing fragmentation +# and to allocate it in a better place), and at the same time, will release the +# old copies of the data. This process, repeated incrementally for all the keys +# will cause the fragmentation to drop back to normal values. +# +# Important things to understand: +# +# 1. This feature is disabled by default, and only works if you compiled Redis +# to use the copy of Jemalloc we ship with the source code of Redis. +# This is the default with Linux builds. +# +# 2. You never need to enable this feature if you don't have fragmentation +# issues. +# +# 3. Once you experience fragmentation, you can enable this feature when +# needed with the command "CONFIG SET activedefrag yes". +# +# The configuration parameters are able to fine tune the behavior of the +# defragmentation process. If you are not sure about what they mean it is +# a good idea to leave the defaults untouched. + +# Enabled active defragmentation +# activedefrag yes + +# Minimum amount of fragmentation waste to start active defrag +# active-defrag-ignore-bytes 100mb + +# Minimum percentage of fragmentation to start active defrag +# active-defrag-threshold-lower 10 + +# Maximum percentage of fragmentation at which we use maximum effort +# active-defrag-threshold-upper 100 + +# Minimal effort for defrag in CPU percentage +# active-defrag-cycle-min 25 + +# Maximal effort for defrag in CPU percentage +# active-defrag-cycle-max 75 + diff --git a/salt/salt/redis/redis.service b/salt/salt/redis/redis.service new file mode 100644 index 0000000..de96ca4 --- /dev/null +++ b/salt/salt/redis/redis.service @@ -0,0 +1,14 @@ +[Unit] +Description=redis daemon +After=network.target + +[Service] +PIDFile=/run/redis/pid +User=redis +Group=redis +RuntimeDirectory=redis +ExecStart=/usr/local/bin/redis-server /etc/redis.conf +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/redis/redis_breached_passwords.conf b/salt/salt/redis/redis_breached_passwords.conf new file mode 100644 index 0000000..bf9f781 --- /dev/null +++ b/salt/salt/redis/redis_breached_passwords.conf @@ -0,0 +1,24 @@ +loadmodule /opt/rebloom/rebloom.so +bind 127.0.0.1 + +# only listen on unix socket +port 0 + +unixsocket /run/redis_breached_passwords/socket +unixsocketperm 777 + +timeout 0 + +supervised systemd +pidfile /run/redis_breached_passwords/pid + +loglevel notice +logfile "" + +databases 1 +rdbchecksum yes + +dir /var/lib/redis +dbfilename breached_passwords_dump.rdb + +appendonly no diff --git a/salt/salt/redis/redis_breached_passwords.service b/salt/salt/redis/redis_breached_passwords.service new file mode 100644 index 0000000..9cd57e9 --- /dev/null +++ b/salt/salt/redis/redis_breached_passwords.service @@ -0,0 +1,14 @@ +[Unit] +Description=redis breached passwords daemon +After=network.target + +[Service] +PIDFile=/run/redis_breached_passwords/pid +User=redis +Group=redis +RuntimeDirectory=redis_breached_passwords +ExecStart=/usr/local/bin/redis-server /etc/redis_breached_passwords.conf +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/redis/transparent_hugepage.service b/salt/salt/redis/transparent_hugepage.service new file mode 100644 index 0000000..3fab02e --- /dev/null +++ b/salt/salt/redis/transparent_hugepage.service @@ -0,0 +1,11 @@ +[Unit] +Description=Disable transparent hugepage for redis +Before=redis.service + +[Service] +Type=oneshot +ExecStart=/bin/bash -c 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' +ExecStart=/bin/bash -c 'echo never > /sys/kernel/mm/transparent_hugepage/defrag' + +[Install] +RequiredBy=redis.service diff --git a/salt/salt/scripts/activate.sh.jinja2 b/salt/salt/scripts/activate.sh.jinja2 new file mode 100644 index 0000000..014536c --- /dev/null +++ b/salt/salt/scripts/activate.sh.jinja2 @@ -0,0 +1,7 @@ +{% from 'common.jinja2' import app_dir, bin_dir -%} +#!/bin/bash +# +# Simple convenience script to activate the venv and switch to the app dir +# (Needs to be run by sourcing or it won't do anything) +cd {{ app_dir }} +source {{ bin_dir }}/activate diff --git a/salt/salt/scripts/generate-site-icons.sh.jinja2 b/salt/salt/scripts/generate-site-icons.sh.jinja2 new file mode 100644 index 0000000..69fec61 --- /dev/null +++ b/salt/salt/scripts/generate-site-icons.sh.jinja2 @@ -0,0 +1,5 @@ +{% from 'common.jinja2' import app_dir -%} +{% from 'site-icons-spriter.sls' import site_icons_venv_dir, site_icons_data_dir -%} +#!/bin/bash +{{ site_icons_venv_dir }}/bin/glue --sprite-namespace= --namespace= --retina --css-template={{ app_dir }}/scripts/site-icons-spriter/css_template.jinja2 {{ site_icons_data_dir }}/site-icons {{ site_icons_data_dir }}/output +cp {{ site_icons_data_dir }}/output/*.png {{ app_dir }}/static/images diff --git a/salt/salt/scripts/init.sls b/salt/salt/scripts/init.sls new file mode 100644 index 0000000..2db61e7 --- /dev/null +++ b/salt/salt/scripts/init.sls @@ -0,0 +1,7 @@ +/usr/local/bin/activate: + file.managed: + - source: salt://scripts/activate.sh.jinja2 + - template: jinja + - user: root + - group: root + - mode: 755 diff --git a/salt/salt/self-signed-cert.sls b/salt/salt/self-signed-cert.sls new file mode 100644 index 0000000..f1108c1 --- /dev/null +++ b/salt/salt/self-signed-cert.sls @@ -0,0 +1,17 @@ +self-signed-cert: + pkg.installed: + - name: python3-openssl + module.run: + - tls.create_self_signed_cert: + - days: 3650 + - require: + - pkg: python3-openssl + - unless: ls /etc/pki/tls/certs/localhost.key + file.managed: + - name: /etc/pki/tls/certs/localhost.key + - mode: 600 + - replace: False + - require: + - module: self-signed-cert + - require_in: + - service: nginx diff --git a/salt/salt/sentry/common.jinja2 b/salt/salt/sentry/common.jinja2 new file mode 100644 index 0000000..055cf3d --- /dev/null +++ b/salt/salt/sentry/common.jinja2 @@ -0,0 +1,4 @@ +{% set sentry_cfg_dir = '/etc/sentry' %} + +{% set sentry_venv_dir = '/opt/venvs/sentry' %} +{% set sentry_bin_dir = sentry_venv_dir + '/bin' %} diff --git a/salt/salt/sentry/config.yml.jinja2 b/salt/salt/sentry/config.yml.jinja2 new file mode 100644 index 0000000..d78a880 --- /dev/null +++ b/salt/salt/sentry/config.yml.jinja2 @@ -0,0 +1,64 @@ +# While a lot of configuration in Sentry can be changed via the UI, for all +# new-style config (as of 8.0) you can also declare values here in this file +# to enforce defaults or to ensure they cannot be changed via the UI. For more +# information see the Sentry documentation. + +############### +# Mail Server # +############### + +mail.backend: 'dummy' # Use dummy if you want to disable email entirely +# mail.host: 'localhost' +# mail.port: 25 +# mail.username: '' +# mail.password: '' +# mail.use-tls: false +# The email address to send on behalf of +# mail.from: 'root@localhost' + +# If you'd like to configure email replies, enable this. +# mail.enable-replies: false + +# When email-replies are enabled, this value is used in the Reply-To header +# mail.reply-hostname: '' + +# If you're using mailgun for inbound mail, set your API key and configure a +# route to forward to /api/hooks/mailgun/inbound/ +# mail.mailgun-api-key: '' + +################### +# System Settings # +################### + +# If this file ever becomes compromised, it's important to regenerate your a new key +# Changing this value will result in all current sessions being invalidated. +# A new key can be generated with `$ sentry config generate-secret-key` +system.secret-key: '{{ pillar['sentry_secret'] }}' + +# The ``redis.clusters`` setting is used, unsurprisingly, to configure Redis +# clusters. These clusters can be then referred to by name when configuring +# backends such as the cache, digests, or TSDB backend. +redis.clusters: + default: + hosts: + 0: + host: 127.0.0.1 + port: 6379 + db: 10 + +################ +# File storage # +################ + +# Uploaded media uses these `filestore` settings. The available +# backends are either `filesystem` or `s3`. + +filestore.backend: 'filesystem' +filestore.options: + location: '/tmp/sentry-files' + +# filestore.backend: 's3' +# filestore.options: +# access_key: 'AKIXXXXXX' +# secret_key: 'XXXXXXX' +# bucket_name: 's3-bucket-name' diff --git a/salt/salt/sentry/init.sls b/salt/salt/sentry/init.sls new file mode 100644 index 0000000..001235e --- /dev/null +++ b/salt/salt/sentry/init.sls @@ -0,0 +1,143 @@ +{% from 'sentry/common.jinja2' import sentry_bin_dir, sentry_cfg_dir, sentry_venv_dir %} + +sentry-user: + group.present: + - name: sentry + user.present: + - name: sentry + - groups: [sentry] + +build-deps: + pkg.installed: + - pkgs: + - python-setuptools + - python-dev + - python-pip + - python-virtualenv + - libxslt1-dev + - gcc + - libffi-dev + - libjpeg-dev + - libxml2-dev + - libxslt-dev + - libyaml-dev + - libpq-dev + - zlib1g-dev + - postgresql-server-dev-{{ pillar['postgresql_version'] }} + +{{ sentry_venv_dir }}: + virtualenv.managed: + - system_site_packages: False + +pip-install: + pip.installed: + - name: sentry + - bin_env: {{ sentry_venv_dir }} + +init-sentry: + cmd.run: + - name: {{ sentry_bin_dir }}/sentry init {{ sentry_cfg_dir }} + - creates: {{ sentry_cfg_dir }} + +postgres-setup: + postgres_user.present: + - name: sentry + postgres_database.present: + - name: sentry + - owner: sentry + # sentry migrations should add this, but it fails due to not being superuser + postgres_extension.present: + - name: citext + - maintenance_db: sentry + +{{ sentry_cfg_dir }}/sentry.conf.py: + file.managed: + - source: salt://sentry/sentry.conf.py + +{{ sentry_cfg_dir }}/config.yml: + file.managed: + - source: salt://sentry/config.yml.jinja2 + - template: jinja + +update-pg_hba: + file.accumulated: + - name: pg_hba_lines + - filename: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/pg_hba.conf + - text: 'host sameuser sentry 127.0.0.1/32 trust' + - require_in: + - file: /etc/postgresql/{{ pillar['postgresql_version'] }}/main/pg_hba.conf + +create-sentry-db: + cmd.run: + - name: {{ sentry_bin_dir }}/sentry upgrade --noinput + - env: + - SENTRY_CONF: '{{ sentry_cfg_dir }}' + - onchanges: + - cmd: init-sentry + +create-sentry-user: + cmd.run: + - name: {{ sentry_bin_dir }}/sentry createuser --no-input --email {{ pillar['sentry_email'] }} --password {{ pillar['sentry_password'] }} + - env: + - SENTRY_CONF: '{{ sentry_cfg_dir }}' + - onchanges: + - cmd: init-sentry + +/etc/systemd/system/sentry-web.service: + file.managed: + - source: salt://sentry/sentry-web.service.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + +/etc/systemd/system/sentry-worker.service: + file.managed: + - source: salt://sentry/sentry-worker.service.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + +/etc/systemd/system/sentry-cron.service: + file.managed: + - source: salt://sentry/sentry-cron.service.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + +sentry-web: + service.running: + - enable: True + +sentry-worker: + service.running: + - enable: True + +sentry-cron: + service.running: + - enable: True + +sentry-cleanup: + cron.present: + - name: {{ sentry_bin_dir }}/sentry cleanup --days=30 + - hour: 4 + - minute: 0 + +/etc/nginx/sites-available/sentry.conf: + file.managed: + - source: salt://sentry/sentry.conf.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + - makedirs: True + +/etc/nginx/sites-enabled/sentry.conf: + file.symlink: + - target: /etc/nginx/sites-available/sentry.conf + - makedirs: True + - user: root + - group: root + - mode: 644 diff --git a/salt/salt/sentry/sentry-cron.service.jinja2 b/salt/salt/sentry/sentry-cron.service.jinja2 new file mode 100644 index 0000000..c533422 --- /dev/null +++ b/salt/salt/sentry/sentry-cron.service.jinja2 @@ -0,0 +1,15 @@ +{% from 'sentry/common.jinja2' import sentry_bin_dir, sentry_cfg_dir, sentry_venv_dir -%} +[Unit] +Description=Sentry Beat Service +After=network.target + +[Service] +Type=simple +User=sentry +Group=sentry +WorkingDirectory={{ sentry_venv_dir }} +Environment=SENTRY_CONF={{ sentry_cfg_dir }} +ExecStart={{ sentry_bin_dir }}/sentry run cron + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/sentry/sentry-web.service.jinja2 b/salt/salt/sentry/sentry-web.service.jinja2 new file mode 100644 index 0000000..d140c28 --- /dev/null +++ b/salt/salt/sentry/sentry-web.service.jinja2 @@ -0,0 +1,17 @@ +{% from 'sentry/common.jinja2' import sentry_bin_dir, sentry_cfg_dir, sentry_venv_dir -%} +[Unit] +Description=Sentry Main Service +After=network.target +Requires=sentry-worker.service +Requires=sentry-cron.service + +[Service] +Type=simple +User=sentry +Group=sentry +WorkingDirectory={{ sentry_venv_dir }} +Environment=SENTRY_CONF={{ sentry_cfg_dir }} +ExecStart={{ sentry_bin_dir }}/sentry run web + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/sentry/sentry-worker.service.jinja2 b/salt/salt/sentry/sentry-worker.service.jinja2 new file mode 100644 index 0000000..42d9373 --- /dev/null +++ b/salt/salt/sentry/sentry-worker.service.jinja2 @@ -0,0 +1,15 @@ +{% from 'sentry/common.jinja2' import sentry_bin_dir, sentry_cfg_dir, sentry_venv_dir -%} +[Unit] +Description=Sentry Background Worker +After=network.target + +[Service] +Type=simple +User=sentry +Group=sentry +WorkingDirectory={{ sentry_venv_dir }} +Environment=SENTRY_CONF={{ sentry_cfg_dir }} +ExecStart={{ sentry_bin_dir }}/sentry run worker + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/sentry/sentry.conf.jinja2 b/salt/salt/sentry/sentry.conf.jinja2 new file mode 100644 index 0000000..96ca7a5 --- /dev/null +++ b/salt/salt/sentry/sentry.conf.jinja2 @@ -0,0 +1,21 @@ +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + + {% for ip in pillar['developer_ips'] %} + allow {{ ip }}; + {% endfor %} + deny all; + + add_header Strict-Transport-Security "max-age={{ pillar['hsts_max_age'] }}; includeSubDomains; preload" always; + + server_name {{ pillar['sentry_server_name'] }}; + + location / { + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_pass http://localhost:9000; + } +} diff --git a/salt/salt/sentry/sentry.conf.py b/salt/salt/sentry/sentry.conf.py new file mode 100644 index 0000000..6e3459d --- /dev/null +++ b/salt/salt/sentry/sentry.conf.py @@ -0,0 +1,135 @@ +# This file is just Python, with a touch of Django which means +# you can inherit and tweak settings to your hearts content. +from sentry.conf.server import * + +import os.path + +CONF_ROOT = os.path.dirname(__file__) + +DATABASES = { + 'default': { + 'ENGINE': 'sentry.db.postgres', + 'NAME': 'sentry', + 'USER': 'sentry', + 'PASSWORD': '', + 'HOST': '127.0.0.1', + 'PORT': '', + 'AUTOCOMMIT': True, + 'ATOMIC_REQUESTS': False, + } +} + +# You should not change this setting after your database has been created +# unless you have altered all schemas first +SENTRY_USE_BIG_INTS = True + +# If you're expecting any kind of real traffic on Sentry, we highly recommend +# configuring the CACHES and Redis settings + +########### +# General # +########### + +# Instruct Sentry that this install intends to be run by a single organization +# and thus various UI optimizations should be enabled. +SENTRY_SINGLE_ORGANIZATION = True +DEBUG = False + +######### +# Cache # +######### + +# Sentry currently utilizes two separate mechanisms. While CACHES is not a +# requirement, it will optimize several high throughput patterns. + +# If you wish to use memcached, install the dependencies and adjust the config +# as shown: +# +# pip install python-memcached +# +# CACHES = { +# 'default': { +# 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', +# 'LOCATION': ['127.0.0.1:11211'], +# } +# } + +# A primary cache is required for things such as processing events +SENTRY_CACHE = 'sentry.cache.redis.RedisCache' + +######### +# Queue # +######### + +# See https://docs.sentry.io/on-premise/server/queue/ for more +# information on configuring your queue broker and workers. Sentry relies +# on a Python framework called Celery to manage queues. + +BROKER_URL = 'redis://localhost:6379' + +############### +# Rate Limits # +############### + +# Rate limits apply to notification handlers and are enforced per-project +# automatically. + +SENTRY_RATELIMITER = 'sentry.ratelimits.redis.RedisRateLimiter' + +################## +# Update Buffers # +################## + +# Buffers (combined with queueing) act as an intermediate layer between the +# database and the storage API. They will greatly improve efficiency on large +# numbers of the same events being sent to the API in a short amount of time. +# (read: if you send any kind of real data to Sentry, you should enable buffers) + +SENTRY_BUFFER = 'sentry.buffer.redis.RedisBuffer' + +########## +# Quotas # +########## + +# Quotas allow you to rate limit individual projects or the Sentry install as +# a whole. + +SENTRY_QUOTAS = 'sentry.quotas.redis.RedisQuota' + +######## +# TSDB # +######## + +# The TSDB is used for building charts as well as making things like per-rate +# alerts possible. + +SENTRY_TSDB = 'sentry.tsdb.redis.RedisTSDB' + +########### +# Digests # +########### + +# The digest backend powers notification summaries. + +SENTRY_DIGESTS = 'sentry.digests.backends.redis.RedisBackend' + +############## +# Web Server # +############## + +# If you're using a reverse SSL proxy, you should enable the X-Forwarded-Proto +# header and uncomment the following settings +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# If you're not hosting at the root of your web server, +# you need to uncomment and set it to the path where Sentry is hosted. +#FORCE_SCRIPT_NAME = '/sentry' + +SENTRY_WEB_HOST = '0.0.0.0' +SENTRY_WEB_PORT = 9000 +SENTRY_WEB_OPTIONS = { + # 'workers': 3, # the number of web workers + # 'protocol': 'uwsgi', # Enable uwsgi protocol instead of http +} diff --git a/salt/salt/site-icons-spriter.sls b/salt/salt/site-icons-spriter.sls new file mode 100644 index 0000000..e732267 --- /dev/null +++ b/salt/salt/site-icons-spriter.sls @@ -0,0 +1,42 @@ +{% from 'common.jinja2' import app_dir, app_username, python_version %} + +{% set site_icons_venv_dir = '/opt/venvs/site-icons-spriter' %} +{% set site_icons_data_dir = '/var/lib/site-icons-spriter' %} + +# Salt seems to use the deprecated pyvenv script, manual for now +site-icons-venv-setup: + cmd.run: + - name: /usr/local/pyenv/versions/{{ python_version }}/bin/python -m venv {{ site_icons_venv_dir }} + - creates: {{ site_icons_venv_dir }} + - require: + - pkg: python3-venv + - pyenv: {{ python_version }} + +site-icons-pip-installs: + cmd.run: + - name: {{ site_icons_venv_dir }}/bin/pip install glue + - unless: ls {{ site_icons_venv_dir }}/lib/python3.6/site-packages/glue + +site-icons-output-placeholder: + file.managed: + - name: {{ site_icons_data_dir }}/output/site-icons.css + - contents: '' + - allow_empty: True + - makedirs: True + - user: {{ app_username }} + - group: {{ app_username }} + - unless: ls {{ site_icons_data_dir }}/output/site-icons.css + +site-icons-input-folder: + file.directory: + - name: {{ site_icons_data_dir }}/site-icons + - user: {{ app_username }} + - group: {{ app_username }} + +/usr/local/bin/generate-site-icons: + file.managed: + - source: salt://scripts/generate-site-icons.sh.jinja2 + - template: jinja + - user: root + - group: root + - mode: 755 diff --git a/salt/salt/top.sls b/salt/salt/top.sls new file mode 100644 index 0000000..e9b9c19 --- /dev/null +++ b/salt/salt/top.sls @@ -0,0 +1,40 @@ +base: + 'dev or prod': + - gunicorn + - nginx + - nginx.site-config + - postgresql + - postgresql.site-db + - postgresql.pgbouncer + - python + - redis + - redis.modules.rebloom + - redis.modules.redis-cell + - rabbitmq + - scripts + - cmark-gfm + - prometheus.exporters.node_exporter + - prometheus.exporters.postgres_exporter + - prometheus.exporters.redis_exporter + - consumers + - site-icons-spriter + - boussole + - webassets + - cronjobs + - final-setup # keep this state file last + 'dev': + - postgresql.test-db + - self-signed-cert + - development + - prometheus + 'prod': + - nginx.static-sites-config + - raven + 'monitoring': + - nginx + - self-signed-cert + - postgresql + - redis + - sentry + - grafana + - prometheus diff --git a/salt/salt/webassets.service.jinja2 b/salt/salt/webassets.service.jinja2 new file mode 100644 index 0000000..69d47ed --- /dev/null +++ b/salt/salt/webassets.service.jinja2 @@ -0,0 +1,12 @@ +{% from 'common.jinja2' import app_dir, bin_dir -%} +[Unit] +Description=Webassets - auto-compile JS files on change + +[Service] +WorkingDirectory={{ app_dir }} +ExecStart={{ bin_dir }}/webassets -c webassets.yaml watch +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/salt/salt/webassets.sls b/salt/salt/webassets.sls new file mode 100644 index 0000000..4cd49c1 --- /dev/null +++ b/salt/salt/webassets.sls @@ -0,0 +1,17 @@ +{% from 'common.jinja2' import app_dir, bin_dir %} + +/etc/systemd/system/webassets.service: + file.managed: + - source: salt://webassets.service.jinja2 + - template: jinja + - user: root + - group: root + - mode: 644 + - require_in: + - service: webassets.service + +webassets.service: + service.running: + - enable: True + - require: + - pip: pip-installs diff --git a/tildes/alembic.ini b/tildes/alembic.ini new file mode 100644 index 0000000..9088ac0 --- /dev/null +++ b/tildes/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql+psycopg2://tildes:@/tildes + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/tildes/alembic/env.py b/tildes/alembic/env.py new file mode 100644 index 0000000..226a22e --- /dev/null +++ b/tildes/alembic/env.py @@ -0,0 +1,80 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig + +# 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) + +# import all DatabaseModel subclasses here for autogenerate support +from tildes.models.comment import ( + Comment, + CommentNotification, + CommentTag, + CommentVote, +) +from tildes.models.group import Group, GroupSubscription +from tildes.models.log import Log +from tildes.models.message import MessageConversation, MessageReply +from tildes.models.topic import Topic, TopicVisit, TopicVote +from tildes.models.user import User, UserGroupSettings, UserInviteCode + +from tildes.models import DatabaseModel +target_metadata = DatabaseModel.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/tildes/alembic/script.py.mako b/tildes/alembic/script.py.mako new file mode 100644 index 0000000..ad2d896 --- /dev/null +++ b/tildes/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises:${" " + down_revision if down_revision else "" | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/tildes/boussole.yaml b/tildes/boussole.yaml new file mode 100644 index 0000000..d018ce2 --- /dev/null +++ b/tildes/boussole.yaml @@ -0,0 +1,6 @@ +EXCLUDES: [] +LIBRARY_PATHS: [] +OUTPUT_STYLES: nested +SOURCES_PATH: scss/ +SOURCE_COMMENTS: false +TARGET_PATH: static/css/ diff --git a/tildes/consumers/topic_metadata_generator.py b/tildes/consumers/topic_metadata_generator.py new file mode 100644 index 0000000..23593b7 --- /dev/null +++ b/tildes/consumers/topic_metadata_generator.py @@ -0,0 +1,80 @@ +"""Consumer that generates content_metadata for topics.""" + +from typing import Sequence + +from amqpy import Message +from html5lib import HTMLParser +import publicsuffix + +from tildes.lib.amqp import PgsqlQueueConsumer +from tildes.lib.string import simplify_string, truncate_string, word_count +from tildes.lib.url import get_domain_from_url +from tildes.models.topic import Topic + + +class TopicMetadataGenerator(PgsqlQueueConsumer): + """Consumer that generates content_metadata for topics.""" + + def __init__(self, queue_name: str, routing_keys: Sequence[str]) -> None: + """Initialize the consumer, including the public suffix list.""" + super().__init__(queue_name, routing_keys) + + # download the public suffix list (would be good to add caching here) + psl_file = publicsuffix.fetch() + self.public_suffix_list = publicsuffix.PublicSuffixList(psl_file) + + def run(self, msg: Message) -> None: + """Process a delivered message.""" + topic = ( + self.db_session.query(Topic) + .filter_by(topic_id=msg.body['topic_id']) + .one() + ) + + if topic.is_text_type: + self._generate_text_metadata(topic) + elif topic.is_link_type: + self._generate_link_metadata(topic) + + @staticmethod + def _generate_text_metadata(topic: Topic) -> None: + """Generate metadata for a text topic (word count and excerpt).""" + html_tree = HTMLParser().parseFragment(topic.rendered_html) + + # extract the text from all of the HTML elements + extracted_text = ''.join( + [element_text for element_text in html_tree.itertext()]) + + # sanitize unicode, remove leading/trailing whitespace, etc. + extracted_text = simplify_string(extracted_text) + + # create a short excerpt by truncating the simplified string + excerpt = truncate_string( + extracted_text, + length=200, + truncate_at_chars=' ', + ) + + topic.content_metadata = { + 'word_count': word_count(extracted_text), + 'excerpt': excerpt, + } + + def _generate_link_metadata(self, topic: Topic) -> None: + """Generate metadata for a link topic (domain).""" + if not topic.link: + return + + parsed_domain = get_domain_from_url(topic.link) + domain = self.public_suffix_list.get_public_suffix(parsed_domain) + + topic.content_metadata = { + 'domain': domain, + } + + +if __name__ == '__main__': + TopicMetadataGenerator( + queue_name='topic_metadata_generator.q', + routing_keys=['topic.created', 'topic.edited'], + ).consume_queue() diff --git a/tildes/development.ini b/tildes/development.ini new file mode 100644 index 0000000..a050c0f --- /dev/null +++ b/tildes/development.ini @@ -0,0 +1,49 @@ +[DEFAULT] +# This setting is used to force the Pyramid app to generate urls using this +# port. This is necessary when using Vagrant for development so that the urls +# will include the correct port that's being forwarded from the host to the +# Vagrant box. This setting's value should match the port defined in the +# Vagrantfile that's being forwarded to port 443 on the guest. +# If not using Vagrant, this setting should probably be removed. +prefixmiddleware_force_port = 4443 + +[app:main] +use = egg:tildes + +pyramid.includes = pyramid_debugtoolbar + +pyramid.reload_templates = true + +redis.unix_socket_path = /run/redis/socket + +# Requests from the host machine through Vagrant will have their IP as +# 10.0.2.2 (which is an alias for the loopback interface) so we need +# to enable the debugtoolbar for that IP as well. +debugtoolbar.hosts = 127.0.0.1 ::1 10.0.2.2 + +# Exclude the metrics page from the debug toolbar, since Prometheus scrapes +# it constantly and we don't need to see that in the list of requests +debugtoolbar.exclude_prefixes = /metrics + +redis.sessions.secret = completely_insecure_secret +redis.sessions.unix_socket_path = %(redis.unix_socket_path)s +redis.sessions.prefix = session: +redis.sessions.cookie_secure = true + +# Set session timeout to 10 mins by default, we'll extend it when people log in +redis.sessions.timeout = 600 + +sqlalchemy.url = postgresql+psycopg2://tildes:@:6432/tildes + +webassets.auto_build = false +webassets.base_dir = %(here)s/static +webassets.base_url = / +webassets.cache = false +webassets.manifest = json + +[server:main] +use = egg:gunicorn#main +bind = unix:/run/gunicorn/socket +workers = 1 +pidfile = /run/gunicorn/pid +reload = true diff --git a/tildes/gunicorn_config.py b/tildes/gunicorn_config.py new file mode 100644 index 0000000..adf43e7 --- /dev/null +++ b/tildes/gunicorn_config.py @@ -0,0 +1,14 @@ +"""Configuration file for gunicorn.""" + +from prometheus_client import multiprocess + + +def child_exit(server, worker): # type: ignore + """Mark worker processes as dead for Prometheus when the worker exits. + + Note that this uses the child_exit hook instead of worker_exit so that + it's handled by the master process (and will still be called if a worker + crashes). + """ + # pylint: disable=unused-argument + multiprocess.mark_process_dead(worker.pid) diff --git a/tildes/mypy.ini b/tildes/mypy.ini new file mode 100644 index 0000000..18aa8b0 --- /dev/null +++ b/tildes/mypy.ini @@ -0,0 +1,4 @@ +[mypy] +mypy_path = /opt/tildes/stubs/ +disallow_untyped_defs = true +ignore_missing_imports = true diff --git a/tildes/production.ini.example b/tildes/production.ini.example new file mode 100644 index 0000000..2a6cd3a --- /dev/null +++ b/tildes/production.ini.example @@ -0,0 +1,37 @@ +[DEFAULT] + +[app:main] +use = egg:tildes + +redis.unix_socket_path = /run/redis/socket + +redis.sessions.secret = SomeReallyLongSecret +redis.sessions.unix_socket_path = %(redis.unix_socket_path)s +redis.sessions.prefix = session: +redis.sessions.cookie_secure = true +redis.sessions.cookie_max_age = 31536000 + +# disable the python timeout management in pyramid-session-redis +redis.sessions.python_expires = false +redis.sessions.timeout_trigger = 0 + +# set session timeout to 1 hour by default, we'll extend it when people log in +redis.sessions.timeout = 3600 + +sqlalchemy.url = postgresql+psycopg2://tildes:@:6432/tildes + +stripe_api_key = sk_live_ActualKeyShouldGoHere + +tildes.welcome_message_sender = Deimos + +webassets.auto_build = false +webassets.base_dir = %(here)s/static +webassets.base_url = / +webassets.cache = false +webassets.manifest = json + +[server:main] +use = egg:gunicorn#main +bind = unix:/run/gunicorn/socket +workers = 8 +pidfile = /run/gunicorn/pid diff --git a/tildes/pylama.ini b/tildes/pylama.ini new file mode 100644 index 0000000..6ffcd2b --- /dev/null +++ b/tildes/pylama.ini @@ -0,0 +1,54 @@ +[pylama] +linters = mccabe,pycodestyle,pydocstyle,pyflakes,pylint +skip = alembic/* + +# ignored checks: +# - D203 - pydocstyle has two mutually exclusive checks (D203/D211) +# for whether a class docstring should have a blank line before +# it or not. I don't want a blank line, so D203 is disabled. +# - D213 - another pair of mutually exclusive pydocstyle checks, this +# time for whether a multi-line docstring's summary line should be +# on the first or second line. I want it to be on the first line, +# so D213 needs to be disabled. +ignore = D203,D213 + +[pylama:pylint] +enable = all + +# disabled pylint checks: +# - missing-docstring (already reported by pydocstyle) +# - too-few-public-methods (more annoying than helpful, especially early on) +# - too-many-instance-attributes (overly-picky when models need many) +# - locally-disabled (don't need a warning about things I disable) +# - locally-enabled (or when checks are (re-)enabled) +# - suppressed-message (...a different message when I disable one?) +disable = + missing-docstring, + too-few-public-methods, + too-many-instance-attributes, + locally-disabled, + locally-enabled, + suppressed-message + +# The APIv0 and venusian.AttachInfo classes need to be ignored because pylint +# can't recognize dynamically-added methods/attrs, so all of the functions in +# cornice.Service like .get(), .post(), etc. cause errors. +ignored-classes = APIv0, venusian.AttachInfo + +[pylama:tildes/schemas/*] +# ignored checks for schemas specifically: +# - R0201 - method could be a function (for @pre_load-type methods) +ignore = R0201 + +[pylama:tests/*] +# ignored checks for tests specifically: +# - D100 - missing module-level docstrings +# - C0103 - invalid function names (tests often have very long ones) +# - W0212 - access to protected member (useful/necessary for tests sometimes) +# - W0621 - redefining name from outer scope (that's how pytest fixtures work) +# - E1101 - has no member (mocks add members this can't detect) +ignore = D100,C0103,W0212,W0621,E1101 + +[pylama:*/__init__.py] +# ignore "imported but unused" inside __init__.py files +ignore = W0611 diff --git a/tildes/pytest.ini b/tildes/pytest.ini new file mode 100644 index 0000000..0e02926 --- /dev/null +++ b/tildes/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -p no:cacheprovider diff --git a/tildes/requirements-to-freeze.txt b/tildes/requirements-to-freeze.txt new file mode 100644 index 0000000..5c14cb1 --- /dev/null +++ b/tildes/requirements-to-freeze.txt @@ -0,0 +1,38 @@ +ago +alembic +amqpy +argon2_cffi +astroid==1.5.3 # pylama has issues with pylint 1.8.1 +bleach +boussole +click==5.1 # boussole needs < 6.0 +cornice +freezegun +gunicorn +html5lib +ipython +mypy +mypy-extensions +prometheus-client +psycopg2 +publicsuffix2 +pydocstyle +pylama +pylama-pylint +pylint==1.7.5 # pylama has issues with 1.8.1 +pyramid +pyramid-debugtoolbar +pyramid-ipython +pyramid-jinja2 +pyramid-session-redis +pyramid-tm +pyramid-webassets +pytest +pytest-mock +SQLAlchemy +SQLAlchemy-Utils +stripe +testing.redis +webargs +webtest +zope.sqlalchemy diff --git a/tildes/requirements.txt b/tildes/requirements.txt new file mode 100644 index 0000000..9dc2c76 --- /dev/null +++ b/tildes/requirements.txt @@ -0,0 +1,105 @@ +ago==0.0.92 +alembic==1.0.0 +amqpy==0.13.1 +argh==0.26.2 +argon2-cffi==18.1.0 +astroid==1.5.3 +atomicwrites==1.1.5 +attrs==18.1.0 +backcall==0.1.0 +beautifulsoup4==4.6.0 +bleach==2.1.3 +boussole==1.2.3 +certifi==2018.4.16 +cffi==1.11.5 +chardet==3.0.4 +click==5.1 +colorama==0.3.9 +colorlog==3.1.4 +cornice==3.4.0 +decorator==4.3.0 +freezegun==0.3.10 +gunicorn==19.9.0 +html5lib==1.0.1 +hupper==1.3 +idna==2.7 +ipython==6.4.0 +ipython-genutils==0.2.0 +isort==4.3.4 +jedi==0.12.1 +Jinja2==2.10 +lazy-object-proxy==1.3.1 +libsass==0.14.5 +Mako==1.0.7 +MarkupSafe==1.0 +marshmallow==2.15.3 +mccabe==0.6.1 +more-itertools==4.2.0 +mypy==0.620 +mypy-extensions==0.3.0 +parso==0.3.1 +PasteDeploy==1.5.2 +pathtools==0.1.2 +pexpect==4.6.0 +pickleshare==0.7.4 +plaster==1.0 +plaster-pastedeploy==0.6 +pluggy==0.6.0 +prometheus-client==0.3.0 +prompt-toolkit==1.0.15 +psycopg2==2.7.5 +ptyprocess==0.6.0 +publicsuffix2==2.20160818 +py==1.5.4 +pyaml==17.12.1 +pycodestyle==2.4.0 +pycparser==2.18 +pydocstyle==2.1.1 +pyflakes==2.0.0 +Pygments==2.2.0 +pylama==7.4.3 +pylama-pylint==3.0.1 +pylint==1.7.5 +pyramid==1.9.2 +pyramid-debugtoolbar==4.4 +pyramid-ipython==0.2 +pyramid-jinja2==2.7 +pyramid-mako==1.0.2 +pyramid-session-redis==1.4.1 +pyramid-tm==2.2 +pyramid-webassets==0.9 +pytest==3.6.3 +pytest-mock==1.10.0 +python-dateutil==2.7.3 +python-editor==1.0.3 +PyYAML==3.13 +redis==2.10.6 +repoze.lru==0.7 +requests==2.19.1 +simplegeneric==0.8.1 +simplejson==3.16.0 +six==1.11.0 +snowballstemmer==1.2.1 +SQLAlchemy==1.2.10 +SQLAlchemy-Utils==0.33.3 +stripe==2.0.1 +testing.common.database==2.0.3 +testing.redis==1.1.1 +traitlets==4.3.2 +transaction==2.2.1 +translationstring==1.3 +typed-ast==1.1.0 +urllib3==1.23 +venusian==1.1.0 +waitress==1.1.0 +watchdog==0.8.3 +wcwidth==0.1.7 +webargs==4.0.0 +webassets==0.12.1 +webencodings==0.5.1 +WebOb==1.8.2 +WebTest==2.0.30 +wrapt==1.10.11 +zope.deprecation==4.3.0 +zope.interface==4.5.0 +zope.sqlalchemy==1.0 diff --git a/tildes/scripts/__init__.py b/tildes/scripts/__init__.py new file mode 100644 index 0000000..d679a7a --- /dev/null +++ b/tildes/scripts/__init__.py @@ -0,0 +1 @@ +"""Contains standalone scripts that exist outside the app.""" diff --git a/tildes/scripts/breached_passwords.py b/tildes/scripts/breached_passwords.py new file mode 100644 index 0000000..f588809 --- /dev/null +++ b/tildes/scripts/breached_passwords.py @@ -0,0 +1,177 @@ +"""Command-line tools for managing a breached-passwords bloom filter. + +This tool will help with creating and updating a bloom filter in Redis (using +ReBloom: https://github.com/RedisLabsModules/rebloom) to hold hashes for +passwords that have been revealed through data breaches (to prevent users from +using these passwords here). The dumps are likely primarily sourced from Troy +Hunt's "Pwned Passwords" files: +https://haveibeenpwned.com/Passwords + +Specifically, the commands in this tool allow building the bloom filter +somewhere else, then the RDB file can be transferred to the production server. +Note that it is expected that a separate redis server instance is running +solely for holding this bloom filter. Replacing the RDB file will result in all +other keys being lost. + +Expected usage of this tool should look something like: + +On the machine building the bloom filter: + python breached_passwords.py init --estimate 350000000 + python breached_passwords.py addhashes pwned-passwords-1.0.txt + python breached_passwords.py addhashes pwned-passwords-update-1.txt + +Then the RDB file can simply be transferred to the production server, +overwriting any previous RDB file. + +""" + +import subprocess +from typing import Any + +import click +from redis import ResponseError, StrictRedis + +from tildes.lib.password import ( + BREACHED_PASSWORDS_BF_KEY, + BREACHED_PASSWORDS_REDIS_SOCKET, +) + + +REDIS = StrictRedis(unix_socket_path=BREACHED_PASSWORDS_REDIS_SOCKET) + + +def generate_redis_protocol(*elements: Any) -> str: + """Generate a command in the Redis protocol from the specified elements. + + Based on the example Ruby code from + https://redis.io/topics/mass-insert#generating-redis-protocol + """ + command = f'*{len(elements)}\r\n' + + for element in elements: + element = str(element) + command += f'${len(element)}\r\n{element}\r\n' + + return command + + +@click.group() +def cli() -> None: + """Create a functionality-less command group to attach subcommands to.""" + pass + + +def validate_init_error_rate(ctx: Any, param: Any, value: Any) -> float: + """Validate the --error-rate arg for the init command.""" + # pylint: disable=unused-argument + if not 0 < value < 1: + raise click.BadParameter('error rate must be a float between 0 and 1') + + return value + + +@cli.command(help='Initialize a new empty bloom filter') +@click.option( + '--estimate', + required=True, + type=int, + help='Expected number of passwords that will be added', +) +@click.option( + '--error-rate', + default=0.01, + show_default=True, + help='Bloom filter desired false positive ratio', + callback=validate_init_error_rate, +) +@click.confirmation_option( + prompt='Are you sure you want to clear any existing bloom filter?', +) +def init(estimate: int, error_rate: float) -> None: + """Initialize a new bloom filter (destroying any existing one). + + It generally shouldn't be necessary to re-init a new bloom filter very + often with this command, only if the previous one was created with too low + of an estimate for number of passwords, or to change to a different false + positive rate. For choosing an estimate value, according to the ReBloom + documentation: "Performance will begin to degrade after adding more items + than this number. The actual degradation will depend on how far the limit + has been exceeded. Performance will degrade linearly as the number of + entries grow exponentially." + """ + REDIS.delete(BREACHED_PASSWORDS_BF_KEY) + + # BF.RESERVE {key} {error_rate} {size} + REDIS.execute_command( + 'BF.RESERVE', + BREACHED_PASSWORDS_BF_KEY, + error_rate, + estimate, + ) + + click.echo( + 'Initialized bloom filter with expected size of {:,} and false ' + 'positive rate of {}%' + .format(estimate, error_rate * 100) + ) + + +@cli.command(help='Add hashes from a file to the bloom filter') +@click.argument('filename', type=click.Path(exists=True, dir_okay=False)) +def addhashes(filename: str) -> None: + """Add all hashes from a file to the bloom filter. + + This uses the method of generating commands in Redis protocol and feeding + them into an instance of `redis-cli --pipe`, as recommended in + https://redis.io/topics/mass-insert + """ + # make sure the key exists and is a bloom filter + try: + REDIS.execute_command('BF.DEBUG', BREACHED_PASSWORDS_BF_KEY) + except ResponseError: + click.echo('Bloom filter is not set up properly - run init first.') + raise click.Abort + + # call wc to count the number of lines in the file for the progress bar + click.echo('Determining hash count...') + result = subprocess.run(['wc', '-l', filename], stdout=subprocess.PIPE) + line_count = int(result.stdout.split(b' ')[0]) + + progress_bar: Any = click.progressbar(length=line_count) + update_interval = 100_000 + + click.echo('Adding {:,} hashes to bloom filter...'.format(line_count)) + + redis_pipe = subprocess.Popen( + ['redis-cli', '-s', BREACHED_PASSWORDS_REDIS_SOCKET, '--pipe'], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + encoding='utf-8', + ) + + for count, line in enumerate(open(filename), start=1): + hashval = line.strip().lower() + + # the Pwned Passwords hash lists now have a frequency count for each + # hash, which is separated from the hash with a colon, so we need to + # handle that if it's present + hashval = hashval.split(':')[0] + + command = generate_redis_protocol( + 'BF.ADD', BREACHED_PASSWORDS_BF_KEY, hashval) + redis_pipe.stdin.write(command) + + if count % update_interval == 0: + progress_bar.update(update_interval) + + # call SAVE to update the RDB file + REDIS.save() + + # manually finish the progress bar so it shows 100% and renders properly + progress_bar.finish() + progress_bar.render_progress() + progress_bar.render_finish() + + +if __name__ == '__main__': + cli() diff --git a/tildes/scripts/clean_private_data.py b/tildes/scripts/clean_private_data.py new file mode 100644 index 0000000..79e9284 --- /dev/null +++ b/tildes/scripts/clean_private_data.py @@ -0,0 +1,124 @@ +"""Script for cleaning up private/deleted data. + +Other things that should probably be added here eventually: + - Delete individual votes on comments/topics after voting has been closed + - Delete which users tagged comments after tagging has been closed + - Delete old used invite codes (30 days after used?) +""" + +from datetime import datetime, timedelta +import logging + +from sqlalchemy.orm.session import Session + +from tildes.lib.database import get_session_from_config +from tildes.models.comment import Comment +from tildes.models.log import Log +from tildes.models.topic import Topic, TopicVisit + + +# sensitive data older than this should be removed +RETENTION_PERIOD = timedelta(days=30) + + +def clean_all_data(config_path: str) -> None: + """Clean all private/deleted data. + + This should generally be the only function called in most cases, and will + initiate the full cleanup process. + """ + db_session = get_session_from_config(config_path) + + cleaner = DataCleaner(db_session, RETENTION_PERIOD) + cleaner.clean_all() + + +class DataCleaner(): + """Container class for all methods related to cleaning up old data.""" + + def __init__( + self, + db_session: Session, + retention_period: timedelta, + ) -> None: + """Create a new DataCleaner.""" + self.db_session = db_session + self.retention_cutoff = datetime.now() - retention_period + + def clean_all(self) -> None: + """Call all the cleanup functions.""" + logging.info( + f'Cleaning up all data (retention cutoff {self.retention_cutoff})') + + self.delete_old_log_entries() + self.delete_old_topic_visits() + + self.clean_old_deleted_comments() + self.clean_old_deleted_topics() + + def delete_old_log_entries(self) -> None: + """Delete all log entries older than the retention cutoff. + + Note that this will also delete all entries from the child tables that + inherit from Log (LogTopics, etc.). + """ + deleted = ( + self.db_session.query(Log) + .filter(Log.event_time <= self.retention_cutoff) + .delete(synchronize_session=False) + ) + self.db_session.commit() + logging.info(f'Deleted {deleted} old log entries.') + + def delete_old_topic_visits(self) -> None: + """Delete all topic visits older than the retention cutoff.""" + deleted = ( + self.db_session.query(TopicVisit) + .filter(TopicVisit.visit_time <= self.retention_cutoff) + .delete(synchronize_session=False) + ) + self.db_session.commit() + logging.info(f'Deleted {deleted} old topic visits.') + + def clean_old_deleted_comments(self) -> None: + """Clean the data of old deleted comments. + + Change the comment's author to the "unknown user" (id 0), and delete + its contents. + """ + updated = ( + self.db_session.query(Comment) + .filter( + Comment.deleted_time <= self.retention_cutoff) # type: ignore + .update({ + 'user_id': 0, + 'markdown': '', + 'rendered_html': '', + }, synchronize_session=False) + ) + self.db_session.commit() + logging.info(f'Cleaned {updated} old deleted comments.') + + def clean_old_deleted_topics(self) -> None: + """Clean the data of old deleted topics. + + Change the topic's author to the "unknown user" (id 0), and delete its + title, contents, tags, and metadata. + """ + updated = ( + self.db_session.query(Topic) + .filter( + Topic.deleted_time <= self.retention_cutoff) # type: ignore + .update({ + 'user_id': 0, + 'title': '', + 'topic_type': 'TEXT', + 'markdown': None, + 'rendered_html': None, + 'link': None, + 'content_metadata': None, + '_tags': [], + }, synchronize_session=False) + ) + self.db_session.commit() + logging.info(f'Cleaned {updated} old deleted topics.') diff --git a/tildes/scripts/initialize_db.py b/tildes/scripts/initialize_db.py new file mode 100644 index 0000000..5b5cb58 --- /dev/null +++ b/tildes/scripts/initialize_db.py @@ -0,0 +1,81 @@ +"""Script for doing the initial setup of database tables.""" + +import os +import subprocess +from typing import Optional + +from alembic import command +from alembic.config import Config +from sqlalchemy.engine import Connectable, Engine + +from tildes.lib.database import get_session_from_config +from tildes.models import DatabaseModel +from tildes.models.group import Group +from tildes.models.log import Log +from tildes.models.user import User + + +def initialize_db( + config_path: str, + alembic_config_path: Optional[str] = None, +) -> None: + """Load the app config and create the database tables.""" + db_session = get_session_from_config(config_path) + engine = db_session.bind + + create_tables(engine) + + run_sql_scripts_in_dir('sql/init/', engine) + + # if an Alembic config file wasn't specified, assume it's alembic.ini in + # the same directory + if not alembic_config_path: + path = os.path.split(config_path)[0] + alembic_config_path = os.path.join(path, 'alembic.ini') + + # mark current Alembic revision in db so migrations start from this point + alembic_cfg = Config(alembic_config_path) + command.stamp(alembic_cfg, 'head') + + +def create_tables(connectable: Connectable) -> None: + """Create the database tables.""" + # tables to skip (due to inheritance or other need to create manually) + excluded_tables = Log.INHERITED_TABLES + + tables = [ + table for table in DatabaseModel.metadata.tables.values() + if table.name not in excluded_tables + ] + DatabaseModel.metadata.create_all(connectable, tables=tables) + + +def run_sql_scripts_in_dir(path: str, engine: Engine) -> None: + """Run all sql scripts in a directory.""" + for root, _, files in os.walk(path): + sql_files = [ + filename for filename in files + if filename.endswith('.sql') + ] + for sql_file in sql_files: + subprocess.call([ + 'psql', + '-U', engine.url.username, + '-f', os.path.join(root, sql_file), + engine.url.database, + ]) + + +def insert_dev_data(config_path: str) -> None: + """Load the app config and insert some "starter" data for a dev version.""" + session = get_session_from_config(config_path) + + session.add_all([ + User('TestUser', 'password'), + Group( + 'testing', + 'An automatically created group to use for testing purposes', + ), + ]) + + session.commit() diff --git a/tildes/scripts/site-icons-spriter/css_template.jinja2 b/tildes/scripts/site-icons-spriter/css_template.jinja2 new file mode 100644 index 0000000..c58b9fa --- /dev/null +++ b/tildes/scripts/site-icons-spriter/css_template.jinja2 @@ -0,0 +1,22 @@ +{% for r, ratio in ratios.items() %} +{% if ratio.ratio == 1.0 %} +.topic-icon { + background-image: url('/images/{{ ratio.sprite_path }}?{{ hash }}'); + background-size: 0 0; +} +{% else %} +@media screen and (min-device-pixel-ratio: {{ ratio.ratio }}), screen and (min-resolution: {{ ratio.ratio }}dppx) { + .topic-icon { + background-image: url('/images/{{ ratio.sprite_path }}?{{ hash }}'); + } +} +{% endif %} +{% endfor %} + +{% for image in images %} +.topic-icon-{{ image.label }} { + background-position: {{ image.x ~ ('px' if image.x) }} {{ image.y ~ ('px' if image.y) }}; + background-size: {{ width }}px {{ height }}px; + border: 0; +} +{% endfor %} diff --git a/tildes/scss/_base.scss b/tildes/scss/_base.scss new file mode 100644 index 0000000..682789e --- /dev/null +++ b/tildes/scss/_base.scss @@ -0,0 +1,208 @@ +// Styles for base elements only (no classes, IDs, etc.) +// Includes overrides for Spectre.css base element styles as well + +html { + font-size: $html-font-size; +} + +a { + color: $blue; + text-decoration: none; + + &:visited { + color: $violet; + } +} + +// this is probably unnecessary, but I'm running into specificity conflicts +// with the colors being set in the theme - `body code` is overriding +// `a code` and not styling elements inside as links. +body a { + code { + color: $blue; + } + + &:visited code { + color: $violet; + } + + &:hover code { + text-decoration: underline; + } +} + +blockquote { + margin-left: 1rem; + max-width: $paragraph-max-width - 1rem; // subtract the left margin + margin-right: 0; + + border-left: 1px dotted; + + // nested blockquotes need reduced margin/padding + & > blockquote { + margin: 0; + margin-bottom: 0.2rem; + padding-top: 0; + padding-bottom: 0; + } +} + +body { + position: relative; + min-height: 100vh; + + @include font-shrink-on-mobile(0.8rem); +} + +code { + display: inline-block; + line-height: 1rem; +} + +fieldset { + margin: 1rem; + margin-right: 0; + padding-left: 0.4rem; + border-left: 3px solid; +} + +figcaption { + font-style: italic; + font-weight: bold; + font-size: 0.6rem; + margin-bottom: 0.4rem; +} + +figure { + display: inline-block; + width: auto; + text-align: center; + margin: 0.4rem; + padding: 0.4rem; + border: 1px solid; + + @media (min-width: $size-sm) { + float: right; + } +} + +form { + max-width: 40rem; +} + +h1, h2, h3, h4, h5, h6 { + margin-bottom: 0.4rem; +} + +h1 { + font-size: 1.2rem; +} + +h2 { + font-size: 1.1rem; +} + +h3 { + font-size: 1rem; +} + +h4 { + font-size: 0.9rem; +} + +h5 { + font-size: 0.8rem; +} + +h6 { + font-size: 0.7rem; +} + +hr { + border-style: solid; + border-width: 0 0 1px 0; +} + +legend { + font-size: 0.8rem; + margin-left: -1.4rem; + margin-bottom: 0; +} + +main { + padding: 0.2rem; + overflow: hidden; + max-width: 100vw; + + @media (min-width: $size-md) { + padding: 0.4rem; + } + + @media (min-width: $show-sidebar-width) { + max-width: calc(100vw - #{$sidebar-width} - 1.2rem); + } +} + +menu { + list-style-type: none; +} + +ol { + list-style-position: outside; + margin: 0 0 1rem 2rem; + + li { + margin-top: 0.2rem; + max-width: $paragraph-max-width - 2rem; + } +} + +p { + max-width: $paragraph-max-width; + margin-bottom: 0.4rem; +} + +p:last-child { + margin-bottom: 0; +} + +pre { + overflow: auto; +} + +section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 2px solid; +} + +summary { + cursor: pointer; +} + +// table, td, th styles copied from Spectre.css to avoid needing to add .table +// and .table-striped classes to all tables in user posts +table { + border-collapse: collapse; + border-spacing: 0; + width: 100%; +} + +td, th { + border-bottom: $border-width solid; + padding: $unit-3 $unit-2; +} + +th { + border-bottom-width: $border-width-lg; +} + +ul { + list-style-position: outside; + margin: 0.4rem 0 0.4rem 1rem; + + li { + margin-top: 0.2rem; + max-width: $paragraph-max-width - 1rem; + } +} diff --git a/tildes/scss/_layout.scss b/tildes/scss/_layout.scss new file mode 100644 index 0000000..543c7cc --- /dev/null +++ b/tildes/scss/_layout.scss @@ -0,0 +1,88 @@ +body { + @supports (display: grid) { + display: grid; + grid-template-rows: auto 1fr auto; + + grid-template-columns: 1fr minmax(auto, $main-max-width) 1fr; + grid-template-areas: + ". header ." + ". main ." + ". footer ."; + grid-row-gap: 0.2rem; + + @media (min-width: $show-sidebar-width) { + grid-template-columns: 1fr minmax(auto, $main-max-width) auto 1fr; + grid-template-areas: + ". header header ." + ". main sidebar ." + ". footer footer ."; + grid-gap: 0.4rem; + } + } +} + +.l-no-sidebar { + grid-template-columns: 1fr minmax(auto, calc(#{$main-max-width} + #{$sidebar-width} + 0.4rem)) 1fr; + grid-template-areas: + ". header ." + ". main ." + ". footer ."; + + #sidebar { + display: none; + } + + @media (min-width: $show-sidebar-width) { + main { + max-width: calc(100vw - 0.4rem); + } + } + + // hide the sidebar button and show user info + .site-header-sidebar-button { + display: none; + } + + #site-header .logged-in-user-info { + display: block; + } +} + +#site-header { + grid-area: header; + + display: flex; + align-items: center; + + max-width: 100vw; + + padding: 0.2rem; + padding-bottom: 0; + + @media (min-width: $size-md) { + padding: 0.4rem; + padding-bottom: 0; + } +} + +#site-footer { + grid-area: footer; +} + +#sidebar { + /* hidden by default, show on wider screens */ + display: none; + @media (min-width: $show-sidebar-width) { + display: block; + grid-area: sidebar; + } + + width: $sidebar-width; + min-width: $sidebar-width; + + padding: 0.4rem; +} + +body > main { + grid-area: main; +} diff --git a/tildes/scss/_mixins.scss b/tildes/scss/_mixins.scss new file mode 100644 index 0000000..afba587 --- /dev/null +++ b/tildes/scss/_mixins.scss @@ -0,0 +1,18 @@ +// shrinks a font size by 0.1rem on mobile screen sizes +@mixin font-shrink-on-mobile($base-size) { + font-size: $base-size - 0.1rem; + @media (min-width: $size-md) { + font-size: $base-size; + } +} + + +// makes sure the element is the "minimum touch size" on mobile +@mixin min-touch-size() { + min-width: $min-touch-size; + min-height: $min-touch-size; + @media (min-width: $size-md) { + min-width: 0; + min-height: 0; + } +} diff --git a/tildes/scss/_spectre_variables.scss b/tildes/scss/_spectre_variables.scss new file mode 100644 index 0000000..7591b84 --- /dev/null +++ b/tildes/scss/_spectre_variables.scss @@ -0,0 +1,20 @@ +// This file should be imported at the top of Spectre's _variables.scss +// Since Spectre uses !default declarations, these values won't be overwritten + +@import 'variables'; + +$primary-color: $blue; + +$success-color: $green; +$warning-color: $orange; +$error-color: $red; + +$border-radius: 0; + +// Responsive breakpoints +$size-xs: 480px; +$size-sm: 600px; +$size-md: $show-sidebar-width; +$size-lg: 960px; +$size-xl: 1080px; +$size-2x: 1200px; diff --git a/tildes/scss/_themes.scss b/tildes/scss/_themes.scss new file mode 100644 index 0000000..41ccb03 --- /dev/null +++ b/tildes/scss/_themes.scss @@ -0,0 +1,280 @@ +// This file should only contain rules that need to differ between the +// different themes, defined inside the `theme-dependent` mixin below. +// Note that all rules inside the mixin will be included in the compiled CSS +// once for each theme, so they should be kept as minimal as possible. + +@mixin commenttag($color, $is-light) { + @if $is-light { + background-color: $color; + } + @else { + background-color: transparent; + color: $color; + border: 1px solid $color; + } +} + +@mixin theme-dependent($background-color, $background-alt-color, $text-color, $text-highlight-color, $text-secondary-color, $border-color) { + // set $is-light as a bool for whether $background-color seems light or dark + $is-light: lightness($background-color) > 50; + + $text-mid-color: mix($text-color, $text-secondary-color); + $text-extreme-color: if($is-light, #000, #fff); + + // if $background-color is light, make the input background even lighter, + // but if it's dark, make input background even darker + $input-background-color: if($is-light, lighten($background-color, 3%), darken($background-color, 3%)); + + background-color: $background-alt-color; + color: $text-color; + + blockquote { + background-color: $background-alt-color; + border-color: $text-highlight-color; + } + + code, pre { + background-color: $background-alt-color; + color: $text-highlight-color; + } + + fieldset { + border-color: $border-color; + } + + figure { + border-color: $border-color; + } + + main { + background-color: $background-color; + } + + section { + border-color: $border-color; + } + + .tab-listing-order { + border-color: $border-color; + } + + .logged-in-user-username { + color: $text-color; + } + + .sidebar-controls { + background-color: $background-alt-color; + } + + .site-header-context, .site-header-username { + color: $text-color; + } + + .site-header-logo { + color: $text-highlight-color; + } + + #sidebar { + background-color: $background-color; + } + + .btn-comment-collapse { + color: $text-secondary-color; + border-color: $border-color; + } + + .comment { + border-color: $border-color; + + header { + background-color: $background-alt-color; + color: $text-highlight-color; + } + } + + .comment[data-comment-depth="0"] { + border-color: $border-color; + } + + .comment-nav-link { + color: $text-secondary-color; + } + + .comment-tags { + .label-comment-tag-joke { @include commenttag($comment-tag-joke-color, $is-light); } + .label-comment-tag-noise { @include commenttag($comment-tag-noise-color, $is-light); } + .label-comment-tag-offtopic { @include commenttag($comment-tag-offtopic-color, $is-light); } + .label-comment-tag-troll { @include commenttag($comment-tag-troll-color, $is-light); } + .label-comment-tag-flame { @include commenttag($comment-tag-flame-color, $is-light); } + } + + .is-comment-collapsed { + header { + background-color: $background-color; + color: $text-secondary-color; + + .link-user { + color: $text-secondary-color; + } + } + } + + .is-comment-deleted, .is-comment-removed { + color: $text-secondary-color; + } + + .is-comment-new { + .comment-text { + color: $text-highlight-color; + } + } + + .divider { + border-color: $border-color; + } + + .divider[data-content]::after { + color: $text-color; + background-color: $background-color; + } + + .empty-subtitle { + color: $text-secondary-color; + } + + .form-input { + background-color: $input-background-color; + color: $text-color; + } + + .form-input:not(:focus) { + border-color: $border-color; + } + + .form-select { + border-color: $border-color; + } + + .form-select:not([multiple]):not([size]) { + background-color: $input-background-color; + } + + .message { + border-color: $border-color; + + header { + background-color: $background-alt-color; + color: $text-highlight-color; + } + } + + .label-topic-tag { + color: $text-mid-color; + + a { + color: $text-mid-color; + } + } + + .post-button { + color: $text-secondary-color; + + &:hover { + color: $text-extreme-color; + } + } + + .post-button-used { + color: $violet; + } + + td { + border-color: $border-color; + } + + th { + border-color: $text-highlight-color; + } + + tbody tr:nth-of-type(2n+1) { + background-color: $background-alt-color; + } + + .text-secondary { + color: $text-secondary-color; + } + + .toast { + background-color: $background-alt-color; + border-color: $border-color; + color: $text-highlight-color; + } + + // Toasts should have colored border + text for dark themes, instead of a + // brightly colored background + @if ($is-light == false) { + .toast-warning { + border-color: $orange; + color: $orange; + background-color: transparent; + } + } + + .topic-listing { + & > li:nth-of-type(2n) { + background-color: mix($background-color, $background-alt-color); + color: mix($text-color, $text-highlight-color); + } + } + + .topic { + border-color: $border-color; + } + + .topic-content-metadata { + color: $text-secondary-color; + } + + .topic-full-byline { + color: $text-secondary-color; + } + + .topic-info { + color: $text-mid-color; + } + + .topic-log-entry-time { + color: $text-secondary-color; + } + + .topic-text-excerpt { + color: $text-secondary-color; + + summary::after { + color: $text-secondary-color; + } + + &[open] { + color: $text-color; + } + } +} + +body { + @include theme-dependent($background-color: #fff, $background-alt-color: #eee, $text-color: #333, $text-highlight-color: #222, $text-secondary-color: #999, $border-color: #ccc); +} + +body.theme-light { + @include theme-dependent($background-color: $bg-lightest, $background-alt-color: $bg-light, $text-color: $fg-dark, $text-highlight-color: $fg-darkest, $text-secondary-color: $fg-lightest, $border-color: #cbc5b6); +} + +body.theme-dark { + @include theme-dependent($background-color: $bg-darkest, $background-alt-color: $bg-dark, $text-color: $fg-light, $text-highlight-color: $fg-lightest, $text-secondary-color: $fg-darkest, $border-color: #33555e); +} + +body.theme-black { + @include theme-dependent($background-color: #000, $background-alt-color: #222, $text-color: #ccc, $text-highlight-color: #ddd, $text-secondary-color: #888, $border-color: #444); +} + +// Note: if you add a new theme, you may also want to add a new theme-color +// meta tag inside the base.jinja2 template, so mobile browsers can match diff --git a/tildes/scss/_variables.scss b/tildes/scss/_variables.scss new file mode 100644 index 0000000..04f9a37 --- /dev/null +++ b/tildes/scss/_variables.scss @@ -0,0 +1,53 @@ +// Color palette is Ethan Schoonover's "Solarized" +// (http://ethanschoonover.com/solarized) + +// "Official" color names from Solarized +$base03: #002b36; +$base02: #073642; +$base01: #586e75; +$base00: #657b83; +$base0: #839496; +$base1: #93a1a1; +$base2: #eee8d5; +$base3: #fdf6e3; + +$blue: #268bd2; +$cyan: #2aa198; +$green: #859900; +$magenta: #d33682; +$orange: #cb4b16; +$red: #dc322f; +$violet: #6c71c4; +$yellow: #b58900; + +// More usable color names for monotone colors +$bg-darkest: $base03; +$bg-dark: $base02; +$bg-light: $base2; +$bg-lightest: $base3; + +$fg-darkest: $base01; +$fg-dark: $base00; +$fg-light: $base0; +$fg-lightest: $base1; + +// Colors for comment tags +$comment-tag-joke-color: $cyan; +$comment-tag-noise-color: $yellow; +$comment-tag-offtopic-color: $orange; +$comment-tag-troll-color: $green; +$comment-tag-flame-color: $red; + +$sidebar-width: 300px; + +// Viewport width that the sidebar is shown by default +$show-sidebar-width: 840px; + +// Minimum size of buttons on small screens +$min-touch-size: 26px; + +// Maximum width of the
element +$main-max-width: 70rem; + +// Maximum width to allow on "paragraph-like" text +$paragraph-max-width: 40rem; diff --git a/tildes/scss/modules/_btn.scss b/tildes/scss/modules/_btn.scss new file mode 100644 index 0000000..0dc0641 --- /dev/null +++ b/tildes/scss/modules/_btn.scss @@ -0,0 +1,121 @@ +.btn { + @include min-touch-size; + + display: inline-flex; + align-items: center; + justify-content: center; + + font-size: 0.6rem; + font-weight: bold; + + background-color: inherit; + + &:hover { + background-color: rgba($blue, 0.2); + } +} + +.btn.btn-sm { + font-size: 0.6rem; +} + +.btn-link-minimal { + display: inline; + height: auto; + width: auto; + padding: 0; + border: 0; + font-weight: normal; + + @media (min-width: $size-md) { + min-height: 0; + } +} + +.btn-used { + border-color: darken($violet, 3%); + color: $violet; + + &:hover { + background-color: darken($violet, 3%); + border-color: darken($violet, 8%); + color: #fff; + } +} + +.btn-comment-collapse { + @include min-touch-size; + + height: 100%; + line-height: 100%; + padding: 0; + + font-weight: normal; + + border-left-width: 0; + margin-right: 0.4rem; + @media (min-width: $size-md) { + border-left-width: 1px; + margin-right: 0.2rem; + min-width: 0.8rem; + } + + &:hover { + color: $blue; + } +} + +.btn-comment-tag { + display: inline-flex; + align-items: center; + margin: 0.4rem; + + font-size: 0.6rem; + font-weight: bold; + text-transform: capitalize; + + cursor: pointer; + + &.btn { + height: 1rem; + text-transform: capitalize; + } + + &.btn-used { + border: 1px solid; + } +} + +@mixin tagbutton($color) { + color: $color; + border-color: $color; + + &:hover { + color: $color; + } + + &.btn-used:hover { + background-color: $color; + color: #fff; + } +} + +.btn-comment-tag-joke { + @include tagbutton($comment-tag-joke-color); +} + +.btn-comment-tag-noise { + @include tagbutton($comment-tag-noise-color); +} + +.btn-comment-tag-offtopic { + @include tagbutton($comment-tag-offtopic-color); +} + +.btn-comment-tag-troll { + @include tagbutton($comment-tag-troll-color); +} + +.btn-comment-tag-flame { + @include tagbutton($comment-tag-flame-color); +} diff --git a/tildes/scss/modules/_comment.scss b/tildes/scss/modules/_comment.scss new file mode 100644 index 0000000..da463eb --- /dev/null +++ b/tildes/scss/modules/_comment.scss @@ -0,0 +1,131 @@ +.comment { + border-left: 1px solid; + margin-bottom: 0.4rem; + + header { + display: flex; + align-items: baseline; + font-size: 0.7rem; + line-height: 0.7rem; + + // no padding on mobile - the collapse button will do the job + padding: 0 0.2rem 0 0; + @media (min-width: $size-md) { + padding: 0.2rem; + } + + & > time { + margin-left: 0.4rem; + font-size: 0.6rem; + } + } +} + +.comment[data-comment-depth="0"] { + border-bottom: 1px solid; +} + +.comment-user-info { + margin-left: 0.2rem; +} + +.comment-edited-time { + font-size: 0.6rem; + font-style: italic; + margin-left: 0.4rem; +} + +.comment-nav-link { + font-size: 0.6rem; + margin-left: 0.4rem; +} + +.comment-replies { + margin-left: 0.4rem; + padding-top: 0.4rem; + + @media (min-width: $size-md) { + margin-left: 1rem; + } +} + +.comment-tags { + display: inline-flex; + align-items: center; + margin: 0 0 0 0.4rem; + list-style-type: none; + + li { + margin-top: 0; + } +} + +.comment-tag-buttons { + display: flex; + margin: 0; + padding: 0 1rem; + justify-content: space-between; + align-items: center; + + @media (min-width: $size-md) { + justify-content: left; + } +} + +.comment-tag-count { + font-weight: bold; + font-size: 0.5rem; + margin-right: 0.4rem; +} + +.comment-text { + padding: 0.2rem; + padding-left: 0.4rem; + overflow: auto; +} + +.comment-votes { + font-size: 0.6rem; + font-weight: bold; + margin: 0 0.4rem; +} + +.is-comment-by-op { + & > .comment-itself { + margin-left: -2px; + border-left: 3px solid !important; + + .comment-user-info { + font-weight: bold; + } + } +} + +.is-comment-collapsed { + &[data-comment-depth="0"] { + border-bottom: 0; + } + + .comment-text, .post-buttons, .comment-replies, .comment-votes, .comment-tags { + display: none; + } +} + +.is-comment-deleted, .is-comment-removed { + font-size: 0.7rem; + font-style: italic; +} + +.is-comment-mine { + & > .comment-itself { + margin-left: -2px; + border-left: 3px solid $violet !important; + } +} + +.is-comment-new { + & > .comment-itself { + margin-left: -2px; + border-left: 3px solid $orange !important; + } +} diff --git a/tildes/scss/modules/_divider.scss b/tildes/scss/modules/_divider.scss new file mode 100644 index 0000000..e80428b --- /dev/null +++ b/tildes/scss/modules/_divider.scss @@ -0,0 +1,3 @@ +.divider, .divider[data-content] { + margin: 1rem; +} diff --git a/tildes/scss/modules/_empty.scss b/tildes/scss/modules/_empty.scss new file mode 100644 index 0000000..ddd8275 --- /dev/null +++ b/tildes/scss/modules/_empty.scss @@ -0,0 +1,4 @@ +.empty { + background: inherit; + color: inherit; +} diff --git a/tildes/scss/modules/_form.scss b/tildes/scss/modules/_form.scss new file mode 100644 index 0000000..1c473b3 --- /dev/null +++ b/tildes/scss/modules/_form.scss @@ -0,0 +1,75 @@ +.form-narrow { + max-width: 20rem; +} + +select.form-select:not([multiple]) { + // would be better to implement autoprefixer to do this + -moz-appearance: none; + -webkit-appearance: none; +} + +.form-listing-options { + .form-group { + margin-top: 0.2rem; + margin-left: 0.4rem; + } + + label, select { + font-size: 0.6rem; + } + + select { + width: auto; + height: 1.4rem; + padding: 0 0 0 0.2rem; + } +} + +.form-buttons { + display: flex; + flex-direction: row-reverse; + justify-content: flex-start; + + margin: 0.2rem 0; + max-width: 40rem; + + button { + margin-left: 0.4rem; + } +} + +textarea.form-input { + height: 8rem; + line-height: 1.5; + max-width: none; + transition: none; +} + +.form-status { + margin: auto 0; + font-size: 0.6rem; +} + +.form-status-error { + color: $red; +} + +.form-input { + max-width: 40rem; +} + +.form-input-note { + font-size: 0.6rem; + line-height: 0.9rem; + margin: 0.2rem; +} + +.form-label { + display: flex; + justify-content: space-between; + max-width: 40rem; + + a { + font-size: 0.6rem; + } +} diff --git a/tildes/scss/modules/_group.scss b/tildes/scss/modules/_group.scss new file mode 100644 index 0000000..d5a15bc --- /dev/null +++ b/tildes/scss/modules/_group.scss @@ -0,0 +1,42 @@ +.group-list { + .link-group { + font-weight: bold; + } + + .group-subscription { + flex-direction: column; + margin: 0; + } + + .group-subscription-count { + margin-bottom: 0.2rem; + } +} + +.group-list-description { + font-style: italic; + margin-left: 1rem; + font-size: 0.6rem; + line-height: 0.8rem; +} + +.group-subscription { + display: flex; + align-items: center; + margin: 1rem 0; + + .group-subscription-count, button { + flex: 1; // makes the two elements equal width + } + + .btn-used { + border: 0; + } +} + +.group-subscription-count { + white-space: nowrap; + font-size: 0.6rem; + text-align: center; + margin-right: 0.2rem; +} diff --git a/tildes/scss/modules/_heading.scss b/tildes/scss/modules/_heading.scss new file mode 100644 index 0000000..e28cb47 --- /dev/null +++ b/tildes/scss/modules/_heading.scss @@ -0,0 +1,3 @@ +.heading-main { + font-weight: bold; +} diff --git a/tildes/scss/modules/_input.scss b/tildes/scss/modules/_input.scss new file mode 100644 index 0000000..9940d70 --- /dev/null +++ b/tildes/scss/modules/_input.scss @@ -0,0 +1,3 @@ +.input-invite-code { + margin-bottom: 0.4rem; +} diff --git a/tildes/scss/modules/_label.scss b/tildes/scss/modules/_label.scss new file mode 100644 index 0000000..49215fc --- /dev/null +++ b/tildes/scss/modules/_label.scss @@ -0,0 +1,16 @@ +.label { + font-size: 0.6rem; +} + +.label-comment-tag { + font-size: 0.5rem; + font-weight: bold; + color: #fff; + text-transform: capitalize; +} + +.label-topic-tag { + background-color: transparent; + margin: 0 0.4rem 0 0; + white-space: nowrap; +} diff --git a/tildes/scss/modules/_link.scss b/tildes/scss/modules/_link.scss new file mode 100644 index 0000000..52418e5 --- /dev/null +++ b/tildes/scss/modules/_link.scss @@ -0,0 +1,5 @@ +a.link-user, a.link-group { + &:visited { + color: $blue; + } +} diff --git a/tildes/scss/modules/_listing.scss b/tildes/scss/modules/_listing.scss new file mode 100644 index 0000000..94210af --- /dev/null +++ b/tildes/scss/modules/_listing.scss @@ -0,0 +1,12 @@ +.listing-options { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-bottom: 0.4rem; + + // right-align the period dropdown at small sizes, left-align at larger + justify-content: flex-end; + @media (min-width: $size-md) { + justify-content: flex-start; + } +} diff --git a/tildes/scss/modules/_logged-in-user.scss b/tildes/scss/modules/_logged-in-user.scss new file mode 100644 index 0000000..fcd22df --- /dev/null +++ b/tildes/scss/modules/_logged-in-user.scss @@ -0,0 +1,24 @@ +.logged-in-user-info { + font-size: 0.8rem; + + a { + @include min-touch-size; + + display: flex; + align-items: center; + + @media (min-width: $size-md) { + justify-content: right; + } + } +} + +.logged-in-user-alert { + font-weight: bold; + font-size: 0.5rem; + color: $orange; + + &:visited { + color: $orange; + } +} diff --git a/tildes/scss/modules/_message.scss b/tildes/scss/modules/_message.scss new file mode 100644 index 0000000..e962f6a --- /dev/null +++ b/tildes/scss/modules/_message.scss @@ -0,0 +1,38 @@ +.message { + margin-bottom: 0.4rem; + border: 1px solid; + + header { + display: flex; + align-items: center; + padding: 0.2rem; + font-size: 0.7rem; + line-height: 0.9rem; + + .link-user { + margin-right: 0.2rem; + } + + time { + margin-left: 0.4rem; + font-size: 0.6rem; + } + } +} + +.message-text { + margin-left: 0.2rem; + overflow: auto; + padding: 0.2rem; +} + +.message-list-unread { + .message-list-subject { + font-weight: bold; + } +} + +.is-message-mine { + margin-left: -2px; + border-left: 3px solid $violet !important; +} diff --git a/tildes/scss/modules/_nav.scss b/tildes/scss/modules/_nav.scss new file mode 100644 index 0000000..2c552bd --- /dev/null +++ b/tildes/scss/modules/_nav.scss @@ -0,0 +1,25 @@ +.nav { + li { + font-size: 0.6rem; + font-weight: bold; + } + + .nav { + margin-left: 0; + + li { + font-size: 0.8rem; + font-weight: normal; + border-bottom: 0; + } + } + + .nav-item a { + color: $blue; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/tildes/scss/modules/_pagination.scss b/tildes/scss/modules/_pagination.scss new file mode 100644 index 0000000..0493f70 --- /dev/null +++ b/tildes/scss/modules/_pagination.scss @@ -0,0 +1,5 @@ +.page-item { + font-size: 0.6rem; + height: auto; + line-height: normal; +} diff --git a/tildes/scss/modules/_post-buttons.scss b/tildes/scss/modules/_post-buttons.scss new file mode 100644 index 0000000..d7a5fe1 --- /dev/null +++ b/tildes/scss/modules/_post-buttons.scss @@ -0,0 +1,38 @@ +.post-buttons { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + margin: 0; + padding: 0.2rem; + + // The buttons don't need to be spaced widely on a desktop + @media (min-width: $size-md) { + justify-content: left; + } + + // Combined with flex-wrap: wrap, this should put any form on its own line + form { + min-width: 100%; + } +} + +.post-button { + @include min-touch-size; + + display: flex; + padding: 0.2rem 0.4rem; + + justify-content: center; + align-items: center; + + font-weight: bold; + font-size: 0.6rem; + line-height: 0.6rem; + + cursor: pointer; +} + +.post-button-used { + text-decoration: underline; +} diff --git a/tildes/scss/modules/_post.scss b/tildes/scss/modules/_post.scss new file mode 100644 index 0000000..81269ee --- /dev/null +++ b/tildes/scss/modules/_post.scss @@ -0,0 +1,43 @@ +.post-listing { + list-style-type: none; + margin-left: 0; + + h2 { + font-size: 0.6rem; + } + + // override the normal nested list behaviors + .comment-text ol, .topic-text-excerpt ol { + list-style-type: decimal; + margin: 0 0 1rem 2rem; + } + + .comment-text ul, .topic-text-excerpt ul { + margin: 0.4rem 0 0.4rem 1rem; + } + + & > li { + margin-bottom: 1rem; + max-width: none; + } + + .is-topic-mine, .is-topic-official { + margin-left: -1px; + } +} + +.post-listing-notifications { + h2 { + font-size: 0.8rem; + line-height: 0.8rem; + margin-bottom: 0; + } + + h2 + article { + margin-top: 0.4rem; + } + + .btn-link-minimal { + margin-left: 0.4rem; + } +} diff --git a/tildes/scss/modules/_settings.scss b/tildes/scss/modules/_settings.scss new file mode 100644 index 0000000..a7f1417 --- /dev/null +++ b/tildes/scss/modules/_settings.scss @@ -0,0 +1,7 @@ +.settings-list { + list-style-type: none; + + li { + margin-bottom: 1rem; + } +} diff --git a/tildes/scss/modules/_sidebar.scss b/tildes/scss/modules/_sidebar.scss new file mode 100644 index 0000000..d7a56f9 --- /dev/null +++ b/tildes/scss/modules/_sidebar.scss @@ -0,0 +1,45 @@ +#sidebar { + p { + margin-bottom: 0.4rem; + line-height: 1rem; + } + + .btn { + width: 100%; + } + + .sidebar-controls .btn { + width: auto; + } +} + +.sidebar-controls { + display: flex; + @media (min-width: $show-sidebar-width) { + display: none; + } + + margin: -0.4rem; + margin-bottom: 0.4rem; + padding: 0.2rem 0.4rem; + align-items: center; + justify-content: space-between; +} + +.is-sidebar-displayed { + @media (max-width: $show-sidebar-width) { + + display: block !important; + position: absolute; + right: 0; + top: 0; + + min-width: 0 !important; + max-width: 90vw; + z-index: 1000; + min-height: 100vh; + height: 100%; + + border-left: 3px double; + } +} diff --git a/tildes/scss/modules/_site-footer.scss b/tildes/scss/modules/_site-footer.scss new file mode 100644 index 0000000..9e01cf8 --- /dev/null +++ b/tildes/scss/modules/_site-footer.scss @@ -0,0 +1,32 @@ +#site-footer { + padding: 0.2rem; + padding-bottom: 1rem; + + font-size: 0.6rem; + font-style: italic; + text-align: center; + + p { + max-width: none; + margin-bottom: 0.2rem; + line-height: 0.7rem; + } +} + +.site-footer-links { + display: flex; + justify-content: center; + + list-style-type: none; + margin: 0; + margin-top: 0.4rem; + font-style: normal; +} + +.site-footer-link { + margin: 0; + + & + & { + margin-left: 1rem; + } +} diff --git a/tildes/scss/modules/_site-header.scss b/tildes/scss/modules/_site-header.scss new file mode 100644 index 0000000..d095a6d --- /dev/null +++ b/tildes/scss/modules/_site-header.scss @@ -0,0 +1,44 @@ +#site-header { + .logged-in-user-info { + // hidden on small screens + display: none; + @media (min-width: $show-sidebar-width) { + display: block; + } + + margin-left: auto; + text-align: right; + } +} + +.site-header-context { + overflow: hidden; + text-overflow: ellipsis; +} + +.site-header-logo { + background-image: url(/favicon-32x32.png); + background-repeat: no-repeat; + padding-left: 40px; + padding-right: 8px; + line-height: 32px; + + font-size: 1.2rem; + font-weight: bold; + + &:hover, &:active, &:focus { + text-decoration: none; + } + + @media (min-width: $size-xs) { + margin-right: 1rem; + } +} + +.site-header-sidebar-button { + @media (min-width: $show-sidebar-width) { + display: none; + } + + margin-left: auto; +} diff --git a/tildes/scss/modules/_tab.scss b/tildes/scss/modules/_tab.scss new file mode 100644 index 0000000..0a824d4 --- /dev/null +++ b/tildes/scss/modules/_tab.scss @@ -0,0 +1,26 @@ +.tab-listing-order { + flex-wrap: nowrap; + padding-left: 0; + margin-top: 0; + + justify-content: space-around; + + font-size: 0.6rem; + + // have the tabs span the full width at small sizes + width: 100%; + @media (min-width: $size-md) { + width: auto; + } + + .tab-item { + white-space: nowrap; + + a { + margin-right: 0; + @media (min-width: $size-md) { + margin-right: 0.4rem; + } + } + } +} diff --git a/tildes/scss/modules/_text.scss b/tildes/scss/modules/_text.scss new file mode 100644 index 0000000..4a03812 --- /dev/null +++ b/tildes/scss/modules/_text.scss @@ -0,0 +1,4 @@ +.text-small { + font-size: 0.6rem; + line-height: 0.9rem; +} diff --git a/tildes/scss/modules/_time.scss b/tildes/scss/modules/_time.scss new file mode 100644 index 0000000..795658d --- /dev/null +++ b/tildes/scss/modules/_time.scss @@ -0,0 +1,17 @@ +// abbreviated timestamp - only displays on small screens +.time-responsive::after { + content: attr(data-abbreviated); + + @media (min-width: $size-lg) { + display: none; + } +} + +// full timestamp - hidden on small screens +.time-responsive-full { + display: none; + + @media (min-width: $size-lg) { + display: inline; + } +} diff --git a/tildes/scss/modules/_toast.scss b/tildes/scss/modules/_toast.scss new file mode 100644 index 0000000..879e93c --- /dev/null +++ b/tildes/scss/modules/_toast.scss @@ -0,0 +1,16 @@ +.toast { + margin: 1rem 0; + font-weight: bold; +} + +.toast-minor { + font-size: 0.6rem; + line-height: 0.9rem; + font-weight: normal; + margin: 0.4rem 0; + + h2 { + font-size: 0.7rem; + font-weight: bold; + } +} diff --git a/tildes/scss/modules/_topic.scss b/tildes/scss/modules/_topic.scss new file mode 100644 index 0000000..cd72e3a --- /dev/null +++ b/tildes/scss/modules/_topic.scss @@ -0,0 +1,272 @@ +.topic-listing { + list-style-type: none; + margin: 0; + + & > li { + margin: 0; + margin-bottom: 0.2rem; + max-width: none; + } +} + +.topic-listing-filter { + font-size: 0.6rem; + margin: 0 0 0.4rem 0.4rem; +} + +.topic { + display: grid; + grid-template-areas: + "title voting" + "metadata voting" + "content voting" + "info voting"; + grid-template-columns: 1fr auto; + + // set some minimum row heights on mobile to space them out a bit + grid-template-rows: + auto + minmax($min-touch-size, auto) + auto + minmax($min-touch-size, auto); + @media (min-width: $size-md) { + grid-template-rows: none; + } + + position: relative; + padding: 0.4rem; + + font-size: 0.6rem; + + header { + grid-area: title; + margin-bottom: 0.2rem; + display: flex; + min-height: 1rem; + } + + .topic-metadata { + grid-area: metadata; + } + + .topic-title { + display: inline; + margin: 0; + margin-right: 0.2rem; + font-size: 0.8rem; + } + + .topic-categories { + grid-area: category; + } + + .topic-info { + grid-area: info; + } + + .topic-text-excerpt { + grid-area: content; + } + + .topic-voting { + grid-area: voting; + } +} + +.topic-categories { + display: flex; + align-items: center; +} + +.topic-content-metadata { + white-space: nowrap; +} + +.topic-group { + margin-right: 0.4rem; +} + +.topic-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-top: 2px; + margin-right: 0.2rem; + border: 1px dashed $blue; +} + +.topic-log { + dt { + display: inline; + } +} + +.topic-log-listing { + list-style-type: none; + margin-left: 0; + font-size: 0.6rem; +} + +.topic-log-entry { + margin-bottom: 0.8rem; +} + +.topic-metadata { + display: flex; + margin-bottom: 0.2rem; + max-height: 4rem; + overflow: hidden; +} + +.topic-tags { + display: flex; + flex-wrap: wrap; + margin: 0; +} + +.topic-voting { + display: flex; + flex-direction: column; + align-items: center; + margin: 0.2rem; + margin-bottom: auto; + padding: 0.2rem; + height: auto; + min-width: 3rem; + + &.btn { + border-style: dashed; + } + + &.btn-used { + border-style: solid; + } +} + +.topic-voting-votes { + font-size: 0.8rem; + font-weight: bold; +} + +.topic-voting-label { + font-size: 0.5rem; + line-height: 0.5rem; +} + +.topic-text-excerpt { + display: none; + @media (min-width: $size-md) { + display: block; + } + + max-width: none; + margin: 0 0.2rem 0.2rem 0; + font-style: italic; + + h1 { + margin: 0 0 0.4rem 0; + } + + ol { + list-style-type: decimal; + } + + summary { + line-height: 0.8rem; + } + + summary::after { + font-style: italic; + content: "Re-collapse topic text"; + display: none; + } + + &[open] { + font-style: normal; + font-size: 0.8rem; + + summary { + font-size: 0.6rem; + + &::after { + display: inline; + } + + span { + display: none; + } + } + } +} + +.topic-info { + display: grid; + grid-auto-columns: 1fr; + grid-auto-flow: column; + grid-column-gap: 0.4rem; + + max-width: 30rem; + margin-top: 0.2rem; + white-space: nowrap; + + line-height: 0.6rem; +} + +.topic-info-comments { + white-space: normal; +} + +.topic-info-comments-new { + white-space: nowrap; + color: $orange; +} + +.topic-full { + .topic-voting { + float: right; + } +} + +.topic-full-byline { + margin-bottom: 0.4rem; + font-size: 0.6rem; +} + +.topic-full-link { + display: flex; + word-break: break-all; +} + +.topic-full-text { + overflow: auto; +} + +.topic-comments { + header { + display: flex; + + h2 { + white-space: nowrap; + } + + .form-listing-options { + margin-left: auto; + } + } +} + +.is-topic-mine { + border-left: 3px solid $violet !important; + margin-left: -3px; +} + +.is-topic-official { + border-left: 3px solid $orange !important; + margin-left: -3px; + + h1 { + a, a:visited { + color: $orange; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_accordions.scss b/tildes/scss/spectre-0.5.1/_accordions.scss new file mode 100644 index 0000000..4c69686 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_accordions.scss @@ -0,0 +1,38 @@ +// Accordions +.accordion { + input:checked ~, + &[open] { + & .accordion-header { + .icon { + transform: rotate(90deg); + } + } + + & .accordion-body { + max-height: 50rem; + } + } + + .accordion-header { + display: block; + padding: $unit-1 $unit-2; + + .icon { + transition: all .2s ease; + } + } + + .accordion-body { + margin-bottom: $layout-spacing; + max-height: 0; + overflow: hidden; + transition: max-height .2s ease; + } +} + +// Remove default details marker in Webkit +summary.accordion-header { + &::-webkit-details-marker { + display: none; + } +} diff --git a/tildes/scss/spectre-0.5.1/_animations.scss b/tildes/scss/spectre-0.5.1/_animations.scss new file mode 100644 index 0000000..e7fde1a --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_animations.scss @@ -0,0 +1,20 @@ +// Animations +@keyframes loading { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@keyframes slide-down { + 0% { + opacity: 0; + transform: translateY(-$unit-8); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} diff --git a/tildes/scss/spectre-0.5.1/_asian.scss b/tildes/scss/spectre-0.5.1/_asian.scss new file mode 100644 index 0000000..5f7533e --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_asian.scss @@ -0,0 +1,33 @@ +// Optimized for East Asian CJK +:lang(zh) { + font-family: $cjk-zh-font-family; +} + +:lang(ja) { + font-family: $cjk-jp-font-family; +} + +:lang(ko) { + font-family: $cjk-ko-font-family; +} + +:lang(zh), +:lang(ja), +.cjk { + ins, + u { + border-bottom: $border-width solid; + text-decoration: none; + } + + del + del, + del + s, + ins + ins, + ins + u, + s + del, + s + s, + u + ins, + u + u { + margin-left: .125em; + } +} diff --git a/tildes/scss/spectre-0.5.1/_autocomplete.scss b/tildes/scss/spectre-0.5.1/_autocomplete.scss new file mode 100644 index 0000000..279fa03 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_autocomplete.scss @@ -0,0 +1,47 @@ +// Autocomplete +.form-autocomplete { + position: relative; + + .form-autocomplete-input { + align-content: flex-start; + display: flex; + flex-wrap: wrap; + height: auto; + min-height: $unit-8; + padding: $unit-h; + + &.is-focused { + @include control-shadow(); + border-color: $primary-color; + } + + .form-input { + border-color: transparent; + box-shadow: none; + display: inline-block; + flex: 1 0 auto; + height: $unit-6; + line-height: $unit-4; + margin: $unit-h; + width: auto; + } + } + + .menu { + left: 0; + position: absolute; + top: 100%; + width: 100%; + } + + &.autocomplete-oneline { + .form-autocomplete-input { + flex-wrap: nowrap; + overflow-x: auto; + } + + .chip { + flex: 1 0 auto; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_avatars.scss b/tildes/scss/spectre-0.5.1/_avatars.scss new file mode 100644 index 0000000..5eb23f8 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_avatars.scss @@ -0,0 +1,77 @@ +// Avatars +.avatar { + @include avatar-base(); + background: $primary-color; + border-radius: 50%; + color: rgba($light-color, .85); + display: inline-block; + font-weight: 300; + line-height: 1.25; + margin: 0; + position: relative; + vertical-align: middle; + + &.avatar-xs { + @include avatar-base($unit-4); + } + &.avatar-sm { + @include avatar-base($unit-6); + } + &.avatar-lg { + @include avatar-base($unit-12); + } + &.avatar-xl { + @include avatar-base($unit-16); + } + + img { + border-radius: 50%; + height: 100%; + position: relative; + width: 100%; + z-index: $zindex-0; + } + + .avatar-icon, + .avatar-presence { + background: $bg-color-light; + bottom: 14.64%; + height: 50%; + padding: $border-width-lg; + position: absolute; + right: 14.64%; + transform: translate(50%, 50%); + width: 50%; + z-index: $zindex-0 + 1; + } + + .avatar-presence { + background: $gray-color; + box-shadow: 0 0 0 $border-width-lg $light-color; + border-radius: 50%; + height: .5em; + width: .5em; + + &.online { + background: $success-color; + } + + &.busy { + background: $error-color; + } + + &.away { + background: $warning-color; + } + } + + &[data-initial]::before { + color: currentColor; + content: attr(data-initial); + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + z-index: $zindex-0; + } +} diff --git a/tildes/scss/spectre-0.5.1/_badges.scss b/tildes/scss/spectre-0.5.1/_badges.scss new file mode 100644 index 0000000..554ab1b --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_badges.scss @@ -0,0 +1,70 @@ +// Badges +.badge { + position: relative; + white-space: nowrap; + + &[data-badge], + &:not([data-badge]) { + &::after { + background: $primary-color; + background-clip: padding-box; + border-radius: .5rem; + box-shadow: 0 0 0 .1rem $bg-color-light; + color: $light-color; + content: attr(data-badge); + display: inline-block; + transform: translate(-.1rem, -.5rem); + } + } + &[data-badge] { + &::after { + font-size: $font-size-sm; + height: .9rem; + line-height: 1; + min-width: .9rem; + padding: .1rem .2rem; + text-align: center; + white-space: nowrap; + } + } + &:not([data-badge]), + &[data-badge=""] { + &::after { + height: 6px; + min-width: 6px; + padding: 0; + width: 6px; + } + } + + // Badges for Buttons + &.btn { + &::after { + position: absolute; + top: 0; + right: 0; + transform: translate(50%, -50%); + } + } + + // Badges for Avatars + &.avatar { + &::after { + position: absolute; + top: 14.64%; + right: 14.64%; + transform: translate(50%, -50%); + z-index: $zindex-1; + } + } + + &.avatar-xs { + &::after { + content: ""; + height: $unit-2; + min-width: $unit-2; + padding: 0; + width: $unit-2; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_bars.scss b/tildes/scss/spectre-0.5.1/_bars.scss new file mode 100644 index 0000000..47e21c9 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_bars.scss @@ -0,0 +1,71 @@ +// Bars +.bar { + background: $bg-color-dark; + border-radius: $border-radius; + display: flex; + flex-wrap: nowrap; + height: $unit-4; + width: 100%; + + &.bar-sm { + height: $unit-1; + } + + // TODO: attr() support + .bar-item { + background: $primary-color; + color: $light-color; + display: block; + font-size: $font-size-sm; + flex-shrink: 0; + line-height: $unit-4; + height: 100%; + position: relative; + text-align: center; + width: 0; + + &:first-child { + border-bottom-left-radius: $border-radius; + border-top-left-radius: $border-radius; + } + &:last-child { + border-bottom-right-radius: $border-radius; + border-top-right-radius: $border-radius; + flex-shrink: 1; + } + } +} + +// Slider bar +.bar-slider { + height: $border-width-lg; + margin: $layout-spacing 0; + position: relative; + + .bar-item { + left: 0; + padding: 0; + position: absolute; + &:not(:last-child):first-child { + background: $bg-color-dark; + z-index: $zindex-0; + } + } + + .bar-slider-btn { + background: $primary-color; + border: 0; + border-radius: 50%; + height: $unit-3; + padding: 0; + position: absolute; + right: 0; + top: 50%; + transform: translate(50%, -50%); + width: $unit-3; + + &:active { + box-shadow: 0 0 0 .1rem $primary-color; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_base.scss b/tildes/scss/spectre-0.5.1/_base.scss new file mode 100644 index 0000000..5f5a8d0 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_base.scss @@ -0,0 +1,40 @@ +// Base +*, +*::before, +*::after { + box-sizing: inherit; +} + +html { + box-sizing: border-box; + font-size: $html-font-size; + line-height: $html-line-height; + -webkit-tap-highlight-color: transparent; +} + +body { + background: $body-bg; + color: $body-font-color; + font-family: $body-font-family; + font-size: $font-size; + overflow-x: hidden; + text-rendering: optimizeLegibility; +} + +a { + color: $link-color; + outline: none; + text-decoration: none; + + &:focus { + @include control-shadow(); + } + + &:focus, + &:hover, + &:active, + &.active { + color: $link-color-dark; + text-decoration: underline; + } +} diff --git a/tildes/scss/spectre-0.5.1/_breadcrumbs.scss b/tildes/scss/spectre-0.5.1/_breadcrumbs.scss new file mode 100644 index 0000000..f2c9185 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_breadcrumbs.scss @@ -0,0 +1,29 @@ +// Breadcrumbs +.breadcrumb { + list-style: none; + margin: $unit-1 0; + padding: $unit-1 0; + + .breadcrumb-item { + color: $gray-color-dark; + display: inline-block; + margin: 0; + padding: $unit-1 0; + + &:not(:last-child) { + margin-right: $unit-1; + + a { + color: $gray-color-dark; + } + } + + &:not(:first-child) { + &::before { + color: $gray-color-light; + content: "/"; + padding-right: $unit-2; + } + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_buttons.scss b/tildes/scss/spectre-0.5.1/_buttons.scss new file mode 100644 index 0000000..7de387a --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_buttons.scss @@ -0,0 +1,191 @@ +// Buttons +.btn { + @include control-transition(); + appearance: none; + background: $bg-color-light; + border: $border-width solid $primary-color; + border-radius: $border-radius; + color: $primary-color; + cursor: pointer; + display: inline-block; + font-size: $font-size; + height: $control-size; + line-height: $line-height; + outline: none; + padding: $control-padding-y $control-padding-x; + text-align: center; + text-decoration: none; + user-select: none; + vertical-align: middle; + white-space: nowrap; + &:focus { + @include control-shadow(); + } + &:focus, + &:hover { + background: $secondary-color; + border-color: $primary-color-dark; + text-decoration: none; + } + &:active, + &.active { + background: $primary-color-dark; + border-color: darken($primary-color-dark, 5%); + color: $light-color; + text-decoration: none; + &.loading { + &::after { + border-bottom-color: $light-color; + border-left-color: $light-color; + } + } + } + &[disabled], + &:disabled, + &.disabled { + cursor: default; + opacity: .5; + pointer-events: none; + } + + // Button Primary + &.btn-primary { + background: $primary-color; + border-color: $primary-color-dark; + color: $light-color; + &:focus, + &:hover { + background: darken($primary-color-dark, 2%); + border-color: darken($primary-color-dark, 5%); + color: $light-color; + } + &:active, + &.active { + background: darken($primary-color-dark, 4%); + border-color: darken($primary-color-dark, 7%); + color: $light-color; + } + &.loading { + &::after { + border-bottom-color: $light-color; + border-left-color: $light-color; + } + } + } + + // Button Colors + &.btn-success { + @include button-variant($success-color); + } + + &.btn-error { + @include button-variant($error-color); + } + + // Button Link + &.btn-link { + background: transparent; + border-color: transparent; + color: $link-color; + &:focus, + &:hover, + &:active, + &.active { + color: $link-color-dark; + } + } + + // Button Sizes + &.btn-sm { + font-size: $font-size-sm; + height: $control-size-sm; + padding: $control-padding-y-sm $control-padding-x-sm; + } + + &.btn-lg { + font-size: $font-size-lg; + height: $control-size-lg; + padding: $control-padding-y-lg $control-padding-x-lg; + } + + // Button Block + &.btn-block { + display: block; + width: 100%; + } + + // Button Action + &.btn-action { + width: $control-size; + padding-left: 0; + padding-right: 0; + + &.btn-sm { + width: $control-size-sm; + } + + &.btn-lg { + width: $control-size-lg; + } + } + + // Button Clear + &.btn-clear { + background: transparent; + border: 0; + color: currentColor; + height: $unit-4; + line-height: $unit-4; + margin-left: $unit-1; + margin-right: -2px; + opacity: 1; + padding: 0; + text-decoration: none; + width: $unit-4; + + &:hover { + opacity: .95; + } + + &::before { + content: "\2715"; + } + } +} + +// Button groups +.btn-group { + display: inline-flex; + flex-wrap: wrap; + + .btn { + flex: 1 0 auto; + &:first-child:not(:last-child) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + } + &:not(:first-child):not(:last-child) { + border-radius: 0; + margin-left: -$border-width; + } + &:last-child:not(:first-child) { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + margin-left: -$border-width; + } + &:focus, + &:hover, + &:active, + &.active { + z-index: $zindex-0; + } + } + + &.btn-group-block { + display: flex; + + .btn { + flex: 1 0 0; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_calendars.scss b/tildes/scss/spectre-0.5.1/_calendars.scss new file mode 100644 index 0000000..a05621f --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_calendars.scss @@ -0,0 +1,203 @@ +// Calendars +.calendar { + border: $border-width solid $border-color; + border-radius: $border-radius; + display: block; + min-width: 280px; + + .calendar-nav { + align-items: center; + background: $bg-color; + border-top-left-radius: $border-radius; + border-top-right-radius: $border-radius; + display: flex; + font-size: $font-size-lg; + padding: $layout-spacing; + } + + .calendar-header, + .calendar-body { + display: flex; + flex-wrap: wrap; + justify-content: center; + padding: $layout-spacing 0; + + .calendar-date { + flex: 0 0 14.28%; // 7 calendar-items each row + max-width: 14.28%; + } + } + + .calendar-header { + background: $bg-color; + border-bottom: $border-width solid $border-color; + color: $gray-color; + font-size: $font-size-sm; + text-align: center; + } + + .calendar-body { + color: $gray-color-dark; + } + + .calendar-date { + border: 0; + padding: $unit-1; + + .date-item { + @include control-transition(); + appearance: none; + background: transparent; + border: $border-width solid transparent; + border-radius: 50%; + color: $gray-color-dark; + cursor: pointer; + font-size: $font-size-sm; + height: $unit-7; + line-height: $unit-5; + outline: none; + padding: $unit-h; + position: relative; + text-align: center; + text-decoration: none; + vertical-align: middle; + white-space: nowrap; + width: $unit-7; + + &.date-today { + border-color: $secondary-color-dark; + color: $primary-color; + } + + &:focus { + @include control-shadow(); + } + + &:focus, + &:hover { + background: $secondary-color-light; + border-color: $secondary-color-dark; + color: $primary-color; + text-decoration: none; + } + &:active, + &.active { + background: $primary-color-dark; + border-color: darken($primary-color-dark, 5%); + color: $light-color; + } + + // Calendar badge support + &.badge { + &::after { + position: absolute; + top: 3px; + right: 3px; + transform: translate(50%, -50%); + } + } + } + + &.disabled .date-item, + &.disabled .calendar-event, + .date-item:disabled, + .calendar-event:disabled { + cursor: default; + opacity: .25; + pointer-events: none; + } + } + + .calendar-range { + position: relative; + + &::before { + background: $secondary-color; + content: ""; + height: $unit-7; + left: 0; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + } + &.range-start { + &::before { + left: 50%; + } + } + &.range-end { + &::before { + right: 50%; + } + } + + .date-item { + color: $primary-color; + } + } + + &.calendar-lg { + .calendar-body { + padding: 0; + + .calendar-date { + border-bottom: $border-width solid $border-color; + border-right: $border-width solid $border-color; + display: flex; + flex-direction: column; + height: 5.5rem; + padding: 0; + + &:nth-child(7n) { + border-right: 0; + } + &:nth-last-child(-n+7) { + border-bottom: 0; + } + } + } + + .date-item { + align-self: flex-end; + height: $unit-7; + margin-right: $layout-spacing-sm; + margin-top: $layout-spacing-sm; + } + + .calendar-range { + &::before { + top: 19px; + } + &.range-start { + &::before { + left: auto; + width: 19px; + } + } + &.range-end { + &::before { + right: 19px; + } + } + } + + .calendar-events { + flex-grow: 1; + line-height: 1; + overflow-y: auto; + padding: $layout-spacing-sm; + } + + .calendar-event { + border-radius: $border-radius; + font-size: $font-size-sm; + display: block; + margin: $unit-h auto; + overflow: hidden; + padding: 3px 4px; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_cards.scss b/tildes/scss/spectre-0.5.1/_cards.scss new file mode 100644 index 0000000..3ecdc0a --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_cards.scss @@ -0,0 +1,39 @@ +// Cards +.card { + background: $bg-color-light; + border: $border-width solid $border-color; + border-radius: $border-radius; + display: flex; + flex-direction: column; + + .card-header, + .card-body, + .card-footer { + padding: $layout-spacing-lg; + padding-bottom: 0; + + &:last-child { + padding-bottom: $layout-spacing-lg; + } + } + + .card-image { + padding-top: $layout-spacing-lg; + + &:first-child { + padding-top: 0; + + img { + border-top-left-radius: $border-radius; + border-top-right-radius: $border-radius; + } + } + + &:last-child { + img { + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: $border-radius; + } + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_carousels.scss b/tildes/scss/spectre-0.5.1/_carousels.scss new file mode 100644 index 0000000..55dc31c --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_carousels.scss @@ -0,0 +1,126 @@ +// Carousels +.carousel { + background: $bg-color; + display: block; + overflow: hidden; + position: relative; + width: 100%; + -webkit-overflow-scrolling: touch; + z-index: $zindex-0; + + .carousel-container { + height: 100%; + left: 0; + position: relative; + &::before { + content: ""; + display: block; + padding-bottom: 56.25%; + } + + .carousel-item { + animation: carousel-slideout 1s ease-in-out 1; + height: 100%; + left: 0; + margin: 0; + opacity: 0; + position: absolute; + top: 0; + width: 100%; + + &:hover { + .item-prev, + .item-next { + opacity: 1; + } + } + } + + .item-prev, + .item-next { + background: rgba($gray-color-light, .25); + border-color: rgba($gray-color-light, .5); + color: $gray-color-light; + opacity: 0; + position: absolute; + top: 50%; + transition: all .4s ease; + transform: translateY(-50%); + z-index: $zindex-1; + } + .item-prev { + left: 1rem; + } + .item-next { + right: 1rem; + } + } + + .carousel-locator { + &:nth-of-type(1):checked ~ .carousel-container .carousel-item:nth-of-type(1), + &:nth-of-type(2):checked ~ .carousel-container .carousel-item:nth-of-type(2), + &:nth-of-type(3):checked ~ .carousel-container .carousel-item:nth-of-type(3), + &:nth-of-type(4):checked ~ .carousel-container .carousel-item:nth-of-type(4) { + animation: carousel-slidein .75s ease-in-out 1; + opacity: 1; + z-index: $zindex-1; + } + &:nth-of-type(1):checked ~ .carousel-nav .nav-item:nth-of-type(1), + &:nth-of-type(2):checked ~ .carousel-nav .nav-item:nth-of-type(2), + &:nth-of-type(3):checked ~ .carousel-nav .nav-item:nth-of-type(3), + &:nth-of-type(4):checked ~ .carousel-nav .nav-item:nth-of-type(4) { + color: $gray-color-light; + } + } + + .carousel-nav { + bottom: $layout-spacing; + display: flex; + justify-content: center; + left: 50%; + position: absolute; + transform: translateX(-50%); + width: 10rem; + z-index: $zindex-1; + + .nav-item { + color: rgba($gray-color-light, .5); + display: block; + flex: 1 0 auto; + height: $unit-8; + margin: $unit-1; + max-width: 2.5rem; + position: relative; + + &::before { + background: currentColor; + content: ""; + display: block; + height: $unit-h; + position: absolute; + top: .5rem; + width: 100%; + } + } + } +} + +@keyframes carousel-slidein { + 0% { + transform: translateX(100%); + } + 100% { + transform: translateX(0); + } +} + +@keyframes carousel-slideout { + 0% { + opacity: 1; + transform: translateX(0); + } + 100% { + opacity: 1; + transform: translateX(-50%); + } +} diff --git a/tildes/scss/spectre-0.5.1/_chips.scss b/tildes/scss/spectre-0.5.1/_chips.scss new file mode 100644 index 0000000..f6d7d6e --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_chips.scss @@ -0,0 +1,26 @@ +// Chips +.chip { + align-items: center; + background: $bg-color-dark; + border-radius: 5rem; + color: $gray-color-dark; + display: inline-flex; + font-size: 90%; + height: $unit-6; + line-height: $unit-4; + margin: $unit-h; + max-width: 100%; + padding: $unit-1 $unit-2; + text-decoration: none; + vertical-align: middle; + + &.active { + background: $primary-color; + color: $light-color; + } + + .avatar { + margin-left: -$unit-2; + margin-right: $unit-1; + } +} diff --git a/tildes/scss/spectre-0.5.1/_codes.scss b/tildes/scss/spectre-0.5.1/_codes.scss new file mode 100644 index 0000000..5b3580f --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_codes.scss @@ -0,0 +1,31 @@ +// Codes +code { + @include label-base(); + @include label-variant($code-color, lighten($code-color, 33%)); + font-size: 85%; +} + +.code { + border-radius: $border-radius; + color: $body-font-color; + position: relative; + + &::before { + color: $gray-color; + content: attr(data-lang); + font-size: $font-size-sm; + position: absolute; + right: $layout-spacing; + top: $unit-h; + } + + code { + background: $bg-color; + color: inherit; + display: block; + line-height: 1.5; + overflow-x: auto; + padding: 1rem; + width: 100%; + } +} diff --git a/tildes/scss/spectre-0.5.1/_comparison-sliders.scss b/tildes/scss/spectre-0.5.1/_comparison-sliders.scss new file mode 100644 index 0000000..72bb25f --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_comparison-sliders.scss @@ -0,0 +1,115 @@ +// Image comparison slider +// Credit: http://codepen.io/solipsistacp/pen/Gpmaq +.comparison-slider { + height: 50vh; + overflow: hidden; + position: relative; + width: 100%; + -webkit-overflow-scrolling: touch; + + .comparison-before, + .comparison-after { + height: 100%; + left: 0; + margin: 0; + overflow: hidden; + position: absolute; + top: 0; + + img { + height: 100%; + object-fit: cover; + object-position: left center; + position: absolute; + width: 100%; + } + } + + .comparison-before { + width: 100%; + z-index: 1; + + .comparison-label { + right: $unit-4; + } + } + + .comparison-after { + max-width: 100%; + min-width: 0; + z-index: 2; + + &::before { + background: transparent; + content: ""; + cursor: default; + height: 100%; + left: 0; + position: absolute; + right: $unit-4; + top: 0; + z-index: $zindex-0; + } + + &::after { + background: currentColor; + border-radius: 50%; + box-shadow: 0 -5px, 0 5px; + color: $light-color; + content: ""; + height: 3px; + position: absolute; + right: $unit-2; + top: 50%; + transform: translate(50%, -50%); + width: 3px; + } + + .comparison-label { + left: $unit-4; + } + } + + .comparison-resizer { + animation: first-run 1.5s 1 ease-in-out; + cursor: ew-resize; + height: $unit-4; + left: 0; + max-width: 100%; + min-width: $unit-4; + opacity: 0; + outline: none; + position: relative; + resize: horizontal; + top: 50%; + transform: translateY(-50%) scaleY(30); + width: 0; + } + + .comparison-label { + background: rgba($dark-color, .5); + bottom: $unit-4; + color: $light-color; + padding: $unit-1 $unit-2; + position: absolute; + user-select: none; + } +} + +@keyframes first-run { + 0% { + width: 0; + } + 25% { + width: $unit-12; + } + 50% { + width: $unit-4; + } + 75% { + width: $unit-6; + } + 100% { + width: 0; + } +} diff --git a/tildes/scss/spectre-0.5.1/_dropdowns.scss b/tildes/scss/spectre-0.5.1/_dropdowns.scss new file mode 100644 index 0000000..324440b --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_dropdowns.scss @@ -0,0 +1,36 @@ +// Dropdown +.dropdown { + display: inline-block; + position: relative; + + .menu { + animation: slide-down .15s ease 1; + display: none; + left: 0; + max-height: 50vh; + overflow-y: auto; + position: absolute; + top: 100%; + } + + &.dropdown-right { + .menu { + left: auto; + right: 0; + } + } + + &.active .menu, + .dropdown-toggle:focus + .menu, + .menu:hover { + display: block; + } + + // Fix dropdown-toggle border radius in button groups + .btn-group { + .dropdown-toggle:nth-last-child(2) { + border-bottom-right-radius: $border-radius; + border-top-right-radius: $border-radius; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_empty.scss b/tildes/scss/spectre-0.5.1/_empty.scss new file mode 100644 index 0000000..accba9c --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_empty.scss @@ -0,0 +1,21 @@ +// Empty states (or Blank slates) +.empty { + background: $bg-color; + border-radius: $border-radius; + color: $gray-color-dark; + text-align: center; + padding: $unit-16 $unit-8; + + .empty-icon { + margin-bottom: $layout-spacing-lg; + } + + .empty-title, + .empty-subtitle { + margin: $layout-spacing auto; + } + + .empty-action { + margin-top: $layout-spacing-lg; + } +} diff --git a/tildes/scss/spectre-0.5.1/_filters.scss b/tildes/scss/spectre-0.5.1/_filters.scss new file mode 100644 index 0000000..37ccc89 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_filters.scss @@ -0,0 +1,37 @@ +// Filters +// The number of filter options +$filter-number: 8 !default; + +%filter-checked-nav { + background: $primary-color; + color: $light-color; +} + +%filter-checked-body { + display: none; +} + +.filter { + .filter-nav { + margin: $layout-spacing 0; + } + + .filter-body { + display: flex; + flex-wrap: wrap; + } + + .filter-tag { + @for $i from 0 through ($filter-number) { + &#tag-#{$i}:checked ~ .filter-nav .chip[for="tag-#{$i}"] { + @extend %filter-checked-nav; + } + } + + @for $i from 1 through ($filter-number) { + &#tag-#{$i}:checked ~ .filter-body .filter-item:not([data-tag~="tag-#{$i}"]) { + @extend %filter-checked-body; + } + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_forms.scss b/tildes/scss/spectre-0.5.1/_forms.scss new file mode 100644 index 0000000..a0b6ee1 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_forms.scss @@ -0,0 +1,527 @@ +// Forms +.form-group { + &:not(:last-child) { + margin-bottom: $layout-spacing; + } +} + +fieldset { + margin-bottom: $layout-spacing-lg; +} + +legend { + font-size: $font-size-lg; + font-weight: 500; + margin-bottom: $layout-spacing-lg; +} + +// Form element: Label +.form-label { + display: block; + line-height: $line-height; + padding: $control-padding-y + $border-width 0; + + &.label-sm { + font-size: $font-size-sm; + padding: $control-padding-y-sm + $border-width 0; + } + + &.label-lg { + font-size: $font-size-lg; + padding: $control-padding-y-lg + $border-width 0; + } +} + +// Form element: Input +.form-input { + @include control-transition(); + appearance: none; + background: $bg-color-light; + background-image: none; + border: $border-width solid $border-color-dark; + border-radius: $border-radius; + color: $body-font-color; + display: block; + font-size: $font-size; + height: $control-size; + line-height: $line-height; + max-width: 100%; + outline: none; + padding: $control-padding-y $control-padding-x; + position: relative; + width: 100%; + &:focus { + @include control-shadow(); + border-color: $primary-color; + } + &::placeholder { + color: $gray-color; + } + + // Input sizes + &.input-sm { + font-size: $font-size-sm; + height: $control-size-sm; + padding: $control-padding-y-sm $control-padding-x-sm; + } + + &.input-lg { + font-size: $font-size-lg; + height: $control-size-lg; + padding: $control-padding-y-lg $control-padding-x-lg; + } + + &.input-inline { + display: inline-block; + vertical-align: middle; + width: auto; + } + + // Input types + &[type="file"] { + height: auto; + } +} + +// Form element: Textarea +textarea.form-input { + height: auto; +} + +// Form element: Input hint +.form-input-hint { + color: $gray-color; + font-size: $font-size-sm; + margin-top: $unit-1; + + .has-success &, + .is-success + & { + color: $success-color; + } + + .has-error &, + .is-error + & { + color: $error-color; + } +} + +// Form element: Select +.form-select { + appearance: none; + border: $border-width solid $border-color-dark; + border-radius: $border-radius; + color: inherit; + font-size: $font-size; + height: $control-size; + line-height: $line-height; + outline: none; + padding: $control-padding-y $control-padding-x; + vertical-align: middle; + width: 100%; + + &[size], + &[multiple] { + height: auto; + + option { + padding: $unit-h $unit-1; + } + } + &:not([multiple]):not([size]) { + background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.4rem .5rem; + padding-right: $control-icon-size + $control-padding-x; + } + &:focus { + @include control-shadow(); + border-color: $primary-color; + } + &::-ms-expand { + display: none; + } + + // Select sizes + &.select-sm { + font-size: $font-size-sm; + height: $control-size-sm; + padding: $control-padding-y-sm ($control-icon-size + $control-padding-x-sm) $control-padding-y-sm $control-padding-x-sm; + } + + &.select-lg { + font-size: $font-size-lg; + height: $control-size-lg; + padding: $control-padding-y-lg ($control-icon-size + $control-padding-x-lg) $control-padding-y-lg $control-padding-x-lg; + } +} + +// Form Icons +.has-icon-left, +.has-icon-right { + position: relative; + + .form-icon { + height: $control-icon-size; + margin: 0 $control-padding-y; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: $control-icon-size; + z-index: $zindex-0 + 1; + } +} + +.has-icon-left { + .form-icon { + left: $border-width; + } + + .form-input { + padding-left: $control-icon-size + $control-padding-y * 2; + } +} + +.has-icon-right { + .form-icon { + right: $border-width; + } + + .form-input { + padding-right: $control-icon-size + $control-padding-y * 2; + } +} + +// Form element: Checkbox and Radio +.form-checkbox, +.form-radio, +.form-switch { + display: inline-block; + line-height: $line-height; + margin: ($control-size - $control-size-sm) / 2 0; + min-height: 1.2rem; + padding: (($control-size-sm - $line-height) / 2) $control-padding-x (($control-size-sm - $line-height) / 2) ($control-icon-size + $control-padding-x); + position: relative; + + input { + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + width: 1px; + &:focus + .form-icon { + @include control-shadow(); + border-color: $primary-color; + } + &:checked + .form-icon { + background: $primary-color; + border-color: $primary-color; + } + } + + .form-icon { + @include control-transition(); + border: $border-width solid $border-color-dark; + cursor: pointer; + display: inline-block; + position: absolute; + } + + // Input checkbox, radio and switch sizes + &.input-sm { + font-size: $font-size-sm; + margin: 0; + } + + &.input-lg { + font-size: $font-size-lg; + margin: ($control-size-lg - $control-size-sm) / 2 0; + } +} + +.form-checkbox, +.form-radio { + .form-icon { + background: $bg-color-light; + height: $control-icon-size; + left: 0; + top: ($control-size-sm - $control-icon-size) / 2; + width: $control-icon-size; + } + + input { + &:active + .form-icon { + background: $bg-color-dark; + } + } +} +.form-checkbox { + .form-icon { + border-radius: $border-radius; + } + + input { + &:checked + .form-icon { + &::before { + background-clip: padding-box; + border: $border-width-lg solid $light-color; + border-left-width: 0; + border-top-width: 0; + content: ""; + height: 12px; + left: 50%; + margin-left: -4px; + margin-top: -8px; + position: absolute; + top: 50%; + transform: rotate(45deg); + width: 8px; + } + } + &:indeterminate + .form-icon { + background: $primary-color; + border-color: $primary-color; + &::before { + background: $bg-color-light; + content: ""; + height: 2px; + left: 50%; + margin-left: -5px; + margin-top: -1px; + position: absolute; + top: 50%; + width: 10px; + } + } + } +} +.form-radio { + .form-icon { + border-radius: 50%; + } + + input { + &:checked + .form-icon { + &::before { + background: $bg-color-light; + border-radius: 50%; + content: ""; + height: 4px; + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 4px; + } + } + } +} + +// Form element: Switch +.form-switch { + padding-left: ($unit-8 + $control-padding-x); + + .form-icon { + background: $gray-color-light; + background-clip: padding-box; + border-radius: $unit-2 + $border-width; + height: $unit-4 + $border-width * 2; + left: 0; + top: ($control-size-sm - $unit-4) / 2 - $border-width; + width: $unit-8; + &::before { + @include control-transition(); + background: $bg-color-light; + border-radius: 50%; + content: ""; + display: block; + height: $unit-4; + left: 0; + position: absolute; + top: 0; + width: $unit-4; + } + } + + input { + &:checked + .form-icon { + &::before { + left: 14px; + } + } + &:active + .form-icon { + &::before { + background: $bg-color; + } + } + } +} + +// Form element: Input groups +.input-group { + display: flex; + + .input-group-addon { + background: $bg-color; + border: $border-width solid $border-color-dark; + border-radius: $border-radius; + line-height: $line-height; + padding: $control-padding-y $control-padding-x; + white-space: nowrap; + + &.addon-sm { + font-size: $font-size-sm; + padding: $control-padding-y-sm $control-padding-x-sm; + } + + &.addon-lg { + font-size: $font-size-lg; + padding: $control-padding-y-lg $control-padding-x-lg; + } + } + + .form-input, + .form-select { + flex: 1 1 auto; + } + + .input-group-btn { + z-index: $zindex-0; + } + + .form-input, + .form-select, + .input-group-addon, + .input-group-btn { + &:first-child:not(:last-child) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + } + &:not(:first-child):not(:last-child) { + border-radius: 0; + margin-left: -$border-width; + } + &:last-child:not(:first-child) { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + margin-left: -$border-width; + } + &:focus { + z-index: $zindex-0 + 1; + } + } + + .form-select { + width: auto; + } + + &.input-inline { + display: inline-flex; + } +} + +// Form validation states +.form-input, +.form-select { + .has-success &, + &.is-success { + border-color: $success-color; + &:focus { + @include control-shadow($success-color); + } + } + + .has-error &, + &.is-error { + border-color: $error-color; + &:focus { + @include control-shadow($error-color); + } + } +} + +.form-checkbox, +.form-radio, +.form-switch { + .has-error &, + &.is-error { + .form-icon { + border-color: $error-color; + } + + input { + &:checked + .form-icon { + background: $error-color; + border-color: $error-color; + } + + &:focus + .form-icon { + @include control-shadow($error-color); + border-color: $error-color; + } + } + } +} + +// validation based on :placeholder-shown (Edge doesn't support it yet) +.form-input { + &:not(:placeholder-shown) { + &:invalid { + border-color: $error-color; + &:focus { + @include control-shadow($error-color); + } + + & + .form-input-hint { + color: $error-color; + } + } + } +} + +// Form disabled and readonly +.form-input, +.form-select { + &:disabled, + &.disabled { + background-color: $bg-color-dark; + cursor: not-allowed; + opacity: .5; + } +} + +.form-input { + &[readonly] { + background-color: $bg-color; + } +} + +input { + &:disabled, + &.disabled { + & + .form-icon { + background: $bg-color-dark; + cursor: not-allowed; + opacity: .5; + } + } +} + +.form-switch { + input { + &:disabled, + &.disabled { + & + .form-icon::before { + background: $bg-color-light; + } + } + } +} + +// Form Horizontal +.form-horizontal { + padding: $layout-spacing 0; + + .form-group { + display: flex; + flex-wrap: wrap; + } +} diff --git a/tildes/scss/spectre-0.5.1/_icons.scss b/tildes/scss/spectre-0.5.1/_icons.scss new file mode 100644 index 0000000..4f3c5ce --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_icons.scss @@ -0,0 +1,5 @@ +// CSS Icons +@import "icons/icons-core"; +@import "icons/icons-navigation"; +@import "icons/icons-action"; +@import "icons/icons-object"; \ No newline at end of file diff --git a/tildes/scss/spectre-0.5.1/_labels.scss b/tildes/scss/spectre-0.5.1/_labels.scss new file mode 100644 index 0000000..ca693cd --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_labels.scss @@ -0,0 +1,34 @@ +// Labels +.label { + @include label-base(); + @include label-variant(lighten($body-font-color, 5%), $bg-color-dark); + display: inline-block; + + // Label rounded + &.label-rounded { + border-radius: 5rem; + padding-left: .4rem; + padding-right: .4rem; + } + + // Label colors + &.label-primary { + @include label-variant($light-color, $primary-color); + } + + &.label-secondary { + @include label-variant($primary-color, $secondary-color); + } + + &.label-success { + @include label-variant($light-color, $success-color); + } + + &.label-warning { + @include label-variant($light-color, $warning-color); + } + + &.label-error { + @include label-variant($light-color, $error-color); + } +} diff --git a/tildes/scss/spectre-0.5.1/_layout.scss b/tildes/scss/spectre-0.5.1/_layout.scss new file mode 100644 index 0000000..83e2c18 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_layout.scss @@ -0,0 +1,424 @@ +// Layout +.container { + margin-left: auto; + margin-right: auto; + padding-left: $layout-spacing; + padding-right: $layout-spacing; + width: 100%; + @extend .clearfix; + + $grid-spacing: ($layout-spacing / ($layout-spacing * 0 + 1)) * $html-font-size; + + &.grid-xl { + max-width: $grid-spacing * 2 + $size-xl; + } + + &.grid-lg { + max-width: $grid-spacing * 2 + $size-lg; + } + + &.grid-md { + max-width: $grid-spacing * 2 + $size-md; + } + + &.grid-sm { + max-width: $grid-spacing * 2 + $size-sm; + } + + &.grid-xs { + max-width: $grid-spacing * 2 + $size-xs; + } +} + +// Responsive breakpoint system +.show-xs, +.show-sm, +.show-md, +.show-lg, +.show-xl { + display: none !important; +} + +// Responsive grid system +.columns { + display: flex; + flex-wrap: wrap; + margin-left: -$layout-spacing; + margin-right: -$layout-spacing; + + &.col-gapless { + margin-left: 0; + margin-right: 0; + + & > .column { + padding-left: 0; + padding-right: 0; + } + } + &.col-oneline { + flex-wrap: nowrap; + overflow-x: auto; + } +} +.column { + flex: 1; + max-width: 100%; + padding-left: $layout-spacing; + padding-right: $layout-spacing; + + &.col-12, + &.col-11, + &.col-10, + &.col-9, + &.col-8, + &.col-7, + &.col-6, + &.col-5, + &.col-4, + &.col-3, + &.col-2, + &.col-1 { + flex: none; + } +} +.col-12 { + width: 100%; +} +.col-11 { + width: 91.66666667%; +} +.col-10 { + width: 83.33333333%; +} +.col-9 { + width: 75%; +} +.col-8 { + width: 66.66666667%; +} +.col-7 { + width: 58.33333333%; +} +.col-6 { + width: 50%; +} +.col-5 { + width: 41.66666667%; +} +.col-4 { + width: 33.33333333%; +} +.col-3 { + width: 25%; +} +.col-2 { + width: 16.66666667%; +} +.col-1 { + width: 8.33333333%; +} +.col-auto { + flex: 0 0 auto; + max-width: none; + width: auto; +} +.col-mx-auto { + margin-left: auto; + margin-right: auto; +} +.col-ml-auto { + margin-left: auto; +} +.col-mr-auto { + margin-right: auto; +} +@media (max-width: $size-xl) { + .col-xl-12, + .col-xl-11, + .col-xl-10, + .col-xl-9, + .col-xl-8, + .col-xl-7, + .col-xl-6, + .col-xl-5, + .col-xl-4, + .col-xl-3, + .col-xl-2, + .col-xl-1 { + flex: none; + } + .col-xl-12 { + width: 100%; + } + .col-xl-11 { + width: 91.66666667%; + } + .col-xl-10 { + width: 83.33333333%; + } + .col-xl-9 { + width: 75%; + } + .col-xl-8 { + width: 66.66666667%; + } + .col-xl-7 { + width: 58.33333333%; + } + .col-xl-6 { + width: 50%; + } + .col-xl-5 { + width: 41.66666667%; + } + .col-xl-4 { + width: 33.33333333%; + } + .col-xl-3 { + width: 25%; + } + .col-xl-2 { + width: 16.66666667%; + } + .col-xl-1 { + width: 8.33333333%; + } + .hide-xl { + display: none !important; + } + .show-xl { + display: block !important; + } +} +@media (max-width: $size-lg) { + .col-lg-12, + .col-lg-11, + .col-lg-10, + .col-lg-9, + .col-lg-8, + .col-lg-7, + .col-lg-6, + .col-lg-5, + .col-lg-4, + .col-lg-3, + .col-lg-2, + .col-lg-1 { + flex: none; + } + .col-lg-12 { + width: 100%; + } + .col-lg-11 { + width: 91.66666667%; + } + .col-lg-10 { + width: 83.33333333%; + } + .col-lg-9 { + width: 75%; + } + .col-lg-8 { + width: 66.66666667%; + } + .col-lg-7 { + width: 58.33333333%; + } + .col-lg-6 { + width: 50%; + } + .col-lg-5 { + width: 41.66666667%; + } + .col-lg-4 { + width: 33.33333333%; + } + .col-lg-3 { + width: 25%; + } + .col-lg-2 { + width: 16.66666667%; + } + .col-lg-1 { + width: 8.33333333%; + } + .hide-lg { + display: none !important; + } + .show-lg { + display: block !important; + } +} +@media (max-width: $size-md) { + .col-md-12, + .col-md-11, + .col-md-10, + .col-md-9, + .col-md-8, + .col-md-7, + .col-md-6, + .col-md-5, + .col-md-4, + .col-md-3, + .col-md-2, + .col-md-1 { + flex: none; + } + .col-md-12 { + width: 100%; + } + .col-md-11 { + width: 91.66666667%; + } + .col-md-10 { + width: 83.33333333%; + } + .col-md-9 { + width: 75%; + } + .col-md-8 { + width: 66.66666667%; + } + .col-md-7 { + width: 58.33333333%; + } + .col-md-6 { + width: 50%; + } + .col-md-5 { + width: 41.66666667%; + } + .col-md-4 { + width: 33.33333333%; + } + .col-md-3 { + width: 25%; + } + .col-md-2 { + width: 16.66666667%; + } + .col-md-1 { + width: 8.33333333%; + } + .hide-md { + display: none !important; + } + .show-md { + display: block !important; + } +} +@media (max-width: $size-sm) { + .col-sm-12, + .col-sm-11, + .col-sm-10, + .col-sm-9, + .col-sm-8, + .col-sm-7, + .col-sm-6, + .col-sm-5, + .col-sm-4, + .col-sm-3, + .col-sm-2, + .col-sm-1 { + flex: none; + } + .col-sm-12 { + width: 100%; + } + .col-sm-11 { + width: 91.66666667%; + } + .col-sm-10 { + width: 83.33333333%; + } + .col-sm-9 { + width: 75%; + } + .col-sm-8 { + width: 66.66666667%; + } + .col-sm-7 { + width: 58.33333333%; + } + .col-sm-6 { + width: 50%; + } + .col-sm-5 { + width: 41.66666667%; + } + .col-sm-4 { + width: 33.33333333%; + } + .col-sm-3 { + width: 25%; + } + .col-sm-2 { + width: 16.66666667%; + } + .col-sm-1 { + width: 8.33333333%; + } + .hide-sm { + display: none !important; + } + .show-sm { + display: block !important; + } +} +@media (max-width: $size-xs) { + .col-xs-12, + .col-xs-11, + .col-xs-10, + .col-xs-9, + .col-xs-8, + .col-xs-7, + .col-xs-6, + .col-xs-5, + .col-xs-4, + .col-xs-3, + .col-xs-2, + .col-xs-1 { + flex: none; + } + .col-xs-12 { + width: 100%; + } + .col-xs-11 { + width: 91.66666667%; + } + .col-xs-10 { + width: 83.33333333%; + } + .col-xs-9 { + width: 75%; + } + .col-xs-8 { + width: 66.66666667%; + } + .col-xs-7 { + width: 58.33333333%; + } + .col-xs-6 { + width: 50%; + } + .col-xs-5 { + width: 41.66666667%; + } + .col-xs-4 { + width: 33.33333333%; + } + .col-xs-3 { + width: 25%; + } + .col-xs-2 { + width: 16.66666667%; + } + .col-xs-1 { + width: 8.33333333%; + } + .hide-xs { + display: none !important; + } + .show-xs { + display: block !important; + } +} diff --git a/tildes/scss/spectre-0.5.1/_media.scss b/tildes/scss/spectre-0.5.1/_media.scss new file mode 100644 index 0000000..4029e4c --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_media.scss @@ -0,0 +1,75 @@ +// Media +// Image responsive +.img-responsive { + display: block; + height: auto; + max-width: 100%; +} + +// object-fit support is coming to Microsoft Edge +// https://developer.microsoft.com/en-us/microsoft-edge/platform/status/objectfitandobjectposition/ +.img-fit-cover { + object-fit: cover; +} + +.img-fit-contain { + object-fit: contain; +} + +// Video responsive +.video-responsive { + display: block; + overflow: hidden; + padding: 0; + position: relative; + width: 100%; + &::before { + content: ""; + display: block; + padding-bottom: 56.25%; // Default ratio 16:9, you can calculate this value by dividing 9 by 16 + } + + iframe, + object, + embed { + border: 0; + bottom: 0; + height: 100%; + left: 0; + position: absolute; + right: 0; + top: 0; + width: 100%; + } +} + +video.video-responsive { + height: auto; + max-width: 100%; + + &::before { + content: none; + } +} + +.video-responsive-4-3 { + &::before { + padding-bottom: 75%; // Ratio 4:3 + } +} + +.video-responsive-1-1 { + &::before { + padding-bottom: 100%; // Ratio 1:1 + } +} + +// Figure +.figure { + margin: 0 0 $layout-spacing 0; + + .figure-caption { + color: $gray-color-dark; + margin-top: $layout-spacing; + } +} diff --git a/tildes/scss/spectre-0.5.1/_menus.scss b/tildes/scss/spectre-0.5.1/_menus.scss new file mode 100644 index 0000000..5bc5ef8 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_menus.scss @@ -0,0 +1,62 @@ +// Menus +.menu { + @include shadow-variant(.05rem); + background: $bg-color-light; + border-radius: $border-radius; + list-style: none; + margin: 0; + min-width: $control-width-xs; + padding: $unit-2; + transform: translateY($layout-spacing-sm); + z-index: $zindex-3; + + &.menu-nav { + background: transparent; + box-shadow: none; + } + + .menu-item { + margin-top: 0; + padding: 0 $unit-2; + text-decoration: none; + user-select: none; + + & > a { + border-radius: $border-radius; + color: inherit; + display: block; + margin: 0 (-$unit-2); + padding: $unit-1 $unit-2; + text-decoration: none; + &:focus, + &:hover { + background: $secondary-color; + color: $primary-color; + } + &:active, + &.active { + background: $secondary-color; + color: $primary-color; + } + } + + .form-checkbox, + .form-radio, + .form-switch { + margin: $unit-h 0; + } + + & + .menu-item { + margin-top: $unit-1; + } + } + + .menu-badge { + float: right; + padding: $unit-1 0; + + .btn { + margin-top: -$unit-h; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_meters.scss b/tildes/scss/spectre-0.5.1/_meters.scss new file mode 100644 index 0000000..9fd98b0 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_meters.scss @@ -0,0 +1,57 @@ +// Meters +// Credit: https://css-tricks.com/html5-meter-element/ +.meter { + appearance: none; + background: $bg-color; + border: 0; + border-radius: $border-radius; + display: block; + width: 100%; + height: $unit-4; + + &::-webkit-meter-inner-element { + display: block; + } + + &::-webkit-meter-bar, + &::-webkit-meter-optimum-value, + &::-webkit-meter-suboptimum-value, + &::-webkit-meter-even-less-good-value { + border-radius: $border-radius; + } + + &::-webkit-meter-bar { + background: $bg-color; + } + + &::-webkit-meter-optimum-value { + background: $success-color; + } + + &::-webkit-meter-suboptimum-value { + background: $warning-color; + } + + &::-webkit-meter-even-less-good-value { + background: $error-color; + } + + &::-moz-meter-bar, + &:-moz-meter-optimum, + &:-moz-meter-sub-optimum, + &:-moz-meter-sub-sub-optimum { + border-radius: $border-radius; + } + + &:-moz-meter-optimum::-moz-meter-bar { + background: $success-color; + } + + &:-moz-meter-sub-optimum::-moz-meter-bar { + background: $warning-color; + } + + &:-moz-meter-sub-sub-optimum::-moz-meter-bar { + background: $error-color; + } +} diff --git a/tildes/scss/spectre-0.5.1/_mixins.scss b/tildes/scss/spectre-0.5.1/_mixins.scss new file mode 100644 index 0000000..54bed34 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_mixins.scss @@ -0,0 +1,11 @@ +// Mixins +@import "mixins/avatar"; +@import "mixins/button"; +@import "mixins/clearfix"; +@import "mixins/color"; +@import "mixins/label"; +@import "mixins/position"; +@import "mixins/shadow"; +@import "mixins/text"; +@import "mixins/toast"; +@import "mixins/transition"; diff --git a/tildes/scss/spectre-0.5.1/_modals.scss b/tildes/scss/spectre-0.5.1/_modals.scss new file mode 100644 index 0000000..59a7e4e --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_modals.scss @@ -0,0 +1,81 @@ +// Modals +.modal { + align-items: center; + bottom: 0; + display: none; + justify-content: center; + left: 0; + opacity: 0; + overflow: hidden; + padding: $layout-spacing; + position: fixed; + right: 0; + top: 0; + + &:target, + &.active { + display: flex; + opacity: 1; + z-index: $zindex-4; + + .modal-overlay { + background: rgba($bg-color, .75); + bottom: 0; + cursor: default; + display: block; + left: 0; + position: absolute; + right: 0; + top: 0; + } + + .modal-container { + animation: slide-down .2s ease 1; + max-width: $control-width-md; + width: 100%; + z-index: $zindex-0; + } + } + + &.modal-sm { + .modal-container { + max-width: $control-width-sm; + padding: 0 $unit-2; + } + } + + &.modal-lg { + .modal-overlay { + background: $bg-color-light; + } + + .modal-container { + box-shadow: none; + max-width: $control-width-lg; + } + } +} + +.modal-container { + @include shadow-variant(.2rem); + background: $bg-color-light; + border-radius: $border-radius; + display: block; + padding: 0 $unit-4; + + .modal-header { + padding: $unit-4; + } + + .modal-body { + max-height: 50vh; + overflow-y: auto; + padding: $unit-4; + position: relative; + } + + .modal-footer { + padding: $unit-4; + text-align: right; + } +} diff --git a/tildes/scss/spectre-0.5.1/_navbar.scss b/tildes/scss/spectre-0.5.1/_navbar.scss new file mode 100755 index 0000000..57585ab --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_navbar.scss @@ -0,0 +1,29 @@ +// Navbar +.navbar { + align-items: stretch; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + + .navbar-section { + align-items: center; + display: flex; + flex: 1 0 0; + + &:not(:first-child):last-child { + justify-content: flex-end; + } + } + + .navbar-center { + align-items: center; + display: flex; + flex: 0 0 auto; + } + + .navbar-brand { + font-size: $font-size-lg; + font-weight: 500; + text-decoration: none; + } +} diff --git a/tildes/scss/spectre-0.5.1/_navs.scss b/tildes/scss/spectre-0.5.1/_navs.scss new file mode 100644 index 0000000..4bedc27 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_navs.scss @@ -0,0 +1,34 @@ +// Navs +.nav { + display: flex; + flex-direction: column; + list-style: none; + margin: $unit-1 0; + + .nav-item { + a { + color: $gray-color-dark; + padding: $unit-1 $unit-2; + text-decoration: none; + &:focus, + &:hover { + color: $primary-color; + } + } + &.active { + & > a { + color: darken($gray-color-dark, 10%); + font-weight: bold; + &:focus, + &:hover { + color: $primary-color; + } + } + } + } + + & .nav { + margin-bottom: $unit-2; + margin-left: $unit-4; + } +} diff --git a/tildes/scss/spectre-0.5.1/_normalize.scss b/tildes/scss/spectre-0.5.1/_normalize.scss new file mode 100644 index 0000000..a098a84 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_normalize.scss @@ -0,0 +1,446 @@ +/* Manually forked from Normalize.css */ +/* normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */ + +/** + * 1. Change the default font family in all browsers (opinionated). + * 2. Correct the line height in all browsers. + * 3. Prevent adjustments of font size after orientation changes in + * IE on Windows Phone and in iOS. + */ + +/* Document + ========================================================================== */ + +html { + font-family: sans-serif; /* 1 */ + -ms-text-size-adjust: 100%; /* 3 */ + -webkit-text-size-adjust: 100%; /* 3 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers (opinionated). + */ + +body { + margin: 0; +} + +/** + * Add the correct display in IE 9-. + */ + +article, +aside, +footer, +header, +nav, +section { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + * 1. Add the correct display in IE. + */ + +figcaption, +figure, +main { /* 1 */ + display: block; +} + +/** + * Add the correct margin in IE 8 (removed). + */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. (removed) + * 2. Correct the odd `em` font sizing in all browsers. + */ + +/* Text-level semantics + ========================================================================== */ + +/** + * 1. Remove the gray background on active links in IE 10. + * 2. Remove gaps in links underline in iOS 8+ and Safari 8+. + */ + +a { + background-color: transparent; /* 1 */ + -webkit-text-decoration-skip: objects; /* 2 */ +} + +/** + * Remove the outline on focused links when they are also active or hovered + * in all browsers (opinionated). + */ + +a:active, +a:hover { + outline-width: 0; +} + +/** + * Modify default styling of address. + */ + +address { + font-style: normal; +} + +/** + * 1. Remove the bottom border in Firefox 39-. + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. (removed) + */ + +/** + * Prevent the duplicate application of `bolder` by the next rule in Safari 6. + */ + +b, +strong { + font-weight: inherit; +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +pre, +samp { + font-family: $mono-font-family; /* 1 (changed) */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font style in Android 4.3-. + */ + +dfn { + font-style: italic; +} + +/** + * Add the correct background and color in IE 9-. (Removed) + */ + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; + font-weight: 400; /* (added) */ +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + */ + +audio, +video { + display: inline-block; +} + +/** + * Add the correct display in iOS 4-7. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Remove the border on images inside links in IE 10-. + */ + +img { + border-style: none; +} + +/** + * Hide the overflow in IE. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers (opinionated). + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 (changed) */ + font-size: inherit; /* 1 (changed) */ + line-height: inherit; /* 1 (changed) */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` + * controls in Android 4. + * 2. Correct the inability to style clickable types in iOS and Safari. + */ + +button, +html [type="button"], /* 1 */ +[type="reset"], +[type="submit"] { + -webkit-appearance: button; /* 2 */ +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule (removed). + */ + + +/** + * Change the border, margin, and padding in all browsers (opinionated) (changed). + */ + +fieldset { + border: 0; + margin: 0; + padding: 0; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * 1. Add the correct display in IE 9-. + * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Remove the default vertical scrollbar in IE. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10-. + * 2. Remove the padding in IE 10-. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding and cancel buttons in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-cancel-button, +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in IE 9-. + * 1. Add the correct display in Edge, IE, and Firefox. + */ + +details, /* 1 */ +menu { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; + outline: none; +} + +/* Scripting + ========================================================================== */ + +/** + * Add the correct display in IE 9-. + */ + +canvas { + display: inline-block; +} + +/** + * Add the correct display in IE. + */ + +template { + display: none; +} + +/* Hidden + ========================================================================== */ + +/** + * Add the correct display in IE 10-. + */ + +[hidden] { + display: none; +} diff --git a/tildes/scss/spectre-0.5.1/_off-canvas.scss b/tildes/scss/spectre-0.5.1/_off-canvas.scss new file mode 100644 index 0000000..27dd7cd --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_off-canvas.scss @@ -0,0 +1,91 @@ +// Off canvas menus +$off-canvas-breakpoint: $size-lg !default; + +.off-canvas { + display: flex; + flex-flow: nowrap; + height: 100%; + position: relative; + width: 100%; + + .off-canvas-toggle { + display: block; + position: absolute; + top: $layout-spacing; + transition: none; + z-index: $zindex-0; + @if $rtl == true { + right: $layout-spacing; + } @else { + left: $layout-spacing; + } + } + + .off-canvas-sidebar { + background: $bg-color; + bottom: 0; + min-width: 10rem; + overflow-y: auto; + position: fixed; + top: 0; + transition: transform .25s ease; + z-index: $zindex-2; + @if $rtl == true { + right: 0; + transform: translateX(100%); + } @else { + left: 0; + transform: translateX(-100%); + } + } + + .off-canvas-content { + flex: 1 1 auto; + height: 100%; + padding: $layout-spacing $layout-spacing $layout-spacing 4rem; + } + + .off-canvas-overlay { + background: rgba($dark-color, .1); + border-color: transparent; + border-radius: 0; + bottom: 0; + display: none; + height: 100%; + left: 0; + position: fixed; + right: 0; + top: 0; + width: 100%; + } + + .off-canvas-sidebar { + &:target, + &.active { + transform: translateX(0); + } + + &:target ~ .off-canvas-overlay, + &.active ~ .off-canvas-overlay { + display: block; + z-index: $zindex-1; + } + } +} + +// Responsive layout +@media (min-width: $off-canvas-breakpoint) { + .off-canvas { + &.off-canvas-sidebar-show { + .off-canvas-toggle { + display: none; + } + + .off-canvas-sidebar { + flex: 0 0 auto; + position: relative; + transform: none; + } + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_pagination.scss b/tildes/scss/spectre-0.5.1/_pagination.scss new file mode 100644 index 0000000..6efc7ba --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_pagination.scss @@ -0,0 +1,61 @@ +// Pagination +.pagination { + display: flex; + list-style: none; + margin: $unit-1 0; + padding: $unit-1 0; + + .page-item { + margin: $unit-1 $unit-o; + + span { + display: inline-block; + padding: $unit-1 $unit-1; + } + + a { + border-radius: $border-radius; + color: $gray-color-dark; + display: inline-block; + padding: $unit-1 $unit-2; + text-decoration: none; + &:focus, + &:hover { + color: $primary-color; + } + } + + &.disabled { + a { + cursor: default; + opacity: .5; + pointer-events: none; + } + } + + &.active { + a { + background: $primary-color; + color: $light-color; + } + } + + &.page-prev, + &.page-next { + flex: 1 0 50%; + } + + &.page-next { + text-align: right; + } + + .page-item-title { + margin: 0; + } + + .page-item-subtitle { + margin: 0; + opacity: .5; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_panels.scss b/tildes/scss/spectre-0.5.1/_panels.scss new file mode 100644 index 0000000..386f96e --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_panels.scss @@ -0,0 +1,23 @@ +// Panels +.panel { + border: $border-width solid $border-color; + border-radius: $border-radius; + display: flex; + flex-direction: column; + + .panel-header, + .panel-footer { + flex: 0 0 auto; + padding: $layout-spacing-lg; + } + + .panel-nav { + flex: 0 0 auto; + } + + .panel-body { + flex: 1 1 auto; + overflow-y: auto; + padding: 0 $layout-spacing-lg; + } +} diff --git a/tildes/scss/spectre-0.5.1/_parallax.scss b/tildes/scss/spectre-0.5.1/_parallax.scss new file mode 100644 index 0000000..acc05be --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_parallax.scss @@ -0,0 +1,135 @@ +// Parallax +$parallax-deg: 3deg !default; +$parallax-offset: 4.5px !default; +$parallax-offset-z: 50px !default; +$parallax-perspective: 1000px !default; +$parallax-scale: .95 !default; +$parallax-fade-color: rgba(255, 255, 255, .35) !default; + +// Mixin: Parallax direction +@mixin parallax-dir() { + height: 50%; + outline: none; + position: absolute; + width: 50%; + z-index: $zindex-1; +} + +.parallax { + display: block; + height: auto; + position: relative; + width: auto; + + .parallax-content { + @include shadow-variant(1rem); + height: auto; + transform: perspective($parallax-perspective); + transform-style: preserve-3d; + transition: all .4s ease; + width: 100%; + + &::before { + content: ""; + display: block; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + } + } + + .parallax-front { + align-items: center; + color: $light-color; + display: flex; + height: 100%; + justify-content: center; + left: 0; + position: absolute; + text-align: center; + text-shadow: 0 0 20px rgba($dark-color, .75); + top: 0; + transform: translateZ($parallax-offset-z) scale($parallax-scale); + transition: all .4s ease; + width: 100%; + z-index: $zindex-0; + } + + .parallax-top-left { + @include parallax-dir(); + left: 0; + top: 0; + + &:focus ~ .parallax-content, + &:hover ~ .parallax-content { + transform: perspective($parallax-perspective) rotateX($parallax-deg) rotateY(-$parallax-deg); + + &::before { + background: linear-gradient(135deg, $parallax-fade-color 0%, transparent 50%); + } + + .parallax-front { + transform: translate3d($parallax-offset, $parallax-offset, $parallax-offset-z) scale($parallax-scale); + } + } + } + + .parallax-top-right { + @include parallax-dir(); + right: 0; + top: 0; + + &:focus ~ .parallax-content, + &:hover ~ .parallax-content { + transform: perspective($parallax-perspective) rotateX($parallax-deg) rotateY($parallax-deg); + + &::before { + background: linear-gradient(-135deg, $parallax-fade-color 0%, transparent 50%); + } + + .parallax-front { + transform: translate3d(-$parallax-offset, $parallax-offset, $parallax-offset-z) scale($parallax-scale); + } + } + } + + .parallax-bottom-left { + @include parallax-dir(); + bottom: 0; + left: 0; + + &:focus ~ .parallax-content, + &:hover ~ .parallax-content { + transform: perspective($parallax-perspective) rotateX(-$parallax-deg) rotateY(-$parallax-deg); + + &::before { + background: linear-gradient(45deg, $parallax-fade-color 0%, transparent 50%); + } + + .parallax-front { + transform: translate3d($parallax-offset, -$parallax-offset, $parallax-offset-z) scale($parallax-scale); + } + } + } + + .parallax-bottom-right { + @include parallax-dir(); + bottom: 0; + right: 0; + + &:focus ~ .parallax-content, + &:hover ~ .parallax-content { + transform: perspective($parallax-perspective) rotateX(-$parallax-deg) rotateY($parallax-deg); + + &::before { + background: linear-gradient(-45deg, $parallax-fade-color 0%, transparent 50%); + } + + .parallax-front { + transform: translate3d(-$parallax-offset, -$parallax-offset, $parallax-offset-z) scale($parallax-scale); + } + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_popovers.scss b/tildes/scss/spectre-0.5.1/_popovers.scss new file mode 100644 index 0000000..34ac7bd --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_popovers.scss @@ -0,0 +1,69 @@ +// Popovers +.popover { + display: inline-block; + position: relative; + + .popover-container { + left: 50%; + opacity: 0; + padding: $layout-spacing; + position: absolute; + top: 0; + transform: translate(-50%, -50%) scale(0); + transition: transform .2s ease; + width: $control-width-sm; + z-index: $zindex-3; + } + + *:focus + .popover-container, + &:hover .popover-container, + .popover-container:hover { + display: block; + opacity: 1; + transform: translate(-50%, -100%) scale(1); + } + + &.popover-right { + .popover-container { + left: 100%; + top: 50%; + } + + :focus + .popover-container, + &:hover .popover-container, + .popover-container:hover { + transform: translate(0, -50%) scale(1); + } + } + + &.popover-bottom { + .popover-container { + left: 50%; + top: 100%; + } + + :focus + .popover-container, + &:hover .popover-container, + .popover-container:hover { + transform: translate(-50%, 0) scale(1); + } + } + + &.popover-left { + .popover-container { + left: 0; + top: 50%; + } + + :focus + .popover-container, + &:hover .popover-container, + .popover-container:hover { + transform: translate(-100%, -50%) scale(1); + } + } + + .card { + @include shadow-variant(.2rem); + border: 0; + } +} diff --git a/tildes/scss/spectre-0.5.1/_progress.scss b/tildes/scss/spectre-0.5.1/_progress.scss new file mode 100644 index 0000000..f173772 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_progress.scss @@ -0,0 +1,45 @@ +// Progress +// Credit: https://css-tricks.com/html5-progress-element/ +.progress { + appearance: none; + background: $bg-color-dark; + border: 0; + border-radius: $border-radius; + color: $primary-color; + height: $unit-1; + position: relative; + width: 100%; + + &::-webkit-progress-bar { + background: transparent; + border-radius: $border-radius; + } + + &::-webkit-progress-value { + background: $primary-color; + border-radius: $border-radius; + } + + &::-moz-progress-bar { + background: $primary-color; + border-radius: $border-radius; + } + + &:indeterminate { + animation: progress-indeterminate 1.5s linear infinite; + background: $bg-color-dark linear-gradient(to right, $primary-color 30%, $bg-color-dark 30%) top left / 150% 150% no-repeat; + + &::-moz-progress-bar { + background: transparent; + } + } +} + +@keyframes progress-indeterminate { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/tildes/scss/spectre-0.5.1/_sliders.scss b/tildes/scss/spectre-0.5.1/_sliders.scss new file mode 100644 index 0000000..01576b9 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_sliders.scss @@ -0,0 +1,99 @@ +// Sliders +// Credit: https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ +.slider { + appearance: none; + background: transparent; + display: block; + width: 100%; + height: $unit-6; + + &:focus { + @include control-shadow(); + outline: none; + } + + &.tooltip:not([data-tooltip]) { + &::after { + content: attr(value); + } + } + + // Slider Thumb + &::-webkit-slider-thumb { + -webkit-appearance: none; + background: $primary-color; + border: 0; + border-radius: 50%; + height: $unit-3; + margin-top: -($unit-3 - $unit-h) / 2; + transition: transform .2s ease; + width: $unit-3; + } + &::-moz-range-thumb { + background: $primary-color; + border: 0; + border-radius: 50%; + height: $unit-3; + transition: transform .2s ease; + width: $unit-3; + } + &::-ms-thumb { + background: $primary-color; + border: 0; + border-radius: 50%; + height: $unit-3; + transition: transform .2s ease; + width: $unit-3; + } + + &:active { + &::-webkit-slider-thumb { + transform: scale(1.25); + } + &::-moz-range-thumb { + transform: scale(1.25); + } + &::-ms-thumb { + transform: scale(1.25); + } + } + + &:disabled, + &.disabled { + &::-webkit-slider-thumb { + background: $gray-color-light; + transform: scale(1); + } + &::-moz-range-thumb { + background: $gray-color-light; + transform: scale(1); + } + &::-ms-thumb { + background: $gray-color-light; + transform: scale(1); + } + } + + // Slider Track + &::-webkit-slider-runnable-track { + background: $bg-color-dark; + border-radius: $border-radius; + height: $unit-h; + width: 100%; + } + &::-moz-range-track { + background: $bg-color-dark; + border-radius: $border-radius; + height: $unit-h; + width: 100%; + } + &::-ms-track { + background: $bg-color-dark; + border-radius: $border-radius; + height: $unit-h; + width: 100%; + } + &::-ms-fill-lower { + background: $primary-color; + } +} diff --git a/tildes/scss/spectre-0.5.1/_steps.scss b/tildes/scss/spectre-0.5.1/_steps.scss new file mode 100644 index 0000000..d5ddc6e --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_steps.scss @@ -0,0 +1,70 @@ +// Steps +.step { + display: flex; + flex-wrap: nowrap; + list-style: none; + margin: $unit-1 0; + width: 100%; + + .step-item { + flex: 1 1 0; + margin-top: 0; + min-height: 1rem; + text-align: center; + position: relative; + + &:not(:first-child)::before { + background: $primary-color; + content: ""; + height: 2px; + left: -50%; + position: absolute; + top: 9px; + width: 100%; + } + + a { + color: $gray-color; + display: inline-block; + padding: 20px 10px 0; + text-decoration: none; + + &::before { + background: $primary-color; + border: $border-width-lg solid $light-color; + border-radius: 50%; + content: ""; + display: block; + height: $unit-3; + left: 50%; + position: absolute; + top: $unit-1; + transform: translateX(-50%); + width: $unit-3; + z-index: $zindex-0; + } + } + + &.active { + a { + &::before { + background: $light-color; + border: $border-width-lg solid $primary-color; + } + } + + & ~ .step-item { + &::before { + background: $border-color; + } + + a { + + &::before { + background: $gray-color-light; + } + } + } + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_tables.scss b/tildes/scss/spectre-0.5.1/_tables.scss new file mode 100644 index 0000000..732718c --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_tables.scss @@ -0,0 +1,57 @@ +// Tables +.table { + border-collapse: collapse; + border-spacing: 0; + width: 100%; + @if $rtl == true { + text-align: right; + } @else { + text-align: left; + } + + &.table-striped { + tbody { + tr:nth-of-type(odd) { + background: $bg-color; + } + } + } + + &, + &.table-striped { + tbody { + tr { + &.active { + background: $bg-color-dark; + } + } + } + } + + &.table-hover { + tbody { + tr { + &:hover { + background: $bg-color-dark; + } + } + } + } + + // Tables with horizontal scrollbar + &.table-scroll { + display: block; + overflow-x: auto; + padding-bottom: .75rem; + white-space: nowrap; + } + + td, + th { + border-bottom: $border-width solid $border-color; + padding: $unit-3 $unit-2; + } + th { + border-bottom-width: $border-width-lg; + } +} diff --git a/tildes/scss/spectre-0.5.1/_tabs.scss b/tildes/scss/spectre-0.5.1/_tabs.scss new file mode 100644 index 0000000..0dcbaf3 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_tabs.scss @@ -0,0 +1,66 @@ +// Tabs +.tab { + align-items: center; + border-bottom: $border-width solid $border-color; + display: flex; + flex-wrap: wrap; + list-style: none; + margin: $unit-1 0 ($unit-1 - $border-width) 0; + + .tab-item { + margin-top: 0; + + a { + border-bottom: $border-width-lg solid transparent; + color: inherit; + display: block; + margin: 0 $unit-2 0 0; + padding: $unit-2 $unit-1 $unit-2 - $border-width-lg $unit-1; + text-decoration: none; + &:focus, + &:hover { + color: $link-color; + } + } + &.active a, + a.active { + border-bottom-color: $primary-color; + color: $link-color; + } + + &.tab-action { + flex: 1 0 auto; + text-align: right; + } + + .btn-clear { + margin-top: -$unit-1; + } + } + + &.tab-block { + .tab-item { + flex: 1 0 0; + text-align: center; + + a { + margin: 0; + } + + .badge { + &[data-badge]::after { + position: absolute; + right: $unit-h; + top: $unit-h; + transform: translate(0, 0); + } + } + } + } + + &:not(.tab-block) { + .badge { + padding-right: 0; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_tiles.scss b/tildes/scss/spectre-0.5.1/_tiles.scss new file mode 100644 index 0000000..742bbae --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_tiles.scss @@ -0,0 +1,38 @@ +// Tiles +.tile { + align-content: space-between; + align-items: flex-start; + display: flex; + + .tile-icon, + .tile-action { + flex: 0 0 auto; + } + .tile-content { + flex: 1 1 auto; + &:not(:first-child) { + padding-left: $unit-2; + } + &:not(:last-child) { + padding-right: $unit-2; + } + } + .tile-title, + .tile-subtitle { + line-height: $line-height; + } + + &.tile-centered { + align-items: center; + + .tile-content { + overflow: hidden; + } + + .tile-title, + .tile-subtitle { + @include text-ellipsis(); + margin-bottom: 0; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_timelines.scss b/tildes/scss/spectre-0.5.1/_timelines.scss new file mode 100644 index 0000000..67041a8 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_timelines.scss @@ -0,0 +1,54 @@ +// Timelines +.timeline { + .timeline-item { + display: flex; + margin-bottom: $unit-6; + position: relative; + &::before { + background: $border-color; + content: ""; + height: 100%; + left: 11px; + position: absolute; + top: $unit-6; + width: 2px; + } + + .timeline-left { + flex: 0 0 auto; + } + + .timeline-content { + flex: 1 1 auto; + padding: 2px 0 2px $layout-spacing-lg; + } + + .timeline-icon { + border-radius: 50%; + color: $light-color; + display: block; + height: $unit-6; + text-align: center; + width: $unit-6; + &::before { + border: $border-width-lg solid $primary-color; + border-radius: 50%; + content: ""; + display: block; + height: $unit-2; + left: $unit-2; + position: absolute; + top: $unit-2; + width: $unit-2; + } + + &.icon-lg { + background: $primary-color; + line-height: $line-height; + &::before { + content: none; + } + } + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_toasts.scss b/tildes/scss/spectre-0.5.1/_toasts.scss new file mode 100644 index 0000000..61e7c5f --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_toasts.scss @@ -0,0 +1,42 @@ +// Toasts +.toast { + @include toast-variant($dark-color); + border: $border-width solid $dark-color; + border-radius: $border-radius; + color: $light-color; + display: block; + padding: $layout-spacing; + width: 100%; + + &.toast-primary { + @include toast-variant($primary-color); + } + + &.toast-success { + @include toast-variant($success-color); + } + + &.toast-warning { + @include toast-variant($warning-color); + } + + &.toast-error { + @include toast-variant($error-color); + } + + a { + color: $light-color; + text-decoration: underline; + + &:focus, + &:hover, + &:active, + &.active { + opacity: .75; + } + } + + .btn-clear { + margin: 4px -2px 4px 4px; + } +} diff --git a/tildes/scss/spectre-0.5.1/_tooltips.scss b/tildes/scss/spectre-0.5.1/_tooltips.scss new file mode 100644 index 0000000..061f9d3 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_tooltips.scss @@ -0,0 +1,79 @@ +// Tooltips +.tooltip { + position: relative; + &::after { + background: rgba($dark-color, .9); + border-radius: $border-radius; + bottom: 100%; + color: $light-color; + content: attr(data-tooltip); + display: block; + font-size: $font-size-sm; + left: 50%; + max-width: $control-width-sm; + opacity: 0; + overflow: hidden; + padding: $unit-1 $unit-2; + pointer-events: none; + position: absolute; + text-overflow: ellipsis; + transform: translate(-50%, $unit-2); + transition: all .2s ease; + white-space: pre; + z-index: $zindex-3; + } + &:focus, + &:hover { + &::after { + opacity: 1; + transform: translate(-50%, -$unit-1); + } + } + &[disabled], + &.disabled { + pointer-events: auto; + } + + &.tooltip-right { + &::after { + bottom: 50%; + left: 100%; + transform: translate(-$unit-1, 50%); + } + &:focus, + &:hover { + &::after { + transform: translate($unit-1, 50%); + } + } + } + + &.tooltip-bottom { + &::after { + bottom: auto; + top: 100%; + transform: translate(-50%, -$unit-2); + } + &:focus, + &:hover { + &::after { + transform: translate(-50%, $unit-1); + } + } + } + + &.tooltip-left { + &::after { + bottom: 50%; + left: auto; + right: 100%; + transform: translate($unit-2, 50%); + } + &:focus, + &:hover { + &::after { + transform: translate(-$unit-1, 50%); + } + } + } +} diff --git a/tildes/scss/spectre-0.5.1/_typography.scss b/tildes/scss/spectre-0.5.1/_typography.scss new file mode 100644 index 0000000..d15d39e --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_typography.scss @@ -0,0 +1,128 @@ +// Typography +// Headings +h1, +h2, +h3, +h4, +h5, +h6 { + color: inherit; + font-weight: 500; + line-height: 1.2; + margin-bottom: .5em; + margin-top: 0; +} +.h1, +.h2, +.h3, +.h4, +.h5, +.h6 { + font-weight: 500; +} +h1, +.h1 { + font-size: 2rem; +} +h2, +.h2 { + font-size: 1.6rem; +} +h3, +.h3 { + font-size: 1.4rem; +} +h4, +.h4 { + font-size: 1.2rem; +} +h5, +.h5 { + font-size: 1rem; +} +h6, +.h6 { + font-size: .8rem; +} + +// Paragraphs +p { + margin: 0 0 $line-height; +} + +// Semantic text elements +a, +ins, +u { + text-decoration-skip: ink edges; +} + +abbr[title] { + border-bottom: $border-width dotted; + cursor: help; + text-decoration: none; +} + +kbd { + @include label-base(); + @include label-variant($light-color, $dark-color); + font-size: $font-size-sm; +} + +mark { + @include label-variant($body-font-color, $highlight-color); + border-radius: $border-radius; + padding: .05rem; +} + +// Blockquote +blockquote { + border-left: $border-width-lg solid $border-color; + margin-left: 0; + padding: $unit-2 $unit-4; + + p:last-child { + margin-bottom: 0; + } +} + +// Lists +ul, +ol { + margin: $unit-4 0 $unit-4 $unit-4; + padding: 0; + + ul, + ol { + margin: $unit-4 0 $unit-4 $unit-4; + } + + li { + margin-top: $unit-2; + } +} + +ul { + list-style: disc inside; + + ul { + list-style-type: circle; + } +} + +ol { + list-style: decimal inside; + + ol { + list-style-type: lower-alpha; + } +} + +dl { + dt { + font-weight: bold; + } + dd { + margin: $unit-2 0 $unit-4 0; + } +} diff --git a/tildes/scss/spectre-0.5.1/_utilities.scss b/tildes/scss/spectre-0.5.1/_utilities.scss new file mode 100644 index 0000000..80f1e0b --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_utilities.scss @@ -0,0 +1,8 @@ +@import "utilities/colors"; +@import "utilities/cursors"; +@import "utilities/display"; +@import "utilities/divider"; +@import "utilities/loading"; +@import "utilities/position"; +@import "utilities/shapes"; +@import "utilities/text"; diff --git a/tildes/scss/spectre-0.5.1/_variables.scss b/tildes/scss/spectre-0.5.1/_variables.scss new file mode 100644 index 0000000..ec7e5a4 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/_variables.scss @@ -0,0 +1,117 @@ +// Include Tildes variable definitions, which will override the standard ones +@import '../_spectre_variables.scss'; + +// Core variables +$version: "0.5.1"; + +// Core features +$rtl: false !default; + +// Core colors +$primary-color: #5755d9 !default; +$primary-color-dark: darken($primary-color, 3%) !default; +$primary-color-light: lighten($primary-color, 3%) !default; +$secondary-color: lighten($primary-color, 37.5%) !default; +$secondary-color-dark: darken($secondary-color, 3%) !default; +$secondary-color-light: lighten($secondary-color, 3%) !default; + +// Gray colors +$dark-color: #454d5d !default; +$light-color: #fff !default; +$gray-color: lighten($dark-color, 40%) !default; +$gray-color-dark: darken($gray-color, 25%) !default; +$gray-color-light: lighten($gray-color, 20%) !default; + +$border-color: lighten($dark-color, 60%) !default; +$border-color-dark: darken($border-color, 10%) !default; +$bg-color: lighten($dark-color, 66%) !default; +$bg-color-dark: darken($bg-color, 3%) !default; +$bg-color-light: $light-color !default; + +// Control colors +$success-color: #32b643 !default; +$warning-color: #ffb700 !default; +$error-color: #e85600 !default; + +// Other colors +$code-color: #e06870 !default; +$highlight-color: #ffe9b3 !default; +$body-bg: $bg-color-light !default; +$body-font-color: lighten($dark-color, 5%) !default; +$link-color: $primary-color !default; +$link-color-dark: darken($link-color, 5%) !default; + +// Fonts +// Credit: https://www.smashingmagazine.com/2015/11/using-system-ui-fonts-practical-guide/ +$base-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto !default; +$mono-font-family: "SF Mono", "Segoe UI Mono", "Roboto Mono", Menlo, Courier, monospace !default; +$fallback-font-family: "Helvetica Neue", sans-serif !default; +$cjk-zh-font-family: $base-font-family, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", $fallback-font-family !default; +$cjk-jp-font-family: $base-font-family, "Hiragino Sans", "Hiragino Kaku Gothic Pro", "Yu Gothic", YuGothic, Meiryo, $fallback-font-family !default; +$cjk-ko-font-family: $base-font-family, "Malgun Gothic", $fallback-font-family !default; +$body-font-family: $base-font-family, $fallback-font-family !default; + +// Unit sizes +$unit-o: .05rem !default; +$unit-h: .1rem !default; +$unit-1: .2rem !default; +$unit-2: .4rem !default; +$unit-3: .6rem !default; +$unit-4: .8rem !default; +$unit-5: 1rem !default; +$unit-6: 1.2rem !default; +$unit-7: 1.4rem !default; +$unit-8: 1.6rem !default; +$unit-9: 1.8rem !default; +$unit-10: 2rem !default; +$unit-12: 2.4rem !default; +$unit-16: 3.2rem !default; + +// Font sizes +$html-font-size: 20px !default; +$html-line-height: 1.5 !default; +$font-size: .8rem !default; +$font-size-sm: .7rem !default; +$font-size-lg: .9rem !default; +$line-height: 1rem !default; + +// Sizes +$layout-spacing: $unit-2 !default; +$layout-spacing-sm: $unit-1 !default; +$layout-spacing-lg: $unit-4 !default; +$border-radius: $unit-h !default; +$border-width: $unit-o !default; +$border-width-lg: $unit-h !default; +$control-size: $unit-9 !default; +$control-size-sm: $unit-7 !default; +$control-size-lg: $unit-10 !default; +$control-padding-x: $unit-2 !default; +$control-padding-x-sm: $unit-2 * .75 !default; +$control-padding-x-lg: $unit-2 * 1.5 !default; +$control-padding-y: ($control-size - $line-height) / 2 - $border-width !default; +$control-padding-y-sm: ($control-size-sm - $line-height) / 2 - $border-width !default; +$control-padding-y-lg: ($control-size-lg - $line-height) / 2 - $border-width !default; +$control-icon-size: .8rem !default; + +$control-width-xs: 180px !default; +$control-width-sm: 320px !default; +$control-width-md: 640px !default; +$control-width-lg: 960px !default; +$control-width-xl: 1280px !default; + +// Responsive breakpoints +$size-xs: 480px !default; +$size-sm: 600px !default; +$size-md: 840px !default; +$size-lg: 960px !default; +$size-xl: 1280px !default; +$size-2x: 1440px !default; + +$responsive-breakpoint: $size-xs !default; + +// Z-index +$zindex-0: 1 !default; +$zindex-1: 100 !default; +$zindex-2: 200 !default; +$zindex-3: 300 !default; +$zindex-4: 400 !default; diff --git a/tildes/scss/spectre-0.5.1/icons/_icons-action.scss b/tildes/scss/spectre-0.5.1/icons/_icons-action.scss new file mode 100644 index 0000000..807f05e --- /dev/null +++ b/tildes/scss/spectre-0.5.1/icons/_icons-action.scss @@ -0,0 +1,316 @@ + +// Icon resize +.icon-resize-horiz, +.icon-resize-vert { + &::before, + &::after { + border: $icon-border-width solid currentColor; + border-bottom: 0; + border-right: 0; + content: ""; + height: .45em; + width: .45em; + } + &::before { + transform: translate(-50%, -90%) rotate(45deg); + } + &::after { + transform: translate(-50%, -10%) rotate(225deg); + } +} + +.icon-resize-horiz { + &::before { + transform: translate(-90%, -50%) rotate(-45deg); + } + &::after { + transform: translate(-10%, -50%) rotate(135deg); + } +} + +// Icon more +.icon-more-horiz, +.icon-more-vert { + &::before { + background: currentColor; + box-shadow: -.4em 0, .4em 0; + border-radius: 50%; + content: ""; + height: 3px; + width: 3px; + } +} + +.icon-more-vert { + &::before { + box-shadow: 0 -.4em, 0 .4em; + } +} + +// Icon plus, minus, cross +.icon-plus, +.icon-minus, +.icon-cross { + &::before { + background: currentColor; + content: ""; + height: $icon-border-width; + width: 100%; + } +} + +.icon-plus, +.icon-cross { + &::after { + background: currentColor; + content: ""; + height: 100%; + width: $icon-border-width; + } +} + +.icon-cross { + &::before { + width: 100%; + } + &::after { + height: 100%; + } + &::before, + &::after { + transform: translate(-50%, -50%) rotate(45deg); + } +} + +// Icon check +.icon-check { + &::before { + border: $icon-border-width solid currentColor; + border-right: 0; + border-top: 0; + content: ""; + height: .5em; + width: .9em; + transform: translate(-50%, -75%) rotate(-45deg); + } +} + +// Icon stop +.icon-stop { + border: $icon-border-width solid currentColor; + border-radius: 50%; + &::before { + background: currentColor; + content: ""; + height: $icon-border-width; + transform: translate(-50%, -50%) rotate(45deg); + width: 1em; + } +} + +// Icon shutdown +.icon-shutdown { + border: $icon-border-width solid currentColor; + border-radius: 50%; + border-top-color: transparent; + &::before { + background: currentColor; + content: ""; + height: .5em; + top: .1em; + width: $icon-border-width; + } +} + +// Icon refresh +.icon-refresh { + &::before { + border: $icon-border-width solid currentColor; + border-radius: 50%; + border-right-color: transparent; + content: ""; + height: 1em; + width: 1em; + } + &::after { + border: .2em solid currentColor; + border-top-color: transparent; + border-left-color: transparent; + content: ""; + height: 0; + left: 80%; + top: 20%; + width: 0; + } +} + +// Icon search +.icon-search { + &::before { + border: $icon-border-width solid currentColor; + border-radius: 50%; + content: ""; + height: .75em; + left: 5%; + top: 5%; + transform: translate(0, 0) rotate(45deg); + width: .75em; + } + &::after { + background: currentColor; + content: ""; + height: $icon-border-width; + left: 80%; + top: 80%; + transform: translate(-50%, -50%) rotate(45deg); + width: .4em; + } +} + +// Icon edit +.icon-edit { + &::before { + border: $icon-border-width solid currentColor; + content: ""; + height: .4em; + transform: translate(-40%, -60%) rotate(-45deg); + width: .85em; + } + &::after { + border: .15em solid currentColor; + border-top-color: transparent; + border-right-color: transparent; + content: ""; + height: 0; + left: 5%; + top: 95%; + transform: translate(0, -100%); + width: 0; + } +} + +// Icon delete +.icon-delete { + &::before { + border: $icon-border-width solid currentColor; + border-bottom-left-radius: $border-radius; + border-bottom-right-radius: $border-radius; + border-top: 0; + content: ""; + height: .75em; + top: 60%; + width: .75em; + } + &::after { + background: currentColor; + box-shadow: -.25em .2em, .25em .2em; + content: ""; + height: $icon-border-width; + top: $icon-border-width/2; + width: .5em; + } +} + +// Icon share +.icon-share { + border: $icon-border-width solid currentColor; + border-radius: $border-radius; + border-right: 0; + border-top: 0; + &::before { + border: $icon-border-width solid currentColor; + border-left: 0; + border-top: 0; + content: ""; + height: .4em; + left: 100%; + top: .25em; + transform: translate(-125%, -50%) rotate(-45deg); + width: .4em; + } + &::after { + border: $icon-border-width solid currentColor; + border-bottom: 0; + border-right: 0; + border-radius: 75% 0; + content: ""; + height: .5em; + width: .6em; + } +} + +// Icon flag +.icon-flag { + &::before { + background: currentColor; + content: ""; + height: 1em; + left: 15%; + width: $icon-border-width; + } + &::after { + border: $icon-border-width solid currentColor; + border-bottom-right-radius: $border-radius; + border-left: 0; + border-top-right-radius: $border-radius; + content: ""; + height: .65em; + top: 35%; + left: 60%; + width: .8em; + } +} + +// Icon bookmark +.icon-bookmark { + &::before { + border: $icon-border-width solid currentColor; + border-bottom: 0; + border-top-left-radius: $border-radius; + border-top-right-radius: $border-radius; + content: ""; + height: .9em; + width: .8em; + } + &::after { + border: $icon-border-width solid currentColor; + border-bottom: 0; + border-left: 0; + border-radius: $border-radius; + content: ""; + height: .5em; + transform: translate(-50%, 35%) rotate(-45deg) skew(15deg, 15deg); + width: .5em; + } +} + +// Icon download & upload +.icon-download, +.icon-upload { + border-bottom: $icon-border-width solid currentColor; + &::before { + border: $icon-border-width solid currentColor; + border-bottom: 0; + border-right: 0; + content: ""; + height: .5em; + width: .5em; + transform: translate(-50%, -60%) rotate(-135deg); + } + &::after { + background: currentColor; + content: ""; + height: .6em; + top: 40%; + width: $icon-border-width; + } +} + +.icon-upload { + &::before { + transform: translate(-50%, -60%) rotate(45deg); + } + &::after { + top: 50%; + } +} diff --git a/tildes/scss/spectre-0.5.1/icons/_icons-core.scss b/tildes/scss/spectre-0.5.1/icons/_icons-core.scss new file mode 100644 index 0000000..577024d --- /dev/null +++ b/tildes/scss/spectre-0.5.1/icons/_icons-core.scss @@ -0,0 +1,53 @@ +// Icon variables +$icon-border-width: $border-width-lg; +$icon-prefix: "icon"; + +// Icon base style +.#{$icon-prefix} { + box-sizing: border-box; + display: inline-block; + font-size: inherit; + font-style: normal; + height: 1em; + position: relative; + text-indent: -9999px; + vertical-align: middle; + width: 1em; + &::before, + &::after { + display: block; + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + } + + // Icon sizes + &.icon-2x { + font-size: 1.6rem; + } + + &.icon-3x { + font-size: 2.4rem; + } + + &.icon-4x { + font-size: 3.2rem; + } +} + +// Component icon support +.accordion, +.btn, +.toast, +.menu { + .#{$icon-prefix} { + vertical-align: -10%; + } +} + +.btn-lg { + .#{$icon-prefix} { + vertical-align: -15%; + } +} diff --git a/tildes/scss/spectre-0.5.1/icons/_icons-navigation.scss b/tildes/scss/spectre-0.5.1/icons/_icons-navigation.scss new file mode 100644 index 0000000..7d7fcd3 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/icons/_icons-navigation.scss @@ -0,0 +1,133 @@ +// Icon arrows +.icon-arrow-down, +.icon-arrow-left, +.icon-arrow-right, +.icon-arrow-up, +.icon-downward, +.icon-back, +.icon-forward, +.icon-upward { + &::before { + border: $icon-border-width solid currentColor; + border-bottom: 0; + border-right: 0; + content: ""; + height: .65em; + width: .65em; + } +} + +.icon-arrow-down { + &::before { + transform: translate(-50%, -75%) rotate(225deg); + } +} + +.icon-arrow-left { + &::before { + transform: translate(-25%, -50%) rotate(-45deg); + } +} + +.icon-arrow-right { + &::before { + transform: translate(-75%, -50%) rotate(135deg); + } +} + +.icon-arrow-up { + &::before { + transform: translate(-50%, -25%) rotate(45deg); + } +} + +.icon-back, +.icon-forward { + &::after { + background: currentColor; + content: ""; + height: $icon-border-width; + width: .8em; + } +} + +.icon-downward, +.icon-upward { + &::after { + background: currentColor; + content: ""; + height: .8em; + width: $icon-border-width; + } +} + +.icon-back { + &::after { + left: 55%; + } + &::before { + transform: translate(-50%, -50%) rotate(-45deg); + } +} + +.icon-downward { + &::after { + top: 45%; + } + &::before { + transform: translate(-50%, -50%) rotate(-135deg); + } +} + +.icon-forward { + &::after { + left: 45%; + } + &::before { + transform: translate(-50%, -50%) rotate(135deg); + } +} + +.icon-upward { + &::after { + top: 55%; + } + &::before { + transform: translate(-50%, -50%) rotate(45deg); + } +} + +// Icon caret +.icon-caret { + &::before { + border-top: .3em solid currentColor; + border-right: .3em solid transparent; + border-left: .3em solid transparent; + content: ""; + height: 0; + transform: translate(-50%, -25%); + width: 0; + } +} + +// Icon menu +.icon-menu { + &::before { + background: currentColor; + box-shadow: 0 -.35em, 0 .35em; + content: ""; + height: $icon-border-width; + width: 100%; + } +} + +// Icon apps +.icon-apps { + &::before { + background: currentColor; + box-shadow: -.35em -.35em, -.35em 0, -.35em .35em, 0 -.35em, 0 .35em, .35em -.35em, .35em 0, .35em .35em; + content: ""; + height: 3px; + width: 3px; + } +} diff --git a/tildes/scss/spectre-0.5.1/icons/_icons-object.scss b/tildes/scss/spectre-0.5.1/icons/_icons-object.scss new file mode 100644 index 0000000..746d25b --- /dev/null +++ b/tildes/scss/spectre-0.5.1/icons/_icons-object.scss @@ -0,0 +1,176 @@ +// Icon time +.icon-time { + border: $icon-border-width solid currentColor; + border-radius: 50%; + &::before { + background: currentColor; + content: ""; + height: .4em; + transform: translate(-50%, -75%); + width: $icon-border-width; + } + &::after { + background: currentColor; + content: ""; + height: .3em; + transform: translate(-50%, -75%) rotate(90deg); + transform-origin: 50% 90%; + width: $icon-border-width; + } +} + +// Icon mail +.icon-mail { + &::before { + border: $icon-border-width solid currentColor; + border-radius: $border-radius; + content: ""; + height: .8em; + width: 1em; + } + &::after { + border: $icon-border-width solid currentColor; + border-right: 0; + border-top: 0; + content: ""; + height: .5em; + transform: translate(-50%, -90%) rotate(-45deg) skew(10deg, 10deg); + width: .5em; + } +} + +// Icon people +.icon-people { + &::before { + border: $icon-border-width solid currentColor; + border-radius: 50%; + content: ""; + height: .45em; + top: 25%; + width: .45em; + } + &::after { + border: $icon-border-width solid currentColor; + border-radius: 50% 50% 0 0; + content: ""; + height: .4em; + top: 75%; + width: .9em; + } +} + +// Icon message +.icon-message { + border: $icon-border-width solid currentColor; + border-bottom: 0; + border-radius: $border-radius; + border-right: 0; + &::before { + border: $icon-border-width solid currentColor; + border-bottom-right-radius: $border-radius; + border-left: 0; + border-top: 0; + content: ""; + height: .8em; + left: 65%; + top: 40%; + width: .7em; + } + &::after { + background: currentColor; + border-radius: $border-radius; + content: ""; + height: .3em; + left: 10%; + top: 100%; + transform: translate(0, -90%) rotate(45deg); + width: $icon-border-width; + } +} + +// Icon photo +.icon-photo { + border: $icon-border-width solid currentColor; + border-radius: $border-radius; + &::before { + border: $icon-border-width solid currentColor; + border-radius: 50%; + content: ""; + height: .25em; + left: 35%; + top: 35%; + width: .25em; + } + &::after { + border: $icon-border-width solid currentColor; + border-bottom: 0; + border-left: 0; + content: ""; + height: .5em; + left: 60%; + transform: translate(-50%, 25%) rotate(-45deg); + width: .5em; + } +} + +// Icon link +.icon-link { + &::before, + &::after { + border: $icon-border-width solid currentColor; + border-radius: 5em 0 0 5em; + border-right: 0; + content: ""; + height: .5em; + width: .75em; + } + &::before { + transform: translate(-70%, -45%) rotate(-45deg); + } + &::after { + transform: translate(-30%, -55%) rotate(135deg); + } +} + +// Icon location +.icon-location { + &::before { + border: $icon-border-width solid currentColor; + border-radius: 50% 50% 50% 0; + content: ""; + height: .8em; + transform: translate(-50%, -60%) rotate(-45deg); + width: .8em; + } + &::after { + border: $icon-border-width solid currentColor; + border-radius: 50%; + content: ""; + height: .2em; + transform: translate(-50%, -80%); + width: .2em; + } +} + +// Icon emoji +.icon-emoji { + border: $icon-border-width solid currentColor; + border-radius: 50%; + &::before { + border-radius: 50%; + box-shadow: -.17em -.15em, .17em -.15em; + content: ""; + height: .1em; + width: .1em; + } + &::after { + border: $icon-border-width solid currentColor; + border-bottom-color: transparent; + border-radius: 50%; + border-right-color: transparent; + content: ""; + height: .5em; + transform: translate(-50%, -40%) rotate(-135deg); + width: .5em; + } +} diff --git a/tildes/scss/spectre-0.5.1/mixins/_avatar.scss b/tildes/scss/spectre-0.5.1/mixins/_avatar.scss new file mode 100644 index 0000000..14617ad --- /dev/null +++ b/tildes/scss/spectre-0.5.1/mixins/_avatar.scss @@ -0,0 +1,6 @@ +// Avatar mixin +@mixin avatar-base($size: $unit-8) { + font-size: $size / 2; + height: $size; + width: $size; +} diff --git a/tildes/scss/spectre-0.5.1/mixins/_button.scss b/tildes/scss/spectre-0.5.1/mixins/_button.scss new file mode 100644 index 0000000..c90a94b --- /dev/null +++ b/tildes/scss/spectre-0.5.1/mixins/_button.scss @@ -0,0 +1,54 @@ +// Button variant mixin +@mixin button-variant($color: $primary-color) { + background: $color; + border-color: darken($color, 3%); + color: $light-color; + &:focus { + @include control-shadow($color); + } + &:focus, + &:hover { + background: darken($color, 2%); + border-color: darken($color, 5%); + color: $light-color; + } + &:active, + &.active { + background: darken($color, 7%); + border-color: darken($color, 10%); + color: $light-color; + } + &.loading { + &::after { + border-bottom-color: $light-color; + border-left-color: $light-color; + } + } +} + +@mixin button-outline-variant($color: $primary-color) { + background: $light-color; + border-color: $color; + color: $color; + &:focus { + @include control-shadow($color); + } + &:focus, + &:hover { + background: lighten($color, 50%); + border-color: darken($color, 2%); + color: $color; + } + &:active, + &.active { + background: $color; + border-color: darken($color, 5%); + color: $light-color; + } + &.loading { + &::after { + border-bottom-color: $color; + border-left-color: $color; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/mixins/_clearfix.scss b/tildes/scss/spectre-0.5.1/mixins/_clearfix.scss new file mode 100644 index 0000000..db6895f --- /dev/null +++ b/tildes/scss/spectre-0.5.1/mixins/_clearfix.scss @@ -0,0 +1,8 @@ +// Clearfix mixin +@mixin clearfix() { + &::after { + clear: both; + content: ""; + display: table; + } +} diff --git a/tildes/scss/spectre-0.5.1/mixins/_color.scss b/tildes/scss/spectre-0.5.1/mixins/_color.scss new file mode 100644 index 0000000..f2e2e5c --- /dev/null +++ b/tildes/scss/spectre-0.5.1/mixins/_color.scss @@ -0,0 +1,24 @@ +// Background color utility mixin +@mixin bg-color-variant($name: ".bg-primary", $color: $primary-color) { + #{$name} { + background: $color; + + @if (lightness($color) < 60) { + color: $light-color; + } + } +} + +// Text color utility mixin +@mixin text-color-variant($name: ".text-primary", $color: $primary-color) { + #{$name} { + color: $color; + } + + a#{$name} { + &:focus, + &:hover { + color: darken($color, 5%); + } + } +} diff --git a/tildes/scss/spectre-0.5.1/mixins/_label.scss b/tildes/scss/spectre-0.5.1/mixins/_label.scss new file mode 100644 index 0000000..fad20c1 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/mixins/_label.scss @@ -0,0 +1,11 @@ +// Label base style +@mixin label-base() { + border-radius: $border-radius; + line-height: 1.2; + padding: .1rem .15rem; +} + +@mixin label-variant($color: $light-color, $bg-color: $primary-color) { + background: $bg-color; + color: $color; +} diff --git a/tildes/scss/spectre-0.5.1/mixins/_position.scss b/tildes/scss/spectre-0.5.1/mixins/_position.scss new file mode 100644 index 0000000..0943469 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/mixins/_position.scss @@ -0,0 +1,65 @@ +// Margin utility mixin +@mixin margin-variant($id: 1, $size: $unit-1) { + .m-#{$id} { + margin: $size; + } + + .mb-#{$id} { + margin-bottom: $size; + } + + .ml-#{$id} { + margin-left: $size; + } + + .mr-#{$id} { + margin-right: $size; + } + + .mt-#{$id} { + margin-top: $size; + } + + .mx-#{$id} { + margin-left: $size; + margin-right: $size; + } + + .my-#{$id} { + margin-bottom: $size; + margin-top: $size; + } +} + +// Padding utility mixin +@mixin padding-variant($id: 1, $size: $unit-1) { + .p-#{$id} { + padding: $size; + } + + .pb-#{$id} { + padding-bottom: $size; + } + + .pl-#{$id} { + padding-left: $size; + } + + .pr-#{$id} { + padding-right: $size; + } + + .pt-#{$id} { + padding-top: $size; + } + + .px-#{$id} { + padding-left: $size; + padding-right: $size; + } + + .py-#{$id} { + padding-bottom: $size; + padding-top: $size; + } +} diff --git a/tildes/scss/spectre-0.5.1/mixins/_shadow.scss b/tildes/scss/spectre-0.5.1/mixins/_shadow.scss new file mode 100644 index 0000000..7984449 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/mixins/_shadow.scss @@ -0,0 +1,9 @@ +// Component focus shadow +@mixin control-shadow($color: $primary-color) { + box-shadow: 0 0 0 .1rem rgba($color, .2); +} + +// Shadow mixin +@mixin shadow-variant($offset) { + box-shadow: 0 $offset ($offset + .05rem) * 2 rgba($dark-color, .3); +} diff --git a/tildes/scss/spectre-0.5.1/mixins/_text.scss b/tildes/scss/spectre-0.5.1/mixins/_text.scss new file mode 100644 index 0000000..97dc99d --- /dev/null +++ b/tildes/scss/spectre-0.5.1/mixins/_text.scss @@ -0,0 +1,6 @@ +// Text Ellipsis +@mixin text-ellipsis() { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/tildes/scss/spectre-0.5.1/mixins/_toast.scss b/tildes/scss/spectre-0.5.1/mixins/_toast.scss new file mode 100644 index 0000000..a7d3bbf --- /dev/null +++ b/tildes/scss/spectre-0.5.1/mixins/_toast.scss @@ -0,0 +1,5 @@ +// Toast variant mixin +@mixin toast-variant($color: $dark-color) { + background: rgba($color, .9); + border-color: $color; +} diff --git a/tildes/scss/spectre-0.5.1/mixins/_transition.scss b/tildes/scss/spectre-0.5.1/mixins/_transition.scss new file mode 100644 index 0000000..0b7497b --- /dev/null +++ b/tildes/scss/spectre-0.5.1/mixins/_transition.scss @@ -0,0 +1,4 @@ +// Component transition +@mixin control-transition() { + transition: all .2s ease; +} diff --git a/tildes/scss/spectre-0.5.1/spectre-exp.scss b/tildes/scss/spectre-0.5.1/spectre-exp.scss new file mode 100644 index 0000000..9ba75ff --- /dev/null +++ b/tildes/scss/spectre-0.5.1/spectre-exp.scss @@ -0,0 +1,17 @@ +// Variables and mixins +@import "variables"; +@import "mixins"; + +/*! Spectre.css Experimentals v#{$version} | MIT License | github.com/picturepan2/spectre */ +// Experimentals +@import "autocomplete"; +@import "calendars"; +@import "carousels"; +@import "comparison-sliders"; +@import "filters"; +@import "meters"; +@import "off-canvas"; +@import "parallax"; +@import "progress"; +@import "sliders"; +@import "timelines"; diff --git a/tildes/scss/spectre-0.5.1/spectre-icons.scss b/tildes/scss/spectre-0.5.1/spectre-icons.scss new file mode 100644 index 0000000..383624e --- /dev/null +++ b/tildes/scss/spectre-0.5.1/spectre-icons.scss @@ -0,0 +1,10 @@ +// Variables and mixins +@import "variables"; +@import "mixins"; + +/*! Spectre.css Icons v#{$version} | MIT License | github.com/picturepan2/spectre */ +// Icons +@import "icons/icons-core"; +@import "icons/icons-navigation"; +@import "icons/icons-action"; +@import "icons/icons-object"; diff --git a/tildes/scss/spectre-0.5.1/spectre.scss b/tildes/scss/spectre-0.5.1/spectre.scss new file mode 100644 index 0000000..c0d9001 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/spectre.scss @@ -0,0 +1,48 @@ +// Variables and mixins +@import "variables"; +@import "mixins"; + +/*! Spectre.css v#{$version} | MIT License | github.com/picturepan2/spectre */ +// Reset and dependencies +@import "normalize"; +@import "base"; + +// Elements +@import "typography"; +@import "asian"; +@import "tables"; +@import "buttons"; +@import "forms"; +@import "labels"; +@import "codes"; +@import "media"; + +// Layout +@import "layout"; +@import "navbar"; + +// Components +@import "accordions"; +@import "avatars"; +@import "badges"; +@import "breadcrumbs"; +@import "bars"; +@import "cards"; +@import "chips"; +@import "dropdowns"; +@import "empty"; +@import "menus"; +@import "modals"; +@import "navs"; +@import "pagination"; +@import "panels"; +@import "popovers"; +@import "steps"; +@import "tabs"; +@import "tiles"; +@import "toasts"; +@import "tooltips"; + +// Utility classes +@import "animations"; +@import "utilities"; diff --git a/tildes/scss/spectre-0.5.1/utilities/_colors.scss b/tildes/scss/spectre-0.5.1/utilities/_colors.scss new file mode 100644 index 0000000..eced5ad --- /dev/null +++ b/tildes/scss/spectre-0.5.1/utilities/_colors.scss @@ -0,0 +1,29 @@ +// Text colors +@include text-color-variant(".text-primary", $primary-color); + +@include text-color-variant(".text-secondary", $secondary-color-dark); + +@include text-color-variant(".text-gray", $gray-color); + +@include text-color-variant(".text-light", $light-color); + +@include text-color-variant(".text-success", $success-color); + +@include text-color-variant(".text-warning", $warning-color); + +@include text-color-variant(".text-error", $error-color); + +// Background colors +@include bg-color-variant(".bg-primary", $primary-color); + +@include bg-color-variant(".bg-secondary", $secondary-color); + +@include bg-color-variant(".bg-dark", $dark-color); + +@include bg-color-variant(".bg-gray", $bg-color); + +@include bg-color-variant(".bg-success", $success-color); + +@include bg-color-variant(".bg-warning", $warning-color); + +@include bg-color-variant(".bg-error", $error-color); diff --git a/tildes/scss/spectre-0.5.1/utilities/_cursors.scss b/tildes/scss/spectre-0.5.1/utilities/_cursors.scss new file mode 100644 index 0000000..bfc4c6b --- /dev/null +++ b/tildes/scss/spectre-0.5.1/utilities/_cursors.scss @@ -0,0 +1,24 @@ +// Cursors +.c-hand { + cursor: pointer; +} + +.c-move { + cursor: move; +} + +.c-zoom-in { + cursor: zoom-in; +} + +.c-zoom-out { + cursor: zoom-out; +} + +.c-not-allowed { + cursor: not-allowed; +} + +.c-auto { + cursor: auto; +} diff --git a/tildes/scss/spectre-0.5.1/utilities/_display.scss b/tildes/scss/spectre-0.5.1/utilities/_display.scss new file mode 100644 index 0000000..c6248e0 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/utilities/_display.scss @@ -0,0 +1,44 @@ +// Display +.d-block { + display: block; +} +.d-inline { + display: inline; +} +.d-inline-block { + display: inline-block; +} +.d-flex { + display: flex; +} +.d-inline-flex { + display: inline-flex; +} +.d-none, +.d-hide { + display: none !important; +} +.d-visible { + visibility: visible; +} +.d-invisible { + visibility: hidden; +} +.text-hide { + background: transparent; + border: 0; + color: transparent; + font-size: 0; + line-height: 0; + text-shadow: none; +} +.text-assistive { + border: 0; + clip: rect(0,0,0,0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} diff --git a/tildes/scss/spectre-0.5.1/utilities/_divider.scss b/tildes/scss/spectre-0.5.1/utilities/_divider.scss new file mode 100644 index 0000000..5d0feb2 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/utilities/_divider.scss @@ -0,0 +1,50 @@ +// Divider +.divider, +.divider-vert { + display: block; + position: relative; + + &[data-content]::after { + background: $bg-color-light; + color: $gray-color; + content: attr(data-content); + display: inline-block; + font-size: $font-size-sm; + padding: 0 $unit-2; + transform: translateY(-$font-size-sm + $border-width); + } +} + +.divider { + border-top: $border-width solid $border-color; + height: $border-width; + margin: $unit-2 0; + + &[data-content] { + margin: $unit-4 0; + } +} + +.divider-vert { + display: block; + padding: $unit-4; + + &::before { + border-left: $border-width solid $border-color; + bottom: $unit-2; + content: ""; + display: block; + left: 50%; + position: absolute; + top: $unit-2; + transform: translateX(-50%); + } + + &[data-content]::after { + left: 50%; + padding: $unit-1 0; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + } +} diff --git a/tildes/scss/spectre-0.5.1/utilities/_loading.scss b/tildes/scss/spectre-0.5.1/utilities/_loading.scss new file mode 100644 index 0000000..1b4ea60 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/utilities/_loading.scss @@ -0,0 +1,34 @@ +// Loading +.loading { + color: transparent !important; + min-height: $unit-4; + pointer-events: none; + position: relative; + &::after { + animation: loading 500ms infinite linear; + border: $border-width-lg solid $primary-color; + border-radius: 50%; + border-right-color: transparent; + border-top-color: transparent; + content: ""; + display: block; + height: $unit-4; + left: 50%; + margin-left: -$unit-2; + margin-top: -$unit-2; + position: absolute; + top: 50%; + width: $unit-4; + z-index: $zindex-0; + } + + &.loading-lg { + min-height: $unit-10; + &::after { + height: $unit-8; + margin-left: -$unit-4; + margin-top: -$unit-4; + width: $unit-8; + } + } +} diff --git a/tildes/scss/spectre-0.5.1/utilities/_position.scss b/tildes/scss/spectre-0.5.1/utilities/_position.scss new file mode 100644 index 0000000..97407d8 --- /dev/null +++ b/tildes/scss/spectre-0.5.1/utilities/_position.scss @@ -0,0 +1,50 @@ +// Position +.clearfix { + @include clearfix(); +} + +.float-left { + float: left !important; +} + +.float-right { + float: right !important; +} + +.relative { + position: relative; +} + +.absolute { + position: absolute; +} + +.fixed { + position: fixed; +} + +.centered { + display: block; + float: none; + margin-left: auto; + margin-right: auto; +} + +.flex-centered { + align-items: center; + display: flex; + justify-content: center; +} + +// Spacing +@include margin-variant(0, 0); + +@include margin-variant(1, $unit-1); + +@include margin-variant(2, $unit-2); + +@include padding-variant(0, 0); + +@include padding-variant(1, $unit-1); + +@include padding-variant(2, $unit-2); diff --git a/tildes/scss/spectre-0.5.1/utilities/_shapes.scss b/tildes/scss/spectre-0.5.1/utilities/_shapes.scss new file mode 100644 index 0000000..c11d5df --- /dev/null +++ b/tildes/scss/spectre-0.5.1/utilities/_shapes.scss @@ -0,0 +1,8 @@ +// Shapes +.rounded { + border-radius: $border-radius; +} + +.circle { + border-radius: 50%; +} diff --git a/tildes/scss/spectre-0.5.1/utilities/_text.scss b/tildes/scss/spectre-0.5.1/utilities/_text.scss new file mode 100644 index 0000000..67793ac --- /dev/null +++ b/tildes/scss/spectre-0.5.1/utilities/_text.scss @@ -0,0 +1,64 @@ +// Text +// Text alignment utilities +.text-left { + text-align: left; +} + +.text-right { + text-align: right; +} + +.text-center { + text-align: center; +} + +.text-justify { + text-align: justify; +} + +// Text transform utilities +.text-lowercase { + text-transform: lowercase; +} + +.text-uppercase { + text-transform: uppercase; +} + +.text-capitalize { + text-transform: capitalize; +} + +// Text style utilities +.text-normal { + font-weight: normal; +} + +.text-bold { + font-weight: bold; +} + +.text-italic { + font-style: italic; +} + +.text-large { + font-size: 1.2em; +} + +// Text overflow utilities +.text-ellipsis { + @include text-ellipsis(); +} + +.text-clip { + overflow: hidden; + text-overflow: clip; + white-space: nowrap; +} + +.text-break { + hyphens: auto; + word-break: break-word; + word-wrap: break-word; +} diff --git a/tildes/scss/styles.scss b/tildes/scss/styles.scss new file mode 100644 index 0000000..d5bd4d7 --- /dev/null +++ b/tildes/scss/styles.scss @@ -0,0 +1,35 @@ +@import 'variables'; +@import 'spectre-0.5.1/_variables.scss'; + +@import 'mixins'; + +@import 'base'; +@import 'layout'; +@import 'themes'; + +@import 'modules/btn'; +@import 'modules/comment'; +@import 'modules/divider'; +@import 'modules/empty'; +@import 'modules/form'; +@import 'modules/group'; +@import 'modules/heading'; +@import 'modules/input'; +@import 'modules/label'; +@import 'modules/link'; +@import 'modules/listing'; +@import 'modules/logged-in-user'; +@import 'modules/message'; +@import 'modules/nav'; +@import 'modules/pagination'; +@import 'modules/post'; +@import 'modules/post-buttons'; +@import 'modules/settings'; +@import 'modules/sidebar'; +@import 'modules/site-footer'; +@import 'modules/site-header'; +@import 'modules/tab'; +@import 'modules/text'; +@import 'modules/time'; +@import 'modules/toast'; +@import 'modules/topic'; diff --git a/tildes/setup.py b/tildes/setup.py new file mode 100644 index 0000000..422012a --- /dev/null +++ b/tildes/setup.py @@ -0,0 +1,14 @@ +"""Extremely minimal setup.py to support pip install -e.""" + +from setuptools import find_packages, setup + + +setup( + name='tildes', + version='0.1', + packages=find_packages(), + entry_points=""" + [paste.app_factory] + main = tildes:main + """ +) diff --git a/tildes/sql/init/insert_base_data.sql b/tildes/sql/init/insert_base_data.sql new file mode 100644 index 0000000..574b0d8 --- /dev/null +++ b/tildes/sql/init/insert_base_data.sql @@ -0,0 +1,4 @@ +-- add an "unknown user" for re-assigning deleted comments to after they're +-- outside the retention period, and similar uses +INSERT INTO users (user_id, username, password_hash) +VALUES (0, 'unknown user', ''); diff --git a/tildes/sql/init/rabbitmq_functions.sql b/tildes/sql/init/rabbitmq_functions.sql new file mode 100644 index 0000000..576ca7d --- /dev/null +++ b/tildes/sql/init/rabbitmq_functions.sql @@ -0,0 +1,3 @@ +CREATE OR REPLACE FUNCTION send_rabbitmq_message(routing_key TEXT, message TEXT) RETURNS VOID AS $$ + SELECT pg_notify('pgsql_events', routing_key || '|' || message); +$$ STABLE LANGUAGE SQL; diff --git a/tildes/sql/init/triggers/comment_notifications/users.sql b/tildes/sql/init/triggers/comment_notifications/users.sql new file mode 100644 index 0000000..f7c8dbc --- /dev/null +++ b/tildes/sql/init/triggers/comment_notifications/users.sql @@ -0,0 +1,42 @@ +CREATE OR REPLACE FUNCTION update_users_num_unread_notifications() RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + UPDATE users + SET num_unread_notifications = num_unread_notifications + 1 + WHERE user_id = NEW.user_id; + ELSIF (TG_OP = 'DELETE') THEN + IF (OLD.is_unread = TRUE) THEN + UPDATE users + SET num_unread_notifications = num_unread_notifications - 1 + WHERE user_id = OLD.user_id; + END IF; + ELSIF (TG_OP = 'UPDATE') THEN + IF (OLD.is_unread = FALSE AND NEW.is_unread = TRUE) THEN + UPDATE users + SET num_unread_notifications = num_unread_notifications + 1 + WHERE user_id = NEW.user_id; + ELSIF (OLD.is_unread = TRUE AND NEW.is_unread = FALSE) THEN + UPDATE users + SET num_unread_notifications = num_unread_notifications - 1 + WHERE user_id = NEW.user_id; + END IF; + END IF; + + RETURN NULL; +END +$$ LANGUAGE plpgsql; + + +-- insert and delete triggers should execute unconditionally +CREATE TRIGGER update_users_num_unread_notifications_insert_delete + AFTER INSERT OR DELETE ON comment_notifications + FOR EACH ROW + EXECUTE PROCEDURE update_users_num_unread_notifications(); + + +-- update trigger only needs to execute if is_unread was changed +CREATE TRIGGER update_users_num_unread_notifications_update + AFTER UPDATE ON comment_notifications + FOR EACH ROW + WHEN (OLD.is_unread IS DISTINCT FROM NEW.is_unread) + EXECUTE PROCEDURE update_users_num_unread_notifications(); diff --git a/tildes/sql/init/triggers/comment_votes/comments.sql b/tildes/sql/init/triggers/comment_votes/comments.sql new file mode 100644 index 0000000..39578c4 --- /dev/null +++ b/tildes/sql/init/triggers/comment_votes/comments.sql @@ -0,0 +1,21 @@ +CREATE OR REPLACE FUNCTION update_comment_num_votes() RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + UPDATE comments + SET num_votes = num_votes + 1 + WHERE comment_id = NEW.comment_id; + ELSIF (TG_OP = 'DELETE') THEN + UPDATE comments + SET num_votes = num_votes - 1 + WHERE comment_id = OLD.comment_id; + END IF; + + RETURN NULL; +END +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER update_comment_num_votes + AFTER INSERT OR DELETE ON comment_votes + FOR EACH ROW + EXECUTE PROCEDURE update_comment_num_votes(); diff --git a/tildes/sql/init/triggers/comments/comment_notifications.sql b/tildes/sql/init/triggers/comments/comment_notifications.sql new file mode 100644 index 0000000..5561388 --- /dev/null +++ b/tildes/sql/init/triggers/comments/comment_notifications.sql @@ -0,0 +1,15 @@ +-- delete any comment notifications related to a comment when it's deleted +CREATE OR REPLACE FUNCTION delete_comment_notifications() RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM comment_notifications + WHERE comment_id = OLD.comment_id; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER delete_comment_notifications_update + AFTER UPDATE ON comments + FOR EACH ROW + WHEN (OLD.is_deleted = false AND NEW.is_deleted = true) + EXECUTE PROCEDURE delete_comment_notifications(); diff --git a/tildes/sql/init/triggers/comments/comments.sql b/tildes/sql/init/triggers/comments/comments.sql new file mode 100644 index 0000000..6fa3446 --- /dev/null +++ b/tildes/sql/init/triggers/comments/comments.sql @@ -0,0 +1,14 @@ +-- set comment.deleted_time when it's deleted +CREATE OR REPLACE FUNCTION set_comment_deleted_time() RETURNS TRIGGER AS $$ +BEGIN + NEW.deleted_time := current_timestamp; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER delete_comment_set_deleted_time_update + BEFORE UPDATE ON comments + FOR EACH ROW + WHEN (OLD.is_deleted = false AND NEW.is_deleted = true) + EXECUTE PROCEDURE set_comment_deleted_time(); diff --git a/tildes/sql/init/triggers/comments/topic_visits.sql b/tildes/sql/init/triggers/comments/topic_visits.sql new file mode 100644 index 0000000..0d9f68a --- /dev/null +++ b/tildes/sql/init/triggers/comments/topic_visits.sql @@ -0,0 +1,35 @@ +-- increment a user's topic visit comment count when they post a comment +CREATE OR REPLACE FUNCTION increment_user_topic_visit_num_comments() RETURNS TRIGGER AS $$ +BEGIN + UPDATE topic_visits + SET num_comments = num_comments + 1 + WHERE user_id = NEW.user_id + AND topic_id = NEW.topic_id; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_topic_visits_num_comments_insert + AFTER INSERT ON comments + FOR EACH ROW + EXECUTE PROCEDURE increment_user_topic_visit_num_comments(); + + +-- decrement all users' topic visit comment counts when a comment is deleted +CREATE OR REPLACE FUNCTION decrement_all_topic_visit_num_comments() RETURNS TRIGGER AS $$ +BEGIN + UPDATE topic_visits + SET num_comments = num_comments - 1 + WHERE topic_id = OLD.topic_id AND + visit_time > OLD.created_time; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_topic_visits_num_comments_update + AFTER UPDATE ON comments + FOR EACH ROW + WHEN (OLD.is_deleted = false AND NEW.is_deleted = true) + EXECUTE PROCEDURE decrement_all_topic_visit_num_comments(); diff --git a/tildes/sql/init/triggers/comments/topics.sql b/tildes/sql/init/triggers/comments/topics.sql new file mode 100644 index 0000000..7837101 --- /dev/null +++ b/tildes/sql/init/triggers/comments/topics.sql @@ -0,0 +1,83 @@ +CREATE OR REPLACE FUNCTION update_topics_num_comments() RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT' AND NEW.is_deleted = FALSE) THEN + UPDATE topics + SET num_comments = num_comments + 1 + WHERE topic_id = NEW.topic_id; + ELSIF (TG_OP = 'DELETE' AND OLD.is_deleted = FALSE) THEN + UPDATE topics + SET num_comments = num_comments - 1 + WHERE topic_id = OLD.topic_id; + ELSIF (TG_OP = 'UPDATE') THEN + IF (OLD.is_deleted = FALSE AND NEW.is_deleted = TRUE) THEN + UPDATE topics + SET num_comments = num_comments - 1 + WHERE topic_id = NEW.topic_id; + ELSIF (OLD.is_deleted = TRUE AND NEW.is_deleted = FALSE) THEN + UPDATE topics + SET num_comments = num_comments + 1 + WHERE topic_id = NEW.topic_id; + END IF; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + + +-- insert and delete triggers should execute unconditionally +CREATE TRIGGER update_topics_num_comments_insert_delete + AFTER INSERT OR DELETE ON comments + FOR EACH ROW + EXECUTE PROCEDURE update_topics_num_comments(); + + +-- update trigger only needs to execute if is_deleted was changed +CREATE TRIGGER update_topics_num_comments_update + AFTER UPDATE ON comments + FOR EACH ROW + WHEN (OLD.is_deleted IS DISTINCT FROM NEW.is_deleted) + EXECUTE PROCEDURE update_topics_num_comments(); + + +-- update a topic's last activity time when a comment is posted or deleted +CREATE OR REPLACE FUNCTION update_topics_last_activity_time() RETURNS TRIGGER AS $$ +DECLARE + most_recent_comment RECORD; +BEGIN + IF (TG_OP = 'INSERT' AND NEW.is_deleted = FALSE) THEN + UPDATE topics + SET last_activity_time = NOW() + WHERE topic_id = NEW.topic_id; + ELSIF (TG_OP = 'UPDATE') THEN + SELECT MAX(created_time) AS max_created_time + INTO most_recent_comment + FROM comments + WHERE topic_id = NEW.topic_id AND + is_deleted = FALSE; + + IF most_recent_comment.max_created_time IS NOT NULL THEN + UPDATE topics + SET last_activity_time = most_recent_comment.max_created_time + WHERE topic_id = NEW.topic_id; + ELSE + UPDATE topics + SET last_activity_time = created_time + WHERE topic_id = NEW.topic_id; + END IF; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_topics_last_activity_time_insert + AFTER INSERT ON comments + FOR EACH ROW + EXECUTE PROCEDURE update_topics_last_activity_time(); + +CREATE TRIGGER update_topics_last_activity_time_update + AFTER UPDATE ON comments + FOR EACH ROW + WHEN (OLD.is_deleted IS DISTINCT FROM NEW.is_deleted) + EXECUTE PROCEDURE update_topics_last_activity_time(); diff --git a/tildes/sql/init/triggers/group_subscriptions/groups.sql b/tildes/sql/init/triggers/group_subscriptions/groups.sql new file mode 100644 index 0000000..ab661c0 --- /dev/null +++ b/tildes/sql/init/triggers/group_subscriptions/groups.sql @@ -0,0 +1,21 @@ +CREATE OR REPLACE FUNCTION update_group_subscription_count() RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + UPDATE groups + SET num_subscriptions = num_subscriptions + 1 + WHERE group_id = NEW.group_id; + ELSIF (TG_OP = 'DELETE') THEN + UPDATE groups + SET num_subscriptions = num_subscriptions - 1 + WHERE group_id = OLD.group_id; + END IF; + + RETURN NULL; +END +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER update_group_subscription_count + AFTER INSERT OR DELETE ON group_subscriptions + FOR EACH ROW + EXECUTE PROCEDURE update_group_subscription_count(); diff --git a/tildes/sql/init/triggers/message_conversations/users.sql b/tildes/sql/init/triggers/message_conversations/users.sql new file mode 100644 index 0000000..9e09fbd --- /dev/null +++ b/tildes/sql/init/triggers/message_conversations/users.sql @@ -0,0 +1,37 @@ +CREATE OR REPLACE FUNCTION update_users_num_unread_messages() RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + -- increment unread count for user(s) in the initial "unread by" list + UPDATE users + SET num_unread_messages = num_unread_messages + 1 + WHERE user_id = ANY(NEW.unread_user_ids); + ELSIF (TG_OP = 'UPDATE') THEN + -- increment unread count for any users that were added by the update + UPDATE users + SET num_unread_messages = num_unread_messages + 1 + WHERE user_id = ANY(NEW.unread_user_ids::int[] - OLD.unread_user_ids::int[]); + + -- decrement unread count for any users that were removed by the update + UPDATE users + SET num_unread_messages = num_unread_messages - 1 + WHERE user_id = ANY(OLD.unread_user_ids::int[] - NEW.unread_user_ids::int[]); + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + + +-- insert trigger should execute unconditionally +CREATE TRIGGER update_users_num_unread_messages_insert + AFTER INSERT ON message_conversations + FOR EACH ROW + EXECUTE PROCEDURE update_users_num_unread_messages(); + + +-- update trigger only needs to execute if unread_user_ids was changed +CREATE TRIGGER update_users_num_unread_messages_update + AFTER UPDATE ON message_conversations + FOR EACH ROW + WHEN (OLD.unread_user_ids IS DISTINCT FROM NEW.unread_user_ids) + EXECUTE PROCEDURE update_users_num_unread_messages(); diff --git a/tildes/sql/init/triggers/message_replies/message_conversations.sql b/tildes/sql/init/triggers/message_replies/message_conversations.sql new file mode 100644 index 0000000..b0f48fb --- /dev/null +++ b/tildes/sql/init/triggers/message_replies/message_conversations.sql @@ -0,0 +1,37 @@ +CREATE OR REPLACE FUNCTION update_conversation() RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + -- Increment num_replies and set last_reply_time to the new reply's + -- created_time, and use a CASE statement to union the id of the + -- "other user" (not the sender) into the unread ids + UPDATE message_conversations + SET num_replies = num_replies + 1, + last_reply_time = NEW.created_time, + unread_user_ids = unread_user_ids | + CASE WHEN sender_id = NEW.sender_id THEN recipient_id + ELSE sender_id + END + WHERE conversation_id = NEW.conversation_id; + ELSIF (TG_OP = 'DELETE') THEN + -- Decrement num_replies and use a subselect to get the created_time + -- for the most recent reply. This isn't necessary when the deleted + -- reply wasn't the newest one, but it won't hurt anything either. + UPDATE message_conversations + SET num_replies = num_replies - 1, + last_reply_time = ( + SELECT MAX(created_time) + FROM message_replies + WHERE conversation_id = OLD.conversation_id + ) + WHERE conversation_id = OLD.conversation_id; + END IF; + + RETURN NULL; +END +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER update_conversation + AFTER INSERT OR DELETE ON message_replies + FOR EACH ROW + EXECUTE PROCEDURE update_conversation(); diff --git a/tildes/sql/init/triggers/topic_votes/topics.sql b/tildes/sql/init/triggers/topic_votes/topics.sql new file mode 100644 index 0000000..60bc558 --- /dev/null +++ b/tildes/sql/init/triggers/topic_votes/topics.sql @@ -0,0 +1,21 @@ +CREATE OR REPLACE FUNCTION update_topic_num_votes() RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + UPDATE topics + SET num_votes = num_votes + 1 + WHERE topic_id = NEW.topic_id; + ELSIF (TG_OP = 'DELETE') THEN + UPDATE topics + SET num_votes = num_votes - 1 + WHERE topic_id = OLD.topic_id; + END IF; + + RETURN NULL; +END +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER update_topic_num_votes + AFTER INSERT OR DELETE ON topic_votes + FOR EACH ROW + EXECUTE PROCEDURE update_topic_num_votes(); diff --git a/tildes/sql/init/triggers/topics/rabbitmq.sql b/tildes/sql/init/triggers/topics/rabbitmq.sql new file mode 100644 index 0000000..ffec211 --- /dev/null +++ b/tildes/sql/init/triggers/topics/rabbitmq.sql @@ -0,0 +1,31 @@ +CREATE OR REPLACE FUNCTION send_rabbitmq_message_for_topic() RETURNS TRIGGER AS $$ +DECLARE + affected_row RECORD; + payload TEXT; +BEGIN + IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN + affected_row := NEW; + ELSIF (TG_OP = 'DELETE') THEN + affected_row := OLD; + END IF; + + payload := json_build_object('topic_id', affected_row.topic_id); + + PERFORM send_rabbitmq_message('topic.' || TG_ARGV[0], payload); + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER send_rabbitmq_message_for_topic_insert + AFTER INSERT ON topics + FOR EACH ROW + EXECUTE PROCEDURE send_rabbitmq_message_for_topic('created'); + + +CREATE TRIGGER send_rabbitmq_message_for_topic_edit + AFTER UPDATE ON topics + FOR EACH ROW + WHEN (OLD.markdown IS DISTINCT FROM NEW.markdown) + EXECUTE PROCEDURE send_rabbitmq_message_for_topic('edited'); diff --git a/tildes/sql/init/triggers/topics/topics.sql b/tildes/sql/init/triggers/topics/topics.sql new file mode 100644 index 0000000..c62891e --- /dev/null +++ b/tildes/sql/init/triggers/topics/topics.sql @@ -0,0 +1,14 @@ +-- set topic.deleted_time when it's deleted +CREATE OR REPLACE FUNCTION set_topic_deleted_time() RETURNS TRIGGER AS $$ +BEGIN + NEW.deleted_time := current_timestamp; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER delete_topic_set_deleted_time_update + BEFORE UPDATE ON topics + FOR EACH ROW + WHEN (OLD.is_deleted = false AND NEW.is_deleted = true) + EXECUTE PROCEDURE set_topic_deleted_time(); diff --git a/tildes/static/android-chrome-192x192.png b/tildes/static/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..1baff014f15d55767feff26b863aafb8162d3bd3 GIT binary patch literal 845 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE8Azrw%`pX1Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBC{YpM6XMFCZN{Kss4?-j zdf#=m?n?~n#tf=@3~IUznnny77CI~DX)m2!RC+eQ_{4#JhFv`jS_^076du{y$-tmu zT3mXTK~tYW+k`>OoI%~Pa}GoA0)_|X?KjO@uKS8y^bu{H$#B`M>9n^PgO*q%dx@v7EBhl> zIW}f-XI@cfpq?mC7srr_TW@bg`!NLyxCROY&f6y-#S+Lk^S{7chft=CzBf-s-Jg4B zbG`Q6*Ni|jz@UEV{1x{~KmMET_+pth>)(U9843;!4S50qz7D30S6M=YVT{*)2Mo%l z&kcR&zH!^P9^se&PMVnP?0z5heRE~TwV;DP9vV(#^O^5-Iq}cmjDIq-wu^rFAtVem zi-D1eg+ls5B`QYf>S=XCtZhO2+-JWu=GUl{yVkMZhmmXP;C0eih2uyS_Yu;jA{f4?iDENtaH zP`UwwKk@eGZtZju_goE1H>xGB5hW>!C8<`)MX5lF!N|bSLf61l*VrJ$z|6|P*viOQ z+rY@mz`({v&ILt7ZhlH;S|x4`zKP3z05ybHg+!DDC6+4`6y>L7=A2I`Q&6e733*TS3j3^P67k_3fW66z6CSpt`OSAa>n$GkX-aqc!ANToup3nK7^LftqoaZ^`bDmU3d$yd6 zstf=?&eq1#833dRB7u~cNYoYXcq$U&fo67Q0F<7VUGbF==~%9fvmF32+5iZX0ay~5 zgdYHif&m!!0l+W|0CZSRrIWFU$kTC;s}&&70f7PVG_aEbbax>j6GP7-46uVHX>Ny2JrNnz;>ImfG;{$wAwZx4JOk($ z;8^%Gd>J)!yqc+wx|0c35XNyoL(}&n5-mt1YlvixBbrMS45W!vO&BJTAp#j9lA)b2 zY-Xw>IH(wOTsAmS+J&cbk{0|=O2jov_;pHnF(vXi+5Z?*^JgoyE0*ecmg)tT>dEFB zsW~7e2V~@fA;zg*dU78!bWm5h)5+8Xg0f` zlT1xXh_aK!J_A!rVCW_c--6*gFnk7vi3IX*WN4TSjgX-d7=A^D`pM8+GW41Z=@F?g zky;AFT14t8XqPmBw!XgJR?)65YCeP?hi%!IA+g&_q)3O^?2Qyjn}2WXXTeMnK=Ewt ztWa+yFp^?u3%M;8A`9Nu(#%!xYH}d>vRi<9<4jfHX`v7+H-a%n;C}px?33storRL$ zY~wy;5M^z}L7h)3)XLwaEsad|#JEr*?|avRb0QUDt2~^Z#op^x+Fiq9uNq$xUxnL5ID0GFTN6UZKS)$?hqh@ya)u{dIbOTuZq^ z>9#Dx!(Sgy{6vE8eXJzt89#hj<~hH3@rUTuG~vX>*2&wEplY_Hdbr`TW&vW3w2wdA z1k0 zVFkxxq=QvGU%iYnFPNB3QfNV8j_Dd=s_3J`?Dz-=e`fsfSXm6xp`Wi7Kn*%7KSjCDz}Ysp?_u6g%zu-ud&X1N?|38PW3N0wOolZV~;M{J>tE2y=L%a zX*zZe-FBpJUr6)4_uZSt-ykPz<=R3uFmIfK1Pdp;nO}xhBos2xIR4uBrBlFK$-hK^ke$Mm)09SUvFPh zl{Z>*Y7utbM4i0%F(x3P)PsLmB~9YaAwk< z>#NGx_0Y7*!77?s%^R^tB)-q|r^4DH4F!bjwwg>&U?eWT{jb;h4>c3PVrE0lw{*@1 zoBV@nP^|UenbfS)g-xe$7M-Qb{OSYqprErfbko1~R`)Nx8+!MF7osm563lsp_81AH z`u#>ardOs04h~654#u)XJrKvG3fp5=CPG)&k}kRB`I*N0tVZ^0&dyA)4A-&Zsm@a) z4r_DqqBdBb-By6OB^SM%#A+*AQoYPK*F)5zG2*x?-IVYoknpVJNVe`=|XLFUz0dGj=R0sQCpX% zLHfEloP^7$VUHFS=XoEQYW$9SS54M z?E!t|+)NW~(e!6z=A~q?^YryLuzh^ACRrolZSy%~QF)0yhDCS&*gJF1+ig`=DC9eK z&2RpOZwP=-31frTa#aYARm0AUh_rZCo;-hFUVtGtB0wYn8Q!H&f@vfQ>zP)Gol7abOWJru^{ z0wBm4`MgOg++A0<$)WkV?U*wV!^!G#WfgIXoO4QIidah_wa#A*SLTXs?tS^R{&~Gx fJ*rt~lfPRJjH4BJpUr6(HeSQl%HHy}xliKnswCp6 literal 0 HcmV?d00001 diff --git a/tildes/static/apple-touch-icon.png b/tildes/static/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e0974b351ab183804711f9e629ecefa6ec022968 GIT binary patch literal 1330 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD8Ax&oe*=;XEa{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$Ysfq}6nz$e6&LEDT$%alRe zlu_4`)y#v{!kgL9QKo2>V*NhFy1fD+mFm4$)p{Zc*#L#Gq-S)^mkH%Y;G8 zjLXAMG%Z~;ElngPOE|VcC8WX3uh!hJR^PLj!@xn$Nh2moIyy>PYtc;Y#j{iwDLR@{cr-3!qkxI$_AZ8PT?|?aXRuj2=M)~vEjk*TzJcGwb89C9gO)jirkTp5 z?!2O7JGvQ!t@MR0^`ui{Kn=G8^wmo z%&ldgQ^Qf*1w`z&NvdpC&WySid?udrO$v9IRBSUT-)>S7qaQZiS#G1d)OvTR4enB@ z_9{~5`falq+GjC1CrMp5YrbmMe9f#m+aTtgkLX!%v2<&-)|o&l>C0wKSInBT3}Q}u zi=FWnOS4vMo5^t2M^wp8BS+soU(YDdz%boX!^BwczLEZ2BmMn`1`0Z+cZ~G!8R_pc zG$_zBdSIk~z|bH;&v=P}p|p;vfVL^Cw%Pyx{|(qr`U4}3G0EHArTNd6^Ot}e&H|6f zVj%4S#%?FG?SKsS5>H=O_D8I8Y|P@$yrRww3{2XdE{-7;x8B~$4h{;GXnV-(>8U8` z!hX?()Aj6>3w_Tt-`0GX5EY>@DKpEpb=I`hzn|(3RGA4gpSkDz{cGj)uQT-2#H zD`U~!#*9UsWgyNi))@JXKofdKCPK7kej!% zM!smF>9QMVEH9~TIj(Qk)!DYFv(L|2>1OE1HHX*Uwe8CNq8c2%C%m*`mX+S@U%!H< zx*xAvrxDuuP6cQk*iHzsF0&+xgWu)WRq4V2cX#31WvfqyoY0;kJhw0Qb=aEMr+Q}> zCVsd!p(oGp+w4i(+uhxKlOG$sKX+tF(f50`N2?at)O^_yE>WeFpYzgd<~%<8*|W^f zo;eliZ!>?kx$d3{%}B}Ak5?Tee~YjGWnyCV)hatGYTCug#XCiV_2y4oWA2r9HLv^g z^*xI(-`u`zYpJa5{!iz&Y|Jd)(Q{7;#i9S#XKddev#sS!lIWu2Y16LxM9wyU# zi(P*4k5yk5UP?PHH9s~^+)iwY|9tb-KjO1DpZ;aJa8IOn>!WQecWjRCmCjza;qTQg z+x<;0x(NGj@fPqkxv1v=ON jMCJ(l=?0GlUV03##05(}IhjrcTEXDy>gTe~DWM4fOCI^h literal 0 HcmV?d00001 diff --git a/tildes/static/browserconfig.xml b/tildes/static/browserconfig.xml new file mode 100644 index 0000000..16a3359 --- /dev/null +++ b/tildes/static/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #002b36 + + + diff --git a/tildes/static/favicon-16x16.png b/tildes/static/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..8d19d7c96790ef767aa302c9f5a5dec3be51372b GIT binary patch literal 298 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9F5M?jcysy3fAQ1FhY zi(`mI@7c?SUe1mJ$3EV-apKaSsFmQ6n#ffxeO}?9-v+qe|L0+TDxrJ{^QTuc6sN+l7GpKamw8q!X;&4v#3QB?~ejOP26j3pK6wboe7k=j<8FAo(U*PAvtqAQzueOs rC;dX`X8iZ)p#1_0R`Eyf|8FgKKW^!+@Wwp@=uZYuS3j3^P6&Lok1@06I4i%KuYDb6f}GTLa75~8fuh+ zlG1ztH6;}#O{6rGh${JrNFeQdDR#UwV_UYdR$2jP$>@LUqO<;mR;0mxD~ z@3F%PGm)wRBtHN6);0#*o#*RwJb5kx-;b_P%7nXd^Kh{?L=fDa{3a-`pHo0m=Q(C= z^DcCT6bK1mlSl+Pu`qu445*MpYGudT*?hrqbGL4CcOxRKu%K4Os|h1uF0i8K&q;pU zTwG4Z?V{YV+EV0a5%KHOF)`h1aK``cJBWbuxcjGrFyQd-E={RGC=tz*=W~M>GnLai z5fP^+XTf^`h*M^V#=(jqhR=W%rd86fSq)jS1}6eaH@YuIdcNq?NC)FexYqLZL1sht zG;S~DdWHd5C&9q*t7p(laN_u|Vi#28 zf(l$x`JmzuR7gTnKB#;ol@BV_5cvkgB9)J-o4!0)dKxGil+HnESpkS)>AV5Rh5@>! lb66V!NP}u~mkwh%-67ZkN+acPx&s#JFdBcn!Tdum4FHKFov;7^ literal 0 HcmV?d00001 diff --git a/tildes/static/images/mark-new-comments.png b/tildes/static/images/mark-new-comments.png new file mode 100644 index 0000000000000000000000000000000000000000..9b84d9ee70962b6989e1b789269b36b346789eee GIT binary patch literal 6532 zcmb7pby$>N*X}3^qNFGcjevBg#Ly`WASpR?!_df3iiC7YOEaW^AdPf43@s@=)F=!! z)Q7+KJLi4>Ip=)mxvu@}Ywc@aYyYv=TKB!~9j>9Ki2s=4F#rI-S5}hKyqmA?2IkTI zyZ__37S6lrp1Y=^44`6^3VCNdu#pB!0{~UAPp-@!-r2Y=O8V{q072K^ajy?rY%Mf^u+j`(!ho$~lzIN6p3{$Txg!!ooo(&u68Al?mBXw<NjYs4MHj1_MV{#c0Py`PX)x#bRvQIl|Uahtv8uDZVnC1A6OSqh`Q z`ZX!;_~_!DQy;SSVFljjq%$t(nW-GyyF=1NM632zH>N&>8}l2|T(*iZ6BBtvY%0vthGWSjTuPOuL`vc}u(EdGG|*6ea9TU8upx%R8& zwZFHXcJ#E%>NtT|`M%-^wXPF~{f@y>7eU^-%(hy1 zOB|hVeD=NaU1u&TM9quwpD@%Gg`p|iqv03dBU2h==EkoO_*zXh{STFe8anXIW2AH8 zw})A@s^X{JF_2cwIHkK`VeE44cF8v6JUhGf_a*c|g0IRw+h^JE#GB^F-o*9o_0iJp zS=oVj)WGXN|HEjV7l~y@b&Aemc{z+LK}(F?AVlXvtIZm*sUhb-SkZ(-`0W^E=`xbvdzs(eC31cc#W!}6Cgx4c+Z!lwogi|UDtc9Je7PLB z^`XaxrXle5L*|%eov=cPOEBeo&^L;KZMf9Cl8wZ(j~%)fX54K8k6dSag6j<5hD#3} z3=C&5ro<0sGN^$!i1+N|T$ax_tFj(m1c!KyS)@S>O0n(U)*ib048KxDiy|v#=yZkG zY>~HnBY{^JiL*fX4*OTFk!;CnHHPez_6I=x|+t(!r!xrhI;rAmOtUK?h33O;lba zVr|@*{-rD|%eS|a2XFJfP22(rUTMgNm8q?OYFB{JnC7Cwk-EMcwG|^6Lf#n~yqSz2 zFOl5_gKpS?Dy8MvY|+E=@EN8#v= z7LislL8lmHU#e7Ft`^Vu^_h(GXLy9BV&BA&BlTg~6FOZ|R=Iw2fj^ST-YkVV&;N0* zD!1>jo95Q6kj!j4i2UjyvTdbldM|U57uc)hw`@b4#m| z@wEgmWt1ep5VacKDBIa_UjI3(NWh#D<$vxlueb_b?(lxIzxv}0tLJ)kbZw(=&9CS2 zG-h+ksm33M}G$sGXUFb`J=Q-v}0RE5M` zt2+{8tPV@QvZ$E9*gB1I4x_h7Hk|!=kG0rA+|1noO$JYY)MW3OpWxmoe9KrB#DsjIa5Hp&YXi z-T@oo&9*~43eBXOt*{LY&_no zO2`j+c?xRgmVxkIDcG4uCCg4$-@7@o*DdJmz{d3}Fqa}*2JK}?PMz#%&zOjF5X7%q zfjZ!7U?syd&JNk@E-P`N>A_FD`)zkd|2zBT@m@Bl`Gh~99mh615q5$6x^((Q+#1u| zxfhtMX4_L3-~jzBYd&At7X;XVfE@c{_;otmS1C(9rhtf)fJ0%vei*{ z$6h=CA~;-Xr`}}`dQkf~VQ@_;uw~#i)D$)Of_^!y*T8eldzt+?*-6#j+yziv+dBVv93 zwHB5f;OTk71h4>GtB-L4qFC?tg(csd9xN>0Z%<9|iQgm*EAe_jh-Lc8jvJIh0YG*r z<-qmYmZe}k0X07H2VhL)*pFIFUIb?)>Xmo&(2@oJ<*iNaG&R(?vE3E$lRpRnX+8Qv zY|PmByQ7D?h&!uscshEkbMcQ;%(}ba*-WRSlNLXz^(1*iMRj_}FqaEC`hBv=HH_a6 zvG8Tg|F__*_{?Ph2(`ezREB=yF0k0(T)ri#d&J0HutJBy&@U*!&Vma$2Qfa6mwuem zD|SjHZmgwQop}f^?ORA(Y6xSo9%3K_J~Gy_f<7enNvC7m^ZJLsux9k_z7DOn@z^Km z+ZOKBRWIspYm;VAkJu4KZsuKB%-EzKKZ$clK5@}ZVpYfEErUsV`M+ou*N~ zOrH=d;O`$l(qL65z$;T(VD{bY2YP`}^ar}6dm!dzPMz>GRrnYq3gCW`s6vzNyZH2q zC}h4dB>QJN>@&34|qQ@05L7cLT)mr3Ga3j3sBi8r1R6GbRQ*{m+f#8^Q6 zbX~`lmUz+}Xrin&f^1$v9YesQmz@EZ-GEI7b(@AoVGh2}$ic z6-ZF6KAaefDOnM`Wi*Cnw5Yp3xJ&6Tng{#%);&SGK zLc-GP^7D_q-CNAf1=Z7KrMfnPR7bqOW~kH_d}Ogo*HAOgu3@f0L;O#kr`Rj2cjiel zU6A)KPrIi3#oBAjZD?aJNi6TmQP!O2~-Jd0~1J0Wsr5A}(kYU zkC>=^;#sE(dKo>Jm~fq#>h)z_y&y@+4mlttDk5_bL3dYWZCJ3xV>k@HxCK#IKZ?gR zBA%zarGId9`Mz&wn^eP$%?j!$O3-h4$ z>SlQX(n3%B%HW9eE5#Z6H}q{X;PyQ`6)R(2O-a3`PE|?X=6^X9a`a_LVcwfw4&CkB z?mr!olb5s7^lwHM8$4)|pV?E?S1t2(-FTQ<+h1y8cse^&_VVIwg7q7x#R>WpiuKaO(o5cI#qPRNBzxl{3}-y1*Y-fs zx_OG5?P5^|`$YfMhPL3gRM%Q%E1P0K#qQ(1u~Ni$rjjRHvvZ%g>VyGNLdHM(@ALfp z3t4l^@7H$LYL=e&%xAiBu ztr07cB{RBe-)|Mee+e6Z5igOV)r)7m!S+fQ;*vxQe15H zS;P~ZPWi9Aa>4%5Y_IbPawiP3Ja{{+54Uo2qx$2-p6u=wMmZc0eInM&3;6(+R%xrN z>xI{yM^bZ{uO&kTdl=Q8;|hDIPASb$GqEr~cR+rQW6I?8CFSY|b$))a!*B>S5(J7H zxAu$f95q(DdlSQLGzG|ueuo;lr$rQgv*OnG)HHc(^GU!pCjI+wtp&GKK5UU1+xsv^ zAWMtVSjq%sYNJ$T%yK^V54MTHE(W8;^ed4wR1+?!I!`PEOH+gwd@o*nUe|b(7R)xs zQG*5tw!GNk8;a~FR$)r-m-XNVz<>P-2)(!f;yL8z2-h&MD8^;*HQAqVboxEOcat_@ zBt+hP?g$;fj@*AFUG4eOpINExxFMwdVx;f^a$%@}w-2LShX%U-Xa$FrrX5ICa zqqNBkeZdhL22Q@|#38%pdI@xN-13j4wM{S*|L(UbvJ^dJ-F3W(18L4v0eXqK(l_H} z6(6@~#C6Q|5{|reo1<~xRcY@w@~2#(HGf1ll0C0~IecoI_~pYB_wVjcCKPpEuH9E9 z$W|LQG|n}NZH&hi8`d+5xH?L>$&+vx4Hw0@<_>-PR)T;C(fEd^R5k`3pzwtZAmZ+6 zAD`>@Bud0zuDL^dGCIHqp+gH_Wl`lkhox!vjmI=h_cy_Y-QM@eYAq`|uJJG2-}VjY zo5*OM1V0Rx*&!*Y{2I5Ls{$m>Saf{xCB$(30`t8z;J&PE7y@#!BYe1`Ms~jRxSl9e zMcu$xHIWA)+3BvFa1hO%5xiaDGb_(?DlkXMkXA#zR+=$n!>ew)XCj5pF{ghlqMgK{ zpn7(797jb+Qf$wNZH}-cghgw#$J(6d^Q2WOKG*=U7bFiZg=H}NrYdi#srsx;H; zYc>^6iUlnfzf&JBk&V??LQI=auS+VvHPNgv*D1+i%ND!7$f~5JW~hGk3{M90t(NX8 z@vS+Ymoz8Kgf496`buPW1Q{T)XmG0VGl#h)MFS%9LXHK~nps z;55GgI{Xw(Pz|vA2O{6zTfTS2M5NIfWQ?p~!lXhEC zaDJ>>;in2pPPo~p$;>fO>qOS^$WlFfIapSH`QnZF9L*j%hP$N$WwscfPOdQkXtE5kb=pzF0S@*a@?KRVuil`&61FY*7V9iC%B z>Y4b%7A7kz?nqH4$%6d?cBJuU9SO<>|JiG1gqRiI+ekVPki5wkVHmCDZJ{`#|I^g%CR}Ua{?$I8w`#bB>iM!0rB)e#zY^D4*qct6O9l*X2W9BcUhIKu~2* z;h_5vdsyK$ZDs2o5tr-!2u))#ZV~IF6qcW6eN6fm7Hm4z1(vW6sgP5M&xss(yGiS{ z8Y34Y*ns?S$kcsa11Ya$KVEtglrr*Om8>1TDQWdoV&0rZ$Dr#o z^^1ZOAMMwok%Pu&8SL50#X;a)J>YvFr2Z3i!EmB~qwc+R&XzeRJcXKM z#IZf*#uMwO#^5RT`#TK$-%kg3K>Jsoh*yHXH8I(G3b2SszL_aE{M#i>Ah38&@1PX8 c;&*)8ru!=N^|AxqU)@4kUQMn-#x&@^03IdzA^-pY literal 0 HcmV?d00001 diff --git a/tildes/static/js/behaviors/auto-focus.js b/tildes/static/js/behaviors/auto-focus.js new file mode 100644 index 0000000..4f3252a --- /dev/null +++ b/tildes/static/js/behaviors/auto-focus.js @@ -0,0 +1,8 @@ +$.onmount('[data-js-auto-focus]', function() { + $input = $(this); + + // just calling .focus() will place the cursor at the start of the field, + // so un-setting and re-setting the value moves the cursor to the end + var original_val = $input.val(); + $input.focus().val('').val(original_val); +}); diff --git a/tildes/static/js/behaviors/autoselect-input.js b/tildes/static/js/behaviors/autoselect-input.js new file mode 100644 index 0000000..5cd6118 --- /dev/null +++ b/tildes/static/js/behaviors/autoselect-input.js @@ -0,0 +1,5 @@ +$.onmount('[data-js-autoselect-input]', function() { + $(this).click(function(event) { + $(this).select(); + }); +}); diff --git a/tildes/static/js/behaviors/autosubmit-on-change.js b/tildes/static/js/behaviors/autosubmit-on-change.js new file mode 100644 index 0000000..4f1c5bf --- /dev/null +++ b/tildes/static/js/behaviors/autosubmit-on-change.js @@ -0,0 +1,5 @@ +$.onmount('[data-js-autosubmit-on-change]', function() { + $(this).change(function(event) { + $(this).closest('form').submit(); + }); +}); diff --git a/tildes/static/js/behaviors/cancel-button.js b/tildes/static/js/behaviors/cancel-button.js new file mode 100644 index 0000000..7fec9ed --- /dev/null +++ b/tildes/static/js/behaviors/cancel-button.js @@ -0,0 +1,25 @@ +$.onmount('[data-js-cancel-button]', function() { + $(this).click(function(event) { + var $parentForm = $(this).closest('form'); + + // confirm removal if the form specifies to + var confirmPrompt = $parentForm.attr('data-js-confirm-cancel'); + if (confirmPrompt) { + // only prompt if any of the inputs aren't empty + $nonEmptyFields = $parentForm.find('input,textarea') + .filter(function() { return $(this).val(); }); + + if ($nonEmptyFields.length > 0) { + var shouldRemove = window.confirm(confirmPrompt); + } else { + var shouldRemove = true; + } + } else { + var shouldRemove = true; + } + + if (shouldRemove) { + $(this).closest('form').remove(); + } + }); +}); diff --git a/tildes/static/js/behaviors/comment-collapse-button.js b/tildes/static/js/behaviors/comment-collapse-button.js new file mode 100644 index 0000000..4edd188 --- /dev/null +++ b/tildes/static/js/behaviors/comment-collapse-button.js @@ -0,0 +1,16 @@ +$.onmount('[data-js-comment-collapse-button]', function() { + $(this).click(function(event) { + $this = $(this); + $comment = $this.closest('.comment'); + + $comment.toggleClass('is-comment-collapsed'); + + if ($comment.hasClass('is-comment-collapsed')) { + $this.text('+'); + } else { + $this.html('−'); + } + + $this.blur(); + }); +}); diff --git a/tildes/static/js/behaviors/comment-parent-button.js b/tildes/static/js/behaviors/comment-parent-button.js new file mode 100644 index 0000000..d73d591 --- /dev/null +++ b/tildes/static/js/behaviors/comment-parent-button.js @@ -0,0 +1,21 @@ +$.onmount('[data-js-comment-parent-button]', function() { + $(this).click(function(event) { + var $comment = $(this).parents('.comment').first(); + var $parentComment = $comment.parents('.comment').first(); + + var backButton = document.createElement('a'); + backButton.setAttribute('href', '#comment-' + $comment.attr('data-comment-id36')); + backButton.setAttribute('class', 'comment-nav-link'); + backButton.setAttribute('data-js-comment-back-button', ''); + backButton.setAttribute('data-js-remove-on-click', ''); + backButton.innerHTML = '[Back]'; + + var $parentHeader = $parentComment.find('header').first(); + + // remove any existing back button + $parentHeader.find('[data-js-comment-back-button]').remove(); + + $parentHeader.append(backButton); + $.onmount(); + }); +}); diff --git a/tildes/static/js/behaviors/comment-reply-button.js b/tildes/static/js/behaviors/comment-reply-button.js new file mode 100644 index 0000000..8858ef8 --- /dev/null +++ b/tildes/static/js/behaviors/comment-reply-button.js @@ -0,0 +1,82 @@ +$.onmount('[data-js-comment-reply-button]', function() { + $(this).click(function(event) { + event.preventDefault(); + + // disable click/hover events on the reply button + $(this).css('pointer-events', 'none'); + + var $comment = $(this).parents('.comment').first(); + + // get the replies div, or create one if it doesn't already exist + var $replies = $comment.children('.comment-replies'); + if (!$replies.length) { + var repliesDiv = document.createElement('div'); + repliesDiv.setAttribute('class', 'comment-replies'); + $comment.append(repliesDiv); + $replies = $(repliesDiv); + } + + var $parentComment = $(this).parents('article.comment:first'); + var parentCommentID = $parentComment.attr('data-comment-id36'); + var parentCommentAuthor = $parentComment.find('header:first .link-user').text(); + var postURL = '/api/web/comments/' + parentCommentID + '/replies'; + var markdownID = 'markdown-reply-' + parentCommentID; + + var replyForm = document.createElement('form'); + replyForm.setAttribute('method', 'post'); + replyForm.setAttribute('autocomplete', 'off'); + replyForm.setAttribute('data-ic-post-to', postURL); + replyForm.setAttribute('data-ic-replace-target', 'true'); + replyForm.setAttribute('data-js-confirm-cancel', 'Discard your reply?'); + replyForm.setAttribute('data-js-prevent-double-submit', ''); + replyForm.setAttribute('data-js-confirm-leave-page-unsaved', ''); + + var label = document.createElement('label'); + label.setAttribute('class', 'form-label'); + label.setAttribute('for', markdownID); + label.innerHTML = 'Replying to ' + parentCommentAuthor + '' + + '' + + 'Formatting help'; + + var textarea = document.createElement('textarea'); + textarea.setAttribute('id', markdownID); + textarea.setAttribute('name', 'markdown'); + textarea.setAttribute('class', 'form-input'); + textarea.setAttribute('placeholder', 'Comment text (markdown)'); + textarea.setAttribute('data-js-ctrl-enter-submit-form', ''); + textarea.setAttribute('data-js-auto-focus', ''); + + var buttonDiv = document.createElement('div'); + buttonDiv.setAttribute('class', 'form-buttons'); + + var postButton = document.createElement('button'); + postButton.setAttribute('type', 'submit'); + postButton.setAttribute('class', 'btn btn-primary'); + postButton.innerHTML = 'Post comment'; + buttonDiv.appendChild(postButton); + + var cancelButton = document.createElement('button'); + cancelButton.setAttribute('type', 'button'); + cancelButton.setAttribute('class', 'btn btn-link'); + cancelButton.setAttribute('data-js-cancel-button', ''); + cancelButton.innerHTML = 'Cancel'; + + $(cancelButton).on('click', function (event) { + // re-enable click/hover events on the reply button + $(this).parents('.comment').first() + .find('.post-button[name=reply]').first() + .css('pointer-events', 'auto'); + }); + buttonDiv.appendChild(cancelButton); + + replyForm.appendChild(label); + replyForm.appendChild(textarea); + replyForm.appendChild(buttonDiv); + + // update Intercooler so it knows about this new form + Intercooler.processNodes(replyForm); + + $replies.prepend(replyForm); + $.onmount(); + }); +}); diff --git a/tildes/static/js/behaviors/comment-tag-button.js b/tildes/static/js/behaviors/comment-tag-button.js new file mode 100644 index 0000000..b0b6ef7 --- /dev/null +++ b/tildes/static/js/behaviors/comment-tag-button.js @@ -0,0 +1,82 @@ +$.onmount('[data-js-comment-tag-button]', function() { + $(this).click(function(event) { + event.preventDefault(); + + var $comment = $(this).parents('.comment').first(); + var user_tags = $comment.attr('data-comment-user-tags'); + + // check if the tagging button div already exists and just remove it if so + $tagButtons = $comment.find('.comment-itself:first').find('.comment-tag-buttons'); + if ($tagButtons.length > 0) { + $tagButtons.remove(); + return; + } + + var commentID = $comment.attr('data-comment-id36'); + var tagURL = '/api/web/comments/' + commentID + '/tags/'; + + var tagtemplate = document.querySelector('#comment-tag-options'); + var clone = document.importNode(tagtemplate.content, true); + var options = clone.querySelectorAll('a'); + + for (i = 0; i < options.length; i++) { + var tag = options[i]; + var tagName = tag.textContent; + + var tagOptionActive = false; + if (user_tags.indexOf(tagName) !== -1) { + tagOptionActive = true; + } + + if (tagOptionActive) { + tag.className += " btn btn-used"; + tag.setAttribute('data-ic-delete-from', tagURL + tagName); + $(tag).on('after.success.ic', function(evt) { + Tildes.removeUserTag(commentID, evt.target.textContent); + }); + } else { + tag.setAttribute('data-ic-put-to', tagURL + tagName); + $(tag).on('after.success.ic', function(evt) { + Tildes.addUserTag(commentID, evt.target.textContent); + }); + } + + tag.setAttribute('data-ic-target', '#comment-' + commentID + ' .comment-itself:first'); + } + + // update Intercooler so it knows about these new elements + Intercooler.processNodes(clone); + + $comment.find(".post-buttons").first().after(clone); + }); +}); + +Tildes.removeUserTag = function(commentID, tagName) { + $comment = $("#comment-" + commentID); + var user_tags = $comment.attr('data-comment-user-tags').split(" "); + + // if the tag isn't there, don't need to do anything + tagIndex = user_tags.indexOf(tagName); + if (tagIndex === -1) { + return; + } + + // remove the tag from the list and update the data attr + user_tags.splice(tagIndex, 1); + $comment.attr('data-comment-user-tags', user_tags.join(" ")); +} + +Tildes.addUserTag = function(commentID, tagName) { + $comment = $("#comment-" + commentID); + var user_tags = $comment.attr('data-comment-user-tags').split(" "); + + // don't add the tag again if it's already there + tagIndex = user_tags.indexOf(tagName); + if (tagIndex !== -1) { + return; + } + + // add the tag to the list and update the data attr + user_tags.push(tagName); + $comment.attr('data-comment-user-tags', user_tags.join(" ")); +} diff --git a/tildes/static/js/behaviors/confirm-leave-page-unsaved.js b/tildes/static/js/behaviors/confirm-leave-page-unsaved.js new file mode 100644 index 0000000..03a3f7d --- /dev/null +++ b/tildes/static/js/behaviors/confirm-leave-page-unsaved.js @@ -0,0 +1,11 @@ +$.onmount('[data-js-confirm-leave-page-unsaved]', function() { + $form = $(this); + $form.areYouSure(); + + // Fixes a strange interaction between Intercooler and AreYouSure, where + // submitting a form by using the keyboard to push the submit button would + // trigger a confirmation prompt before leaving the page. + $form.on('success.ic', function() { + $form.removeClass('dirty'); + }); +}); diff --git a/tildes/static/js/behaviors/ctrl-enter-submit-form.js b/tildes/static/js/behaviors/ctrl-enter-submit-form.js new file mode 100644 index 0000000..3e8715c --- /dev/null +++ b/tildes/static/js/behaviors/ctrl-enter-submit-form.js @@ -0,0 +1,8 @@ +$.onmount('[data-js-ctrl-enter-submit-form]', function() { + $(this).keydown(function(event) { + if ((event.ctrlKey || event.metaKey) && + (event.keyCode == 13 || event.keyCode == 10)) { + $(this).closest('form').submit(); + } + }); +}); diff --git a/tildes/static/js/behaviors/fadeout-parent-on-success.js b/tildes/static/js/behaviors/fadeout-parent-on-success.js new file mode 100644 index 0000000..9eae946 --- /dev/null +++ b/tildes/static/js/behaviors/fadeout-parent-on-success.js @@ -0,0 +1,5 @@ +$.onmount('[data-js-fadeout-parent-on-success]', function() { + $(this).on('after.success.ic', function() { + $(this).parent().fadeOut('fast'); + }); +}); diff --git a/tildes/static/js/behaviors/hide-sidebar-if-open.js b/tildes/static/js/behaviors/hide-sidebar-if-open.js new file mode 100644 index 0000000..19c0339 --- /dev/null +++ b/tildes/static/js/behaviors/hide-sidebar-if-open.js @@ -0,0 +1,9 @@ +$.onmount('[data-js-hide-sidebar-if-open]', function() { + $(this).on('click', function(event) { + if ($('#sidebar').hasClass('is-sidebar-displayed')) { + event.preventDefault(); + event.stopPropagation(); + $('#sidebar').removeClass('is-sidebar-displayed'); + } + }); +}); diff --git a/tildes/static/js/behaviors/prevent-double-submit.js b/tildes/static/js/behaviors/prevent-double-submit.js new file mode 100644 index 0000000..982f955 --- /dev/null +++ b/tildes/static/js/behaviors/prevent-double-submit.js @@ -0,0 +1,16 @@ +$.onmount('[data-js-prevent-double-submit]', function() { + $(this).on('beforeSend.ic', function(evt, elt, data, settings, xhr, requestId) { + var $form = $(this); + + if ($form.attr('data-js-submitting') !== undefined) { + xhr.abort(); + return false; + } else { + $form.attr('data-js-submitting', true); + } + }); + + $(this).on('complete.ic', function() { + $(this).removeAttr('data-js-submitting'); + }); +}); diff --git a/tildes/static/js/behaviors/remove-on-click.js b/tildes/static/js/behaviors/remove-on-click.js new file mode 100644 index 0000000..51fd759 --- /dev/null +++ b/tildes/static/js/behaviors/remove-on-click.js @@ -0,0 +1,5 @@ +$.onmount('[data-js-remove-on-click]', function() { + $(this).on('click', function() { + $(this).remove(); + }); +}); diff --git a/tildes/static/js/behaviors/remove-on-success.js b/tildes/static/js/behaviors/remove-on-success.js new file mode 100644 index 0000000..a1977dd --- /dev/null +++ b/tildes/static/js/behaviors/remove-on-success.js @@ -0,0 +1,5 @@ +$.onmount('[data-js-remove-on-success]', function() { + $(this).on('after.success.ic', function() { + $(this).remove(); + }); +}); diff --git a/tildes/static/js/behaviors/sidebar-toggle.js b/tildes/static/js/behaviors/sidebar-toggle.js new file mode 100644 index 0000000..3914646 --- /dev/null +++ b/tildes/static/js/behaviors/sidebar-toggle.js @@ -0,0 +1,8 @@ +$.onmount('[data-js-sidebar-toggle]', function() { + $(this).click(function(event) { + event.preventDefault(); + event.stopPropagation(); + + $('#sidebar').toggleClass('is-sidebar-displayed'); + }); +}); diff --git a/tildes/static/js/behaviors/theme-selector.js b/tildes/static/js/behaviors/theme-selector.js new file mode 100644 index 0000000..33f1501 --- /dev/null +++ b/tildes/static/js/behaviors/theme-selector.js @@ -0,0 +1,26 @@ +$.onmount('[data-js-theme-selector]', function() { + $(this).change(function(event) { + event.preventDefault(); + + var new_theme = $(this).val(); + + // persist the new theme for the user in their cookie + document.cookie = 'theme=' + new_theme + ';' + + 'path=/;max-age=315360000;secure'; + + // remove any theme classes currently on the body + $body = $('body').first(); + var bodyClasses = $body[0].className.split(' '); + for (i = 0; i < bodyClasses.length; i++) { + cls = bodyClasses[i]; + if (cls.indexOf('theme-') === 0) { + $body.removeClass(cls); + } + } + + // if a non-default theme was chosen, add the class to the body + if (new_theme) { + $body.addClass('theme-' + new_theme); + } + }); +}); diff --git a/tildes/static/js/behaviors/time-period-select.js b/tildes/static/js/behaviors/time-period-select.js new file mode 100644 index 0000000..4605f60 --- /dev/null +++ b/tildes/static/js/behaviors/time-period-select.js @@ -0,0 +1,32 @@ +$.onmount('[data-js-time-period-select]', function() { + $(this).change(function(event) { + var periodValue = this.value; + + if (periodValue === 'other') { + var enteredPeriod = ''; + validRegex = /^\d+[hd]?$/i; + + // prompt for a time period until they enter a valid one + while (!validRegex.test(enteredPeriod)) { + enteredPeriod = prompt('Enter a custom time period (number of hours, or add a "d" suffix for days):'); + + // exit if they specifically cancelled the prompt + if (enteredPeriod === null) { + return false; + } + } + + // if it was just a bare number, append "h" + if (/^\d+$/.test(enteredPeriod)) { + enteredPeriod += 'h'; + } + + // need to add the option to the ",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){a.innerHTML="";var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+K+"*[*^$|!~]?="),2!==a.querySelectorAll(":enabled").length&&q.push(":enabled",":disabled"),o.appendChild(a).disabled=!0,2!==a.querySelectorAll(":disabled").length&&q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Y.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"*"),s.call(a,"[s!='']:x"),r.push("!=",N)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Y.test(o.compareDocumentPosition),t=b||Y.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?I(k,a)-I(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?I(k,a)-I(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?la(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(S,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.escape=function(a){return(a+"").replace(ba,ca)},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(_,aa),a[3]=(a[3]||a[4]||a[5]||"").replace(_,aa),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return V.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&T.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(_,aa).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:!b||(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(O," ")+" ").indexOf(c)>-1:"|="===b&&(e===c||e.slice(0,c.length+1)===c+"-"))}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(P,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(_,aa),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return U.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(_,aa).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:oa(!1),disabled:oa(!0),checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:pa(function(){return[0]}),last:pa(function(a,b){return[b-1]}),eq:pa(function(a,b,c){return[c<0?c+b:c]}),even:pa(function(a,b){for(var c=0;c=0;)a.push(d);return a}),gt:pa(function(a,b,c){for(var d=c<0?c+b:c;++d1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function va(a,b,c){for(var d=0,e=b.length;d-1&&(f[j]=!(g[j]=l))}}else r=wa(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ya(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ta(function(a){return a===b},h,!0),l=ta(function(a){return I(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];i1&&ua(m),i>1&&sa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(P,"$1"),c,i0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=E.call(i));u=wa(u)}G.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&ga.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=ya(b[c]),f[u]?d.push(f):e.push(f);f=A(a,za(e,d)),f.selector=a}return f},i=ga.select=function(a,b,c,e){var f,i,j,k,l,m="function"==typeof a&&a,n=!e&&g(a=m.selector||a);if(c=c||[],1===n.length){if(i=n[0]=n[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&9===b.nodeType&&p&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(_,aa),b)||[])[0],!b)return c;m&&(b=b.parentNode),a=a.slice(i.shift().value.length)}f=V.needsContext.test(a)?0:i.length;while(f--){if(j=i[f],d.relative[k=j.type])break;if((l=d.find[k])&&(e=l(j.matches[0].replace(_,aa),$.test(i[0].type)&&qa(b.parentNode)||b))){if(i.splice(f,1),a=e.length&&sa(i),!a)return G.apply(c,e),c;break}}}return(m||h(a,n))(e,b,!p,c,!b||$.test(a)&&qa(b.parentNode)||b),c},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("fieldset"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){if(!c)return a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){if(!c&&"input"===a.nodeName.toLowerCase())return a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(J,function(a,b,c){var d;if(!c)return a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);r.find=x,r.expr=x.selectors,r.expr[":"]=r.expr.pseudos,r.uniqueSort=r.unique=x.uniqueSort,r.text=x.getText,r.isXMLDoc=x.isXML,r.contains=x.contains,r.escapeSelector=x.escape;var y=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&r(a).is(c))break;d.push(a)}return d},z=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},A=r.expr.match.needsContext,B=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i,C=/^.[^:#\[\.,]*$/;function D(a,b,c){return r.isFunction(b)?r.grep(a,function(a,d){return!!b.call(a,d,a)!==c}):b.nodeType?r.grep(a,function(a){return a===b!==c}):"string"!=typeof b?r.grep(a,function(a){return i.call(b,a)>-1!==c}):C.test(b)?r.filter(b,a,c):(b=r.filter(b,a),r.grep(a,function(a){return i.call(b,a)>-1!==c&&1===a.nodeType}))}r.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?r.find.matchesSelector(d,a)?[d]:[]:r.find.matches(a,r.grep(b,function(a){return 1===a.nodeType}))},r.fn.extend({find:function(a){var b,c,d=this.length,e=this;if("string"!=typeof a)return this.pushStack(r(a).filter(function(){for(b=0;b1?r.uniqueSort(c):c},filter:function(a){return this.pushStack(D(this,a||[],!1))},not:function(a){return this.pushStack(D(this,a||[],!0))},is:function(a){return!!D(this,"string"==typeof a&&A.test(a)?r(a):a||[],!1).length}});var E,F=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,G=r.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||E,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:F.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof r?b[0]:b,r.merge(this,r.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),B.test(e[1])&&r.isPlainObject(b))for(e in b)r.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&(this[0]=f,this.length=1),this}return a.nodeType?(this[0]=a,this.length=1,this):r.isFunction(a)?void 0!==c.ready?c.ready(a):a(r):r.makeArray(a,this)};G.prototype=r.fn,E=r(d);var H=/^(?:parents|prev(?:Until|All))/,I={children:!0,contents:!0,next:!0,prev:!0};r.fn.extend({has:function(a){var b=r(a,this),c=b.length;return this.filter(function(){for(var a=0;a-1:1===c.nodeType&&r.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?r.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?i.call(r(a),this[0]):i.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(r.uniqueSort(r.merge(this.get(),r(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function J(a,b){while((a=a[b])&&1!==a.nodeType);return a}r.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return y(a,"parentNode")},parentsUntil:function(a,b,c){return y(a,"parentNode",c)},next:function(a){return J(a,"nextSibling")},prev:function(a){return J(a,"previousSibling")},nextAll:function(a){return y(a,"nextSibling")},prevAll:function(a){return y(a,"previousSibling")},nextUntil:function(a,b,c){return y(a,"nextSibling",c)},prevUntil:function(a,b,c){return y(a,"previousSibling",c)},siblings:function(a){return z((a.parentNode||{}).firstChild,a)},children:function(a){return z(a.firstChild)},contents:function(a){return a.contentDocument||r.merge([],a.childNodes)}},function(a,b){r.fn[a]=function(c,d){var e=r.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=r.filter(d,e)),this.length>1&&(I[a]||r.uniqueSort(e),H.test(a)&&e.reverse()),this.pushStack(e)}});var K=/[^\x20\t\r\n\f]+/g;function L(a){var b={};return r.each(a.match(K)||[],function(a,c){b[c]=!0}),b}r.Callbacks=function(a){a="string"==typeof a?L(a):r.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),c<=h&&h--}),this},has:function(a){return a?r.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||b||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j};function M(a){return a}function N(a){throw a}function O(a,b,c){var d;try{a&&r.isFunction(d=a.promise)?d.call(a).done(b).fail(c):a&&r.isFunction(d=a.then)?d.call(a,b,c):b.call(void 0,a)}catch(a){c.call(void 0,a)}}r.extend({Deferred:function(b){var c=[["notify","progress",r.Callbacks("memory"),r.Callbacks("memory"),2],["resolve","done",r.Callbacks("once memory"),r.Callbacks("once memory"),0,"resolved"],["reject","fail",r.Callbacks("once memory"),r.Callbacks("once memory"),1,"rejected"]],d="pending",e={state:function(){return d},always:function(){return f.done(arguments).fail(arguments),this},"catch":function(a){return e.then(null,a)},pipe:function(){var a=arguments;return r.Deferred(function(b){r.each(c,function(c,d){var e=r.isFunction(a[d[4]])&&a[d[4]];f[d[1]](function(){var a=e&&e.apply(this,arguments);a&&r.isFunction(a.promise)?a.promise().progress(b.notify).done(b.resolve).fail(b.reject):b[d[0]+"With"](this,e?[a]:arguments)})}),a=null}).promise()},then:function(b,d,e){var f=0;function g(b,c,d,e){return function(){var h=this,i=arguments,j=function(){var a,j;if(!(b=f&&(d!==N&&(h=void 0,i=[a]),c.rejectWith(h,i))}};b?k():(r.Deferred.getStackHook&&(k.stackTrace=r.Deferred.getStackHook()),a.setTimeout(k))}}return r.Deferred(function(a){c[0][3].add(g(0,a,r.isFunction(e)?e:M,a.notifyWith)),c[1][3].add(g(0,a,r.isFunction(b)?b:M)),c[2][3].add(g(0,a,r.isFunction(d)?d:N))}).promise()},promise:function(a){return null!=a?r.extend(a,e):e}},f={};return r.each(c,function(a,b){var g=b[2],h=b[5];e[b[1]]=g.add,h&&g.add(function(){d=h},c[3-a][2].disable,c[0][2].lock),g.add(b[3].fire),f[b[0]]=function(){return f[b[0]+"With"](this===f?void 0:this,arguments),this},f[b[0]+"With"]=g.fireWith}),e.promise(f),b&&b.call(f,f),f},when:function(a){var b=arguments.length,c=b,d=Array(c),e=f.call(arguments),g=r.Deferred(),h=function(a){return function(c){d[a]=this,e[a]=arguments.length>1?f.call(arguments):c,--b||g.resolveWith(d,e)}};if(b<=1&&(O(a,g.done(h(c)).resolve,g.reject),"pending"===g.state()||r.isFunction(e[c]&&e[c].then)))return g.then();while(c--)O(e[c],h(c),g.reject);return g.promise()}});var P=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;r.Deferred.exceptionHook=function(b,c){a.console&&a.console.warn&&b&&P.test(b.name)&&a.console.warn("jQuery.Deferred exception: "+b.message,b.stack,c)},r.readyException=function(b){a.setTimeout(function(){throw b})};var Q=r.Deferred();r.fn.ready=function(a){return Q.then(a)["catch"](function(a){r.readyException(a)}),this},r.extend({isReady:!1,readyWait:1,holdReady:function(a){a?r.readyWait++:r.ready(!0)},ready:function(a){(a===!0?--r.readyWait:r.isReady)||(r.isReady=!0,a!==!0&&--r.readyWait>0||Q.resolveWith(d,[r]))}}),r.ready.then=Q.then;function R(){d.removeEventListener("DOMContentLoaded",R), +a.removeEventListener("load",R),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",R),a.addEventListener("load",R));var S=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)S(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h1,null,!0)},removeData:function(a){return this.each(function(){W.remove(this,a)})}}),r.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=V.get(a,b),c&&(!d||r.isArray(c)?d=V.access(a,b,r.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=r.queue(a,b),d=c.length,e=c.shift(),f=r._queueHooks(a,b),g=function(){r.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return V.get(a,c)||V.access(a,c,{empty:r.Callbacks("once memory").add(function(){V.remove(a,[b+"queue",c])})})}}),r.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length\x20\t\r\n\f]+)/i,ka=/^$|\/(?:java|ecma)script/i,la={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};la.optgroup=la.option,la.tbody=la.tfoot=la.colgroup=la.caption=la.thead,la.th=la.td;function ma(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&r.nodeName(a,b)?r.merge([a],c):c}function na(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=ma(l.appendChild(f),"script"),j&&na(g),c){k=0;while(f=g[k++])ka.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var qa=d.documentElement,ra=/^key/,sa=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ta=/^([^.]*)(?:\.(.+)|)/;function ua(){return!0}function va(){return!1}function wa(){try{return d.activeElement}catch(a){}}function xa(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)xa(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=va;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=V.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(qa,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(K)||[""],j=b.length;while(j--)h=ta.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=V.hasData(a)&&V.get(a);if(q&&(i=q.events)){b=(b||"").match(K)||[""],j=b.length;while(j--)if(h=ta.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&V.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(V.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,za=/\s*$/g;function Da(a,b){return r.nodeName(a,"table")&&r.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a:a}function Ea(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function Fa(a){var b=Ba.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Ga(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(V.hasData(a)&&(f=V.access(a),g=V.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;c1&&"string"==typeof q&&!o.checkClone&&Aa.test(q))return a.each(function(e){var f=a.eq(e);s&&(b[0]=q.call(this,e,f.html())),Ia(f,b,c,d)});if(m&&(e=pa(b,a[0].ownerDocument,!1,a,d),f=e.firstChild,1===e.childNodes.length&&(e=f),f||d)){for(h=r.map(ma(e,"script"),Ea),i=h.length;l")},clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=r.contains(a.ownerDocument,a);if(!(o.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||r.isXMLDoc(a)))for(g=ma(h),f=ma(a),d=0,e=f.length;d0&&na(g,!i&&ma(a,"script")),h},cleanData:function(a){for(var b,c,d,e=r.event.special,f=0;void 0!==(c=a[f]);f++)if(T(c)){if(b=c[V.expando]){if(b.events)for(d in b.events)e[d]?r.event.remove(c,d):r.removeEvent(c,d,b.handle);c[V.expando]=void 0}c[W.expando]&&(c[W.expando]=void 0)}}}),r.fn.extend({detach:function(a){return Ja(this,a,!0)},remove:function(a){return Ja(this,a)},text:function(a){return S(this,function(a){return void 0===a?r.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=a)})},null,a,arguments.length)},append:function(){return Ia(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Da(this,a);b.appendChild(a)}})},prepend:function(){return Ia(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Da(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ia(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ia(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(r.cleanData(ma(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null!=a&&a,b=null==b?a:b,this.map(function(){return r.clone(this,a,b)})},html:function(a){return S(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!za.test(a)&&!la[(ja.exec(a)||["",""])[1].toLowerCase()]){a=r.htmlPrefilter(a);try{for(;c1)}});function Ya(a,b,c,d,e){return new Ya.prototype.init(a,b,c,d,e)}r.Tween=Ya,Ya.prototype={constructor:Ya,init:function(a,b,c,d,e,f){this.elem=a,this.prop=c,this.easing=e||r.easing._default,this.options=b,this.start=this.now=this.cur(),this.end=d,this.unit=f||(r.cssNumber[c]?"":"px")},cur:function(){var a=Ya.propHooks[this.prop];return a&&a.get?a.get(this):Ya.propHooks._default.get(this)},run:function(a){var b,c=Ya.propHooks[this.prop];return this.options.duration?this.pos=b=r.easing[this.easing](a,this.options.duration*a,0,1,this.options.duration):this.pos=b=a,this.now=(this.end-this.start)*b+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),c&&c.set?c.set(this):Ya.propHooks._default.set(this),this}},Ya.prototype.init.prototype=Ya.prototype,Ya.propHooks={_default:{get:function(a){var b;return 1!==a.elem.nodeType||null!=a.elem[a.prop]&&null==a.elem.style[a.prop]?a.elem[a.prop]:(b=r.css(a.elem,a.prop,""),b&&"auto"!==b?b:0)},set:function(a){r.fx.step[a.prop]?r.fx.step[a.prop](a):1!==a.elem.nodeType||null==a.elem.style[r.cssProps[a.prop]]&&!r.cssHooks[a.prop]?a.elem[a.prop]=a.now:r.style(a.elem,a.prop,a.now+a.unit)}}},Ya.propHooks.scrollTop=Ya.propHooks.scrollLeft={set:function(a){a.elem.nodeType&&a.elem.parentNode&&(a.elem[a.prop]=a.now)}},r.easing={linear:function(a){return a},swing:function(a){return.5-Math.cos(a*Math.PI)/2},_default:"swing"},r.fx=Ya.prototype.init,r.fx.step={};var Za,$a,_a=/^(?:toggle|show|hide)$/,ab=/queueHooks$/;function bb(){$a&&(a.requestAnimationFrame(bb),r.fx.tick())}function cb(){return a.setTimeout(function(){Za=void 0}),Za=r.now()}function db(a,b){var c,d=0,e={height:a};for(b=b?1:0;d<4;d+=2-b)c=ba[d],e["margin"+c]=e["padding"+c]=a;return b&&(e.opacity=e.width=a),e}function eb(a,b,c){for(var d,e=(hb.tweeners[b]||[]).concat(hb.tweeners["*"]),f=0,g=e.length;f1)},removeAttr:function(a){return this.each(function(){r.removeAttr(this,a)})}}),r.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return"undefined"==typeof a.getAttribute?r.prop(a,b,c):(1===f&&r.isXMLDoc(a)||(e=r.attrHooks[b.toLowerCase()]||(r.expr.match.bool.test(b)?ib:void 0)), +void 0!==c?null===c?void r.removeAttr(a,b):e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:(a.setAttribute(b,c+""),c):e&&"get"in e&&null!==(d=e.get(a,b))?d:(d=r.find.attr(a,b),null==d?void 0:d))},attrHooks:{type:{set:function(a,b){if(!o.radioValue&&"radio"===b&&r.nodeName(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}},removeAttr:function(a,b){var c,d=0,e=b&&b.match(K);if(e&&1===a.nodeType)while(c=e[d++])a.removeAttribute(c)}}),ib={set:function(a,b,c){return b===!1?r.removeAttr(a,c):a.setAttribute(c,c),c}},r.each(r.expr.match.bool.source.match(/\w+/g),function(a,b){var c=jb[b]||r.find.attr;jb[b]=function(a,b,d){var e,f,g=b.toLowerCase();return d||(f=jb[g],jb[g]=e,e=null!=c(a,b,d)?g:null,jb[g]=f),e}});var kb=/^(?:input|select|textarea|button)$/i,lb=/^(?:a|area)$/i;r.fn.extend({prop:function(a,b){return S(this,r.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[r.propFix[a]||a]})}}),r.extend({prop:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return 1===f&&r.isXMLDoc(a)||(b=r.propFix[b]||b,e=r.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){var b=r.find.attr(a,"tabindex");return b?parseInt(b,10):kb.test(a.nodeName)||lb.test(a.nodeName)&&a.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),o.optSelected||(r.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null},set:function(a){var b=a.parentNode;b&&(b.selectedIndex,b.parentNode&&b.parentNode.selectedIndex)}}),r.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){r.propFix[this.toLowerCase()]=this});function mb(a){var b=a.match(K)||[];return b.join(" ")}function nb(a){return a.getAttribute&&a.getAttribute("class")||""}r.fn.extend({addClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).addClass(a.call(this,b,nb(this)))});if("string"==typeof a&&a){b=a.match(K)||[];while(c=this[i++])if(e=nb(c),d=1===c.nodeType&&" "+mb(e)+" "){g=0;while(f=b[g++])d.indexOf(" "+f+" ")<0&&(d+=f+" ");h=mb(d),e!==h&&c.setAttribute("class",h)}}return this},removeClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).removeClass(a.call(this,b,nb(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof a&&a){b=a.match(K)||[];while(c=this[i++])if(e=nb(c),d=1===c.nodeType&&" "+mb(e)+" "){g=0;while(f=b[g++])while(d.indexOf(" "+f+" ")>-1)d=d.replace(" "+f+" "," ");h=mb(d),e!==h&&c.setAttribute("class",h)}}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):r.isFunction(a)?this.each(function(c){r(this).toggleClass(a.call(this,c,nb(this),b),b)}):this.each(function(){var b,d,e,f;if("string"===c){d=0,e=r(this),f=a.match(K)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else void 0!==a&&"boolean"!==c||(b=nb(this),b&&V.set(this,"__className__",b),this.setAttribute&&this.setAttribute("class",b||a===!1?"":V.get(this,"__className__")||""))})},hasClass:function(a){var b,c,d=0;b=" "+a+" ";while(c=this[d++])if(1===c.nodeType&&(" "+mb(nb(c))+" ").indexOf(b)>-1)return!0;return!1}});var ob=/\r/g;r.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=r.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,r(this).val()):a,null==e?e="":"number"==typeof e?e+="":r.isArray(e)&&(e=r.map(e,function(a){return null==a?"":a+""})),b=r.valHooks[this.type]||r.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=r.valHooks[e.type]||r.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(ob,""):null==c?"":c)}}}),r.extend({valHooks:{option:{get:function(a){var b=r.find.attr(a,"value");return null!=b?b:mb(r.text(a))}},select:{get:function(a){var b,c,d,e=a.options,f=a.selectedIndex,g="select-one"===a.type,h=g?null:[],i=g?f+1:e.length;for(d=f<0?i:g?f:0;d-1)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),r.each(["radio","checkbox"],function(){r.valHooks[this]={set:function(a,b){if(r.isArray(b))return a.checked=r.inArray(r(a).val(),b)>-1}},o.checkOn||(r.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})});var pb=/^(?:focusinfocus|focusoutblur)$/;r.extend(r.event,{trigger:function(b,c,e,f){var g,h,i,j,k,m,n,o=[e||d],p=l.call(b,"type")?b.type:b,q=l.call(b,"namespace")?b.namespace.split("."):[];if(h=i=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!pb.test(p+r.event.triggered)&&(p.indexOf(".")>-1&&(q=p.split("."),p=q.shift(),q.sort()),k=p.indexOf(":")<0&&"on"+p,b=b[r.expando]?b:new r.Event(p,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=q.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:r.makeArray(c,[b]),n=r.event.special[p]||{},f||!n.trigger||n.trigger.apply(e,c)!==!1)){if(!f&&!n.noBubble&&!r.isWindow(e)){for(j=n.delegateType||p,pb.test(j+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),i=h;i===(e.ownerDocument||d)&&o.push(i.defaultView||i.parentWindow||a)}g=0;while((h=o[g++])&&!b.isPropagationStopped())b.type=g>1?j:n.bindType||p,m=(V.get(h,"events")||{})[b.type]&&V.get(h,"handle"),m&&m.apply(h,c),m=k&&h[k],m&&m.apply&&T(h)&&(b.result=m.apply(h,c),b.result===!1&&b.preventDefault());return b.type=p,f||b.isDefaultPrevented()||n._default&&n._default.apply(o.pop(),c)!==!1||!T(e)||k&&r.isFunction(e[p])&&!r.isWindow(e)&&(i=e[k],i&&(e[k]=null),r.event.triggered=p,e[p](),r.event.triggered=void 0,i&&(e[k]=i)),b.result}},simulate:function(a,b,c){var d=r.extend(new r.Event,c,{type:a,isSimulated:!0});r.event.trigger(d,null,b)}}),r.fn.extend({trigger:function(a,b){return this.each(function(){r.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];if(c)return r.event.trigger(a,b,c,!0)}}),r.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(a,b){r.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),r.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}}),o.focusin="onfocusin"in a,o.focusin||r.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){r.event.simulate(b,a.target,r.event.fix(a))};r.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=V.access(d,b);e||d.addEventListener(a,c,!0),V.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=V.access(d,b)-1;e?V.access(d,b,e):(d.removeEventListener(a,c,!0),V.remove(d,b))}}});var qb=a.location,rb=r.now(),sb=/\?/;r.parseXML=function(b){var c;if(!b||"string"!=typeof b)return null;try{c=(new a.DOMParser).parseFromString(b,"text/xml")}catch(d){c=void 0}return c&&!c.getElementsByTagName("parsererror").length||r.error("Invalid XML: "+b),c};var tb=/\[\]$/,ub=/\r?\n/g,vb=/^(?:submit|button|image|reset|file)$/i,wb=/^(?:input|select|textarea|keygen)/i;function xb(a,b,c,d){var e;if(r.isArray(b))r.each(b,function(b,e){c||tb.test(a)?d(a,e):xb(a+"["+("object"==typeof e&&null!=e?b:"")+"]",e,c,d)});else if(c||"object"!==r.type(b))d(a,b);else for(e in b)xb(a+"["+e+"]",b[e],c,d)}r.param=function(a,b){var c,d=[],e=function(a,b){var c=r.isFunction(b)?b():b;d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(null==c?"":c)};if(r.isArray(a)||a.jquery&&!r.isPlainObject(a))r.each(a,function(){e(this.name,this.value)});else for(c in a)xb(c,a[c],b,e);return d.join("&")},r.fn.extend({serialize:function(){return r.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=r.prop(this,"elements");return a?r.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!r(this).is(":disabled")&&wb.test(this.nodeName)&&!vb.test(a)&&(this.checked||!ia.test(a))}).map(function(a,b){var c=r(this).val();return null==c?null:r.isArray(c)?r.map(c,function(a){return{name:b.name,value:a.replace(ub,"\r\n")}}):{name:b.name,value:c.replace(ub,"\r\n")}}).get()}});var yb=/%20/g,zb=/#.*$/,Ab=/([?&])_=[^&]*/,Bb=/^(.*?):[ \t]*([^\r\n]*)$/gm,Cb=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Db=/^(?:GET|HEAD)$/,Eb=/^\/\//,Fb={},Gb={},Hb="*/".concat("*"),Ib=d.createElement("a");Ib.href=qb.href;function Jb(a){return function(b,c){"string"!=typeof b&&(c=b,b="*");var d,e=0,f=b.toLowerCase().match(K)||[];if(r.isFunction(c))while(d=f[e++])"+"===d[0]?(d=d.slice(1)||"*",(a[d]=a[d]||[]).unshift(c)):(a[d]=a[d]||[]).push(c)}}function Kb(a,b,c,d){var e={},f=a===Gb;function g(h){var i;return e[h]=!0,r.each(a[h]||[],function(a,h){var j=h(b,c,d);return"string"!=typeof j||f||e[j]?f?!(i=j):void 0:(b.dataTypes.unshift(j),g(j),!1)}),i}return g(b.dataTypes[0])||!e["*"]&&g("*")}function Lb(a,b){var c,d,e=r.ajaxSettings.flatOptions||{};for(c in b)void 0!==b[c]&&((e[c]?a:d||(d={}))[c]=b[c]);return d&&r.extend(!0,a,d),a}function Mb(a,b,c){var d,e,f,g,h=a.contents,i=a.dataTypes;while("*"===i[0])i.shift(),void 0===d&&(d=a.mimeType||b.getResponseHeader("Content-Type"));if(d)for(e in h)if(h[e]&&h[e].test(d)){i.unshift(e);break}if(i[0]in c)f=i[0];else{for(e in c){if(!i[0]||a.converters[e+" "+i[0]]){f=e;break}g||(g=e)}f=f||g}if(f)return f!==i[0]&&i.unshift(f),c[f]}function Nb(a,b,c,d){var e,f,g,h,i,j={},k=a.dataTypes.slice();if(k[1])for(g in a.converters)j[g.toLowerCase()]=a.converters[g];f=k.shift();while(f)if(a.responseFields[f]&&(c[a.responseFields[f]]=b),!i&&d&&a.dataFilter&&(b=a.dataFilter(b,a.dataType)),i=f,f=k.shift())if("*"===f)f=i;else if("*"!==i&&i!==f){if(g=j[i+" "+f]||j["* "+f],!g)for(e in j)if(h=e.split(" "),h[1]===f&&(g=j[i+" "+h[0]]||j["* "+h[0]])){g===!0?g=j[e]:j[e]!==!0&&(f=h[0],k.unshift(h[1]));break}if(g!==!0)if(g&&a["throws"])b=g(b);else try{b=g(b)}catch(l){return{state:"parsererror",error:g?l:"No conversion from "+i+" to "+f}}}return{state:"success",data:b}}r.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:qb.href,type:"GET",isLocal:Cb.test(qb.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Hb,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":r.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(a,b){return b?Lb(Lb(a,r.ajaxSettings),b):Lb(r.ajaxSettings,a)},ajaxPrefilter:Jb(Fb),ajaxTransport:Jb(Gb),ajax:function(b,c){"object"==typeof b&&(c=b,b=void 0),c=c||{};var e,f,g,h,i,j,k,l,m,n,o=r.ajaxSetup({},c),p=o.context||o,q=o.context&&(p.nodeType||p.jquery)?r(p):r.event,s=r.Deferred(),t=r.Callbacks("once memory"),u=o.statusCode||{},v={},w={},x="canceled",y={readyState:0,getResponseHeader:function(a){var b;if(k){if(!h){h={};while(b=Bb.exec(g))h[b[1].toLowerCase()]=b[2]}b=h[a.toLowerCase()]}return null==b?null:b},getAllResponseHeaders:function(){return k?g:null},setRequestHeader:function(a,b){return null==k&&(a=w[a.toLowerCase()]=w[a.toLowerCase()]||a,v[a]=b),this},overrideMimeType:function(a){return null==k&&(o.mimeType=a),this},statusCode:function(a){var b;if(a)if(k)y.always(a[y.status]);else for(b in a)u[b]=[u[b],a[b]];return this},abort:function(a){var b=a||x;return e&&e.abort(b),A(0,b),this}};if(s.promise(y),o.url=((b||o.url||qb.href)+"").replace(Eb,qb.protocol+"//"),o.type=c.method||c.type||o.method||o.type,o.dataTypes=(o.dataType||"*").toLowerCase().match(K)||[""],null==o.crossDomain){j=d.createElement("a");try{j.href=o.url,j.href=j.href,o.crossDomain=Ib.protocol+"//"+Ib.host!=j.protocol+"//"+j.host}catch(z){o.crossDomain=!0}}if(o.data&&o.processData&&"string"!=typeof o.data&&(o.data=r.param(o.data,o.traditional)),Kb(Fb,o,c,y),k)return y;l=r.event&&o.global,l&&0===r.active++&&r.event.trigger("ajaxStart"),o.type=o.type.toUpperCase(),o.hasContent=!Db.test(o.type),f=o.url.replace(zb,""),o.hasContent?o.data&&o.processData&&0===(o.contentType||"").indexOf("application/x-www-form-urlencoded")&&(o.data=o.data.replace(yb,"+")):(n=o.url.slice(f.length),o.data&&(f+=(sb.test(f)?"&":"?")+o.data,delete o.data),o.cache===!1&&(f=f.replace(Ab,"$1"),n=(sb.test(f)?"&":"?")+"_="+rb++ +n),o.url=f+n),o.ifModified&&(r.lastModified[f]&&y.setRequestHeader("If-Modified-Since",r.lastModified[f]),r.etag[f]&&y.setRequestHeader("If-None-Match",r.etag[f])),(o.data&&o.hasContent&&o.contentType!==!1||c.contentType)&&y.setRequestHeader("Content-Type",o.contentType),y.setRequestHeader("Accept",o.dataTypes[0]&&o.accepts[o.dataTypes[0]]?o.accepts[o.dataTypes[0]]+("*"!==o.dataTypes[0]?", "+Hb+"; q=0.01":""):o.accepts["*"]);for(m in o.headers)y.setRequestHeader(m,o.headers[m]);if(o.beforeSend&&(o.beforeSend.call(p,y,o)===!1||k))return y.abort();if(x="abort",t.add(o.complete),y.done(o.success),y.fail(o.error),e=Kb(Gb,o,c,y)){if(y.readyState=1,l&&q.trigger("ajaxSend",[y,o]),k)return y;o.async&&o.timeout>0&&(i=a.setTimeout(function(){y.abort("timeout")},o.timeout));try{k=!1,e.send(v,A)}catch(z){if(k)throw z;A(-1,z)}}else A(-1,"No Transport");function A(b,c,d,h){var j,m,n,v,w,x=c;k||(k=!0,i&&a.clearTimeout(i),e=void 0,g=h||"",y.readyState=b>0?4:0,j=b>=200&&b<300||304===b,d&&(v=Mb(o,y,d)),v=Nb(o,v,y,j),j?(o.ifModified&&(w=y.getResponseHeader("Last-Modified"),w&&(r.lastModified[f]=w),w=y.getResponseHeader("etag"),w&&(r.etag[f]=w)),204===b||"HEAD"===o.type?x="nocontent":304===b?x="notmodified":(x=v.state,m=v.data,n=v.error,j=!n)):(n=x,!b&&x||(x="error",b<0&&(b=0))),y.status=b,y.statusText=(c||x)+"",j?s.resolveWith(p,[m,x,y]):s.rejectWith(p,[y,x,n]),y.statusCode(u),u=void 0,l&&q.trigger(j?"ajaxSuccess":"ajaxError",[y,o,j?m:n]),t.fireWith(p,[y,x]),l&&(q.trigger("ajaxComplete",[y,o]),--r.active||r.event.trigger("ajaxStop")))}return y},getJSON:function(a,b,c){return r.get(a,b,c,"json")},getScript:function(a,b){return r.get(a,void 0,b,"script")}}),r.each(["get","post"],function(a,b){r[b]=function(a,c,d,e){return r.isFunction(c)&&(e=e||d,d=c,c=void 0),r.ajax(r.extend({url:a,type:b,dataType:e,data:c,success:d},r.isPlainObject(a)&&a))}}),r._evalUrl=function(a){return r.ajax({url:a,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,"throws":!0})},r.fn.extend({wrapAll:function(a){var b;return this[0]&&(r.isFunction(a)&&(a=a.call(this[0])),b=r(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this},wrapInner:function(a){return r.isFunction(a)?this.each(function(b){r(this).wrapInner(a.call(this,b))}):this.each(function(){var b=r(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=r.isFunction(a);return this.each(function(c){r(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(a){return this.parent(a).not("body").each(function(){r(this).replaceWith(this.childNodes)}),this}}),r.expr.pseudos.hidden=function(a){return!r.expr.pseudos.visible(a)},r.expr.pseudos.visible=function(a){return!!(a.offsetWidth||a.offsetHeight||a.getClientRects().length)},r.ajaxSettings.xhr=function(){try{return new a.XMLHttpRequest}catch(b){}};var Ob={0:200,1223:204},Pb=r.ajaxSettings.xhr();o.cors=!!Pb&&"withCredentials"in Pb,o.ajax=Pb=!!Pb,r.ajaxTransport(function(b){var c,d;if(o.cors||Pb&&!b.crossDomain)return{send:function(e,f){var g,h=b.xhr();if(h.open(b.type,b.url,b.async,b.username,b.password),b.xhrFields)for(g in b.xhrFields)h[g]=b.xhrFields[g];b.mimeType&&h.overrideMimeType&&h.overrideMimeType(b.mimeType),b.crossDomain||e["X-Requested-With"]||(e["X-Requested-With"]="XMLHttpRequest");for(g in e)h.setRequestHeader(g,e[g]);c=function(a){return function(){c&&(c=d=h.onload=h.onerror=h.onabort=h.onreadystatechange=null,"abort"===a?h.abort():"error"===a?"number"!=typeof h.status?f(0,"error"):f(h.status,h.statusText):f(Ob[h.status]||h.status,h.statusText,"text"!==(h.responseType||"text")||"string"!=typeof h.responseText?{binary:h.response}:{text:h.responseText},h.getAllResponseHeaders()))}},h.onload=c(),d=h.onerror=c("error"),void 0!==h.onabort?h.onabort=d:h.onreadystatechange=function(){4===h.readyState&&a.setTimeout(function(){c&&d()})},c=c("abort");try{h.send(b.hasContent&&b.data||null)}catch(i){if(c)throw i}},abort:function(){c&&c()}}}),r.ajaxPrefilter(function(a){a.crossDomain&&(a.contents.script=!1)}),r.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(a){return r.globalEval(a),a}}}),r.ajaxPrefilter("script",function(a){void 0===a.cache&&(a.cache=!1),a.crossDomain&&(a.type="GET")}),r.ajaxTransport("script",function(a){if(a.crossDomain){var b,c;return{send:function(e,f){b=r("' + sanitized = convert_markdown_to_safe_html(markdown) + + assert ' +{% endassets %} + +{% assets "javascript" -%} + +{% endassets %} + + + diff --git a/tildes/tildes/templates/base_no_sidebar.jinja2 b/tildes/tildes/templates/base_no_sidebar.jinja2 new file mode 100644 index 0000000..728a98a --- /dev/null +++ b/tildes/tildes/templates/base_no_sidebar.jinja2 @@ -0,0 +1,9 @@ +{% extends 'base.jinja2' %} + +{% block body_tag %} + {% if request.cookies.get('theme', '') %} + + {% else %} + + {% endif %} +{% endblock %} diff --git a/tildes/tildes/templates/donate_stripe.jinja2 b/tildes/tildes/templates/donate_stripe.jinja2 new file mode 100644 index 0000000..5cb6f28 --- /dev/null +++ b/tildes/tildes/templates/donate_stripe.jinja2 @@ -0,0 +1,19 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% block title %}Stripe donation{% endblock %} + +{% block main_heading %} + {% if payment_successful %} + Thanks for donating to Tildes! + {% else %} + Donation failed + {% endif %} +{% endblock %} + +{% block content %} + {% if payment_successful %} +

You should receive an email receipt. If you have any questions, please feel free to contact donate@tildes.net

+ {% else %} +

The Stripe payment failed for some reason (your credit card has not been charged). Please go back to the donation page and try again. If the payment fails again, please send an email so it can be looked into.

+ {% endif %} +{% endblock %} diff --git a/tildes/tildes/templates/error_403.jinja2 b/tildes/tildes/templates/error_403.jinja2 new file mode 100644 index 0000000..857ab94 --- /dev/null +++ b/tildes/tildes/templates/error_403.jinja2 @@ -0,0 +1,35 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% block title %} + {% if not request.user %} + Invite-only alpha + {% else %} + Error 403 (Forbidden) + {% endif %} +{% endblock %} + +{% block main_heading %} + {% if request.user %} + Error 403 (Forbidden) + {% endif %} +{% endblock %} + +{% block content %} + +{% if not request.user %} +
+

Tildes is currently in invite-only alpha

+ +

To learn more about Tildes, read the announcement post.

+

There are also some pages available on the docs site.

+ + +
+{% else %} +

You don't have access to this page.

+{% endif %} + +{% endblock %} diff --git a/tildes/tildes/templates/groups.jinja2 b/tildes/tildes/templates/groups.jinja2 new file mode 100644 index 0000000..1526868 --- /dev/null +++ b/tildes/tildes/templates/groups.jinja2 @@ -0,0 +1,34 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% from 'macros/groups.jinja2' import render_group_subscription_box with context %} +{% from 'macros/links.jinja2' import group_linked %} + +{% block title %}Browse groups{% endblock %} + +{% block main_heading %} +

Browse groups

+{% endblock %} + +{% block content %} + + + + + + + + + {% for group in groups %} + + + + + {% endfor %} + +
GroupSubscribe
+ {{ group_linked(group.path) }} + {% if group.short_description %} +

{{ group.short_description }}

+ {% endif %} +
{{ render_group_subscription_box(group) }}
+{% endblock %} diff --git a/tildes/tildes/templates/home.jinja2 b/tildes/tildes/templates/home.jinja2 new file mode 100644 index 0000000..4e7aca8 --- /dev/null +++ b/tildes/tildes/templates/home.jinja2 @@ -0,0 +1,50 @@ +{% extends 'topic_listing.jinja2' %} + +{% block title_full %}Tildes{% endblock %} + +{% block header_context_link %}{% endblock %} + +{% block content %} + {% if request.user and request.user.subscriptions %} + {{ super() }} + {% else %} +
+

You aren't subscribed to any groups yet

+

This page will show a combined listing of topics from groups that you're subscribed to.

+ +
+ {% endif %} +{% endblock %} + +{% block sidebar %} +

Home

+

The home page shows topics from groups that you're subscribed to.

+ {% if request.user %} + {% if request.user.subscriptions %} + + Browse the list of groups + {% endif %} + + {% if not (tag or unfiltered) %} +
+
+ Filtered topic tags ({{ request.user.filtered_topic_tags|length }}) +
    + {% for tag in request.user.filtered_topic_tags %} +
  • {{ tag }}
  • + {% else %} +
  • No filtered tags
  • + {% endfor %} +
+ Edit filtered tags +
+ {% endif %} + {% endif %} +{% endblock %} diff --git a/tildes/tildes/templates/includes/password_restrictions.jinja2 b/tildes/tildes/templates/includes/password_restrictions.jinja2 new file mode 100644 index 0000000..12a83b7 --- /dev/null +++ b/tildes/tildes/templates/includes/password_restrictions.jinja2 @@ -0,0 +1,8 @@ +
Password restrictions
+
+
    +
  • At least 8 characters long.
  • +
  • Does not contain the username, and is not contained in the username.
  • +
  • Has not been previously exposed in a data breach (checked locally against a list downloaded from Troy Hunt's "Have I been pwned?").
  • +
+
diff --git a/tildes/tildes/templates/intercooler/comment_contents.jinja2 b/tildes/tildes/templates/intercooler/comment_contents.jinja2 new file mode 100644 index 0000000..65eb1ee --- /dev/null +++ b/tildes/tildes/templates/intercooler/comment_contents.jinja2 @@ -0,0 +1,3 @@ +{% from 'macros/comments.jinja2' import render_comment_contents with context %} + +{{ render_comment_contents(comment) }} diff --git a/tildes/tildes/templates/intercooler/comment_edit.jinja2 b/tildes/tildes/templates/intercooler/comment_edit.jinja2 new file mode 100644 index 0000000..198e355 --- /dev/null +++ b/tildes/tildes/templates/intercooler/comment_edit.jinja2 @@ -0,0 +1,30 @@ +{% from 'macros/forms.jinja2' import markdown_textarea %} + +
+ {{ markdown_textarea( + 'Edit your comment', + id='markdown-edit-%s' % comment.comment_id36, + text=comment.markdown, + auto_focus=True, + ) }} +
+ + +
+
diff --git a/tildes/tildes/templates/intercooler/group_subscription_box.jinja2 b/tildes/tildes/templates/intercooler/group_subscription_box.jinja2 new file mode 100644 index 0000000..f34e986 --- /dev/null +++ b/tildes/tildes/templates/intercooler/group_subscription_box.jinja2 @@ -0,0 +1,3 @@ +{% from 'macros/groups.jinja2' import render_group_subscription_box with context %} + +{{ render_group_subscription_box(group) }} diff --git a/tildes/tildes/templates/intercooler/invite_code.jinja2 b/tildes/tildes/templates/intercooler/invite_code.jinja2 new file mode 100644 index 0000000..dd81f1f --- /dev/null +++ b/tildes/tildes/templates/intercooler/invite_code.jinja2 @@ -0,0 +1,12 @@ + + +{% if num_remaining > 0 %} + +{% endif %} diff --git a/tildes/tildes/templates/intercooler/single_comment.jinja2 b/tildes/tildes/templates/intercooler/single_comment.jinja2 new file mode 100644 index 0000000..3e84ed1 --- /dev/null +++ b/tildes/tildes/templates/intercooler/single_comment.jinja2 @@ -0,0 +1,3 @@ +{% from 'macros/comments.jinja2' import render_single_comment with context %} + +{{ render_single_comment(comment) }} diff --git a/tildes/tildes/templates/intercooler/single_message.jinja2 b/tildes/tildes/templates/intercooler/single_message.jinja2 new file mode 100644 index 0000000..a3c75e7 --- /dev/null +++ b/tildes/tildes/templates/intercooler/single_message.jinja2 @@ -0,0 +1,3 @@ +{% from 'macros/messages.jinja2' import render_message with context %} + +{{ render_message(message) }} diff --git a/tildes/tildes/templates/intercooler/topic_contents.jinja2 b/tildes/tildes/templates/intercooler/topic_contents.jinja2 new file mode 100644 index 0000000..31dc63f --- /dev/null +++ b/tildes/tildes/templates/intercooler/topic_contents.jinja2 @@ -0,0 +1 @@ +{{ topic.rendered_html|safe }} diff --git a/tildes/tildes/templates/intercooler/topic_edit.jinja2 b/tildes/tildes/templates/intercooler/topic_edit.jinja2 new file mode 100644 index 0000000..83956fd --- /dev/null +++ b/tildes/tildes/templates/intercooler/topic_edit.jinja2 @@ -0,0 +1,25 @@ +{% from 'macros/forms.jinja2' import markdown_textarea %} + +
+ {{ markdown_textarea('Edit your topic', id="topic-markdown", text=topic.markdown, auto_focus=True) }} +
+ + +
+
diff --git a/tildes/tildes/templates/intercooler/topic_group_edit.jinja2 b/tildes/tildes/templates/intercooler/topic_group_edit.jinja2 new file mode 100644 index 0000000..2a7aff4 --- /dev/null +++ b/tildes/tildes/templates/intercooler/topic_group_edit.jinja2 @@ -0,0 +1,15 @@ +
+ + +
+ + +
+
diff --git a/tildes/tildes/templates/intercooler/topic_tags.jinja2 b/tildes/tildes/templates/intercooler/topic_tags.jinja2 new file mode 100644 index 0000000..292d8be --- /dev/null +++ b/tildes/tildes/templates/intercooler/topic_tags.jinja2 @@ -0,0 +1,5 @@ +
    + {% for tag in topic.tags %} +
  • {{ tag }}
  • + {% endfor %} +
diff --git a/tildes/tildes/templates/intercooler/topic_tags_edit.jinja2 b/tildes/tildes/templates/intercooler/topic_tags_edit.jinja2 new file mode 100644 index 0000000..dd848f6 --- /dev/null +++ b/tildes/tildes/templates/intercooler/topic_tags_edit.jinja2 @@ -0,0 +1,17 @@ +{% from 'macros/forms.jinja2' import topic_tagging %} + +
+ {{ topic_tagging(value=topic.tags|join(', '), auto_focus=True) }} +
+ + +
+
diff --git a/tildes/tildes/templates/intercooler/topic_title_edit.jinja2 b/tildes/tildes/templates/intercooler/topic_title_edit.jinja2 new file mode 100644 index 0000000..0501fd8 --- /dev/null +++ b/tildes/tildes/templates/intercooler/topic_title_edit.jinja2 @@ -0,0 +1,16 @@ +
+ + +
+ + +
+
diff --git a/tildes/tildes/templates/intercooler/topic_voting.jinja2 b/tildes/tildes/templates/intercooler/topic_voting.jinja2 new file mode 100644 index 0000000..7e00108 --- /dev/null +++ b/tildes/tildes/templates/intercooler/topic_voting.jinja2 @@ -0,0 +1,3 @@ +{% from 'macros/topics.jinja2' import topic_voting with context %} + +{{ topic_voting(topic) }} diff --git a/tildes/tildes/templates/invite.jinja2 b/tildes/tildes/templates/invite.jinja2 new file mode 100644 index 0000000..7ae3d49 --- /dev/null +++ b/tildes/tildes/templates/invite.jinja2 @@ -0,0 +1,39 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% block title %}Invite someone{% endblock %} + +{% block main_heading %} + Invite someone to be able to register +{% endblock %} + +{% block content %} + +

This page allows you to generate invite codes you can give to others so that they can register an account on Tildes. A couple of things to keep in mind:

+ +
    +
  • A record of which users you invite will be kept internally (but won't be visible to any users, including you). Please invite people that you think will be good community members—if you repeatedly invite users that cause issues, you may lose inviting privileges.
  • +
  • The active invite codes will stay visible on this page until they're used. You don't need to worry about losing them if you leave the page.
  • +
+ +
+ +{% if codes %} +

You have the following invite codes active that have not been used yet:

+ {% for code in codes %} + + {% endfor %} +{% endif %} + +{% if request.user.invite_codes_remaining > 0 %} + +{% else %} +

You don't currently have any invite codes available.

+{% endif %} +{% endblock %} diff --git a/tildes/tildes/templates/login.jinja2 b/tildes/tildes/templates/login.jinja2 new file mode 100644 index 0000000..81027fe --- /dev/null +++ b/tildes/tildes/templates/login.jinja2 @@ -0,0 +1,32 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% block title %}Log In{% endblock %} + +{% block main_heading %}Log in{% endblock %} + +{% block content %} +
+ + +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+
+{% endblock %} diff --git a/tildes/tildes/templates/macros/comments.jinja2 b/tildes/tildes/templates/macros/comments.jinja2 new file mode 100644 index 0000000..5f7c3dc --- /dev/null +++ b/tildes/tildes/templates/macros/comments.jinja2 @@ -0,0 +1,183 @@ +{% from 'datetime.jinja2' import time_ago_responsive %} +{% from 'links.jinja2' import username_linked %} + +{% macro render_single_comment(comment) %} + {{ render_comment_tree([comment], is_individual_comment=True) }} +{% endmacro %} + +{% macro render_comment_tree(comments, mark_newer_than=None, is_individual_comment=False) %} + {% for comment in comments recursive %} +
+ {{ render_comment_contents(comment, is_individual_comment) }} + + {% if comment.replies is defined and comment.replies %} +
+ {# Recursively display reply comments #} + {{ loop(comment.replies) }} +
+ {% endif %} +
+ {% endfor %} +{% endmacro %} + +{% macro render_comment_contents(comment, is_individual_comment=False) %} +
+
+ + + {% if request.has_permission('view', comment) %} + {{ username_linked(comment.user.username) }} + + {% if request.has_permission('view_author', comment.topic) and comment.topic.user == comment.user %} + + {% endif %} + + {{ time_ago_responsive(comment.created_time) }} + + {% if comment.last_edited_time %} + + (edited {{ time_ago_responsive(comment.last_edited_time) }}) + + {% endif %} + {% else %} + {% if comment.is_deleted %} +
Comment deleted by author
+ {% elif comment.is_removed %} +
Comment removed by site admin
+ {% endif %} + {% endif %} + + Link + {% if comment.parent_comment_id %} + Parent + {% endif %} +
+ + {% if request.has_permission('view', comment) %} + {# Show votes at the top only if it's your own comment #} + {% if request.user == comment.user and comment.num_votes > 0 %} + {{ comment.num_votes }} votes + {% endif %} + + {% if comment.tag_counts %} +
    + {% for tag, count in comment.tag_counts.most_common() %} +
  • + {{ tag }} + x{{ count }} +
  • + {% endfor %} +
+ {% endif %} + +
+ {% if comment.is_removed and 'admin' in request.effective_principals %} +

Comment removed

+ {% endif %} + + {{ comment.rendered_html|safe }} +
+ + + {% if request.has_permission('vote', comment) %} + {% if comment.user_voted is defined and comment.user_voted %} +
  • Voted + {% else %} +
  • Vote + {% endif %} + {% if comment.num_votes > 0 %} + ({{ comment.num_votes }}) + {% endif %} +
  • + {% endif %} + + {% if request.has_permission('tag', comment) %} +
  • Tag
  • + {% endif %} + + {% if request.has_permission('edit', comment) %} +
  • Edit
  • + {% endif %} + + {% if request.has_permission('delete', comment) %} +
  • Delete
  • + {% endif %} + + {% if request.has_permission('reply', comment) %} +
  • Reply
  • + {% endif %} +
    + {% endif %} +
    +{% endmacro %} + +{% macro comment_classes(comment, mark_newer_than=None) %} + {% set classes = ['comment'] %} + + {% if not comment.is_deleted %} + {% if request.user == comment.user %} + {% do classes.append('is-comment-mine') %} + {# done as an elif so we never mark a user's own comments as "new" #} + {% elif mark_newer_than and comment.created_time > mark_newer_than %} + {% do classes.append('is-comment-new') %} + {% elif request.has_permission('view_author', comment.topic) and comment.user == comment.topic.user %} + {% do classes.append('is-comment-by-op') %} + {% endif %} + {% endif %} + + {{ classes|join(' ') }} +{% endmacro %} + +{% macro comment_tag_options_template(options) %} + +{% endmacro %} diff --git a/tildes/tildes/templates/macros/datetime.jinja2 b/tildes/tildes/templates/macros/datetime.jinja2 new file mode 100644 index 0000000..7c5887f --- /dev/null +++ b/tildes/tildes/templates/macros/datetime.jinja2 @@ -0,0 +1,16 @@ +{% macro time_ago(datetime) -%} + +{%- endmacro %} + +{% macro time_ago_abbreviated(datetime) -%} + +{%- endmacro %} + +{% macro time_ago_responsive(datetime) -%} + +{%- endmacro %} diff --git a/tildes/tildes/templates/macros/forms.jinja2 b/tildes/tildes/templates/macros/forms.jinja2 new file mode 100644 index 0000000..2a53f10 --- /dev/null +++ b/tildes/tildes/templates/macros/forms.jinja2 @@ -0,0 +1,31 @@ +{% macro markdown_textarea(caption='Text (Markdown)', id='markdown', text=None, auto_focus=False) %} + + +{% endmacro %} + +{% macro topic_tagging(value=None, auto_focus=False) %} +
    + + +
    +{% endmacro %} diff --git a/tildes/tildes/templates/macros/groups.jinja2 b/tildes/tildes/templates/macros/groups.jinja2 new file mode 100644 index 0000000..2514375 --- /dev/null +++ b/tildes/tildes/templates/macros/groups.jinja2 @@ -0,0 +1,33 @@ +{% macro render_group_subscription_box(group) %} +
    + + {% trans num_subscriptions=group.num_subscriptions %} + {{ num_subscriptions }} subscriber + {% pluralize %} + {{ num_subscriptions }} subscribers + {% endtrans %} + + + {% if request.has_permission('subscribe', group) %} + {% if group.user_subscribed %} + + {% else %} + + {% endif %} + {% endif %} +
    +{% endmacro %} diff --git a/tildes/tildes/templates/macros/links.jinja2 b/tildes/tildes/templates/macros/links.jinja2 new file mode 100644 index 0000000..66bbd95 --- /dev/null +++ b/tildes/tildes/templates/macros/links.jinja2 @@ -0,0 +1,7 @@ +{% macro username_linked(username) %} + {{ username }} +{% endmacro %} + +{% macro group_linked(group_path) %} + ~{{ group_path }} +{% endmacro %} diff --git a/tildes/tildes/templates/macros/messages.jinja2 b/tildes/tildes/templates/macros/messages.jinja2 new file mode 100644 index 0000000..f7e8e84 --- /dev/null +++ b/tildes/tildes/templates/macros/messages.jinja2 @@ -0,0 +1,18 @@ +{% from 'macros/datetime.jinja2' import time_ago %} +{% from 'macros/links.jinja2' import username_linked %} + +{% macro render_message(message) %} + {% if message.sender == request.user %} +
    + {% else %} +
    + {% endif %} + +
    + {{ username_linked(message.sender.username) }} + {{ time_ago(message.created_time) }} +
    + +
    {{ message.rendered_html|safe }}
    +
    +{% endmacro %} diff --git a/tildes/tildes/templates/macros/topics.jinja2 b/tildes/tildes/templates/macros/topics.jinja2 new file mode 100644 index 0000000..092ef4e --- /dev/null +++ b/tildes/tildes/templates/macros/topics.jinja2 @@ -0,0 +1,137 @@ +{% from 'macros/datetime.jinja2' import time_ago_responsive %} +{% from 'macros/links.jinja2' import group_linked, username_linked %} + +{% macro render_topic_for_listing(topic, show_group=False, rank=None) %} +
    +
    + {% if topic.is_link_type %} + + {% endif %} + +
    +

    + {% if topic.is_text_type %} + {{ topic.title }} + {% elif topic.is_link_type %} + {{ topic.title }} + {% endif %} +

    + +
    +
    + + + + {% if topic.is_text_type and topic.get_content_metadata('excerpt') %} + {# if the "excerpt" is the full text, don't wrap in
    #} + {% if not topic.get_content_metadata('excerpt').endswith('...') %} +

    {{ topic.get_content_metadata('excerpt') }}

    + {% else %} +
    + + {{ topic.get_content_metadata('excerpt') }} + + {{ topic.rendered_html|safe }} +
    + {% endif %} + {% endif %} + + + + {{ topic_voting(topic) }} + +
    +{% endmacro %} + +{% macro topic_voting(topic) %} + {% if request.has_permission('vote', topic) %} + {% if topic.user_voted %} + + {% else %} + + {% endif %} +{% endmacro %} + +{% macro topic_classes(topic) %} + {% set classes = ['topic'] %} + + {% if request.user == topic.user %} + {% do classes.append('is-topic-mine') %} + {% endif %} + + {% if topic.is_official %} + {% do classes.append('is-topic-official') %} + {% endif %} + + {{ classes|join(' ') }} +{% endmacro %} diff --git a/tildes/tildes/templates/macros/user.jinja2 b/tildes/tildes/templates/macros/user.jinja2 new file mode 100644 index 0000000..847cca5 --- /dev/null +++ b/tildes/tildes/templates/macros/user.jinja2 @@ -0,0 +1,27 @@ +{% macro logged_in_user_info() %} + {% if request.user %} + + {% endif %} +{% endmacro %} diff --git a/tildes/tildes/templates/message_conversation.jinja2 b/tildes/tildes/templates/message_conversation.jinja2 new file mode 100644 index 0000000..267c2ee --- /dev/null +++ b/tildes/tildes/templates/message_conversation.jinja2 @@ -0,0 +1,35 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% from 'macros/forms.jinja2' import markdown_textarea %} +{% from 'macros/messages.jinja2' import render_message with context %} + +{% block title %}Message: {{ conversation.subject }}{% endblock %} + +{% block main_heading %}{{ conversation.subject }}{% endblock %} + +{% block content %} + {{ render_message(conversation) }} + + {% for reply in conversation.replies %} + {{ render_message(reply) }} + {% endfor %} + +
    +

    Add a new reply to this conversation

    +
    + + + {{ markdown_textarea() }} +
    + +
    +
    +
    +{% endblock %} diff --git a/tildes/tildes/templates/messages.jinja2 b/tildes/tildes/templates/messages.jinja2 new file mode 100644 index 0000000..d9df80b --- /dev/null +++ b/tildes/tildes/templates/messages.jinja2 @@ -0,0 +1,36 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% from 'macros/datetime.jinja2' import time_ago_responsive %} +{% from 'macros/links.jinja2' import username_linked %} + +{% block title %}Message Inbox{% endblock %} + +{% block main_heading %}Message Inbox{% endblock %} + +{% block content %} + + + + + + + + + + + {% for conversation in conversations %} + {% if conversation.is_unread_by_user(request.user) %} + + {% else %} + + {% endif %} + + + + + + {% endfor %} +
    SubjectUserLast messageMessages
    + {{ conversation.subject }} + {{ username_linked(conversation.other_user(request.user).username) }}{{ time_ago_responsive(conversation.last_activity_time) }}{{ conversation.num_replies + 1 }}
    +{% endblock %} diff --git a/tildes/tildes/templates/messages_sent.jinja2 b/tildes/tildes/templates/messages_sent.jinja2 new file mode 100644 index 0000000..4cd3e0e --- /dev/null +++ b/tildes/tildes/templates/messages_sent.jinja2 @@ -0,0 +1,5 @@ +{% extends 'messages.jinja2' %} + +{% block title %}Sent Messages{% endblock %} + +{% block main_heading %}Sent Messages{% endblock %} diff --git a/tildes/tildes/templates/messages_unread.jinja2 b/tildes/tildes/templates/messages_unread.jinja2 new file mode 100644 index 0000000..708f685 --- /dev/null +++ b/tildes/tildes/templates/messages_unread.jinja2 @@ -0,0 +1,5 @@ +{% extends 'messages.jinja2' %} + +{% block title %}Unread Messages{% endblock %} + +{% block main_heading %}Unread Messages{% endblock %} diff --git a/tildes/tildes/templates/new_message.jinja2 b/tildes/tildes/templates/new_message.jinja2 new file mode 100644 index 0000000..c23af4d --- /dev/null +++ b/tildes/tildes/templates/new_message.jinja2 @@ -0,0 +1,37 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% from 'macros/forms.jinja2' import markdown_textarea %} + +{% block title %}New private message conversation{% endblock %} + +{% block header_context_link %} +{{ user.username }} +{% endblock %} + +{% block main_heading %} + Send a private message to {{ user.username }} +{% endblock %} + +{% block content %} +
    + + +
    + + +
    + + {{ markdown_textarea() }} + +
    + +
    +
    +{% endblock %} diff --git a/tildes/tildes/templates/new_topic.jinja2 b/tildes/tildes/templates/new_topic.jinja2 new file mode 100644 index 0000000..beef5ea --- /dev/null +++ b/tildes/tildes/templates/new_topic.jinja2 @@ -0,0 +1,58 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% from 'macros/forms.jinja2' import markdown_textarea, topic_tagging %} + +{% block title %}New topic{% endblock %} + +{% block header_context_link %} +~{{ group.path }} +{% endblock %} + +{% block main_heading %}Post a new topic in ~{{ group.path }}{% endblock %} + +{% block content %} +
    + + +
    +

    Tildes prioritizes high-quality content and discussions

    +

    Please post topics that are interesting, informative, or have the potential to start a good discussion.

    +

    Please avoid posting topics that are primarily for entertainment or that don't have discussion value.

    +
    + +
    + + +
    + +
    + Enter a link, text, or both: +
    + + +

    If you enter a link, your post will be a link topic (whether you also include text or not).

    +
    + +
    + {{ markdown_textarea() }} +

    If you enter only text (and no link), your post will be a text topic.

    +

    If you also enter a link, this text will be posted as the first comment and can be used to add more information or give your thoughts on the linked content. Adding text when posting a link is not required, but it can help get the discussion started.

    +

    +
    +
    + + {{ topic_tagging() }} + +
    + +
    +
    +{% endblock %} diff --git a/tildes/tildes/templates/notifications.jinja2 b/tildes/tildes/templates/notifications.jinja2 new file mode 100644 index 0000000..9cec6a6 --- /dev/null +++ b/tildes/tildes/templates/notifications.jinja2 @@ -0,0 +1,10 @@ +{% extends 'notifications_unread.jinja2' %} + +{% block title %}Previously read notifications{% endblock %} + +{% block main_heading %}Previously read notifications{% endblock %} + +{% block content %} +

    This page shows your most recent, previously read notifications (up to a max of 100, pagination coming soon)

    +{{ super() }} +{% endblock %} diff --git a/tildes/tildes/templates/notifications_unread.jinja2 b/tildes/tildes/templates/notifications_unread.jinja2 new file mode 100644 index 0000000..7ae6a63 --- /dev/null +++ b/tildes/tildes/templates/notifications_unread.jinja2 @@ -0,0 +1,41 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% from 'macros/comments.jinja2' import comment_tag_options_template, render_single_comment with context %} +{% from 'macros/links.jinja2' import group_linked %} + +{% block title %}Unread notifications{% endblock %} + +{% block main_heading %}Unread notifications{% endblock %} + +{% block content %} +{% if notifications %} +
      + {% for notification in notifications: %} +
    1. + {% if notification.is_comment_reply %} +

      Reply to your comment on {{ notification.comment.topic.title }} in {{ group_linked(notification.comment.topic.group.path) }}

      + {% elif notification.is_topic_reply %} +

      Reply to your topic {{ notification.comment.topic.title }} in {{ group_linked(notification.comment.topic.group.path) }}

      + {% endif %} + + {% if notification.is_unread and not request.user.auto_mark_notifications_read %} + + {% endif %} + {{ render_single_comment(notification.comment) }} +
    2. + {% endfor %} +
    +{% else %} +

    No unread notifications.

    +

    Go to previously read notifications

    +{% endif %} + +{{ comment_tag_options_template(comment_tag_options) }} +{% endblock %} diff --git a/tildes/tildes/templates/register.jinja2 b/tildes/tildes/templates/register.jinja2 new file mode 100644 index 0000000..1ab5f50 --- /dev/null +++ b/tildes/tildes/templates/register.jinja2 @@ -0,0 +1,66 @@ +{% extends 'base.jinja2' %} + +{% block title %}Register a new account{% endblock %} + +{% block main_heading %}Register a new account{% endblock %} + +{% block content %} +

    + Registration is currently invite-only. You must have a valid invite code to be able to register. +

    + +

    Please check the sidebar for information about username and password restrictions.

    + +
    + + +
    + + +
    + +
    + + +
    +

    Please don't abuse your early access to Tildes to register a "famous" username unless you have a reasonable claim to it.

    + +
    + + +
    + +
    + + +
    + +
    + +
    + +
    + +
    +
    +{% endblock %} + +{% block sidebar %} +

    Restrictions

    +
    +
    Username restrictions
    +
    +
      +
    • 3 - 20 characters long.
    • +
    • Valid characters are ASCII letters, numbers, underscore and dash.
    • +
    • Must start and end with a number or letter.
    • +
    • No consecutive underscores and/or dashes.
    • +
    +
    + {% include 'includes/password_restrictions.jinja2' %} +
    +{% endblock %} diff --git a/tildes/tildes/templates/settings.jinja2 b/tildes/tildes/templates/settings.jinja2 new file mode 100644 index 0000000..0b6c85f --- /dev/null +++ b/tildes/tildes/templates/settings.jinja2 @@ -0,0 +1,53 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% block title %}User settings{% endblock %} + +{% block main_heading %}User settings{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/tildes/tildes/templates/settings_account_recovery.jinja2 b/tildes/tildes/templates/settings_account_recovery.jinja2 new file mode 100644 index 0000000..6ae8611 --- /dev/null +++ b/tildes/tildes/templates/settings_account_recovery.jinja2 @@ -0,0 +1,60 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% block title %}Set up account recovery{% endblock %} + +{% block main_heading %}Set up account recovery{% endblock %} + +{% block content %} +

    In order to support account recovery while maximizing user privacy, Tildes's recovery process is a bit unusual.

    + +

    The email address you enter below will be cryptographically hashed (using Argon2) and only the hash stored. Your actual address is not stored, so it is impossible for anyone to see what it is or use it to send you email, and your address can't be leaked (due to a data breach, account compromise, etc.).

    + +

    Because of this, the account recovery process has to be initiated differently from most sites:

    + +
      +
    1. If you lose access to your account, you send an email from the associated address, requesting a password reset for that specific username.
    2. +
    3. The sending email address is hashed, and if the result matches the stored hash for that user, a message is sent back (to the same address) that includes a password reset link.
    4. +
    5. You receive the email, reset your password, and are able to log into the account again.
    6. +
    + +

    This means that you must be able to both send and receive email with the address, so make sure to use an address where you can do both.

    + +

    Finally, since even you won't be able to see which email address is attached to the account in the future, there is a space for you to leave a short description of the address in case you forget which one it is. Please don't include the actual address in the description, or use a description that makes it obvious what the address is. I don't want to know your email address. Seriously.

    + +
    + +{% if request.user.email_address_hash %} + {% if request.user.email_address_note %} +

    You already have an email address hash associated with your account, with the following description:

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

    You already have an email address hash associated with your account.

    + {% endif %} + +

    Submitting the form below will replace the currently stored hash and description. The previous hash and description will be retained for 30 days (in case of account compromise).

    +{% endif %} + +
    +
    + + +
    + +
    + + +
    + +
    + +
    +
    +{% endblock %} diff --git a/tildes/tildes/templates/settings_comment_visits.jinja2 b/tildes/tildes/templates/settings_comment_visits.jinja2 new file mode 100644 index 0000000..e1ae187 --- /dev/null +++ b/tildes/tildes/templates/settings_comment_visits.jinja2 @@ -0,0 +1,43 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% block title %}Toggle marking new comments{% endblock %} + +{% block main_heading %}Toggle marking new comments{% endblock %} + +{% block content %} +
    +
    How new comments are displayed
    + Examples of how new comments are displayed +
    + +

    Tildes can mark which comments were posted since your previous visit to a topic's comments (and which topics have any new ones), but doing so requires keeping track of when that previous visit happened. This has a privacy implication, so the feature is opt-in.

    + +

    While this feature is enabled, we will record and store data about your most recent visit to each topic's comments. We store only the single most recent visit—any previous visit data for that topic is replaced if you visit the same one again later.

    + +

    This data is retained for 30 days. After not visiting a particular topic for 30 days, the data about your last visit to it will be deleted.

    + +

    Disabling the feature will stop marking comments but will not delete any existing data, only prevent new data from being stored. The previously-stored data will be deleted after 30 days, as usual.

    + +
    + +
    +
    + +
    + + +
    +{% endblock %} diff --git a/tildes/tildes/templates/settings_filters.jinja2 b/tildes/tildes/templates/settings_filters.jinja2 new file mode 100644 index 0000000..0feede4 --- /dev/null +++ b/tildes/tildes/templates/settings_filters.jinja2 @@ -0,0 +1,27 @@ +{% extends 'base_no_sidebar.jinja2' %} + +{% block title %}Define topic tag filters{% endblock %} + +{% block main_heading %}Define topic tag filters{% endblock %} + +{% block content %} +

    You can configure a list of filtered topic tags below. If a topic has any of these tags, it will be filtered out (not shown) by default, but you can toggle the filtering off to see a full list.

    + +

    These filters are global and will apply both to your home page as well as inside specific groups.

    + +
    + +
    + + +
    + +
    +
    +{% endblock %} diff --git a/tildes/tildes/templates/settings_password_change.jinja2 b/tildes/tildes/templates/settings_password_change.jinja2 new file mode 100644 index 0000000..78333ac --- /dev/null +++ b/tildes/tildes/templates/settings_password_change.jinja2 @@ -0,0 +1,41 @@ +{% extends 'base.jinja2' %} + +{% block title %}Change your password{% endblock %} + +{% block main_heading %}Change your password{% endblock %} + +{% block content %} +
    + + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    +
    +{% endblock %} + +{% block sidebar %} +
    + {% include 'includes/password_restrictions.jinja2' %} +
    +{% endblock %} diff --git a/tildes/tildes/templates/topic.jinja2 b/tildes/tildes/templates/topic.jinja2 new file mode 100644 index 0000000..15b7a20 --- /dev/null +++ b/tildes/tildes/templates/topic.jinja2 @@ -0,0 +1,265 @@ +{% extends 'base.jinja2' %} + +{% from 'macros/comments.jinja2' import comment_tag_options_template, render_comment_tree with context %} +{% from 'macros/datetime.jinja2' import time_ago, time_ago_abbreviated, time_ago_responsive %} +{% from 'macros/forms.jinja2' import markdown_textarea %} +{% from 'macros/links.jinja2' import group_linked, username_linked %} +{% from 'macros/topics.jinja2' import topic_voting with context %} + +{% block title %}{{ topic.title }} - ~{{ topic.group.path }}{% endblock %} + +{% block header_context_link %} +~{{ topic.group.path }} +{% endblock %} + +{% block content %} +
    +
    + {{ topic_voting(topic) }} +

    {{ topic.title }}

    + +
    + +{% if topic.is_deleted %} +
    Topic deleted by author
    +{% elif topic.is_removed %} +
    Topic removed by site admin
    +{% endif %} + +{% if request.has_permission('view_content', topic) %} + {% if topic.is_text_type %} +
    {{ topic.rendered_html|safe }}
    + {% elif topic.is_link_type %} + + {% endif %} +{% endif %} + +{% if request.has_any_permission(('edit', 'delete', 'tag', 'lock', 'move', 'edit_title'), topic) %} + + {% if request.has_permission('edit', topic) %} +
  • Edit
  • + {% endif %} + + {% if request.has_permission('tag', topic) %} +
  • Tag + {% endif %} + + {% if request.has_permission('delete', topic) %} +
  • Delete
  • + {% endif %} + + {% if request.has_permission('move', topic) %} +
  • Move + {% endif %} + + {% if request.has_permission('edit_title', topic) %} +
  • Edit title + {% endif %} + + {% if request.has_permission('lock', topic) %} +
  • + {% if not topic.is_locked %} + Lock + {% else %} + Unlock + {% endif %} +
  • + {% endif %} +
    +{% endif %} + +{% if topic.is_locked %} +
    This topic is locked. New comments can not be posted.
    +{% endif %} + +{% if topic.num_comments > 0 %} +
    +
    +

    + {% trans num_comments=topic.num_comments %} + {{ num_comments }} comment + {% pluralize %} + {{ num_comments }} comments + {% endtrans %} +

    + +
    +
    + + +
    + + {# add a submit button for people with js disabled so this is still usable #} + +
    +
    + + {{ render_comment_tree(comments, mark_newer_than=topic.last_visit_time) }} +
    +{% endif %} + +{% if request.has_permission('comment', topic) %} +
    +

    Post a comment

    +
    + + + {{ markdown_textarea('New top-level comment') }} + +
    + +
    +
    +
    +{% endif %} + +
    + +{{ comment_tag_options_template(comment_tag_options) }} +{% endblock content %} + +{% block sidebar %} +

    Topic info

    +
    +
    Tags
    +
    +
      + {% for tag in topic.tags %} +
    • + {{ tag }} +
    • + {% else %} +
    • No tags
    • + {% endfor %} +
    +
    + +
    Comments
    + {% if topic.num_comments > 0 %} +
    + {% trans num_comments=topic.num_comments %} + {{ num_comments }} comment + {% pluralize %} + {{ num_comments }} comments + {% endtrans %} + + {% trans num_top_level=comments.num_top_level %} + ({{ num_top_level }} thread) + {% pluralize %} + ({{ num_top_level }} threads) + {% endtrans %} +
    + +
    Last comment posted
    +
    + + {{ time_ago(topic.last_activity_time) }} + +
    + {% else %} +
    No comments yet
    + {% endif %} + + {% if log %} +
    +
    Topic log ({{ log|length }})
    +
    +
      + {% for entry in log %} +
    1. + {% if entry.user == topic.user and not request.has_permission('view_author', topic) %} + Unknown user + {% else %} + {{ username_linked(entry.user) }} + {% endif %} + {{ entry }} + ({{ time_ago_abbreviated(entry.event_time) }}) +
    2. + {% endfor %} +
    +
    +
    + {% endif %} +
    +{% endblock %} diff --git a/tildes/tildes/templates/topic_listing.jinja2 b/tildes/tildes/templates/topic_listing.jinja2 new file mode 100644 index 0000000..611fed2 --- /dev/null +++ b/tildes/tildes/templates/topic_listing.jinja2 @@ -0,0 +1,198 @@ +{% extends 'base.jinja2' %} + +{% from 'macros/groups.jinja2' import render_group_subscription_box with context %} +{% from 'macros/topics.jinja2' import render_topic_for_listing with context %} + +{% block title %}Topics in ~{{ group.path }}{% endblock %} + +{% block header_context_link %} + {# Split the link out for each "segment" of the group path #} + ~{{ group.path[0:1] }} + {% for i in range(1, group.path|length) %} + .{{ group.path[i:i+1] }} + {% endfor %} +{% endblock %} + +{% block content %} + +{% if request.context is group %} + {% set is_single_group = True %} +{% else %} + {% set is_single_group = False %} +{% endif %} + +
    + + {% for option in order_options %} + {% if option == order %} +
  • + {% else %} +
  • + {% endif %} + + {% if period %} + {% set period_string = period.as_short_form() %} + {% else %} + {% set period_string = 'all' %} + {% endif %} + + + {{ option.descending_description.capitalize() }} + + +
  • + {% endfor %} +
    + +
    + + {% if tag %} + + {% endif %} +
    + + +
    + + {# add a submit button for people with js disabled so this is still usable #} + +
    + + {% if not is_default_view %} +
    + + {% if period %} + + {% endif %} + +
    + {% endif %} +
    + +
    +{% if tag %} + Showing only topics with the tag "{{ tag|replace('_', ' ') }}". + Back to normal view +{% elif unfiltered %} + Showing unfiltered topic list. + Back to normal view +{% elif request.user.filtered_topic_tags %} + Topic tag filters active (see sidebar). + View unfiltered list +{% endif %} +
    + +{% if not topics %} +
    +

    No topics in the selected time period

    + {% if is_single_group and request.has_permission('post_topic', group) %} +

    Choose a longer time period, or break the silence by posting one yourself.

    + + {% else %} +

    Choose a longer time period to find some.

    + {% endif %} +
    +{% endif %} + +{% if topics %} +
      + + {% for topic in topics: %} +
    1. + + {# only display the rank on topics if the rank_start variable is set #} + {% if rank_start is not none %} + {{ render_topic_for_listing( + topic, + show_group=topic.group != request.context, + rank=rank_start + loop.index0, + ) }} + {% else %} + {{ render_topic_for_listing(topic, show_group=topic.group != request.context) }} + {% endif %} + +
    2. + {% endfor %} + +
    +{% endif %} + +{% if topics.has_prev_page or topics.has_next_page %} + +{% endif %} + +{% endblock %} + +{% block sidebar %} +

    ~{{ group.path }}

    + + {% if group.short_description %} +
    {{ group.short_description }}
    + {% endif %} + + {{ render_group_subscription_box(group) }} + + {% if request.has_permission('post_topic', group) %} + Post a new topic + {% endif %} + + {% if request.user and not (tag or unfiltered) %} +
    +
    + Filtered topic tags ({{ request.user.filtered_topic_tags|length }}) +
      + {% for tag in request.user.filtered_topic_tags %} +
    • {{ tag }}
    • + {% else %} +
    • No filtered tags
    • + {% endfor %} +
    + Edit filtered tags +
    + {% endif %} +{% endblock %} diff --git a/tildes/tildes/templates/user.jinja2 b/tildes/tildes/templates/user.jinja2 new file mode 100644 index 0000000..1bb53d6 --- /dev/null +++ b/tildes/tildes/templates/user.jinja2 @@ -0,0 +1,86 @@ +{% extends 'base.jinja2' %} + +{% from 'macros/comments.jinja2' import render_single_comment with context %} +{% from 'macros/links.jinja2' import group_linked, username_linked %} +{% from 'macros/topics.jinja2' import render_topic_for_listing with context %} + +{% block title %}User: {{ user.username }}{% endblock %} + +{% block header_context_link %} +{{ user.username }} +{% endblock %} + +{% block main_heading %}{{ user.username }}'s recent activity{% endblock %} + +{% block content %} + +{% if merged_posts %} +
      + {% for post in merged_posts if request.has_permission('view', post) %} +
    1. + {% if post is topic %} + {{ render_topic_for_listing(post, show_group=True) }} + {% elif post is comment %} +

      Comment on {{ post.topic.title }} in {{ group_linked(post.topic.group.path) }}

      + {{ render_single_comment(post) }} + {% endif %} +
    2. + {% endfor %} +
    +{% else %} +
    +

    This user hasn't made any posts

    +
    +{% endif %} + +{% endblock %} + +{% block sidebar %} +{% if user == request.user %} +

    User menu

    + +
    +{% endif %} + +

    User info

    +
    +
    Registered
    +
    {{ user.created_time.strftime('%B %-d, %Y') }}
    +
    + +{% if request.has_permission('message', user) %} + Send a private message +{% endif %} +{% endblock %} diff --git a/tildes/tildes/views/__init__.py b/tildes/tildes/views/__init__.py new file mode 100644 index 0000000..6f2ab13 --- /dev/null +++ b/tildes/tildes/views/__init__.py @@ -0,0 +1,13 @@ +"""Contains the application's views.""" + +from pyramid.response import Response + + +# Intercooler uses an empty response as a no-op and won't replace anything. +# 204 would probably be more correct than 200, but Intercooler errors on it +IC_NOOP = Response(status_int=200) +IC_NOOP_404 = Response(status_int=404) + +# Because of the above, in order to deliberately cause Intercooler to replace +# an element with whitespace, the response needs to contain at least two spaces +IC_EMPTY = Response(' ') diff --git a/tildes/tildes/views/api/__init__.py b/tildes/tildes/views/api/__init__.py new file mode 100644 index 0000000..1928361 --- /dev/null +++ b/tildes/tildes/views/api/__init__.py @@ -0,0 +1 @@ +"""Contains API views.""" diff --git a/tildes/tildes/views/api/v0/__init__.py b/tildes/tildes/views/api/v0/__init__.py new file mode 100644 index 0000000..54b9f00 --- /dev/null +++ b/tildes/tildes/views/api/v0/__init__.py @@ -0,0 +1 @@ +"""Contains views for v0 of the JSON API.""" diff --git a/tildes/tildes/views/api/v0/group.py b/tildes/tildes/views/api/v0/group.py new file mode 100644 index 0000000..25f41d4 --- /dev/null +++ b/tildes/tildes/views/api/v0/group.py @@ -0,0 +1,15 @@ +"""API v0 endpoints related to groups.""" + +from pyramid.request import Request + +from tildes.api import APIv0 +from tildes.resources.group import group_by_path + + +ONE = APIv0(name='group', path='/groups/{group_path}', factory=group_by_path) + + +@ONE.get() +def get_group(request: Request) -> dict: + """Get a single group's data.""" + return request.context diff --git a/tildes/tildes/views/api/v0/topic.py b/tildes/tildes/views/api/v0/topic.py new file mode 100644 index 0000000..08387da --- /dev/null +++ b/tildes/tildes/views/api/v0/topic.py @@ -0,0 +1,19 @@ +"""API v0 endpoints related to topics.""" + +from pyramid.request import Request + +from tildes.api import APIv0 +from tildes.resources.topic import topic_by_id36 + + +ONE = APIv0( + name='topic', + path='/groups/{group_path}/topics/{topic_id36}', + factory=topic_by_id36, +) + + +@ONE.get() +def get_topic(request: Request) -> dict: + """Get a single topic's data.""" + return request.context diff --git a/tildes/tildes/views/api/v0/user.py b/tildes/tildes/views/api/v0/user.py new file mode 100644 index 0000000..cafbe04 --- /dev/null +++ b/tildes/tildes/views/api/v0/user.py @@ -0,0 +1,15 @@ +"""API v0 endpoints related to users.""" + +from pyramid.request import Request + +from tildes.api import APIv0 +from tildes.resources.user import user_by_username + + +ONE = APIv0(name='user', path='/users/{username}', factory=user_by_username) + + +@ONE.get() +def get_user(request: Request) -> dict: + """Get a single user's data.""" + return request.context diff --git a/tildes/tildes/views/api/web/__init__.py b/tildes/tildes/views/api/web/__init__.py new file mode 100644 index 0000000..ca18ec7 --- /dev/null +++ b/tildes/tildes/views/api/web/__init__.py @@ -0,0 +1 @@ +"""Contains views for the web API (used by Intercooler).""" diff --git a/tildes/tildes/views/api/web/comment.py b/tildes/tildes/views/api/web/comment.py new file mode 100644 index 0000000..412ec33 --- /dev/null +++ b/tildes/tildes/views/api/web/comment.py @@ -0,0 +1,327 @@ +"""Web API endpoints related to comments.""" + +from pyramid.request import Request +from pyramid.response import Response +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.exc import FlushError +from webargs.pyramidparser import use_kwargs +from zope.sqlalchemy import mark_changed + +from tildes.enums import CommentNotificationType, CommentTagOption +from tildes.lib.datetime import utc_now +from tildes.models.comment import ( + Comment, + CommentNotification, + CommentTag, + CommentVote, +) +from tildes.models.topic import TopicVisit +from tildes.schemas.comment import CommentSchema, CommentTagSchema +from tildes.views import IC_NOOP +from tildes.views.decorators import ic_view_config + + +@ic_view_config( + route_name='topic_comments', + request_method='POST', + renderer='single_comment.jinja2', + permission='comment', +) +@use_kwargs(CommentSchema(only=('markdown',))) +def post_toplevel_comment(request: Request, markdown: str) -> dict: + """Post a new top-level comment on a topic with Intercooler.""" + topic = request.context + + new_comment = Comment( + topic=topic, + author=request.user, + markdown=markdown, + ) + request.db_session.add(new_comment) + + if topic.user != request.user and not topic.is_deleted: + notification = CommentNotification( + topic.user, + new_comment, + CommentNotificationType.TOPIC_REPLY, + ) + request.db_session.add(notification) + + # commit and then re-query the new comment to get complete data + request.tm.commit() + + new_comment = ( + request.query(Comment) + .join_all_relationships() + .filter_by(comment_id=new_comment.comment_id) + .one() + ) + + return {'comment': new_comment, 'topic': topic} + + +@ic_view_config( + route_name='comment_replies', + request_method='POST', + renderer='single_comment.jinja2', + permission='reply', +) +@use_kwargs(CommentSchema(only=('markdown',))) +def post_comment_reply(request: Request, markdown: str) -> dict: + """Post a reply to a comment with Intercooler.""" + parent_comment = request.context + new_comment = Comment( + topic=parent_comment.topic, + author=request.user, + markdown=markdown, + parent_comment=parent_comment, + ) + request.db_session.add(new_comment) + + if parent_comment.user != request.user: + notification = CommentNotification( + parent_comment.user, + new_comment, + CommentNotificationType.COMMENT_REPLY, + ) + request.db_session.add(notification) + + # commit and then re-query the new comment to get complete data + request.tm.commit() + + new_comment = ( + request.query(Comment) + .join_all_relationships() + .filter_by(comment_id=new_comment.comment_id) + .one() + ) + + return {'comment': new_comment} + + +@ic_view_config( + route_name='comment', + request_method='GET', + renderer='comment_contents.jinja2', + permission='view', +) +def get_comment_contents(request: Request) -> dict: + """Get a comment's body with Intercooler.""" + return {'comment': request.context} + + +@ic_view_config( + route_name='comment', + request_method='GET', + request_param='ic-trigger-name=edit', + renderer='comment_edit.jinja2', + permission='edit', +) +def get_comment_edit(request: Request) -> dict: + """Get the edit form for a comment with Intercooler.""" + return {'comment': request.context} + + +@ic_view_config( + route_name='comment', + request_method='PATCH', + renderer='comment_contents.jinja2', + permission='edit', +) +@use_kwargs(CommentSchema(only=('markdown',))) +def patch_comment(request: Request, markdown: str) -> dict: + """Update a comment with Intercooler.""" + comment = request.context + + comment.markdown = markdown + + return {'comment': comment} + + +@ic_view_config( + route_name='comment', + request_method='DELETE', + renderer='comment_contents.jinja2', + permission='delete', +) +def delete_comment(request: Request) -> dict: + """Delete a comment with Intercooler.""" + comment = request.context + comment.is_deleted = True + + return {'comment': comment} + + +@ic_view_config( + route_name='comment_vote', + request_method='PUT', + permission='vote', + renderer='comment_contents.jinja2', +) +def vote_comment(request: Request) -> dict: + """Vote on a comment with Intercooler.""" + comment = request.context + + savepoint = request.tm.savepoint() + + new_vote = CommentVote(request.user, comment) + request.db_session.add(new_vote) + + try: + # manually flush before attempting to commit, to avoid having all + # objects detached from the session in case of an error + request.db_session.flush() + request.tm.commit() + except IntegrityError: + # the user has already voted on this comment + savepoint.rollback() + + # re-query the comment to get complete data + comment = ( + request.query(Comment) + .join_all_relationships() + .filter_by(comment_id=comment.comment_id) + .one() + ) + + return {'comment': comment} + + +@ic_view_config( + route_name='comment_vote', + request_method='DELETE', + permission='vote', + renderer='comment_contents.jinja2', +) +def unvote_comment(request: Request) -> dict: + """Remove the user's vote from a comment with Intercooler.""" + comment = request.context + + request.query(CommentVote).filter( + CommentVote.comment == comment, + CommentVote.user == request.user, + ).delete(synchronize_session=False) + + # manually commit the transaction so triggers will execute + request.tm.commit() + + # re-query the comment to get complete data + comment = ( + request.query(Comment) + .join_all_relationships() + .filter_by(comment_id=comment.comment_id) + .one() + ) + + return {'comment': comment} + + +@ic_view_config( + route_name='comment_tag', + request_method='PUT', + permission='tag', + renderer='comment_contents.jinja2', +) +@use_kwargs(CommentTagSchema(only=('name',)), locations=('matchdict',)) +def tag_comment(request: Request, name: CommentTagOption) -> Response: + """Add a tag to a comment.""" + comment = request.context + + savepoint = request.tm.savepoint() + + tag = CommentTag(comment, request.user, name) + request.db_session.add(tag) + + try: + # manually flush before attempting to commit, to avoid having all + # objects detached from the session in case of an error + request.db_session.flush() + request.tm.commit() + except FlushError: + savepoint.rollback() + + # re-query the comment to get complete data + comment = ( + request.query(Comment) + .join_all_relationships() + .filter_by(comment_id=comment.comment_id) + .one() + ) + + return {'comment': comment} + + +@ic_view_config( + route_name='comment_tag', + request_method='DELETE', + permission='tag', + renderer='comment_contents.jinja2', +) +@use_kwargs(CommentTagSchema(only=('name',)), locations=('matchdict',)) +def untag_comment(request: Request, name: CommentTagOption) -> Response: + """Remove a tag (that the user previously added) from a comment.""" + comment = request.context + + request.query(CommentTag).filter( + CommentTag.comment_id == comment.comment_id, + CommentTag.user_id == request.user.user_id, + CommentTag.tag == name, + ).delete(synchronize_session=False) + + # commit and then re-query the comment to get complete data + request.tm.commit() + + comment = ( + request.query(Comment) + .join_all_relationships() + .filter_by(comment_id=comment.comment_id) + .one() + ) + + return {'comment': comment} + + +@ic_view_config( + route_name='comment_mark_read', + request_method='PUT', + permission='mark_read', +) +def mark_read_comment(request: Request) -> Response: + """Mark a comment read (clear all notifications).""" + comment = request.context + + request.query(CommentNotification).filter( + CommentNotification.user == request.user, + CommentNotification.comment == comment, + ).update( + {CommentNotification.is_unread: False}, synchronize_session=False) + + # If the user has the "track comment visits" feature enabled, we want to + # increment the number of comments they've seen in the thread that the + # comment came from, so that they don't *both* get a notification as well + # as have the thread highlight with "(1 new)". This should only happen if + # their last visit was before the comment was posted, however. + # Below, this is implemented as a INSERT ... ON CONFLICT DO UPDATE so that + # it will insert a new topic visit with 1 comment if they didn't previously + # have one at all. + if request.user.track_comment_visits: + statement = ( + insert(TopicVisit.__table__) + .values( + user_id=request.user.user_id, + topic_id=comment.topic_id, + visit_time=utc_now(), + num_comments=1, + ) + .on_conflict_do_update( + constraint=TopicVisit.__table__.primary_key, + set_={'num_comments': TopicVisit.num_comments + 1}, + where=TopicVisit.visit_time < comment.created_time, + ) + ) + + request.db_session.execute(statement) + mark_changed(request.db_session) + + return IC_NOOP diff --git a/tildes/tildes/views/api/web/exceptions.py b/tildes/tildes/views/api/web/exceptions.py new file mode 100644 index 0000000..362b0bf --- /dev/null +++ b/tildes/tildes/views/api/web/exceptions.py @@ -0,0 +1,102 @@ +"""Web API exception views.""" + +from typing import Sequence + +from marshmallow.exceptions import ValidationError +from pyramid.httpexceptions import ( + HTTPFound, + HTTPNotFound, + HTTPTooManyRequests, + HTTPUnprocessableEntity, +) +from pyramid.request import Request +from pyramid.response import Response + +from tildes.resources.comment import comment_by_id36 +from tildes.resources.topic import topic_by_id36 +from tildes.views.decorators import ic_view_config + + +def _422_response_with_errors(errors: Sequence[str]) -> Response: + response = Response('\n'.join(errors)) + response.status_int = 422 + + return response + + +@ic_view_config(context=HTTPUnprocessableEntity) +@ic_view_config(context=ValidationError) +def unprocessable_entity(request: Request) -> Response: + """Exception view for 422 errors.""" + if isinstance(request.exception, ValidationError): + validation_error = request.exception + else: + # see if there's an underlying ValidationError using exception chain + validation_error = request.exception.__context__ + if not isinstance(validation_error, ValidationError): + validation_error = None + + # if no ValidationError, we can just use the exception's message directly + if not validation_error: + return _422_response_with_errors([str(request.exception)]) + + errors_by_field = validation_error.messages + + error_strings = [] + for field, errors in errors_by_field.items(): + joined_errors = ' '.join(errors) + if field != '_schema': + error_strings.append(f'{field}: {joined_errors}') + else: + error_strings.append(joined_errors) + + return _422_response_with_errors(error_strings) + + +@ic_view_config(context=ValueError) +def valueerror(request: Request) -> Response: + """Convert a ValueError to a 422 response.""" + return _422_response_with_errors([request.exception.args[0]]) + + +@ic_view_config(context=HTTPNotFound) +def httpnotfound(request: Request) -> Response: + """Convert a 404 error to a text response (instead of HTML).""" + response = request.exception + + if request.matched_route.factory == comment_by_id36: + response.text = 'Comment not found (or it was deleted)' + elif request.matched_route.factory == topic_by_id36: + response.text = 'Topic not found (or it was deleted)' + else: + response.text = 'Not found' + + return response + + +@ic_view_config(context=HTTPTooManyRequests) +def httptoomanyrequests(request: Request) -> Response: + """Update a 429 error to show wait time info in the response text.""" + response = request.exception + + retry_seconds = request.exception.headers['Retry-After'] + response.text = ( + 'Rate limit exceeded. ' + f'Please wait {retry_seconds} seconds before retrying.' + ) + + return response + + +@ic_view_config(context=HTTPFound) +def httpfound(request: Request) -> Response: + """Convert an HTTPFound to a 200 with the header for a redirect. + + Intercooler won't handle a 302 response as a "full" redirect, and will just + load the content of the destination page into the target element, the same + as any other response. However, it has support for a special X-IC-Redirect + header, which allows the response to trigger a client-side redirect. This + exception view will convert a 302 into a 200 with that header so it works + as a redirect for both standard requests as well as Intercooler ones. + """ + return Response(headers={'X-IC-Redirect': request.exception.location}) diff --git a/tildes/tildes/views/api/web/group.py b/tildes/tildes/views/api/web/group.py new file mode 100644 index 0000000..ab53c75 --- /dev/null +++ b/tildes/tildes/views/api/web/group.py @@ -0,0 +1,118 @@ +"""Web API endpoints related to groups.""" + +from typing import Optional + +from pyramid.request import Request +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.exc import IntegrityError +from webargs.pyramidparser import use_kwargs +from zope.sqlalchemy import mark_changed + +from tildes.enums import TopicSortOption +from tildes.models.group import Group, GroupSubscription +from tildes.models.user import UserGroupSettings +from tildes.schemas.fields import Enum, ShortTimePeriod +from tildes.views import IC_NOOP +from tildes.views.decorators import ic_view_config + + +@ic_view_config( + route_name='group_subscribe', + request_method='PUT', + permission='subscribe', + renderer='group_subscription_box.jinja2', +) +def subscribe_group(request: Request) -> dict: + """Subscribe to a group with Intercooler.""" + group = request.context + + savepoint = request.tm.savepoint() + + new_subscription = GroupSubscription(request.user, group) + request.db_session.add(new_subscription) + + try: + # manually flush before attempting to commit, to avoid having all + # objects detached from the session in case of an error + request.db_session.flush() + request.tm.commit() + except IntegrityError: + # the user is already subscribed to this group + savepoint.rollback() + + # re-query the group to get complete data + group = ( + request.query(Group) + .join_all_relationships() + .filter_by(group_id=group.group_id) + .one() + ) + + return {'group': group} + + +@ic_view_config( + route_name='group_subscribe', + request_method='DELETE', + permission='subscribe', + renderer='group_subscription_box.jinja2', +) +def unsubscribe_group(request: Request) -> dict: + """Remove the user's subscription from a group with Intercooler.""" + group = request.context + + request.query(GroupSubscription).filter( + GroupSubscription.group == group, + GroupSubscription.user == request.user, + ).delete(synchronize_session=False) + + # manually commit the transaction so triggers will execute + request.tm.commit() + + # re-query the group to get complete data + group = ( + request.query(Group) + .join_all_relationships() + .filter_by(group_id=group.group_id) + .one() + ) + + return {'group': group} + + +@ic_view_config( + route_name='group_user_settings', + request_method='PATCH', +) +@use_kwargs({ + 'order': Enum(TopicSortOption), + 'period': ShortTimePeriod(allow_none=True), +}) +def patch_group_user_settings( + request: Request, + order: TopicSortOption, + period: Optional[ShortTimePeriod], +) -> dict: + """Set the user's default listing options.""" + if period: + default_period = period.as_short_form() + else: + default_period = 'all' + + statement = ( + insert(UserGroupSettings.__table__) + .values( + user_id=request.user.user_id, + group_id=request.context.group_id, + default_order=order, + default_period=default_period, + ) + .on_conflict_do_update( + constraint=UserGroupSettings.__table__.primary_key, + set_={'default_order': order, 'default_period': default_period}, + ) + ) + request.db_session.execute(statement) + mark_changed(request.db_session) + + return IC_NOOP diff --git a/tildes/tildes/views/api/web/message.py b/tildes/tildes/views/api/web/message.py new file mode 100644 index 0000000..c736d12 --- /dev/null +++ b/tildes/tildes/views/api/web/message.py @@ -0,0 +1,38 @@ +"""Web API endpoints related to messages.""" + +from pyramid.request import Request +from webargs.pyramidparser import use_kwargs + +from tildes.models.message import MessageReply +from tildes.schemas.message import MessageReplySchema +from tildes.views.decorators import ic_view_config + + +@ic_view_config( + route_name='message_conversation_replies', + request_method='POST', + renderer='single_message.jinja2', + permission='reply', +) +@use_kwargs(MessageReplySchema(only=('markdown',))) +def post_message_reply(request: Request, markdown: str) -> dict: + """Post a reply to a message conversation with Intercooler.""" + conversation = request.context + new_reply = MessageReply( + conversation=conversation, + sender=request.user, + markdown=markdown, + ) + request.db_session.add(new_reply) + + # commit and then re-query the reply to get complete data + request.tm.commit() + + new_reply = ( + request.query(MessageReply) + .join_all_relationships() + .filter_by(reply_id=new_reply.reply_id) + .one() + ) + + return {'message': new_reply} diff --git a/tildes/tildes/views/api/web/topic.py b/tildes/tildes/views/api/web/topic.py new file mode 100644 index 0000000..b97d565 --- /dev/null +++ b/tildes/tildes/views/api/web/topic.py @@ -0,0 +1,312 @@ +"""Web API endpoints related to topics.""" + +from marshmallow import ValidationError +from marshmallow.fields import String +from pyramid.httpexceptions import HTTPNotFound +from pyramid.response import Response +from pyramid.request import Request +from sqlalchemy.exc import IntegrityError +from webargs.pyramidparser import use_kwargs + +from tildes.enums import LogEventType +from tildes.models.group import Group +from tildes.models.log import LogTopic +from tildes.models.topic import Topic, TopicVote +from tildes.schemas.group import GroupSchema +from tildes.schemas.topic import TopicSchema +from tildes.views import IC_EMPTY, IC_NOOP +from tildes.views.decorators import ic_view_config + + +@ic_view_config( + route_name='topic', + request_method='GET', + request_param='ic-trigger-name=edit', + renderer='topic_edit.jinja2', + permission='edit', +) +def get_topic_edit(request: Request) -> dict: + """Get the edit form for a topic with Intercooler.""" + return {'topic': request.context} + + +@ic_view_config( + route_name='topic', + request_method='GET', + renderer='topic_contents.jinja2', + permission='view', +) +def get_topic_contents(request: Request) -> dict: + """Get a topic's body with Intercooler.""" + return {'topic': request.context} + + +@ic_view_config( + route_name='topic', + request_method='PATCH', + renderer='topic_contents.jinja2', + permission='edit', +) +@use_kwargs(TopicSchema(only=('markdown',))) +def patch_topic(request: Request, markdown: str) -> dict: + """Update a topic with Intercooler.""" + topic = request.context + + topic.markdown = markdown + + return {'topic': topic} + + +@ic_view_config( + route_name='topic', + request_method='DELETE', + permission='delete', +) +def delete_topic(request: Request) -> Response: + """Delete a topic with Intercooler and redirect to its group.""" + topic = request.context + topic.is_deleted = True + + response = Response() + response.headers['X-IC-Redirect'] = request.route_url( + 'group', group_path=topic.group.path) + + return response + + +@ic_view_config( + route_name='topic_vote', + request_method='PUT', + renderer='topic_voting.jinja2', + permission='vote', +) +def vote_topic(request: Request) -> Response: + """Vote on a topic with Intercooler.""" + topic = request.context + + savepoint = request.tm.savepoint() + + new_vote = TopicVote(request.user, topic) + request.db_session.add(new_vote) + + try: + # manually flush before attempting to commit, to avoid having all + # objects detached from the session in case of an error + request.db_session.flush() + request.tm.commit() + except IntegrityError: + # the user has already voted on this topic + savepoint.rollback() + + # re-query the topic to get complete data + topic = ( + request.query(Topic) + .join_all_relationships() + .filter_by(topic_id=topic.topic_id) + .one() + ) + + return {'topic': topic} + + +@ic_view_config( + route_name='topic_vote', + request_method='DELETE', + renderer='topic_voting.jinja2', + permission='vote', +) +def unvote_topic(request: Request) -> Response: + """Remove the user's vote from a topic with Intercooler.""" + topic = request.context + + request.query(TopicVote).filter( + TopicVote.topic == topic, + TopicVote.user == request.user, + ).delete(synchronize_session=False) + + # manually commit the transaction so triggers will execute + request.tm.commit() + + # re-query the topic to get complete data + topic = ( + request.query(Topic) + .join_all_relationships() + .filter_by(topic_id=topic.topic_id) + .one() + ) + + return {'topic': topic} + + +@ic_view_config( + route_name='topic_tags', + request_method='GET', + renderer='topic_tags_edit.jinja2', + permission='tag', +) +def get_topic_tags(request: Request) -> dict: + """Get the tagging form for a topic with Intercooler.""" + return {'topic': request.context} + + +@ic_view_config( + route_name='topic_tags', + request_method='PUT', + renderer='topic_tags.jinja2', + permission='tag', +) +@use_kwargs({'tags': String()}) +def tag_topic(request: Request, tags: str) -> dict: + """Apply tags to a topic with Intercooler.""" + topic = request.context + + if not tags: + request.db_session.add( + LogTopic( + LogEventType.TOPIC_TAG, + request, + topic, + info={'old': topic.tags, 'new': []}, + ), + ) + topic.tags = [] + return IC_EMPTY + + # split the tag string on commas + split_tags = tags.split(',') + + old_tags = topic.tags + + try: + topic.tags = split_tags + except ValidationError: + raise ValidationError({'tags': ['Invalid tags']}) + + request.db_session.add( + LogTopic( + LogEventType.TOPIC_TAG, + request, + topic, + info={'old': old_tags, 'new': topic.tags}, + ), + ) + + return {'topic': topic} + + +@ic_view_config( + route_name='topic_group', + request_method='GET', + renderer='topic_group_edit.jinja2', + permission='move', +) +def get_topic_group(request: Request) -> dict: + """Get the form for moving a topic with Intercooler.""" + return {'topic': request.context} + + +@ic_view_config( + route_name='topic', + request_param='ic-trigger-name=topic-move', + request_method='PATCH', + permission='move', +) +@use_kwargs(GroupSchema(only=('path',))) +def move_topic(request: Request, path: str) -> dict: + """Move a topic to a different group with Intercooler.""" + topic = request.context + + new_group = ( + request.query(Group) + .filter(Group.path == path) + .one_or_none() + ) + if not new_group: + raise HTTPNotFound('Group not found') + + old_group = topic.group + + if new_group == old_group: + return IC_NOOP + + topic.group = new_group + + request.db_session.add( + LogTopic( + LogEventType.TOPIC_MOVE, + request, + topic, + info={'old': str(old_group.path), 'new': str(topic.group.path)} + ), + ) + + return Response('Moved') + + +@ic_view_config( + route_name='topic_lock', + request_method='PUT', + permission='lock', +) +def lock_topic(request: Request) -> Response: + """Lock a topic with Intercooler.""" + topic = request.context + + topic.is_locked = True + request.db_session.add(LogTopic(LogEventType.TOPIC_LOCK, request, topic)) + + return Response('Locked') + + +@ic_view_config( + route_name='topic_lock', + request_method='DELETE', + permission='lock', +) +def unlock_topic(request: Request) -> Response: + """Unlock a topic with Intercooler.""" + topic = request.context + + topic.is_locked = False + request.db_session.add(LogTopic(LogEventType.TOPIC_UNLOCK, request, topic)) + + return Response('Unlocked') + + +@ic_view_config( + route_name='topic_title', + request_method='GET', + renderer='topic_title_edit.jinja2', + permission='edit_title', +) +def get_topic_title(request: Request) -> dict: + """Get the form for editing a topic's title with Intercooler.""" + return {'topic': request.context} + + +@ic_view_config( + route_name='topic', + request_param='ic-trigger-name=topic-title-edit', + request_method='PATCH', + permission='edit_title', +) +@use_kwargs(TopicSchema(only=('title',))) +def edit_topic_title(request: Request, title: str) -> dict: + """Edit a topic's title with Intercooler.""" + topic = request.context + + if title == topic.title: + return IC_NOOP + + request.db_session.add( + LogTopic( + LogEventType.TOPIC_TITLE_EDIT, + request, + topic, + info={'old': topic.title, 'new': title} + ), + ) + + topic.title = title + + return Response(topic.title) diff --git a/tildes/tildes/views/api/web/user.py b/tildes/tildes/views/api/web/user.py new file mode 100644 index 0000000..06bb8e2 --- /dev/null +++ b/tildes/tildes/views/api/web/user.py @@ -0,0 +1,213 @@ +"""Web API endpoints related to users.""" + +from typing import Optional + +from marshmallow import ValidationError +from marshmallow.fields import String +from pyramid.httpexceptions import HTTPForbidden, HTTPUnprocessableEntity +from pyramid.request import Request +from pyramid.response import Response +from sqlalchemy.exc import IntegrityError +from webargs.pyramidparser import use_kwargs + +from tildes.enums import LogEventType, TopicSortOption +from tildes.models.log import Log +from tildes.models.user import User, UserInviteCode +from tildes.schemas.fields import Enum, ShortTimePeriod +from tildes.schemas.topic import TopicSchema +from tildes.schemas.user import UserSchema +from tildes.views import IC_NOOP +from tildes.views.decorators import ic_view_config + + +PASSWORD_FIELD = UserSchema(only=('password',)).fields['password'] + + +@ic_view_config( + route_name='user', + request_method='PATCH', + request_param='ic-trigger-name=password-change', + permission='change_password', +) +@use_kwargs({ + 'old_password': PASSWORD_FIELD, + 'new_password': PASSWORD_FIELD, + 'new_password_confirm': PASSWORD_FIELD, +}) +def change_password( + request: Request, + old_password: str, + new_password: str, + new_password_confirm: str, +) -> Response: + """Change the logged-in user's password.""" + user = request.context + + # enable checking the new password against the breached-passwords list + user.schema.context['check_breached_passwords'] = True + + if new_password != new_password_confirm: + raise HTTPUnprocessableEntity( + 'New password and confirmation do not match.') + + user.change_password(old_password, new_password) + + return Response('Your password has been updated') + + +@ic_view_config( + route_name='user', + request_method='PATCH', + request_param='ic-trigger-name=account-recovery-email', + permission='change_email_address', +) +@use_kwargs(UserSchema(only=('email_address', 'email_address_note'))) +def change_email_address( + request: Request, + email_address: str, + email_address_note: str +) -> Response: + """Change the user's email address (and descriptive note).""" + user = request.context + + # If the user already has an email address set, we need to retain the + # previous hash and description in the log. Otherwise, if an account is + # compromised and the attacker changes the email address, we'd have no way + # to support recovery for the owner. + log_info = None + if user.email_address_hash: + log_info = { + 'old_hash': user.email_address_hash, + 'old_note': user.email_address_note, + } + request.db_session.add(Log(LogEventType.USER_EMAIL_SET, request, log_info)) + + user.email_address = email_address + user.email_address_note = email_address_note + + return Response('Your email address has been updated') + + +@ic_view_config( + route_name='user', + request_method='PATCH', + request_param='ic-trigger-name=auto-mark-notifications-read', + permission='change_auto_mark_notifications_read_setting', +) +def change_auto_mark_notifications(request: Request) -> Response: + """Change the user's "automatically mark notifications read" setting.""" + user = request.context + + auto_mark = bool(request.params.get('auto_mark_notifications_read')) + user.auto_mark_notifications_read = auto_mark + + return IC_NOOP + + +@ic_view_config( + route_name='user', + request_method='PATCH', + request_param='ic-trigger-name=comment-visits', + permission='change_comment_visits_setting', +) +def change_track_comment_visits(request: Request) -> Response: + """Change the user's "track comment visits" setting.""" + user = request.context + + track_comment_visits = bool(request.params.get('track_comment_visits')) + user.track_comment_visits = track_comment_visits + + if track_comment_visits: + return Response("Enabled tracking of last comment visit.") + + return Response("Disabled tracking of last comment visit.") + + +@ic_view_config( + route_name='user_invite_code', + request_method='GET', + permission='view_invite_code', + renderer='invite_code.jinja2', +) +def get_invite_code(request: Request) -> dict: + """Generate a new invite code owned by the user.""" + user = request.context + + if request.user.invite_codes_remaining < 1: + raise HTTPForbidden('No invite codes remaining') + + # obtain a lock to prevent concurrent requests generating multiple codes + request.obtain_lock('generate_invite_code', user.user_id) + + # it's possible to randomly generate an existing code, so we'll retry + # until we create a new one (will practically always be the first try) + while True: + savepoint = request.tm.savepoint() + + code = UserInviteCode(user) + request.db_session.add(code) + + try: + request.db_session.flush() + break + except IntegrityError: + savepoint.rollback() + + # doing an atomic decrement on request.user.invite_codes_remaining is going + # to make it unusable as an integer in the template, so store the expected + # value after the decrement first, to be able to use that instead + num_remaining = request.user.invite_codes_remaining - 1 + request.user.invite_codes_remaining = User.invite_codes_remaining - 1 + + return {'code': code, 'num_remaining': num_remaining} + + +@ic_view_config( + route_name='user_default_listing_options', + request_method='PUT', + permission='edit_default_listing_options', +) +@use_kwargs({ + 'order': Enum(TopicSortOption), + 'period': ShortTimePeriod(allow_none=True), +}) +def put_default_listing_options( + request: Request, + order: TopicSortOption, + period: Optional[ShortTimePeriod], +) -> dict: + """Set the user's default listing options.""" + user = request.context + + user.home_default_order = order + if period: + user.home_default_period = period.as_short_form() + else: + user.home_default_period = 'all' + + return IC_NOOP + + +@ic_view_config( + route_name='user_filtered_topic_tags', + request_method='PUT', + permission='edit_filtered_topic_tags', +) +@use_kwargs({'tags': String()}) +def put_filtered_topic_tags(request: Request, tags: str) -> dict: + """Update a user's filtered topic tags list.""" + if not tags: + request.user.filtered_topic_tags = [] + return IC_NOOP + + split_tags = tags.split(',') + + try: + schema = TopicSchema(only=('tags',)) + result = schema.load({'tags': split_tags}) + except ValidationError: + raise ValidationError({'tags': ['Invalid tags']}) + + request.user.filtered_topic_tags = result.data['tags'] + + return IC_NOOP diff --git a/tildes/tildes/views/decorators.py b/tildes/tildes/views/decorators.py new file mode 100644 index 0000000..e64afff --- /dev/null +++ b/tildes/tildes/views/decorators.py @@ -0,0 +1,64 @@ +"""Contains decorators for view functions.""" + +from typing import Any, Callable + +from pyramid.httpexceptions import HTTPFound, HTTPTooManyRequests +from pyramid.request import Request +from pyramid.view import view_config + + +def ic_view_config(**kwargs: Any) -> Callable: + """Wrap the @view_config decorator for Intercooler views.""" + if 'route_name' in kwargs: + kwargs['route_name'] = 'ic_' + kwargs['route_name'] + + if 'renderer' in kwargs: + kwargs['renderer'] = 'intercooler/' + kwargs['renderer'] + + if 'header' in kwargs: + raise ValueError("Can't add a header check to Intercooler view.") + kwargs['header'] = 'X-IC-Request:true' + + return view_config(**kwargs) + + +def rate_limit_view(action_name: str) -> Callable: + """Decorate a view function to rate-limit calls to it. + + Needs to be used with the name of the rate-limited action, such as: + @rate_limit_view('register') + + If the ratelimit check comes back with the action being blocked, a 429 + response with appropriate headers will be raised instead of calling the + decorated view. + """ + def decorator(func: Callable) -> Callable: + def wrapper(*args: Any, **kwargs: Any) -> Any: + request = args[0] + result = request.check_rate_limit(action_name) + + if not result.is_allowed: + raise result.add_headers_to_response(HTTPTooManyRequests()) + + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def not_logged_in(func: Callable) -> Callable: + """Decorate a view function to prevent access by logged-in users. + + If a logged-in user attempts to access a view decorated by this function, + they will be redirected to the home page instead. This is useful for views + such as the login page, registration page, etc. which only logged-out users + should be accessing. + """ + def wrapper(request: Request, **kwargs: Any) -> Any: + if request.user: + raise HTTPFound(location=request.route_url('home')) + + return func(request, **kwargs) + + return wrapper diff --git a/tildes/tildes/views/donate.py b/tildes/tildes/views/donate.py new file mode 100644 index 0000000..6340891 --- /dev/null +++ b/tildes/tildes/views/donate.py @@ -0,0 +1,52 @@ +"""The view for donating via Stripe.""" + +import stripe +from marshmallow.fields import Email, Float, String +from marshmallow.validate import OneOf, Range +from pyramid.httpexceptions import HTTPInternalServerError +from pyramid.request import Request +from pyramid.security import NO_PERMISSION_REQUIRED +from pyramid.view import view_config +from webargs.pyramidparser import use_kwargs + + +@view_config( + route_name='donate_stripe', + request_method='POST', + renderer='donate_stripe.jinja2', + permission=NO_PERMISSION_REQUIRED, + require_csrf=False, +) +@use_kwargs({ + 'stripe_token': String(required=True), + 'donator_email': Email(required=True), + 'amount': Float(required=True, validate=Range(min=1.0)), + 'currency': String(required=True, validate=OneOf(('CAD', 'USD'))), +}) +def post_donate_stripe( + request: Request, + stripe_token: str, + donator_email: str, + amount: int, + currency: str, +) -> dict: + """Process a Stripe donation.""" + try: + stripe.api_key = request.registry.settings['stripe_api_key'] + except KeyError: + raise HTTPInternalServerError + + payment_successful = True + try: + stripe.Charge.create( + source=stripe_token, + amount=int(amount*100), + currency=currency, + receipt_email=donator_email, + description='One-time donation', + statement_descriptor='Donation - tildes.net', + ) + except stripe.error.StripeError: + payment_successful = False + + return {'payment_successful': payment_successful} diff --git a/tildes/tildes/views/exceptions.py b/tildes/tildes/views/exceptions.py new file mode 100644 index 0000000..eb1631f --- /dev/null +++ b/tildes/tildes/views/exceptions.py @@ -0,0 +1,11 @@ +"""Views used by Pyramid when an exception is raised.""" + +from pyramid.request import Request +from pyramid.view import forbidden_view_config + + +@forbidden_view_config(xhr=False, renderer='error_403.jinja2') +def forbidden(request: Request) -> dict: + """403 Forbidden page.""" + request.response.status_int = 403 + return {} diff --git a/tildes/tildes/views/group.py b/tildes/tildes/views/group.py new file mode 100644 index 0000000..a0ff40a --- /dev/null +++ b/tildes/tildes/views/group.py @@ -0,0 +1,14 @@ +"""Views related to groups.""" + +from pyramid.request import Request +from pyramid.view import view_config + +from tildes.models.group import Group + + +@view_config(route_name='groups', renderer='groups.jinja2') +def list_groups(request: Request) -> dict: + """Show a list of all groups.""" + groups = request.query(Group).order_by(Group.path).all() + + return {'groups': groups} diff --git a/tildes/tildes/views/login.py b/tildes/tildes/views/login.py new file mode 100644 index 0000000..ec9aac7 --- /dev/null +++ b/tildes/tildes/views/login.py @@ -0,0 +1,89 @@ +"""Views related to logging in/out.""" + +from pyramid.httpexceptions import HTTPFound, HTTPUnprocessableEntity +from pyramid.request import Request +from pyramid.security import NO_PERMISSION_REQUIRED, remember +from pyramid.view import view_config +from webargs.pyramidparser import use_kwargs + +from tildes.enums import LogEventType +from tildes.metrics import incr_counter +from tildes.models.log import Log +from tildes.models.user import User +from tildes.schemas.user import UserSchema +from tildes.views.decorators import not_logged_in, rate_limit_view + + +@view_config( + route_name='login', + renderer='login.jinja2', + permission=NO_PERMISSION_REQUIRED, +) +@not_logged_in +def get_login(request: Request) -> dict: + """Display the login form.""" + # pylint: disable=unused-argument + return {} + + +@view_config( + route_name='login', + request_method='POST', + permission=NO_PERMISSION_REQUIRED, +) +@use_kwargs( + UserSchema(only=('username', 'password'), strict=True), +) +@not_logged_in +@rate_limit_view('login') +def post_login(request: Request, username: str, password: str) -> HTTPFound: + """Process a log in request.""" + incr_counter('logins') + + # Look up the user for the supplied username + user = ( + request.query(User) + .undefer_all_columns() + .filter(User.username == username) + .one_or_none() + ) + + # If that user doesn't exist or the password was wrong, error out + if not user or not user.is_correct_password(password): + incr_counter('login_failures') + + # log the failure - need to manually commit because of the exception + log_entry = Log( + LogEventType.USER_LOG_IN_FAIL, request, {'username': username}) + request.db_session.add(log_entry) + request.tm.commit() + + raise HTTPUnprocessableEntity('Incorrect username or password') + + # Don't allow banned users to log in + if user.is_banned: + raise HTTPUnprocessableEntity('This account has been banned') + + # Username/password were correct - attach the user_id to the session + remember(request, user.user_id) + + # Depending on "keep me logged in", set session timeout to 1 year or 1 day + if request.params.get('keep'): + request.session.adjust_timeout_for_session(31_536_000) + else: + request.session.adjust_timeout_for_session(86_400) + + # set request.user before logging so the user is associated with the event + request.user = user + request.db_session.add(Log(LogEventType.USER_LOG_IN, request)) + + raise HTTPFound(location='/') + + +@view_config(route_name='logout') +def get_logout(request: Request) -> HTTPFound: + """Process a log out request.""" + request.session.invalidate() + request.db_session.add(Log(LogEventType.USER_LOG_OUT, request)) + + raise HTTPFound(location='/') diff --git a/tildes/tildes/views/message.py b/tildes/tildes/views/message.py new file mode 100644 index 0000000..267ee1b --- /dev/null +++ b/tildes/tildes/views/message.py @@ -0,0 +1,143 @@ +"""Views related to sending and viewing messages.""" + +from pyramid.httpexceptions import HTTPFound +from pyramid.request import Request +from pyramid.view import view_config +from sqlalchemy.dialects.postgresql import array +from sqlalchemy.sql.expression import and_, desc, or_ +from webargs.pyramidparser import use_kwargs + +from tildes.models.message import MessageConversation, MessageReply +from tildes.schemas.message import ( + MessageConversationSchema, + MessageReplySchema, +) + + +@view_config( + route_name='new_message', + renderer='new_message.jinja2', + permission='message', +) +def get_new_message_form(request: Request) -> dict: + """Form for entering a new private message to send.""" + return {'user': request.context} + + +@view_config(route_name='messages', renderer='messages.jinja2') +def get_user_messages(request: Request) -> dict: + """Show the logged-in user's message conversations.""" + # select conversations where either the user is the recipient, or they + # were the sender and there is at least one reply (don't need to show + # conversations the user started but haven't been replied to) + conversations = ( + request.query(MessageConversation) + .filter(or_( + MessageConversation.recipient == request.user, + and_( + MessageConversation.sender == request.user, + MessageConversation.num_replies > 0, + ), + )) + .order_by(desc(MessageConversation.last_reply_time)) + .all() + ) + + return {'conversations': conversations} + + +@view_config(route_name='messages_unread', renderer='messages_unread.jinja2') +def get_user_unread_messages(request: Request) -> dict: + """Show the logged-in user's unread message conversations.""" + conversations = ( + request.query(MessageConversation) + .filter(MessageConversation.unread_user_ids.contains( # type: ignore + array([request.user.user_id]))) + .order_by(desc(MessageConversation.last_reply_time)) + .all() + ) + + return {'conversations': conversations} + + +@view_config(route_name='messages_sent', renderer='messages_sent.jinja2') +def get_user_sent_messages(request: Request) -> dict: + """Show the logged-in user's sent message conversations.""" + # select conversations where either the user was the sender, or they + # were the recipient and there is at least one reply + conversations = ( + request.query(MessageConversation) + .filter(or_( + MessageConversation.sender == request.user, + and_( + MessageConversation.recipient == request.user, + MessageConversation.num_replies > 0, + ), + )) + .order_by(desc(MessageConversation.last_reply_time)) + .all() + ) + + return {'conversations': conversations} + + +@view_config( + route_name='message_conversation', + request_method='GET', + renderer='message_conversation.jinja2', + permission='view', +) +def get_message_conversation(request: Request) -> dict: + """View an individual message conversation.""" + conversation = request.context + + conversation.mark_read_for_user(request.user) + + return {'conversation': conversation} + + +@view_config( + route_name='message_conversation', + request_method='POST', + permission='reply', +) +@use_kwargs(MessageReplySchema(only=('markdown',))) +def post_message_reply(request: Request, markdown: str) -> HTTPFound: + """Post a reply to a message conversation.""" + conversation = request.context + new_reply = MessageReply( + conversation=conversation, + sender=request.user, + markdown=markdown, + ) + request.db_session.add(new_reply) + + conversation_url = request.route_url( + 'message_conversation', + conversation_id36=conversation.conversation_id36, + ) + raise HTTPFound(location=conversation_url) + + +@view_config( + route_name='user_messages', + request_method='POST', + permission='message', +) +@use_kwargs(MessageConversationSchema(only=('subject', 'markdown'))) +def post_user_message( + request: Request, + subject: str, + markdown: str, +) -> HTTPFound: + """Start a new message conversation with a user.""" + new_conversation = MessageConversation( + sender=request.user, + recipient=request.context, + subject=subject, + markdown=markdown, + ) + request.db_session.add(new_conversation) + + user_url = request.route_url('user', username=request.context.username) + raise HTTPFound(location=user_url) diff --git a/tildes/tildes/views/metrics.py b/tildes/tildes/views/metrics.py new file mode 100644 index 0000000..3d20d1c --- /dev/null +++ b/tildes/tildes/views/metrics.py @@ -0,0 +1,26 @@ +"""The view for exposing metrics to be picked up by Prometheus.""" + +from prometheus_client import CollectorRegistry, generate_latest, multiprocess +from pyramid.request import Request +from pyramid.security import NO_PERMISSION_REQUIRED +from pyramid.view import view_config + + +@view_config( + route_name='metrics', + renderer='string', + permission=NO_PERMISSION_REQUIRED, +) +def metrics(request: Request) -> str: + """Merge together the metrics from all workers and output them.""" + registry = CollectorRegistry() + multiprocess.MultiProcessCollector(registry) + data = generate_latest(registry) + + # When Prometheus accesses this page it will always create a new session. + # This session is useless and will never be used again, so we can just + # invalidate it to cause it to be deleted from storage. It would be even + # better to find a way to not create it in the first place. + request.session.invalidate() + + return data.decode('utf-8') diff --git a/tildes/tildes/views/notifications.py b/tildes/tildes/views/notifications.py new file mode 100644 index 0000000..4d39d3f --- /dev/null +++ b/tildes/tildes/views/notifications.py @@ -0,0 +1,61 @@ +"""Views related to notifications.""" + +from pyramid.request import Request +from pyramid.view import view_config +from sqlalchemy.sql.expression import desc + +from tildes.enums import CommentTagOption +from tildes.models.comment import CommentNotification + + +@view_config( + route_name='notifications_unread', + renderer='notifications_unread.jinja2', +) +def get_user_unread_notifications(request: Request) -> dict: + """Show the logged-in user's unread notifications.""" + notifications = ( + request.query(CommentNotification) + .join_all_relationships() + .filter( + CommentNotification.user == request.user, + CommentNotification.is_unread == True, # noqa + ) + .order_by(desc(CommentNotification.created_time)) + .all() + ) + + # if the user has the "automatically mark notifications as read" setting + # enabled, mark all their notifications as read + if request.user.auto_mark_notifications_read: + for notification in notifications: + notification.is_unread = False + + return { + 'notifications': notifications, + 'comment_tag_options': CommentTagOption, + } + + +@view_config( + route_name='notifications', + renderer='notifications.jinja2', +) +def get_user_notifications(request: Request) -> dict: + """Show the most recent 100 of the logged-in user's read notifications.""" + notifications = ( + request.query(CommentNotification) + .join_all_relationships() + .filter( + CommentNotification.user == request.user, + CommentNotification.is_unread == False, # noqa + ) + .order_by(desc(CommentNotification.created_time)) + .limit(100) + .all() + ) + + return { + 'notifications': notifications, + 'comment_tag_options': CommentTagOption, + } diff --git a/tildes/tildes/views/register.py b/tildes/tildes/views/register.py new file mode 100644 index 0000000..68960a1 --- /dev/null +++ b/tildes/tildes/views/register.py @@ -0,0 +1,152 @@ +"""Views related to registration.""" + +from marshmallow.fields import String +from pyramid.httpexceptions import HTTPFound, HTTPUnprocessableEntity +from pyramid.request import Request +from pyramid.security import NO_PERMISSION_REQUIRED, remember +from pyramid.view import view_config +from sqlalchemy.exc import IntegrityError +from webargs.pyramidparser import use_kwargs + +from tildes.enums import LogEventType +from tildes.lib.message import WELCOME_MESSAGE_SUBJECT, WELCOME_MESSAGE_TEXT +from tildes.metrics import incr_counter +from tildes.models.group import Group, GroupSubscription +from tildes.models.log import Log +from tildes.models.message import MessageConversation +from tildes.models.user import User, UserInviteCode +from tildes.schemas.user import UserSchema +from tildes.views.decorators import not_logged_in, rate_limit_view + + +@view_config( + route_name='register', + renderer='register.jinja2', + permission=NO_PERMISSION_REQUIRED, +) +@not_logged_in +def get_register(request: Request) -> dict: + """Display the registration form.""" + # pylint: disable=unused-argument + return {} + + +def user_schema_check_breaches(request: Request) -> UserSchema: + """Return a UserSchema that will check the password against breaches. + + It would probably be good to generalize this function at some point, + probably similar to: + http://webargs.readthedocs.io/en/latest/advanced.html#reducing-boilerplate + """ + # pylint: disable=unused-argument + return UserSchema( + only=('username', 'password'), + context={'check_breached_passwords': True}, + ) + + +@view_config( + route_name='register', + request_method='POST', + permission=NO_PERMISSION_REQUIRED, +) +@use_kwargs(user_schema_check_breaches) +@use_kwargs({ + 'invite_code': String(required=True), + 'password_confirm': String(required=True), +}) +@not_logged_in +@rate_limit_view('register') +def post_register( + request: Request, + username: str, + password: str, + password_confirm: str, + invite_code: str, +) -> HTTPFound: + """Process a registration request.""" + if not request.params.get('accepted_terms'): + raise HTTPUnprocessableEntity( + 'Terms of Use and Privacy Policy must be accepted.') + + if password != password_confirm: + raise HTTPUnprocessableEntity( + 'Password and confirmation do not match.') + + # attempt to fetch and lock the row for the specified invite code (lock + # prevents concurrent requests from using the same invite code) + lookup_code = UserInviteCode.prepare_code_for_lookup(invite_code) + code_row = ( + request.query(UserInviteCode) + .filter( + UserInviteCode.code == lookup_code, + UserInviteCode.invitee_id == None, # noqa + ) + .with_for_update(skip_locked=True) + .one_or_none() + ) + + if not code_row: + incr_counter('invite_code_failures') + raise HTTPUnprocessableEntity('Invalid invite code') + + # create the user and set inviter to the owner of the invite code + user = User(username, password) + user.inviter_id = code_row.user_id + + # flush the user insert to db, will fail if username is already taken + request.db_session.add(user) + try: + request.db_session.flush() + except IntegrityError: + raise HTTPUnprocessableEntity( + 'That username has already been registered.') + + # the flush above will generate the new user's ID, so use that to update + # the invite code with info about the user that registered with it + code_row.invitee_id = user.user_id + + # subscribe the new user to all groups except ~test + all_groups = request.query(Group).all() + for group in all_groups: + if group.path == 'test': + continue + request.db_session.add(GroupSubscription(user, group)) + + _send_welcome_message(user, request) + + incr_counter('registrations') + + # log the user in to the new account + remember(request, user.user_id) + + # set request.user before logging so the user is associated with the event + request.user = user + request.db_session.add(Log(LogEventType.USER_REGISTER, request)) + + # redirect to the front page + raise HTTPFound(location='/') + + +def _send_welcome_message(recipient: User, request: Request) -> None: + """Send the welcome message if a sender is configured in the INI.""" + sender_username = request.registry.settings.get( + 'tildes.welcome_message_sender') + if not sender_username: + return + + sender = ( + request.query(User) + .filter(User.username == sender_username) + .one_or_none() + ) + if not sender: + return + + welcome_message = MessageConversation( + sender, + recipient, + WELCOME_MESSAGE_SUBJECT, + WELCOME_MESSAGE_TEXT, + ) + request.db_session.add(welcome_message) diff --git a/tildes/tildes/views/settings.py b/tildes/tildes/views/settings.py new file mode 100644 index 0000000..deb2324 --- /dev/null +++ b/tildes/tildes/views/settings.py @@ -0,0 +1,91 @@ +"""Views related to user settings.""" + +from pyramid.httpexceptions import HTTPUnprocessableEntity +from pyramid.request import Request +from pyramid.response import Response +from pyramid.view import view_config +from webargs.pyramidparser import use_kwargs + +from tildes.schemas.user import EMAIL_ADDRESS_NOTE_MAX_LENGTH, UserSchema + + +PASSWORD_FIELD = UserSchema(only=('password',)).fields['password'] + + +@view_config(route_name='settings', renderer='settings.jinja2') +def get_settings(request: Request) -> dict: + """Generate the user settings page.""" + current_theme = request.cookies.get('theme', '') + theme_options = { + '': 'White (default)', + 'light': 'Solarized Light', + 'dark': 'Solarized Dark', + 'black': 'Black', + } + + return {'current_theme': current_theme, 'theme_options': theme_options} + + +@view_config( + route_name='settings_account_recovery', + renderer='settings_account_recovery.jinja2', +) +def get_settings_account_recovery(request: Request) -> dict: + """Generate the account recovery page.""" + # pylint: disable=unused-argument + return {'note_max_length': EMAIL_ADDRESS_NOTE_MAX_LENGTH} + + +@view_config( + route_name='settings_comment_visits', + renderer='settings_comment_visits.jinja2', +) +def get_settings_comment_visits(request: Request) -> dict: + """Generate the comment visits settings page.""" + # pylint: disable=unused-argument + return {} + + +@view_config(route_name='settings_filters', renderer='settings_filters.jinja2') +def get_settings_filters(request: Request) -> dict: + """Generate the filters settings page.""" + # pylint: disable=unused-argument + return {} + + +@view_config( + route_name='settings_password_change', + renderer='settings_password_change.jinja2', +) +def get_settings_password_change(request: Request) -> dict: + """Generate the password change page.""" + # pylint: disable=unused-argument + return {} + + +@view_config( + route_name='settings_password_change', + request_method='POST', +) +@use_kwargs({ + 'old_password': PASSWORD_FIELD, + 'new_password': PASSWORD_FIELD, + 'new_password_confirm': PASSWORD_FIELD, +}) +def post_settings_password_change( + request: Request, + old_password: str, + new_password: str, + new_password_confirm: str, +) -> Response: + """Change the logged-in user's password.""" + # enable checking the new password against the breached-passwords list + request.user.schema.context['check_breached_passwords'] = True + + if new_password != new_password_confirm: + raise HTTPUnprocessableEntity( + 'New password and confirmation do not match.') + + request.user.change_password(old_password, new_password) + + return Response('Your password has been updated') diff --git a/tildes/tildes/views/topic.py b/tildes/tildes/views/topic.py new file mode 100644 index 0000000..d728028 --- /dev/null +++ b/tildes/tildes/views/topic.py @@ -0,0 +1,304 @@ +"""Views related to posting/viewing topics and comments on them.""" + +from collections import namedtuple +from typing import Any, Optional + +from marshmallow import missing, ValidationError +from marshmallow.fields import String +from pyramid.httpexceptions import HTTPFound +from pyramid.request import Request +from pyramid.view import view_config +from sqlalchemy.sql.expression import desc +from sqlalchemy_utils import Ltree +from webargs.pyramidparser import use_kwargs +from zope.sqlalchemy import mark_changed + +from tildes.enums import ( + CommentNotificationType, + CommentSortOption, + CommentTagOption, + LogEventType, + TopicSortOption, +) +from tildes.lib.datetime import SimpleHoursPeriod +from tildes.models.comment import Comment, CommentNotification, CommentTree +from tildes.models.group import Group +from tildes.models.log import LogTopic +from tildes.models.topic import Topic, TopicVisit +from tildes.models.user import UserGroupSettings +from tildes.schemas.comment import CommentSchema +from tildes.schemas.fields import Enum, ShortTimePeriod +from tildes.schemas.topic import TopicSchema +from tildes.schemas.topic_listing import TopicListingSchema + + +DefaultSettings = namedtuple('DefaultSettings', ['order', 'period']) + + +@view_config( + route_name='group_topics', + request_method='POST', + permission='post_topic', +) +@use_kwargs(TopicSchema(only=('title', 'markdown', 'link'))) +@use_kwargs({'tags': String()}) +def post_group_topics( + request: Request, + title: str, + markdown: str, + link: str, + tags: str, +) -> HTTPFound: + """Post a new topic to a group.""" + if link: + new_topic = Topic.create_link_topic( + group=request.context, + author=request.user, + title=title, + link=link, + ) + + # if they specified both a link and markdown, use the markdown to post + # an initial comment on the topic + if markdown: + new_comment = Comment( + topic=new_topic, + author=request.user, + markdown=markdown, + ) + request.db_session.add(new_comment) + else: + new_topic = Topic.create_text_topic( + group=request.context, + author=request.user, + title=title, + markdown=markdown, + ) + + try: + new_topic.tags = tags.split(',') + except ValidationError: + raise ValidationError({'tags': ['Invalid tags']}) + + request.db_session.add(new_topic) + + request.db_session.add( + LogTopic(LogEventType.TOPIC_POST, request, new_topic)) + + # flush the changes to the database so the new topic's ID is generated + request.db_session.flush() + + raise HTTPFound(location=new_topic.permalink) + + +@view_config(route_name='home', renderer='home.jinja2') +@view_config(route_name='group', renderer='topic_listing.jinja2') +@use_kwargs(TopicListingSchema()) +def get_group_topics( + request: Request, + order: Any, # more specific would be better, but missing isn't typed + period: Any, # more specific would be better, but missing isn't typed + after: str, + before: str, + per_page: int, + rank_start: Optional[int], + tag: Optional[Ltree], + unfiltered: bool, +) -> dict: + """Get a listing of topics in the group.""" + # pylint: disable=too-many-arguments + if request.matched_route.name == 'home': + # on the home page, include topics from the user's subscribed groups + groups = [sub.group for sub in request.user.subscriptions] + else: + # otherwise, just topics from the single group that we're looking at + groups = [request.context] + + default_settings = _get_default_settings(request, order) + + if order is missing: + order = default_settings.order + + if period is missing: + period = default_settings.period + + query = ( + request.query(Topic) + .join_all_relationships() + .inside_groups(groups) + .inside_time_period(period) + .has_tag(tag) + .apply_sort_option(order) + .after_id36(after) + .before_id36(before) + ) + + # apply topic tag filters unless they're disabled or viewing a single tag + if not (tag or unfiltered): + # pylint: disable=protected-access + query = query.filter( + ~Topic._tags.overlap( # type: ignore + request.user._filtered_topic_tags) + ) + + topics = query.get_page(per_page) + + period_options = [ + SimpleHoursPeriod(hours) for hours in (1, 12, 24, 72)] + + # add the current period to the bottom of the dropdown if it's not one of + # the "standard" ones + if period and period not in period_options: + period_options.append(period) + + return { + 'group': request.context, + 'topics': topics, + 'order': order, + 'order_options': TopicSortOption, + 'period': period, + 'period_options': period_options, + 'is_default_period': period == default_settings.period, + 'is_default_view': ( + period == default_settings.period and + order == default_settings.order + ), + 'rank_start': rank_start, + 'tag': tag, + 'unfiltered': unfiltered, + } + + +@view_config( + route_name='new_topic', + renderer='new_topic.jinja2', + permission='post_topic', +) +def get_new_topic_form(request: Request) -> dict: + """Form for entering a new topic to post.""" + group = request.context + + return {'group': group} + + +@view_config(route_name='topic', renderer='topic.jinja2') +@use_kwargs({ + 'comment_order': Enum(CommentSortOption, missing='votes'), +}) +def get_topic(request: Request, comment_order: CommentSortOption) -> dict: + """View a single topic.""" + topic = request.context + + # deleted and removed comments need to be included since they're necessary + # for building the tree if they have replies + comments = ( + request.query(Comment) + .include_deleted() + .include_removed() + .filter(Comment.topic == topic) + .order_by(Comment.created_time) + .all() + ) + tree = CommentTree(comments, comment_order) + + # check if there are any items in the log to show + visible_events = ( + LogEventType.TOPIC_LOCK, + LogEventType.TOPIC_MOVE, + LogEventType.TOPIC_TAG, + LogEventType.TOPIC_TITLE_EDIT, + LogEventType.TOPIC_UNLOCK, + ) + log = ( + request.query(LogTopic) + .filter( + LogTopic.topic == topic, + LogTopic.event_type.in_(visible_events) # noqa + ) + .order_by(desc(LogTopic.event_time)) + .all() + ) + + # if the feature's enabled, update their last visit to this topic + if request.user and request.user.track_comment_visits: + statement = TopicVisit.generate_insert_statement(request.user, topic) + + request.db_session.execute(statement) + mark_changed(request.db_session) + + return { + 'topic': topic, + 'log': log, + 'comments': tree, + 'comment_order': comment_order, + 'comment_order_options': CommentSortOption, + 'comment_tag_options': CommentTagOption, + } + + +@view_config(route_name='topic', request_method='POST', permission='comment') +@use_kwargs(CommentSchema(only=('markdown',))) +def post_comment_on_topic(request: Request, markdown: str) -> HTTPFound: + """Post a new top-level comment on a topic.""" + topic = request.context + + new_comment = Comment( + topic=topic, + author=request.user, + markdown=markdown, + ) + request.db_session.add(new_comment) + + if topic.user != request.user and not topic.is_deleted: + notification = CommentNotification( + topic.user, + new_comment, + CommentNotificationType.TOPIC_REPLY, + ) + request.db_session.add(notification) + + raise HTTPFound(location=topic.permalink) + + +def _get_default_settings(request: Request, order: Any) -> DefaultSettings: + if isinstance(request.context, Group): + user_settings = ( + request.query(UserGroupSettings) + .filter( + UserGroupSettings.user == request.user, + UserGroupSettings.group == request.context, + ) + .one_or_none() + ) + else: + user_settings = None + + if user_settings and user_settings.default_order: + default_order = user_settings.default_order + elif request.user.home_default_order: + default_order = request.user.home_default_order + else: + default_order = TopicSortOption.ACTIVITY + + # the default period depends on what the order is, so we need to see if + # we're going to end up using the default order here as well + if order is missing: + order = default_order + + if user_settings and user_settings.default_period: + user_default = user_settings.default_period + default_period = ShortTimePeriod().deserialize(user_default) + elif request.user.home_default_period: + user_default = request.user.home_default_period + default_period = ShortTimePeriod().deserialize(user_default) + else: + # default to "all time" if sorting by new, 3d if activity, otherwise + # last 24h + if order == TopicSortOption.NEW: + default_period = None + elif order == TopicSortOption.ACTIVITY: + default_period = SimpleHoursPeriod(72) + else: + default_period = SimpleHoursPeriod(24) + + return DefaultSettings(order=default_order, period=default_period) diff --git a/tildes/tildes/views/user.py b/tildes/tildes/views/user.py new file mode 100644 index 0000000..7627861 --- /dev/null +++ b/tildes/tildes/views/user.py @@ -0,0 +1,89 @@ +"""Views related to a specific user.""" + +from pyramid.request import Request +from pyramid.view import view_config +from sqlalchemy.sql.expression import desc + +from tildes.models.comment import Comment +from tildes.models.topic import Topic +from tildes.models.user import UserInviteCode + + +@view_config(route_name='user', renderer='user.jinja2') +def get_user(request: Request) -> dict: + """Generate the main user info page.""" + user = request.context + + page_size = 20 + + # Since we don't know how many comments or topics will be needed to make + # up a page, we'll fetch the full page size of both types, merge them, + # and then trim down to the size afterwards + query = ( + request.query(Comment) + .filter(Comment.user == user) + .order_by(desc(Comment.created_time)) + .limit(page_size) + .join_all_relationships() + ) + + # include removed comments if the user's looking at their own page + if user == request.user: + query = query.include_removed() + + comments = query.all() + + query = ( + request.query(Topic) + .filter(Topic.user == user) + .order_by(desc(Topic.created_time)) + .limit(page_size) + .join_all_relationships() + ) + + # include removed topics if the user's looking at their own page + if user == request.user: + query = query.include_removed() + + topics = query.all() + + merged_posts = sorted( + topics + comments, + key=lambda post: post.created_time, + reverse=True, + ) + merged_posts = merged_posts[:page_size] + + # if the user is on their own page, check if they have active invite codes + num_active_invite_codes = None + if user == request.user: + num_active_invite_codes = ( + request.query(UserInviteCode) + .filter( + UserInviteCode.user_id == request.user.user_id, + UserInviteCode.invitee_id == None, # noqa + ) + .count() + ) + + return { + 'user': user, + 'merged_posts': merged_posts, + 'num_active_invite_codes': num_active_invite_codes, + } + + +@view_config(route_name='invite', renderer='invite.jinja2') +def get_invite(request: Request) -> dict: + """Generate the invite page.""" + # get any existing unused invite codes + codes = ( + request.query(UserInviteCode) + .filter( + UserInviteCode.user_id == request.user.user_id, + UserInviteCode.invitee_id == None, # noqa + ) + .all() + ) + + return {'codes': codes} diff --git a/tildes/webassets.yaml b/tildes/webassets.yaml new file mode 100644 index 0000000..e25f76b --- /dev/null +++ b/tildes/webassets.yaml @@ -0,0 +1,28 @@ +directory: static +url: / +manifest: json + +bundles: + javascript: + contents: + # keep scripts.js at the top so it can define things needed in other ones + - js/scripts.js + - js/behaviors/*.js + output: js/tildes.js + javascript-third-party: + contents: + # jquery needs to be at the top since others depend on it + - js/third_party/jquery-*.js + - js/third_party/*.js + output: js/third_party.js + css: + contents: + # keep styles.css at the bottom so it can override Spectre + - css/spectre-0.5.1/spectre.css + - css/styles.css + output: css/tildes.css + site-icons-css: + merge: false + contents: + - /var/lib/site-icons-spriter/output/site-icons.css + output: css/site-icons.css