time to open sause & place into public domain!

Merry Christmas!

I AM READY

I love you all!

	new file:   .gitignore
	new file:   MANIFEST.in
	new file:   Makefile
	new file:   README.rst
	new file:   development.ini
	new file:   index.html
	new file:   index2.html
	new file:   journal.rst
	new file:   readme-postgres-notes.rst
	new file:   readme-themes.rst
	new file:   remarkbox/__init__.py
	new file:   remarkbox/lib/__init__.py
	new file:   remarkbox/lib/mail.py
	new file:   remarkbox/lib/mail_messages.py
	new file:   remarkbox/lib/notify.py
	new file:   remarkbox/lib/render.py
	new file:   remarkbox/lib/svgtar/__init__.py
	new file:   remarkbox/lib/svgtar/svg.py
	new file:   remarkbox/models/__init__.py
	new file:   remarkbox/models/event.py
	new file:   remarkbox/models/meta.py
	new file:   remarkbox/models/namespace.py
	new file:   remarkbox/models/namespace_request.py
	new file:   remarkbox/models/namespace_user.py
	new file:   remarkbox/models/node.py
	new file:   remarkbox/models/node_cache.py
	new file:   remarkbox/models/notification.py
	new file:   remarkbox/models/oauth.py
	new file:   remarkbox/models/pay_what_you_can.py
	new file:   remarkbox/models/uri.py
	new file:   remarkbox/models/user.py
	new file:   remarkbox/models/vote.py
	new file:   remarkbox/models/watcher.py
	new file:   remarkbox/routes.py
	new file:   remarkbox/scripts/__init__.py
	new file:   remarkbox/scripts/alembic/README
	new file:   remarkbox/scripts/alembic/env.py
	new file:   remarkbox/scripts/alembic/script.py.mako
	new file:   remarkbox/scripts/alembic/versions/00211ed642cc_add_ip_address_column_to_rb_node.py
	new file:   remarkbox/scripts/alembic/versions/0208c10ea67f_create_new_node_cache_table.py
	new file:   remarkbox/scripts/alembic/versions/04e52337756e_autogenerated_indicies.py
	new file:   remarkbox/scripts/alembic/versions/051f8b810f26_namespace_ignore_query_string.py
	new file:   remarkbox/scripts/alembic/versions/1040ee4836af_namespace_description.py
	new file:   remarkbox/scripts/alembic/versions/184a349cd589_uri_accessed_timestamp.py
	new file:   remarkbox/scripts/alembic/versions/256468780798_root_nodes_to_namespace_.py
	new file:   remarkbox/scripts/alembic/versions/2e00b92495e4_node_disabled_timestamp.py
	new file:   remarkbox/scripts/alembic/versions/3ee195d142fb_add_subscription_type_to_namespace.py
	new file:   remarkbox/scripts/alembic/versions/40ded1163f95_add_user_surrogate_columns.py
	new file:   remarkbox/scripts/alembic/versions/43c1e5a99d4e_new_namespace_column_3.py
	new file:   remarkbox/scripts/alembic/versions/4aeda8e797ab_new_namespace_column_for_stylesheet_uris.py
	new file:   remarkbox/scripts/alembic/versions/503a2cef2378_root_stats.py
	new file:   remarkbox/scripts/alembic/versions/591800942adf_node_data_html_column.py
	new file:   remarkbox/scripts/alembic/versions/59592cf325fb_stylesheet_timestamp.py
	new file:   remarkbox/scripts/alembic/versions/5a5d0b68db78_add_root_id_field.py
	new file:   remarkbox/scripts/alembic/versions/6a440d3921d2_added_email_id_column_to_user_table.py
	new file:   remarkbox/scripts/alembic/versions/6bbc92ccc112_add_changed_column_to_node_table.py
	new file:   remarkbox/scripts/alembic/versions/6db39621f46c_namespace_group_conversations.py
	new file:   remarkbox/scripts/alembic/versions/6fc1d0cf350c_notification_columns_to_rb_user_table.py
	new file:   remarkbox/scripts/alembic/versions/76deff88cfd6_create_graph_depth_column_on_node_model.py
	new file:   remarkbox/scripts/alembic/versions/771ef7c868de_added_created_timestamp_column_to_.py
	new file:   remarkbox/scripts/alembic/versions/7a3a4755b4a8_add_hide_unless_approved_column.py
	new file:   remarkbox/scripts/alembic/versions/87fbd2b4e06e_namespace_theme_embed_column.py
	new file:   remarkbox/scripts/alembic/versions/953d42406dbb_namespace_public_column.py
	new file:   remarkbox/scripts/alembic/versions/98a90d2add21_namespace_model.py
	new file:   remarkbox/scripts/alembic/versions/98ced5f6ff36_stripe_id_for_user.py
	new file:   remarkbox/scripts/alembic/versions/a26302bcfa25_add_created_timestamp_column_to_oauth_.py
	new file:   remarkbox/scripts/alembic/versions/a38c12eeff2d_locked_rb_node.py
	new file:   remarkbox/scripts/alembic/versions/a738d6859f18_new_namespace_columns_2.py
	new file:   remarkbox/scripts/alembic/versions/aa2dfb4decda_hide_powered_by_column.py
	new file:   remarkbox/scripts/alembic/versions/b132ec968f0c_pay_what_you_can_table.py
	new file:   remarkbox/scripts/alembic/versions/ba8e83a3b8fd_namespace_stylesheet_embed_column.py
	new file:   remarkbox/scripts/alembic/versions/c9793937e756_make_all_verified_namespace_request_.py
	new file:   remarkbox/scripts/alembic/versions/c97ddfcbc406_approve_column.py
	new file:   remarkbox/scripts/alembic/versions/cbdf3cd4ccba_avatar_size_in_namespace.py
	new file:   remarkbox/scripts/alembic/versions/d1a0ab4f617f_add_has_uri_to_node.py
	new file:   remarkbox/scripts/alembic/versions/f5f88293428a_add_mathjax_column_to_rb_namespace.py
	new file:   remarkbox/scripts/alembic/versions/f7b42b2024fb_namespace_reverse_node_order.py
	new file:   remarkbox/scripts/alembic/versions/fa8402aa1a00_add_link_protection_column_to_namespace.py
	new file:   remarkbox/scripts/dump_sqlite3_to_csv.sh
	new file:   remarkbox/scripts/init_db.py
	new file:   remarkbox/scripts/invalidate_node_cache.py
	new file:   remarkbox/scripts/json_import.py
	new file:   remarkbox/scripts/load_csv_into_postgres.sh
	new file:   remarkbox/scripts/merge_dupes.py
	new file:   remarkbox/scripts/modify_namespace.py
	new file:   remarkbox/scripts/modify_node.py
	new file:   remarkbox/scripts/modify_uris.py
	new file:   remarkbox/scripts/modify_user.py
	new file:   remarkbox/scripts/recompute_node_depths.py
	new file:   remarkbox/scripts/safe_approve_all_nodes.py
	new file:   remarkbox/scripts/send_node_digest_notifications.py
	new file:   remarkbox/static/css/common.css
	new file:   remarkbox/static/css/embed.css
	new file:   remarkbox/static/css/pygments.css
	new file:   remarkbox/static/favicon.ico
	new file:   remarkbox/static/img/remarkbox-logo.png
	new file:   remarkbox/static/img/remarkbox-minified.png
	new file:   remarkbox/static/js/custom.js
	new file:   remarkbox/static/js/iframe-resizer/ie8.polyfils.map
	new file:   remarkbox/static/js/iframe-resizer/ie8.polyfils.min.js
	new file:   remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.js
	new file:   remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.map
	new file:   remarkbox/static/js/iframe-resizer/iframeResizer.contentWindow.min.js
	new file:   remarkbox/static/js/iframe-resizer/iframeResizer.js
	new file:   remarkbox/static/js/iframe-resizer/iframeResizer.map
	new file:   remarkbox/static/js/iframe-resizer/iframeResizer.min.js
	new file:   remarkbox/static/js/iframe-resizer/index.js
	new file:   remarkbox/static/js/iframe-resizer/readme.rst
	new file:   remarkbox/static/js/jquery-2.1.3.min.js
	new file:   remarkbox/static/theme/readme.rst
	new file:   remarkbox/templates/base.j2
	new file:   remarkbox/templates/billing.j2
	new file:   remarkbox/templates/dynamic-remarkbox.css.j2
	new file:   remarkbox/templates/edit-node.j2
	new file:   remarkbox/templates/embed-iframe.txt.j2
	new file:   remarkbox/templates/home.j2
	new file:   remarkbox/templates/join-or-log-in.j2
	new file:   remarkbox/templates/list-namespace-owners.j2
	new file:   remarkbox/templates/list-namespace-requests.j2
	new file:   remarkbox/templates/list-nodes.j2
	new file:   remarkbox/templates/list-notifications.j2
	new file:   remarkbox/templates/mail_digest_html.j2
	new file:   remarkbox/templates/mail_digest_text.j2
	new file:   remarkbox/templates/mail_immediate_html.j2
	new file:   remarkbox/templates/mail_immediate_text.j2
	new file:   remarkbox/templates/mail_snippet_html.j2
	new file:   remarkbox/templates/mail_snippet_text.j2
	new file:   remarkbox/templates/namespace-settings.j2
	new file:   remarkbox/templates/new.j2
	new file:   remarkbox/templates/rss.xml.j2
	new file:   remarkbox/templates/setup-namespace.j2
	new file:   remarkbox/templates/show-count.j2
	new file:   remarkbox/templates/show-node.j2
	new file:   remarkbox/templates/show-node2.j2
	new file:   remarkbox/templates/sitemap.xml.j2
	new file:   remarkbox/templates/snippets/add-to-slack.j2
	new file:   remarkbox/templates/snippets/create.j2
	new file:   remarkbox/templates/snippets/csrf.j2
	new file:   remarkbox/templates/snippets/email.j2
	new file:   remarkbox/templates/snippets/flash-alerts.j2
	new file:   remarkbox/templates/snippets/forms.j2
	new file:   remarkbox/templates/snippets/google-analytics.j2
	new file:   remarkbox/templates/snippets/javascript-includes.j2
	new file:   remarkbox/templates/snippets/owner-key-comment.j2
	new file:   remarkbox/templates/snippets/phone-menu.j2
	new file:   remarkbox/templates/snippets/powered-by.j2
	new file:   remarkbox/templates/snippets/search.j2
	new file:   remarkbox/templates/snippets/snippets.j2
	new file:   remarkbox/templates/snippets/stripe.j2
	new file:   remarkbox/templates/snippets/stylesheet-includes.j2
	new file:   remarkbox/templates/snippets/submit.j2
	new file:   remarkbox/templates/update-card.j2
	new file:   remarkbox/templates/user-settings.j2
	new file:   remarkbox/templates/user-watching.j2
	new file:   remarkbox/tests/test_models.py
	new file:   remarkbox/tests/test_render.py
	new file:   remarkbox/tests/test_views.py
	new file:   remarkbox/views/__init__.py
	new file:   remarkbox/views/authenticated/__init__.py
	new file:   remarkbox/views/authenticated/authenticated.py
	new file:   remarkbox/views/authenticated/lock.py
	new file:   remarkbox/views/authenticated/notifications.py
	new file:   remarkbox/views/authenticated/oauth.py
	new file:   remarkbox/views/authenticated/stripe.py
	new file:   remarkbox/views/authenticated/topsecret.py
	new file:   remarkbox/views/authenticated/watch.py
	new file:   remarkbox/views/authentication.py
	new file:   remarkbox/views/list_nodes.py
	new file:   remarkbox/views/misc.py
	new file:   remarkbox/views/modify_node.py
	new file:   remarkbox/views/new_thread.py
	new file:   remarkbox/views/reply_node.py
	new file:   requirements-dev.txt
	new file:   requirements-prod.txt
	new file:   requirements-test.txt
	new file:   requirements.py3.txt
	new file:   requirements.txt
	new file:   setup.py
	new file:   test.ini
This commit is contained in:
Russell Ballestrini 2021-12-26 07:44:05 -05:00
commit 91a2a77711
179 changed files with 15738 additions and 0 deletions

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
*.xml
*.json
*.pyc
*.swp
*.egg
*.egg-info
*.sqlite
*.sqlite.*
*.sqlite-journal
__pycache__
.coverage
.cache
.env
.pytest_cache
env/*
build/*
# ignore iframe-resizer repo
iframe-resizer/iframe-resizer/*
# this is a place to put private vars for stripe or whatever.
vars.fish
vars.sh
venv/
index3.html
index4.html
index5.html
remarkbox-theme-meta/
env.vanilla/

2
MANIFEST.in Normal file
View file

@ -0,0 +1,2 @@
include *.txt *.ini *.cfg *.rst
recursive-include remarkbox *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.j2 *.map

34
Makefile Normal file
View file

@ -0,0 +1,34 @@
env:
python3 -m venv env
. env/bin/activate
env/bin/pip install --upgrade pip
env/bin/pip install --upgrade -r requirements.py3.txt
cp -rp env env.vanilla
dev: env
env/bin/pip install --editable .
env/bin/pip install --upgrade -r requirements-dev.txt
env/bin/pip install --upgrade -r requirements-test.txt
prod: env
env/bin/pip install .
env/bin/pip install --upgrade -r requirements-prod.txt
clean:
rm -rf env
rm -rf env.vanilla
test: dev
env/bin/py.test
serve: dev
# In the first shell, run a copy of remarkbox using:
env/bin/pserve development.ini --reload
http: dev
# In the second shell, run a "mock" simple HTTP webserver to serve index.html:
env/bin/python -m http.server 8000
smtp: dev
# In the third shell, run a "mock" SMTP server to catch log in emails:
sudo env/bin/python -m smtpd -n -c DebuggingServer localhost:25

198
README.rst Normal file
View file

@ -0,0 +1,198 @@
Remarkbox
#########
What is RemarkBox?:
A stand alone question and answer site (forum) or an embedded comments or product reviews service.
Works anywhere that supports HTML.
Original Developer:
Russell Ballestrini (https://russell.ballestrini.net)
Project Goals
=============
Note:
These goals are not in priority order.
#. To be a suitable for:
* question and answer sites (StackOverflow)
* embedded comment system for static sites
* forums
* product review sections of e-commerce sites
|
#. To choose popular libraries instead of proper libraries, for example:
* Github over Bitbucket (seriously considering GitLab)
* Git over HG Mercurial
* Jinja2 templates over Mako templates
* Markdown over ReStructuredText
* etc
|
Basically I have been burned too many times trying to pick the proper
library or tool for the job, so this time around, I will make effort
to choose solutions that the majority uses.
#. To be popular
#. To be safe from spammers
#. To be easy to manage and clean up spam if it happens
#. To be passwordless. Registration, verification and authentication happen via one-time-password links sent via email.
#. To scale horizontally
#. To be multitenant
#. To have low friction for new users to join (posters and commenters)
#. To be engaging for users (posters and commenters)
#. To be search engine optimized
#. To have great test coverage
Local Installation
==================
We utilize a ``Makefile`` to capture targets for building a local Remarkbox environment. Please make sure you have ``make`` installed.
#. ``make dev``
#. ``make test``
Functional testing environment
-------------------------------
To setup a "functional testing" environment on your personal workstation, open three terminal shells.
In the first shell, run a copy of remarkbox using:
.. code-block:: bash
make serve
In the second shell, run a "mock" simple HTTP webserver to serve index.html:
.. code-block:: bash
make http
In the third shell, run a "mock" SMTP server to catch log in emails:
.. code-block:: bash
make smtp
Now browse to http://127.0.0.1:8000 and index.html will load and will have an embeded copy of Remarkbox which running on localhost.
If you attempt to log in, your third shell will capture the email and you can copy / paste the verification link to log in!
SQL Migrations
===============
If your deployment is brand new, you don't need to run any migrations.
You should however run this to stamp the database as ready:
.. code-block:: bash
alembic -c development.ini stamp head
Otherwise, it should be safe to run this at anytime to catch your database up:
.. code-block:: bash
alembic -c development.ini upgrade head
To look at the current revision and the history run these:
.. code-block:: bash
alembic -c development.ini history
alembic -c development.ini current
If you ever want to cut a new migration script, you can run this:
.. code-block:: bash
alembic -c development.ini revision -m "Added email_id column to User table."
Then you can edit / modify the generated ``.py`` file with your changes.
You can also autogenerate a new migration script using `--autogenerate`.
Alembic will prepare a migration script by comparing the state of the
database with the state of the model:
.. code-block:: bash
alembic -c development.ini revision --autogenerate -m "autogenerated indicies."
You should review the recommended migration script before `upgrade`.
Cleaning the homepage
========================
Sometimes (all the times) it's nice to clear all the test comments from the
the homepage of our marketing site. Use this query.
.. code-block:: sql
sqlite> UPDATE rb_uri SET data = "https://www.remarkbox.com/?cleaned=2018-09-28" WHERE data = "https://www.remarkbox.com/";
sqlite> SELECT * FROM rb_uri WHERE data LIKE "%https://www.remarkbox.com/?cleaned%";
1e631dd85d104555b41b300961d2f909|82008b2b178f4daab64c35ab5c5f9b56|https://www.remarkbox.com/?cleaned=2017-11-01
6b2a4772679611e8ad95040140774501|6b2a42ae679611e8ad95040140774501|https://www.remarkbox.com/?cleaned=2018-09-28
Looking up paying customers
==============================
.. code-block:: sql
SELECT * FROM rb_pay_what_you_can
INNER JOIN rb_user ON rb_user.id = rb_pay_what_you_can.user_id
WHERE amount > 0 and rb_user.stripe_id is not null;
Python Pyramid Shell
==========================
If you want to use an interactive Python interpreter to interact with the Remarkbox app/models and database:
.. code-block:: bash
pshell development.ini
Here is a full `pshell` script to modify every `Node` who has a `Uri`:
.. code-block:: python
# begin the database transaction.
request.tm.begin()
# get all Uri objects.
uris = m.uri.get_all_uris(request.dbsession)
# interate over all Uri objects.
for uri in uris:
# modify the Uri's related Node.
uri.node.has_uri = True
# add the related Node object to the sqlalchemy session.
request.dbsession.add(uri.node)
# flush / commit all changes stored the the sqlachemy session.
request.dbsession.flush()
# commit/close the database transaction to really make changes.
request.tm.commit()

154
development.ini Normal file
View file

@ -0,0 +1,154 @@
# Config File Reference:
# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
[alembic]
script_location = remarkbox:scripts/alembic
sqlalchemy.url = sqlite:///%(here)s/remarkbox.sqlite
[app:main]
use = egg:remarkbox
#sqlalchemy.url = postgres://localhost/remarkbox
sqlalchemy.url = sqlite:///%(here)s/remarkbox.sqlite
###
# Pyramid configuration.
###
pyramid.reload_templates = true
pyramid.debug_authorization = false
pyramid.debug_notfound = false
pyramid.debug_routematch = false
pyramid.default_locale_name = en
pyramid.includes =
pyramid_debugtoolbar
pyramid_tm
# By default, the toolbar only appears for clients from IP addresses
# '127.0.0.1' and '::1'.
# debugtoolbar.hosts = 127.0.0.1 ::1
###
# session / cookie configuration.
# http://docs.pylonsproject.org/docs/pyramid/en/latest/api/session.html
###
session.hashalg = sha512
session.secret = test-secret
session.timeout = 31104000
session.max_age = 31104000
session.reissue_time = 15552000
session.samesite = None
###
# custom app configuration.
###
app.avatar.size = 35
# In prod I set this to: remarkbox.com (default = "")
#app.root_domain =
app.root_domain = ${REMARKBOX_APP_ROOT_DOMAIN}
# If set we force all links related to `join or log in` to this base url
# and use this url for links in email notifications.
# Also useful when multiple subdomains point at the same Remarkbox cluster.
# In prod I set this to: https://my.remarkbox.com (default = request.host_url)
#app.app_url =
app.app_url = ${REMARKBOX_APP_URL}
# if your marketing / main landing page base url is different then app url.
# In prod I set this to: https://www.remarkbox.com (default = request.app_url)
#app.marketing_url =
# if Node is None, we may safely redirect to this uri. optional.
#app.safe_redirect = https://www.remarkbox.com
app.email.relay = localhost
app.email.sender = no-reply@remarkbox.com
# set this to the path of your private DKIM key.
# reference: https://russell.ballestrini.net/quickstart-to-dkim-signed-email-with-python/
#app.email.dkim_private_key_path =
#app.email.dkim_selector =
# The app name in the email subject (default = request.app_domain)
# In production I set this to "Remarkbox".
app.email.subject_postfix = Remarkbox
# optional theme plugin name.
#app.theme = westworld
app.theme = meta
# optional theme plugin name for embed mode.
#app.theme_embed = meta
# stand-alone mode disables a bunch of views and templates
# related to billing and registering Namespaces.
# choices enabled or disabled. defaults to disabled.
#app.stand_alone_mode = disabled
# stripe: credit card storage and processing.
# This syntax will automatically expand an ENV var of the same name.
app.stripe.secret = ${REMARKBOX_APP_STRIPE_SECRET}
app.stripe.public = ${REMARKBOX_APP_STRIPE_PUBLIC}
# slack: bot notifications.
app.slack.secret = ${REMARKBOX_APP_SLACK_SECRET}
app.slack.public = ${REMARKBOX_APP_SLACK_PUBLIC}
app.slack.oauth_scope = channels:read,chat:write:bot
# feature flag for email notifications.
#app.deliver_email_notifications = true
# robots.txt contents.
#app.robots_dot_txt =
# User-agent: *
# Disallow:
###
# wsgi server configuration
###
[server:main]
use = egg:waitress#main
host = 0.0.0.0
port = 6543
###
# logging configuration
# http://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
###
[loggers]
keys = root, remarkbox, sqlalchemy
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = INFO
handlers = console
[logger_remarkbox]
level = DEBUG
handlers =
qualname = remarkbox
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
# "level = INFO" logs SQL queries.
# "level = DEBUG" logs SQL queries and results.
# "level = WARN" logs neither. (Recommended for production systems.)
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(message)s
[pshell]
m = remarkbox.models

95
index.html Normal file
View file

@ -0,0 +1,95 @@
<!doctype html>
<html>
<head>
<title>Example of Remarkbox in an Iframe.</title>
<style>
.css-grid-rocks {
display: grid;
grid-template-columns: 1fr;
grid-gap: 10px;
}
@media screen and (min-width:568px) {
.css-grid-rocks {
grid-template-columns: 1fr 5fr;
}
}
</style>
</head>
<body>
<div class="css-grid-rocks">
<div>
<p>Menu is menu!</p>
<p>option 1</p>
<p>option 2</p>
<p>remarkbox</p>
</div>
<div>
<h4>Example of Remarkbox in an Iframe</h4>
<p>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</p>
<p>
It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like).
</p>
<p>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</p>
<!-- Remarkbox - Your readers want to communicate with you -->
<div id="remarkbox-div">
<noscript>
<iframe id=remarkbox-iframe src="http://127.0.0.1:6543/embed?nojs=true" style="height:600px;width:100%;border:none!important" tabindex=0></iframe>
</noscript>
</div>
<script src="http://127.0.0.1:6543/static/js/iframe-resizer/iframeResizer.min.js"></script>
<script>
var rb_owner_key = "f7ea7fb0-cd90-11e7-a69d-9c4e369c7158";
var thread_uri = window.location.href;
var thread_title = window.document.title;
var thread_fragment = window.location.hash;
<!-- rb owner was here -->
var rb_src = "http://127.0.0.1:6543/embed" +
"?rb_owner_key=" + rb_owner_key +
"&thread_title=" + escape(thread_title) +
"&thread_uri=" + encodeURIComponent(thread_uri) +
thread_fragment;
function create_remarkbox_iframe() {
var ifrm = document.createElement("iframe");
ifrm.setAttribute("id", "remarkbox-iframe");
ifrm.setAttribute("scrolling", "no");
ifrm.setAttribute("src", rb_src);
ifrm.setAttribute("frameborder", "0");
ifrm.setAttribute("tabindex", "0");
ifrm.setAttribute("title", "Remarkbox");
ifrm.style.width = "100%";
document.getElementById("remarkbox-div").appendChild(ifrm);
}
create_remarkbox_iframe();
iFrameResize(
{
checkOrigin: ["http://127.0.0.1:6543"],
inPageLinks: true,
initCallback: function(e) {e.iFrameResizer.moveToAnchor(thread_fragment)}
},
document.getElementById("remarkbox-iframe")
);
</script>
</div>
</div>
</body>
</html>

90
index2.html Normal file
View file

@ -0,0 +1,90 @@
<!doctype html>
<html>
<head>
<title>Example of Remarkbox in an Iframe.</title>
<style>
.css-grid-rocks {
display: grid;
grid-template-columns: 1fr 5fr;
grid-gap: 10px;
}
@media screen and (max-width:568px) {
.css-grid-rocks {
display: grid;
grid-template-columns: 1fr;
grid-gap: 10px;
}
}
</style>
</head>
<body>
<div class="css-grid-rocks">
<div>
<p>Menu is menu!</p>
<p>option 1</p>
<p>option 2</p>
<p>remarkbox</p>
</div>
<div>
<h4>Example of Remarkbox in an Iframe</h4>
<p>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</p>
<p>
It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using 'Content here, content here', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for 'lorem ipsum' will uncover many web sites still in their infancy. Various versions have evolved over the years, sometimes by accident, sometimes on purpose (injected humour and the like).
</p>
<p>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</p>
<!-- Remarkbox - Your readers want to communicate with you -->
<div id="remarkbox-div">
<noscript>
<iframe id=remarkbox-iframe src="http://127.0.0.1:6543/embed?nojs=true" style="height:600px;width:100%;border:none!important" tabindex=0></iframe>
</noscript>
</div>
<script src="http://127.0.0.1:6543/static/js/iframe-resizer/iframeResizer.min.js"></script>
<script>
var rb_owner_key = "275f613e-cd8c-11e7-8970-9c4e369c7158";
//var rb_owner_key = "f7ea7fb0-cd90-11e7-a69d-9c4e369c7158";
var thread_uri = window.location.href;
var thread_fragment = window.location.hash;
var email = "russell.ballestrini@gmail.com";
function create_remarkbox_iframe() {
var src = "http://127.0.0.1:6543/embed?rb_owner_key=" + rb_owner_key + "&email=" + email + "&thread_uri=" + thread_uri;
var ifrm = document.createElement("iframe");
ifrm.setAttribute("id", "remarkbox-iframe");
ifrm.setAttribute("scrolling", "no");
ifrm.setAttribute("src", src);
ifrm.setAttribute("frameborder", "0");
ifrm.setAttribute("tabindex", "0");
ifrm.setAttribute("title", "Remarkbox");
ifrm.style.width = "100%";
document.getElementById("remarkbox-div").appendChild(ifrm);
}
create_remarkbox_iframe();
iFrameResize(
{
checkOrigin: ["http://127.0.0.1:6543"],
inPageLinks: true,
initCallback: function(e) {e.iFrameResizer.moveToAnchor(thread_fragment)}
},
document.getElementById("remarkbox-iframe")
);
</script>
</div>
</div>
</body>
</html>

260
journal.rst Normal file
View file

@ -0,0 +1,260 @@
Tue 04 Aug 2020 12:48:06 AM EDT
=====================================
- [ ]: TODO regressions are sad, let's write more functional tests.
Tue 11 Aug 2020 09:50:33 PM EDT
=====================================
- [x]: TODO fix the defect
Found some common errors in prod::
sudo zgrep UnicodeEncodeError /var/log/syslog.*.gz | wc -l
223
This was the bad code which didn't support unicode::
def _reduce_whitespace(text):
return " ".join(map(str.strip, map(str, text.splitlines())))
Here is the replacement which supports unicode::
def _reduce_whitespace(text):
"""a way to remove newlines and leading/trailing whitespace per line."""
return " ".join([line.strip() for line in text.splitlines()])
Check back in a few days to make sure the errors are gone, by running::
sudo zgrep UnicodeEncodeError /var/log/syslog.*.gz | wc -l
223
Update: Fixed!::
fox@remarkbox:~$ date
Fri Aug 21 16:28:32 EDT 2020
fox@remarkbox:~$ sudo zgrep UnicodeEncodeError /var/log/syslog.*.gz | wc -l
1
Tue 11 Aug 2020 09:58:07 PM EDT
=====================================
- [ ]: TODO fix the defect
::
sudo zgrep "Incorrect padding" /var/log/syslog.*.gz
214
The fix is maybe removing the premature optimization of "short" uuids.
I made the call, rightfully, to avoid this premature optimization in
the `make_post_sell` code base after seeing the issues it caused in
Remarkbox.
Here is a snippet of the Traceback::
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: return get_object_by_id(dbsession, node_id, Node)
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/remarkbox/models/meta.py", line 116, in get_object_by_id
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: object_uuid = id_to_uuid(object_id)
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/remarkbox/models/meta.py", line 107, in id_to_uuid
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: return uuid.UUID(bytes=short_id_to_bytes(the_id))
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/remarkbox/models/meta.py", line 80, in short_id_to_bytes
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: return (short_id + "==").replace("_", "/").replace("-", "+").decode("base64")
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: File "/usr/lib/python2.7/encodings/base64_codec.py", line 42, in base64_decode
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: output = base64.decodestring(input)
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: File "/usr/lib/python2.7/base64.py", line 328, in decodestring
Aug 10 10:05:35 remarkbox.com demo.remarkbox.com[12893]: return binascii.a2b_base64(s)
Some quick research suggests maybe a simple fix is appending 3 `=` signs as padding, instead of 2.
I'll try that and check back to see if the errors go away.
Wed 12 Aug 2020 08:54:24 AM EDT
====================================
- [ ]: TODO fix the defect
In this error the user is not authenticated and is trying to click a log-in/verify link
derived from on an embeded thread which has unicode in it's URI path::
return-to=https://post-tenebras-lire.net/%D0%A7%D0%BE%D1%80%D0%BD%D0%BE%D0%B1%D0%B8%D0%BB%D1%8C/
When the log-in/verify view attempts to `return HTTPFound(return_to)` back to the
`thread_uri` (the page embedding Remarkbx) we get the following exception::
Aug 10 09:43:27 remarkbox.com demo.remarkbox.com[12893]: [pid: 12909|app: -|req: -/-] 213.55.244.1 (-) {48 vars in 1261 bytes} [Mon Aug 10 09:43:27 2020] GET /join-or-log-in?email=posttenebraslire%40caenevet.net&raw-otp=<removed>&return-to=https://post-tenebras-lire.net/%D0%A7%D0%BE%D1%80%D0%BD%D0%BE%D0%B1%D0%B8%D0%BB%D1%8C/ => generated 0 bytes in 344 msecs (HTTP/1.0 500) 0 headers in 0 bytes (0 switches on core 0)
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: Traceback (most recent call last):
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: return HTTPFound(return_to)
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/pyramid/httpexceptions.py", line 547, in __init__
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: **kw
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/pyramid/httpexceptions.py", line 236, in __init__
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: Response.__init__(self, status=status, **kw)
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/webob/response.py", line 321, in __init__
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: setattr(self, name, value)
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: File "/www/demo.remarkbox.com/env/lib/python2.7/site-packages/webob/descriptors.py", line 148, in fset
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: value = value.encode('latin-1')
Aug 10 09:43:31 remarkbox.com demo.remarkbox.com[12893]: UnicodeEncodeError: 'latin-1' codec can't encode characters in position 31-39: ordinal not in range(256)
The defect is seemingly related to
the `%` quoted `/%D0%A7%D0%BE%D1%80%D0%BD%D0%BE%D0%B1%D0%B8%D0%BB%D1%8C/`
in the `return-to` url which decodes to `/Чорнобиль/`.
Looking at the webob code this `value = value.encode('latin-1')` is a Python2
compatibility pathway which does not get called for Python3
Reference:
https://github.com/Pylons/webob/blob/ac2d238f50bed7fbbf115818ab7f208b71d7bdca/src/webob/descriptors.py#L147-L155
The obvious answer to this is to upgrade Remarkbox (and MakePostSell) to use
a Python3 environment. Likely this will bring it's own trouble so I'll defer
on fixing this until I have time and energy to tackle that project.
Fri 21 Aug 2020 02:18:55 PM EDT
=======================================
- [X]: TODO tune uwsgi processes/threads
- [ ]: TODO tune uwsgi processes/threads for maximum performance measured by requests-per-second (RPS) on a semi-random traffic shape.
.. code-block::
processes = 2
threads = 8
enable-threads = True
Fri 21 Aug 2020 02:56:20 PM EDT
====================================
[ ]: TODO adjust terms of service and/or privacy policy
I need to adjust my terms of service to make Remarkbox more useful.
My target market are developers and engineers who like to run their own systems.
My target market cares deeply about page speed and computer performance.
My target market likes numbers and statistics, they want to see growth, they want analytics and metrics, it might be vanity but it's important.
When thinking about their site, I wanted them to think about checking Remarkbx for their stats and comments.
Could Remarkbox become the next Google Analytics? No, but it does need to compete with YouTube studio for example.
Remarkbox should be in a unique situation where we may have a competitive advantage over other metric systems.
Analytics could power the desire of site owners to continue interacting with their user base.
We could use the analytics data to power a goals system (for example measuring blog post throughput, etc)
This data is really important. This data would be made available to site owners as a `.json` file.
Fri 21 Aug 2020 04:24:27 PM EDT
========================================
- [ ]: TODO start tracking `per request analytic data` for auditing the Remarkbox Service, for capacity planning, security, performance, as well as for finding or fueling feature, product, or service growth opportunities.
Additionally this data is also just as important to you, our free and paying customers of the Remarkbox Service.
In order to better serve all of us, and to facilitate a search for a sustainable path forward for Remarkbox to grow into,
I'm proposing alignment of what a `per request analytic datum` looks like::
id, namespace_id, uri_id, user_id, user_authed, timestamp
With fresh eyes I'm not sure how much energy I want to devote to this idea considering things like Google Analytics already exists.
Maybe just collecting hits per hour and rolled up per day after 2 weeks, whether or not the user was authenticated (so two buckets)
and then a separate table for particular actions like comments per day or something.
Speaking with smarter people than me, they confirmed ElasticSearch would be a good choice as a data store so if I want to do it "right"
that's what I should use.
But if I want to do it "fast / cheap / good enough" storing it in the relational database might be fine until it becomes painful.
If I introduced elasticsearch, I could also have an index for ingesting the embeded pages content (strip out HTML, just plain-text)
and offer full-text search capabilities for static sites. This might cause the dataset to balloon up a lot more than
just `per request analytic data` so this would be a follow up project once we have an elasticsearch cluster.
That said, as a first pass, we could just create a really tiny single node elasticsearch cluster for analytics type data with
many small documents. Additionally these statistics and analytics do not need to have a great uptime SLA. No redundancy and hourly
snapshots such that if the cluster ever went red and needed to be recovered from snapshot, we would only lose an hour of data.
anyways, this isn't a high priority project, it's more of a fun and explority, nice to have rather than a need to have.
Thought I'd get it out of my head and into a journal entry.
https://www.elastic.co/guide/en/elasticsearch/reference/current/glossary.html
Sun Jan 24 06:13:30 PM EST 2021
==================================
After much internal debate, I have decided to YOLO and make Remarkbox pay-what-you-can!
This means by default Namespaces will be created in the production subscription_type such that the use does not need to pay in order to unlock production settings.
TODO:
[x] modify codebase business logic to always create "production" Namespaces by default.
[x] modify codebase business logic to collect pay-what-you-can amount and frequency into a model/table
[x] modify sales funnel, setup sales copy
[x] modify pricing page to pay-what-you-can
[x] modify meta theme to let the customer know how to pay (paypal.me or /billing)
[x] modify landing page
[x] migrate all existing development users to production
[ ] create a mailchimp email list dump of all users in the database and send them an email
letting them know their Namespace was automatically upgraded and unlocked.
[ ] write a script and schedule a cronjob to trigger on the 1st of each month to charge credit cards based on user preferences.
[ ] modify Namespace settings page to remind people to check their "pay-what-you-can" settings. Only display this dialog to people who have never paid or people who have added a new namespace to their account but have not made an update to their pay-what-you-can model.
code this so it could have various upsells?
Sat Jan 30 03:32:55 PM EST 2021
=====================================
We should build a Remarkbox to matrix bridge. I bet it is a lot like working with slack API by now or if not, maybe I could build that client as open source?
Sat Apr 3 10:40:05 PM EDT 2021
=====================================
this is a useful SQL query to SELECT users who want to pay and also gave a credit card.
::
SELECT * FROM rb_pay_what_you_can
INNER JOIN rb_user ON rb_user.id = rb_pay_what_you_can.user_id
WHERE amount > 0 and rb_user.stripe_id is not null;
Tue Nov 16 08:40:00 PM EST 2021
====================================
we need to suppliment the magic link with a 6 alpha-numeric one-time-password
Sun Nov 28 01:51:30 PM EST 2021
=====================================
we need to allow Namespace owner/moderator to edit the external uri of a thread.
Fri Dec 24 07:44:13 AM EST 2021
====================================
[] It's time to open the sause and place it into the public domain!
I was nervous but now I AM ready.
I love you all!

101
readme-postgres-notes.rst Normal file
View file

@ -0,0 +1,101 @@
Setting up a development environment on Ubuntu
##############################################
#. we need ``git`` and ``PostgreSQL`` installed. If not, do:
.. code-block:: bash
sudo apt-get install git docker postgres-client libpq-dev (libpqxx-devel on fedora)
sudo docker pull postgres:9.6.9-alpine
create PostgreSQL docker container:
.. code-block:: bash
docker run --name development-postgres -d -p 5432:5432 postgres:9.6.9-alpine
make yourself a Postgres Superuser (in my case `russellballestrini`):
.. code-block:: bash
psql --host=localhost --port 5432 --username postgres --command \
"CREATE USER russellballestrini with SUPERUSER;"
make the ``remarkbox`` database.
.. code-block:: bash
psql --host=localhost --port 5432 --username postgres --command \
"CREATE DATABASE remarkbox;"
make the ``remarkbox_test`` database.
.. code-block:: bash
psql --host=localhost --port 5432 --username postgres --command \
"CREATE DATABASE remarkbox_test;"
#. checkout the Remarkbox codebase:
.. code-block:: bash
cd ~
git clone https://russellballestrini@bitbucket.org/russellballestrini/remarkbox.git
cd remarkbox
#. install ``python-pip``:
.. code-block:: bash
sudo apt-get install python-pip
#. use ``pip`` to upgrade ``pip`` and ``setuptools``:
.. code-block:: bash
sudo pip install --upgrade pip setuptools
#. use pip to install ``virtualenv``:
.. code-block:: bash
sudo pip install virtualenv
#. create a ``virtualenv``:
.. code-block:: bash
virtualenv env
#. source the new ``virtualenv``:
.. code-block:: bash
. env/bin/activate
#. install Remarkbox, using setuptool's in development mode:
.. code-block:: bash
python setup.py develop
#. install dev and test dependencies:
.. code-block:: bash
pip install -r requirements-dev.txt
pip install -r requirements-test.txt
#. initialize the database schema:
.. code-block:: bash
remarkbox_init_db development.ini
#. spin up the application server using ``waitress``:
.. code-block:: bash
pserve development.ini --reload
Note: if you get an error about TemplateNotFound, you likely need to install the Remarkbox Meta theme. See readme-themes.rst for details.

14
readme-themes.rst Normal file
View file

@ -0,0 +1,14 @@
Install meta theme
-----------------------
Remarkbox supports custom themes which makes it possible to provide "whitelabel" deployments which are rebranded.
The default Remarkbox theme is called `meta` and may be installed in development like this:
.. code-block:: bash
git clone https://github.com/russellballestrini/remarkbox-theme-meta.git
cd remarkbox-theme-meta
python setup.py develop
If you don't want to install this theme, that is ok, comment out `app.theme = meta` in the `development.ini`

490
remarkbox/__init__.py Normal file
View file

@ -0,0 +1,490 @@
from pyramid.config import Configurator
from sqlalchemy import engine_from_config
from .models import (
get_user_by_id,
get_or_create_user_by_email,
get_node_by_id,
get_or_create_node_by_uri,
get_nodes_who_share_root,
get_node_id_map,
get_graph_from_nodes,
get_or_create_namespace,
get_namespace_request_by_id,
get_conversation_graph_from_nodes,
flatten_graph,
)
# reject spammers.
from pyramid.httpexceptions import HTTPUnauthorized
# cookie only session, not encrypted but signed to prevent tampering!
from pyramid.session import SignedCookieSessionFactory
# email validation.
import re
# needed to load themes.
from pkg_resources import iter_entry_points
# needed to support expanding ENV vars from ini.
from os.path import expandvars
from miscutils import get_children_settings
import logging
log = logging.getLogger(__name__)
JINJA2_EXTENSION = ".j2"
def load_entry_points(group_name):
"""Return a dictionary of entry_points related to given group_name"""
entry_points = {}
for entry_point in iter_entry_points(group=group_name, name=None):
entry_points[entry_point.name] = entry_point.load()
return entry_points
def load_jinja2_themes(config):
"""Automatically load any entry_point registered Remarkbox theme."""
themes = load_entry_points("remarkbox.themes")
for theme_name, theme_module in themes.items():
theme_module_name = theme_module.__name__
# teach Jinja2 about the template dir in the theme package.
config.add_jinja2_search_path(
"{}:templates/".format(theme_module_name), name=JINJA2_EXTENSION
)
# teach Pyramid about the static assets in the theme package.
config.add_static_view(
"static/theme/{}".format(theme_name),
"{}:static/theme/{}".format(theme_module_name, theme_name),
cache_max_age=3600,
)
return config
def is_ipv4(string):
try:
parts = list(map(int, string.split(".")))
except:
return False
if len(parts) != 4:
return False
for part in parts:
if part < 0 or part > 256:
return False
return True
def maybe_root_domain(string):
"""
Maybe get valid "root" domain from given string, for example -
valid:
* hi.www.example.com -> example.com
* www.example.com -> example.com
* example.com -> example.com
invalid:
* comments.cryptocoin.com.au -> com.au
"""
return ".".join(string.split(".")[-2:])
def main(global_config, **settings):
""" This function returns a Pyramid WSGI application."""
app_settings = get_children_settings(settings, "app")
session_settings = get_children_settings(settings, "session")
def wild_signed_cookie_session_factory(request):
"""
Hack SignedCookieSessionFactory to have `wild_domain`.
Pyramid devs do not want to support this.
In addition the domain is determined per request!
This means a single deployment of this app can support managing a
`wild_domain` cookie for any domain (likely using CNAMES).
"""
root_domain = maybe_root_domain(request.domain)
if is_ipv4(request.domain) or root_domain.startswith("com"):
session_settings["domain"] = request.domain
else:
session_settings["domain"] = root_domain
factory = SignedCookieSessionFactory(**session_settings)
return factory(request)
# setup session factory to use unencrypted but signed cookies.
# session_factory = SignedCookieSessionFactory(**session_settings)
session_factory = wild_signed_cookie_session_factory
# build app config object from ini.
config = Configurator(settings=settings, session_factory=session_factory)
# setup and require automatic CSRF checking.
config.set_default_csrf_options(require_csrf=True)
# Create database engine from connection details in ini.
# make request.dbsession available for use in Pyramid.
config.include(".models")
# setup jinja2 template support.
config.include("pyramid_jinja2")
config.add_jinja2_search_path("remarkbox:templates/", name=JINJA2_EXTENSION)
config.add_jinja2_renderer(JINJA2_EXTENSION)
config = load_jinja2_themes(config)
# compile the email validator regex outside of the functions.
_email_regex = re.compile("^[^@]+@[^@]+\.[^.@]+$")
'''
def add_redis(request):
"""Return Redis Connection"""
redis_host = request.app.get("redis.host", "localhost")
redis_port = request.app.get("redis.port", 6379)
redis_db = request.app.get("redis.db", 0)
return redis.Redis(host=redis_host, port=redis_port, db=redis_db)
'''
def add_email(request):
"""Return Email or None. Email must pass regex."""
email = request.params.get("email", "")
if _email_regex.match(email) is not None:
return email
def add_user(request):
"""Return User object or None. User.authenticated may be True or False."""
user = None
authenticated_user_id = request.session.get("authenticated_user_id", None)
if authenticated_user_id:
# attach the user object from DB to the request.
user = get_user_by_id(request.dbsession, authenticated_user_id)
if user is not None:
user.authenticated = True
elif request.email:
user = get_or_create_user_by_email(request.dbsession, request.email)
return user
def add_csrf_token(request):
if request.user and request.user.authenticated:
return request.session.get_csrf_token()
def add_node(request):
"""Return Node object or None from matchdict or params."""
thread_uri = request.params.get("thread_uri")
thread_title = request.params.get("thread_title")
node_id = request.matchdict.get("node_id")
nojs = request.params.get("nojs", False)
node = None
# TODO: thread_uri must take priority over node_id because our forms
# are dumb. If we fix our forms to only pass node_id/node_path when not
# None, the priority / order will not matter.
if thread_uri:
node = get_or_create_node_by_uri(request.dbsession, thread_uri, thread_title)
elif node_id:
node = get_node_by_id(request.dbsession, node_id)
elif nojs and request.referer:
# if javascript is disabled, use the referer.
node = get_or_create_node_by_uri(request.dbsession, request.referer)
return node
def add_root_node(request):
return request.node.root
def add_nodes(request):
if request.node:
return get_nodes_who_share_root(
request.dbsession,
request.node.root,
request.node_order,
)
def add_node_id_map(request):
return get_node_id_map(request.nodes)
def add_node_graph(request):
return get_graph_from_nodes(request.nodes)
def add_node_flat_graph(request):
return flatten_graph(request.node.root.id, request.node_graph)
def add_conversation_graph(request):
return get_conversation_graph_from_nodes(
request.nodes,
graph=request.node_graph,
node_id_map=request.node_id_map,
order=request.node_order,
)
def add_namespace(request):
namespace_name = request.matchdict.get(
"namespace", request.params.get("namespace", None)
)
if namespace_name:
return get_or_create_namespace(request.dbsession, namespace_name)
elif request.node:
return request.node.root.namespace
return get_or_create_namespace(request.dbsession, request.domain)
def add_mode(request):
"""return mode of 'embed' or 'basic'"""
return "embed" if "embed" in request.matched_route.name else "basic"
def add_link_prefix(request):
if request.mode == "embed":
return "/embed/ns/{}".format(request.namespace.name)
return ""
def add_spam(request):
"""Test if request looks spammy HTTP Error or False."""
if request.params.get("email2", "") != "":
# spam filter, hidden email2 field tricks bots.
return HTTPUnauthorized("you smell like a spammer")
return False
def add_owner_key(request):
return request.params.get("rb_owner_key", None)
def add_namespace_request(request):
"""Add namespace_request if pending, else None"""
# we reduce database lookups by only querying when owner_key is
# present and request.namespace has a pending owner_request.
if request.owner_key and request.namespace.owner_request_pending:
namespace_request = get_namespace_request_by_id(
request.dbsession, request.owner_key
)
# log.info("owner_key={}, pending={}".format(request.owner_key,request.namespace.owner_request_pending))
log.info("{} {}".format(namespace_request.namespace,request.namespace))
if namespace_request and namespace_request.namespace == request.namespace:
return namespace_request
def add_app(request):
"""Attach app settings dictionary."""
return app_settings
def add_app_url(request):
"""
Use the app_url from config if request.domain ends with the
configured root_domain. Otherwise just use the request's host_url
"""
config_app_url = request.app.get("app_url", request.host_url)
root_domain = request.app.get("root_domain")
if root_domain and request.domain.endswith(root_domain):
return config_app_url
elif request.domain == "localhost":
return config_app_url
return request.host_url
def add_app_domain(request):
return request.app_url.split("://")[-1].split(":")[0]
def add_marketing_url(request):
return request.app.get("marketing_url", request.app_url)
def add_marketing_domain(request):
return request.marketing_url.split("://")[-1].split(":")[0]
def add_faq_home(request):
"""Return True if this request's domain equals namespace name."""
return request.domain == request.namespace.name
def add_saas_home(request):
"""Only one domain should have this method return True per deployment."""
root_domain = request.app.get("root_domain")
return (
root_domain and
request.domain.endswith(root_domain) and
request.app_domain == request.namespace.name
)
def add_stripe(request):
"""Attach a stripe object with creds to request."""
import stripe
stripe.api_key = request.app.get("stripe.secret")
return stripe
def add_stripe_customer(request):
if request.user:
if not request.user.stripe_id:
# create a new stripe customer.
customer = request.stripe.Customer.create(email=request.user.email)
request.user.stripe_id = customer.id
request.dbsession.add(request.user)
request.dbsession.flush()
return request.stripe.Customer.retrieve(request.user.stripe_id)
return None
def add_stripe_saved_cards(request):
if request.user and request.user.stripe_id:
return request.stripe_customer.sources
return []
def add_stripe_active_card(request):
if request.user and request.user.stripe_id:
if request.stripe_customer.default_source:
return request.stripe_customer.sources.retrieve(
request.stripe_customer.default_source
)
return None
def add_avatar_size(request):
"""Attach avatar size or default."""
if request.namespace:
if request.namespace.avatar_size:
return request.namespace.avatar_size
return request.app.get("avatar.size", 30)
def add_stand_alone_mode(request):
if request.app.get("stand_alone_mode", "disabled") == "enabled":
return True
return False
def add_theme(request):
"""Attach theme name or None."""
if request.mode == "embed":
if request.namespace and request.namespace.theme_embed:
return request.namespace.theme_embed
return request.app.get("theme_embed", None)
if request.namespace and request.namespace.theme:
return request.namespace.theme
return request.app.get("theme", None)
def add_stylesheet(request):
"""Attach stylesheet or empty string."""
if request.mode == "embed":
return request.stylesheet_embed
return request.stylesheet_basic
def add_stylesheet_basic(request):
"""Attach stylesheet or empty string."""
if request.namespace and request.namespace.stylesheet:
return request.namespace.stylesheet
return ""
def add_stylesheet_embed(request):
"""Attach stylesheet_embed or empty string."""
if request.namespace and request.namespace.stylesheet_embed:
return request.namespace.stylesheet_embed
return ""
def add_stylesheet_uri(request):
"""Attach stylesheet uri or empty string."""
if request.mode == "embed":
return request.stylesheet_embed_uri
return request.stylesheet_basic_uri
def add_stylesheet_basic_uri(request):
"""Attach stylesheet uri or empty string."""
if request.namespace and request.namespace.stylesheet_uri:
return request.namespace.stylesheet_uri
return ""
def add_stylesheet_embed_uri(request):
"""Attach stylesheet_embed uri or empty string."""
if request.namespace and request.namespace.stylesheet_embed_uri:
return request.namespace.stylesheet_embed_uri
return ""
def add_base_template(request):
"""Attach base_template filename."""
if request.theme:
return "{}-base.j2".format(request.theme)
return "base.j2"
def add_base_funnel_template(request):
"""Attach base_funnel template filename."""
if request.theme:
return "{}-base-funnel.j2".format(request.theme)
return "base.j2"
def add_page_number(request):
"""Attach page_number starting at 0"""
page_number = int(request.params.get("page", 1))
return page_number if page_number >= 1 else 1
def add_page_size(request):
# if we want to support custom page sizes, set it here.
return 100
def add_page_offset(request):
return (request.page_number - 1) * request.page_size
def add_node_order(request):
return request.params.get("order", request.namespace.node_order)
def add_mathjax(request):
return "true" if request.namespace.mathjax else "false"
# register functions to app config as request methods.
# each request instance will run these functions and attach results.
# cache result with `reify=True` to prevent multiple db lookups.
#config.add_request_method(add_redis, "redis", reify=True)
config.add_request_method(add_user, "user", reify=True)
config.add_request_method(add_csrf_token, "csrf_token", reify=True)
config.add_request_method(add_email, "email", reify=True)
config.add_request_method(add_node, "node", reify=True)
config.add_request_method(add_root_node, "root_node", reify=True)
config.add_request_method(add_nodes, "nodes", reify=True)
config.add_request_method(add_node_id_map, "node_id_map", reify=True)
config.add_request_method(add_node_graph, "node_graph", reify=True)
config.add_request_method(add_node_flat_graph, "node_flat_graph", reify=True)
config.add_request_method(add_conversation_graph, "conversation_graph", reify=True)
config.add_request_method(add_namespace, "namespace", reify=True)
config.add_request_method(add_mode, "mode", reify=True)
config.add_request_method(add_link_prefix, "link_prefix", reify=True)
config.add_request_method(add_spam, "spam", reify=True)
config.add_request_method(add_owner_key, "owner_key", reify=True)
config.add_request_method(add_namespace_request, "namespace_request", reify=True)
config.add_request_method(add_app, "app", reify=True)
config.add_request_method(add_app_url, "app_url", reify=True)
config.add_request_method(add_app_domain, "app_domain", reify=True)
config.add_request_method(add_marketing_url, "marketing_url", reify=True)
config.add_request_method(add_marketing_domain, "marketing_domain", reify=True)
config.add_request_method(add_faq_home, "faq_home", reify=True)
config.add_request_method(add_saas_home, "saas_home", reify=True)
config.add_request_method(add_stripe, "stripe", reify=True)
config.add_request_method(add_stripe_customer, "stripe_customer", reify=True)
config.add_request_method(add_stripe_saved_cards, "stripe_saved_cards", reify=True)
config.add_request_method(add_stripe_active_card, "stripe_active_card", reify=True)
config.add_request_method(add_stand_alone_mode, "stand_alone_mode", reify=True)
config.add_request_method(add_avatar_size, "avatar_size", reify=True)
config.add_request_method(add_theme, "theme", reify=True)
config.add_request_method(add_stylesheet_basic, "stylesheet_basic", reify=True)
config.add_request_method(add_stylesheet_embed, "stylesheet_embed", reify=True)
config.add_request_method(add_stylesheet, "stylesheet", reify=True)
config.add_request_method(
add_stylesheet_basic_uri, "stylesheet_basic_uri", reify=True
)
config.add_request_method(
add_stylesheet_embed_uri, "stylesheet_embed_uri", reify=True
)
config.add_request_method(add_stylesheet_uri, "stylesheet_uri", reify=True)
config.add_request_method(add_base_template, "base_template", reify=True)
config.add_request_method(add_base_funnel_template, "base_funnel_template", reify=True)
config.add_request_method(add_page_number, "page_number", reify=True)
config.add_request_method(add_page_size, "page_size", reify=True)
config.add_request_method(add_page_offset, "page_offset", reify=True)
config.add_request_method(add_node_order, "node_order", reify=True)
config.add_request_method(add_mathjax, "mathjax", reify=True)
# all of the web application routes.
config.include(".routes")
# Scan for views.
config.scan()
return config.make_wsgi_app()

36
remarkbox/lib/__init__.py Normal file
View file

@ -0,0 +1,36 @@
from hashlib import md5
from ago import human
from datetime import datetime
def timestamp_to_datetime(timestamp):
"""Accepts a timestamp and returns a date string"""
return datetime.fromtimestamp(timestamp / 1000.0)
def timestamp_to_date_string(timestamp):
return timestamp_to_datetime(timestamp).strftime("%b %d, %Y %I:%M %P")
def timestamp_to_ago_string(timestamp):
"""Accepts a timestamp and returns a human readable string"""
return human(timestamp_to_datetime(timestamp), 2, abbreviate=True)
def gravatar_client(email, size=48, rating="g", default="retro", force_default=False):
"""Return a gravatar API URI."""
query_params = ["s={}".format(size), "r={}".format(rating), "d={}".format(default)]
if force_default:
query_params.append("f=y")
uri = "https://secure.gravatar.com/avatar/{0}?{1}"
try:
# Python 2.
email_md5 = md5(email).hexdigest()
except TypeError:
# Python 3.
email_md5 = md5(email.encode("utf-8")).hexdigest()
return uri.format(email_md5, "&".join(query_params))

88
remarkbox/lib/mail.py Normal file
View file

@ -0,0 +1,88 @@
from miscutils.mail import send_pyramid_email
# quote email address in OTP so that a plus address
# is not decoded as a space during authentication.
try:
# Python 2.
from urllib import quote_plus
except ImportError:
# Python 3.
from urllib.parse import quote_plus
from remarkbox.lib.mail_messages import (
WELCOME_1_TEXT,
WELCOME_1_HTML,
WELCOME_2_TEXT,
WELCOME_2_HTML,
OPERATOR_HTML,
)
from jinja2 import Environment, PackageLoader, select_autoescape
jinja2_env = Environment(
loader=PackageLoader("remarkbox", "templates"),
autoescape=select_autoescape(["html", "xml"]),
)
def send_otp_email(request, to_email, raw_otp, return_to):
"""
Send email with OTP (one time password) link.
request
the request (of the successful log in attempt)
to_email
the email address to send the OTP link
raw_otp
the raw (unencrypted) one time password
return_to
the URI to return the user on successful authentication
"""
query_params = [
"email={}".format(quote_plus(to_email)),
"raw-otp={}".format(raw_otp),
]
if return_to:
query_params.append("return-to={}".format(return_to))
link = "{0}/join-or-log-in?{1}".format(request.host_url, "&".join(query_params))
subject = "Magic sign-in link for comments"
if not request.user.verified:
message_text = WELCOME_1_TEXT.format(link)
message_html = WELCOME_1_HTML.format(subject, link)
else:
message_text = WELCOME_2_TEXT.format(link)
message_html = WELCOME_2_HTML.format(subject, link)
send_pyramid_email(request, to_email, subject, message_text, message_html)
def send_operator_email(request, msg):
send_pyramid_email(
request,
"russell.ballestrini@gmail.com",
msg,
msg,
OPERATOR_HTML.format(msg),
)
def send_template_email(
request, to_email, subject, text_template_name, html_template_name, context
):
text_template = jinja2_env.get_template(text_template_name)
html_template = jinja2_env.get_template(html_template_name)
send_pyramid_email(
request,
to_email,
subject,
text_template.render(**context),
html_template.render(**context),
)

View file

@ -0,0 +1,139 @@
WELCOME_1_TEXT = """
Hello!
Thanks for joining the discussion.
To get reply notifications, you should paste this link into your browser:
\n{0}\n
This will verify your email and log you in.
What's next?
* A random username was generated just for you!
Once signed in, you have the power to change your username as well as edit or delete past comments.
You may opt-in to get notified when people reply.
Talk to you soon!
"""
WELCOME_1_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>{0}</title>
</head>
<body>
<h2>Hello!</h2>
<p>
Thanks for joining the discussion.
</p>
<p>
To get reply notifcations, you should click this link:
</p>
<p>
<a href="{1}" style="font-weight: bold;" target="_blank">Click here to verify and log in!</a>
</p>
<p style="font-size: .8em;">
<span style="color: #aaaaaa;">
You may paste this link into your browser:
</span>
<br>
<br>
<a href="{1}" style="color: #439fe0; font-weight: normal; text-decoration: none; word-break: break-word;" target="_blank">
{1}
</a>
</p>
<p>
This will verify your email and log you in.
</p>
<h3>What's next?</h3>
<p>
A random username was generated just for you!
</p>
<p>
Once signed in, you have the power to change your username as well as edit or delete past comments.
You may opt-in to get notified when people reply.
</p>
<p>
Talk to you soon!
</p>
</body>
</html>
"""
WELCOME_2_TEXT = """
Hello again!
To log in please paste this link into your browser:
\n{0}\n
Don't forget to check out your notification settings.
Talk to you soon!
"""
WELCOME_2_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>{0}</title>
</head>
<body>
<h2>Hello again!</h2>
<p>
To log in please click the link below.
</p>
<p>
<a href="{1}" style="font-weight: bold;" target="_blank">Click here to log in!</a>
</p>
<p style="font-size: .8em;">
<span style="color: #aaaaaa;">
You may paste this link into your browser:
</span>
<br>
<br>
<a href="{1}" style="color: #439fe0; font-weight: normal; text-decoration: none; word-break: break-word;" target="_blank">
{1}
</a>
</p>
<h3>What's next?</h3>
<p>
Don't forget to check out your notification settings.
</p>
<p>
Talk to you soon!
</p>
</body>
</html>
"""
OPERATOR_HTML = """<!DOCTYPE html>
<html>
<head>
<title>Operator Notification</title>
</head>
<body>
<h2>Operator Notification!</h2>
<p>{}</p>
</body>
</html>
"""

260
remarkbox/lib/notify.py Normal file
View file

@ -0,0 +1,260 @@
from __future__ import unicode_literals
from collections import defaultdict
from slacker import Slacker
from remarkbox.lib.mail import send_template_email
from remarkbox.models import NodeEventNotification
import logging
log = logging.getLogger(__name__)
SLACK_MESSAGE = """:mailbox_with_mail: *{}* added a {} on *{}*:
>>> {}
:link: {}/r/{}
"""
def immediately_notify_namespace_moderators_via_slack(request, node_event):
node = node_event.node
namespace = node.root.namespace
node_event_to_slack_event = {"created": "new thread", "commented": "new comment"}
if namespace.slack_oauth_records:
for oauth in namespace.slack_oauth_records:
slack = Slacker(oauth.token)
slack.chat.post_message(
"#remarks",
SLACK_MESSAGE.format(
node.user.name,
node_event_to_slack_event[node_event.action],
namespace.name,
node.data,
request.host_url,
node.id,
),
)
def filter_watchers(watchers, exclude_users=None, include_users=None):
f_watchers = []
tally = defaultdict(list)
for watcher in watchers:
if not watcher.user.verified:
# skip unverified users to avoid sending unsolicited emails.
# example: somebody writes a comment with another user's email.
continue
if exclude_users is not None and watcher.user in exclude_users:
# skip all excluded users.
# example: don't notify the user who generated the event.
continue
if include_users is not None and watcher.user not in include_users:
# skip all users not in the include_users list.
# example: we only want to notify moderators.
continue
if watcher.user in tally[watcher.notification_method]:
# this user already has a watcher of this method, for this event.
# example: user was replied to and also follows the thread.
continue
# add the user to tally notify once per event, per method.
tally[watcher.notification_method].append(watcher.user)
# finally add watcher to the filtered watcher list.
f_watchers.append(watcher)
return f_watchers
def get_all_watchers(request, node_event):
"""Given a request and node_event, return all watcher objects."""
# Note: we only notify a user once per method per event.
# aggregate a list of watcher objects.
watchers = []
# get node and namespace from event.
node = node_event.node
namespace = node.root.namespace
# Note: the order that we collect watchers, acts as the order of priority
# when we encounter duplicate notification method: reply, node, namespace.
if node.parent and node.parent.verified and node.parent != node.root:
# extend the watchers list to let the owner of the parent node
# know there was a new child node added to the conversation.
watchers.extend(node.parent.user.reply_watchers)
# extend the watchers list with any watchers of the request's root (thread).
watchers.extend(node.root.watchers)
# extend the watchers list with any watchers of the request's Namespace.
watchers.extend(namespace.watchers)
# the user who causes an event does not need a notification.
exclude_users = [node_event.user]
include_users = None
if not node.approved:
# this may happen if moderation is enabled for a Namespace.
# if the event node is not approved, only notify moderators.
include_users = namespace.moderators
# filter dupes and excluded users.
return filter_watchers(watchers, exclude_users, include_users)
def schedule_notifications(request, node_event):
# Slack notifications currently only supports created and commented.
if node_event.action in {"created", "commented"}:
immediately_notify_namespace_moderators_via_slack(request, node_event)
# fan out and create a notification object for each watcher.
notifications = []
for watcher in get_all_watchers(request, node_event):
notification = watcher.new_notification(node_event)
request.dbsession.add(notification)
notifications.append(notification)
# really create all notifcation objects in database, in one transaction.
request.dbsession.flush()
# filter out all the notifications which should be sent immediately.
immediate_notifications = [n for n in notifications if n.frequency == "immediately"]
# send them.
send_immediate_notifications(request, immediate_notifications)
def get_email_notifications(dbsession, frequency):
"""
Returns a notification dictionary where each key holds a list of
notifications objects for a particular user.
"""
notifications = (
dbsession.query(NodeEventNotification)
.filter(NodeEventNotification.sent == False)
.filter(NodeEventNotification.watcher.has(notification_frequency=frequency))
.filter(NodeEventNotification.watcher.has(notification_method="email"))
.order_by(NodeEventNotification.created_timestamp)
.all()
)
# a notification dictionary where the key is the user_id
# and value is a list of notification objects.
notification_dict = defaultdict(list)
for notification in notifications:
notification_dict[notification.user_id].append(notification)
return notification_dict
def deliver_scheduled_notifications(request=None):
from datetime import datetime
from pyramid.scripting import prepare
# this allows us to pass a None request.
with prepare(request) as env, env["request"].tm as tm:
request = env["request"]
notification_dict = get_email_notifications(request.dbsession, "daily")
send_digest_notifications(request, notification_dict, "daily")
# Send weekly on Monday.
if datetime.today().weekday() == 0:
notification_dict = get_email_notifications(request.dbsession, "weekly")
send_digest_notifications(request, notification_dict, "weekly")
def send_immediate_notifications(request, notifications):
deliver_email_notifications = request.app.get("deliver_email_notifications", True)
if len(notifications) == 0:
return None
node_event = notifications[0].node_event
root = node_event.node.root
namespace = root.namespace
subject = "[{}] new activity".format(namespace.name)
for notification in notifications:
if deliver_email_notifications and notification.method == "email":
send_template_email(
request,
notification.user.email,
subject,
"mail_immediate_text.j2",
"mail_immediate_html.j2",
{
"request": request,
"notification": notification,
"root": root,
"subject": subject,
},
)
log.info(
"notification frequency=immediately email={}, count={}".format(
notification.user.email,
notification.id,
)
)
notification.sent = True
request.dbsession.add(notification)
request.dbsession.flush()
def group_notifications_by_root(notifications):
"""
group notifications by root node, where the key is
the root node and the value is a list of notification objects.
"""
groups = defaultdict(list)
for notification in notifications:
groups[notification.node_event.node.root].append(notification)
return groups
def send_digest_notifications(request, notification_dict, frequency="daily"):
day_or_week = "day" if frequency == "daily" else "week"
deliver_email_notifications = request.app.get("deliver_email_notifications", True)
if not deliver_email_notifications:
return None
for user_id, notifications in notification_dict.items():
user = notifications[0].user
recipient_email = user.email
notifications_count = len(notifications)
plural = "" if notifications_count == 1 else "s"
subject = "{} notification{} over the past {}".format(notifications_count, plural, day_or_week)
send_template_email(
request,
recipient_email,
subject,
"mail_digest_text.j2",
"mail_digest_html.j2",
{
"request": request,
"notifications": notifications,
"subject": subject,
"user": user,
"frequency": frequency,
"day_or_week": day_or_week,
"plural": plural,
"notifications_count": notifications_count,
"grouped_notifications": group_notifications_by_root(notifications),
},
)
log.info(
"notification frequency={} email={}, count={}".format(
frequency,
recipient_email,
notifications_count,
)
)
for notification in notifications:
notification.sent = True
request.dbsession.add(notification)
request.dbsession.flush()

60
remarkbox/lib/render.py Normal file
View file

@ -0,0 +1,60 @@
from miscutils.sanitize_html import (
default_cleaner,
default_tag_acl,
markdown_to_raw_html,
clean_raw_html,
)
import logging
log = logging.getLogger(__name__)
def remarkbox_tag_acl(namespace):
"""
Returns a tag_acl document, in this form:
tag_acl = {
"script": [
("type", "math/tex; mode=display", "allow"),
],
}
"""
# defaultdict which assumes new keys are lists.
tag_acl = default_tag_acl()
allow_mathjax_acl = ("type", "math/tex; mode=display", "allow")
deny_mathjax_acl = ("type", "math/tex; mode=display", "deny")
log.info("Building remarkbox_tag_acl.")
mathjax_acl = deny_mathjax_acl
if namespace.mathjax:
log.info("This Namespace has Mathjax enabled.")
mathjax_acl = allow_mathjax_acl
tag_acl["script"].append(mathjax_acl)
return tag_acl
def make_cleaner_from_namespace(namespace):
"""Given a Namespace return a bleach Cleaner object."""
cleaner = default_cleaner(
# pass tag_acl for Remarkbox.
remarkbox_tag_acl(namespace)
)
# add some properties onto the cleaner object.
cleaner.link_protection = namespace.link_protection
cleaner.mathjax = namespace.mathjax
cleaner.whitelist_domains.append(namespace.name)
cleaner.absolute_domain = namespace.name
return cleaner
def markdown_to_html(data, namespace=None):
raw_html = markdown_to_raw_html(data, extra_extensions=["mdx_math"])
if namespace:
cleaner = make_cleaner_from_namespace(namespace)
else:
cleaner = default_cleaner()
return clean_raw_html(raw_html, cleaner)

View file

@ -0,0 +1,54 @@
import hashlib
try:
# Python 2.
from urllib import quote
except ImportError:
# Python 3.
from urllib.parse import quote
from .svg import make_svg
def _bg_color(colors=None, text=u"", h=u"salty-salt"):
"""Selects a background color to use.
If `colors` is given, the value is choosen from that.
If `colors` is not given, a unique and consistent color value is generated.
:return (str)
"""
h = hashlib.md5((h + text).encode("utf-8")).hexdigest()
if colors:
ind = int(h, 16) % len(colors)
return colors[ind]
cut = 142
r = int(h[:2], 16) % cut
g = int(h[2:4], 16) % cut
b = int(h[4:6], 16) % cut
return "#{:02x}{:02x}{:02x}".format(r, g, b)
def _reduce_whitespace(text):
"""a way to remove newlines and leading/trailing whitespace per line."""
return " ".join([line.strip() for line in text.splitlines()])
def inline_svgtar(*args, **kwargs):
quoted_svgtar = quote(_reduce_whitespace(svgtar(*args, **kwargs)))
return "data:image/svg+xml;charset=utf-8,{}".format(quoted_svgtar)
def svgtar(
text, h, size=55, color="#ffffff", bg=None, font_size=None, font_family="Arial"
):
"""Return an multiline svg string."""
bg = _bg_color(h=h) if bg is None else bg
font_size = size * .6 if font_size is None else font_size
options = {
"text": text,
"size": size,
"color": color,
"bg": bg,
"font_size": font_size,
"font_family": font_family,
}
return make_svg(**options)

View file

@ -0,0 +1,46 @@
defaults = {
"size": 100,
"font_size": 60,
"font_family": "Arial",
"bg": "black",
"color": "white",
"text": "KB",
}
def make_svg(**options):
"""Builds a simple SVG text square with a centered text.
options: size, font_size, font_family, bg, color, text
:return (str)
"""
if options is not None and not isinstance(options, dict):
raise ValueError("options should be None or a type of dict.")
if "size" in options and "font_size" not in options:
options["font_size"] = int(options["size"] * 0.6)
dc = defaults.copy()
dc.update(options)
options = dc
svg = u"""
<svg xmlns="http://www.w3.org/2000/svg"
width="{size}px" height="{size}px">
<g>
<rect x="0" y="0" fill="{bg}" width="{size}px" height="{size}px">
</rect>
<text y="50%" x="50%" fill="{color}"
text-anchor="middle" dominant-baseline="central"
style="font-family: {font_family}; font-size: {font_size}px">
{text}
</text>
</g>
</svg>
""".format(
**options
)
return svg

View file

@ -0,0 +1,88 @@
from sqlalchemy import engine_from_config
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import configure_mappers
import zope.sqlalchemy
# import all models here to ensure they are attached to the
# Base.metadata prior to any initialization routines ( init_db).
from .uri import *
from .user import *
from .namespace import *
from .namespace_user import *
from .namespace_request import *
from .node import *
from .node_cache import *
from .vote import *
from .oauth import *
from .watcher import *
from .event import *
from .notification import *
from .pay_what_you_can import *
# run configure_mappers after defining all of the models to ensure
# all relationships can be setup
configure_mappers()
def get_engine(settings, prefix="sqlalchemy."):
return engine_from_config(settings, prefix)
def get_session_factory(engine):
factory = sessionmaker()
factory.configure(bind=engine)
return factory
def get_tm_session(session_factory, transaction_manager):
"""
Get a ``sqlalchemy.orm.Session`` instance backed by a transaction.
This function will hook the session to the transaction manager which
will take care of committing any changes.
- When using pyramid_tm it will automatically be committed or aborted
depending on whether an exception is raised.
- When using scripts you should wrap the session in a manager yourself.
For example::
import transaction
engine = get_engine(settings)
session_factory = get_session_factory(engine)
with transaction.manager:
dbsession = get_tm_session(session_factory, transaction.manager)
"""
dbsession = session_factory()
zope.sqlalchemy.register(dbsession, transaction_manager=transaction_manager)
return dbsession
def includeme(config):
"""
Initialize the model for a Pyramid app.
Activate this setup using ``config.include('pyratest.models')``.
"""
settings = config.get_settings()
settings["tm.manager_hook"] = "pyramid_tm.explicit_manager"
# use pyramid_tm to hook the transaction lifecycle to the request
config.include("pyramid_tm")
# use pyramid_retry to retry a request when transient exceptions occur
config.include("pyramid_retry")
session_factory = get_session_factory(get_engine(settings))
config.registry["dbsession_factory"] = session_factory
# make request.dbsession available for use in Pyramid
config.add_request_method(
# r.tm is the transaction manager used by pyramid_tm
lambda r: get_tm_session(session_factory, r.tm),
"dbsession",
reify=True,
)

41
remarkbox/models/event.py Normal file
View file

@ -0,0 +1,41 @@
from .meta import NODE_EVENT_ACTIONS
from .meta import Base, RBase, UUIDType, foreign_key, get_object_by_id, now_timestamp
from sqlalchemy import Column, BigInteger, Unicode, UnicodeText, Enum
from sqlalchemy.orm import relationship, backref
import uuid
class NodeEvent(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
# the Node this event is for.
node_id = Column(UUIDType, foreign_key("Node", "id"), index=True)
# the User who performed the event action on the Node.
user_id = Column(UUIDType, foreign_key("User", "id"), index=True)
# the type of action that was performed on Node.
action = Column(Enum(*NODE_EVENT_ACTIONS, name="action"), nullable=False)
# the timestamp when the event was created.
created_timestamp = Column(BigInteger, nullable=False)
def __init__(self, user, node, action):
self.id = uuid.uuid1()
self.user = user
self.node = node
self.action = action
self.created_timestamp = now_timestamp()
# 1-to-1 relationships.
node = relationship(argument="Node", uselist=False, lazy="joined")
user = relationship(
argument="User",
uselist=False,
lazy="joined",
backref=backref("events", cascade="save-update, merge, delete"),
)
def get_node_event_by_id(dbsession, node_event_id):
"""Try to get NodeEvent object by id or return None."""
return get_object_by_id(dbsession, node_event_id, NodeEvent)

152
remarkbox/models/meta.py Normal file
View file

@ -0,0 +1,152 @@
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm.session import object_session
from sqlalchemy.schema import MetaData
from sqlalchemy import ForeignKey
from time import time
import uuid
from sqlalchemy_utils import UUIDType as TempUUIDType
UUIDType = TempUUIDType(binary=False)
# Recommended naming convention used by Alembic, as various different database
# providers will autogenerate vastly different names making migrations more
# difficult. See: http://alembic.zzzcomputing.com/en/latest/naming.html
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
CLASS_TO_TABLE = {
"Uri": "rb_uri",
"User": "rb_user",
"UserSurrogate": "rb_user_surrogate",
"Vote": "rb_vote",
"Node": "rb_node",
"NodeCache": "rb_node_cache",
"Namespace": "rb_namespace",
"NamespaceUser": "rb_namespace_user",
"NamespaceRequest": "rb_namespace_request",
"Oauth": "rb_oauth",
"Watcher": "rb_watcher",
"NodeEvent": "rb_node_event",
"NodeEventNotification": "rb_node_event_notification",
"PayWhatYouCan": "rb_pay_what_you_can",
}
# node (threads), namespace (forum)
WATCHER_TYPES = {"reply", "node", "namespace"}
NOTIFICATION_FREQUENCIES = {"never", "immediately", "daily", "weekly"}
NOTIFICATION_METHODS = {"email"}
NODE_EVENT_ACTIONS = {
"enabled",
"disabled",
"deleted",
"verified",
"created", # this is a new thread.
"commented", # this is a reply to an existing thread.
"approved",
"edited",
# These event type actions don't exist yet, but could fuel some ideas for the future.
# "flagged",
# "voted",
}
SUBSCRIPTION_TYPES = {"development", "production", "trial"}
# currently not used anywhere.
USER_EVENT_ACTIONS = {"granted", "promoted", "demoted", "blocked"}
metadata = MetaData(naming_convention=NAMING_CONVENTION)
# Base = declarative_base(metadata=metadata)
Base = declarative_base()
def short_id_to_bytes(short_id):
"""Accept a short_id (sanitized url safe base64 string) and return a byte string.
>>> short_id_to_bytes('dbHeSEFLEeeuz5xONpxxWA')
'u\xb1\xdeHAK\x11\xe7\xae\xcf\x9cN6\x9cqX'
"""
return (short_id + "===").replace("_", "/").replace("-", "+").decode("base64")
def id_to_uuid(the_id):
"""
Accept an id string, return a UUID object.
>>> id_to_uuid('dbHeSEFLEeeuz5xONpxxWA')
UUID('75b1de48-414b-11e7-aecf-9c4e369c7158')
>>> id_to_uuid('75b1de48414b11e7aecf9c4e369c7158')
UUID('75b1de48-414b-11e7-aecf-9c4e369c7158')
>>> id_to_uuid('75b1de48-414b-11e7-aecf-9c4e369c7158')
UUID('75b1de48-414b-11e7-aecf-9c4e369c7158')
"""
if isinstance(the_id, uuid.UUID):
# if the_id is already a UUID object, return it.
return the_id
try:
# assume the_id is already a hex uuid string.
return uuid.UUID(hex=the_id)
except ValueError:
try:
# assume the_id is a base64 "short_id" string.
return uuid.UUID(bytes=short_id_to_bytes(the_id))
except ValueError:
pass
return None
def get_object_by_id(dbsession, object_id, cls):
"""Try to get object from database by id or return None"""
object_uuid = id_to_uuid(object_id)
if object_uuid:
return dbsession.query(cls).filter(cls.id == object_uuid).one_or_none()
def now_timestamp():
return int(time() * 1000)
def foreign_key(class_name, column_name):
return ForeignKey("{}.{}".format(CLASS_TO_TABLE[class_name], column_name))
class RBase(object):
"""Mixin for RemarkBox models."""
def __eq__(self, other):
"""Determine if equal by id."""
if other:
return self.id == other.id
return False
def __ne__(self, other):
"""Determine if not equal by user id."""
return not self.__eq__(other)
def __hash__(self):
return hash(self.id)
@declared_attr
def __tablename__(cls):
return CLASS_TO_TABLE[cls.__name__]
@property
def dbsession(self):
return object_session(self)

View file

@ -0,0 +1,428 @@
from sqlalchemy import (
Integer,
BigInteger,
Boolean,
Column,
Enum,
Unicode,
UnicodeText,
or_,
)
from sqlalchemy.orm import relationship, backref
from sqlalchemy.ext.associationproxy import association_proxy
import uuid
from collections import defaultdict
from .meta import Base, RBase
from .meta import UUIDType
from .meta import SUBSCRIPTION_TYPES
from .meta import now_timestamp, foreign_key
from .node import Node, get_nodes_who_share_roots_query
from .namespace_user import NamespaceUser
from .oauth import Oauth
from remarkbox.lib import timestamp_to_ago_string, timestamp_to_datetime
try:
unicode("")
except:
from six import u as unicode
# these attributes are protected such that they return the defaults
# when a Namespace is frozen or subscription is expired.
PROTECTED_ATTRIBUTES = {
"description": None,
"placeholder_text": None,
"no_nodes_text": None,
"theme": None,
"theme_embed": None,
"stylesheet": None,
"stylesheet_uri": None,
"stylesheet_embed": None,
"stylesheet_embed_uri": None,
"wiki": False,
"google_analytics_id": None,
"hide_unverified": False,
"hide_unless_approved": False,
"hide_powered_by": False,
"mathjax": False,
"link_protection": False,
"ignore_query_string": False,
"reverse_order": False,
"group_conversations": False,
}
class Namespace(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
name = Column(Unicode(256), nullable=False, unique=True, index=True)
description = Column(Unicode(256), default=None)
# optional placeholder text for new node textarea.
placeholder_text = Column(Unicode(256), default=None, nullable=True)
# optional placeholder text when there are no nodes in thread.
no_nodes_text = Column(Unicode(256), default=None, nullable=True)
# the basic mode theme. TODO: change this column name to `theme_basic`.
theme = Column(Unicode(256), default=None)
# the embed mode theme.
theme_embed = Column(Unicode(256), default=None)
avatar_size = Column(Integer, default=None)
# TODO: change this to `stylesheet_basic` and `stylesheet_basic_timestamp`.
stylesheet = Column(UnicodeText, default=None)
stylesheet_timestamp = Column(BigInteger)
stylesheet_uri = Column(Unicode(256), default=None)
stylesheet_embed = Column(UnicodeText, default=None)
stylesheet_embed_timestamp = Column(BigInteger)
stylesheet_embed_uri = Column(Unicode(256), default=None)
# when was the last time we tested/verified the namespace owner request?
owner_request_timestamp = Column(BigInteger, nullable=True)
# optional google analytics id for stand alone mode.
google_analytics_id = Column(Unicode(18), nullable=True)
# allow anyone to edit the root node. (not implemented yet)
wiki = Column(Boolean, default=False)
# by default we show all nodes but a Namespace can change this behavior.
# should a node be hidden if unverified by owner or moderator?
hide_unverified = Column(Boolean, default=False)
# should a node be hidden until approved by a moderator?
hide_unless_approved = Column(Boolean, default=False)
# should we hide the poweredby Remarkbox logo?
hide_powered_by = Column(Boolean, default=False)
# should the list of root nodes in this namespace be public or hidden?
public = Column(Boolean, default=False)
# should we enable MathJax?
mathjax = Column(Boolean, default=False)
# should we enable Link Protection to prevent comments from having links?
link_protection = Column(Boolean, default=False)
# check this box if the external site does _not_ use
# query strings for page identification or navigation.
ignore_query_string = Column(Boolean, default=False)
# should we reverse the node order and put newest on top?
reverse_order = Column(Boolean, default=False)
# should we group conversations and limit to nesting 2 deep?
group_conversations = Column(Boolean, default=False)
# the type of subscription of this Namespace.
subscription_type = Column(
Enum(*SUBSCRIPTION_TYPES, name="subscription_type"),
# Hooray, Remarkbox becomes pay-what-you-can!
# https://en.wikipedia.org/wiki/Pay_what_you_can
default="production",
nullable=False,
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
roots = relationship(argument="Node", lazy="dynamic", back_populates="namespace")
users = association_proxy(
"namespace_users", "user", creator=lambda u: NamespaceUser(user=u)
)
user_surrogates = relationship(
argument="UserSurrogate", lazy="dynamic", back_populates="namespace"
)
namespace_owner_requests = relationship(
argument="NamespaceRequest", lazy="dynamic", back_populates="namespace"
)
oauth_records = relationship(
argument="Oauth", lazy="dynamic", back_populates="namespace"
)
watchers = relationship(
argument="Watcher", lazy="dynamic", back_populates="namespace"
)
@property
def powered_up(self):
if self.subscription_type in ["production", "trial"]:
return True
return False
def reload_memoized_attr_protection(self):
"""If True, protect attributes from access."""
if self.powered_up:
# do not protect attributes, the namespace is powered up.
self.memoized_attr_protection = False
else:
# protect attributes, the namespace is not powered up.
self.memoized_attr_protection = True
def __getattribute__(self, attr):
"""Protect certain attributes."""
# return early if attribute isn't protected.
if attr not in PROTECTED_ATTRIBUTES:
return object.__getattribute__(self, attr)
if not hasattr(self, "memoized_attr_protection"):
self.reload_memoized_attr_protection()
if self.memoized_attr_protection:
# attributes currently under protection.
# load and return the attribute's default value.
return PROTECTED_ATTRIBUTES[attr]
# attributes currently not under protection.
# load and return the attribute's real value.
return object.__getattribute__(self, attr)
def __init__(self, name=None):
self.id = uuid.uuid1()
self.name = unicode(name)
def add_oauth_record(self, user, service, token, data=None):
self.oauth_records.append(
Oauth(user=user, namespace=self, service=service, token=token, data=data)
)
@property
def slack_oauth_records(self):
return [oauth for oauth in self.oauth_records if oauth.service == "slack"]
def get_namespace_user_for_user(self, user):
"""Given a User object, return the NamespaceUser object."""
for nsu in self.namespace_users:
if nsu.user == user:
return nsu
def set_role_for_user(self, user, role=None):
"""Add User object to NamespaceUser. Default role is moderator."""
nsu = self.get_namespace_user_for_user(user)
if nsu is not None:
nsu.role = role
else:
nsu = NamespaceUser(role=role, namespace=self, user=user)
self.namespace_users.append(nsu)
user.watch_namespace(self)
def disable_user(self, user):
nsu = self.get_namespace_user_for_user(user)
if nsu is not None:
user.unwatch_namespace(self)
nsu.disabled = True
def enable_user(self, user):
nsu = self.get_namespace_user_for_user(user)
if nsu is not None:
user.watch_namespace(self)
nsu.disabled = None
def remove_user(self, user):
nsu = self.get_namespace_user_for_user(user)
if nsu is not None:
self.disable_user(user)
self.dbsession.delete(nsu)
@property
def enabled_namespace_users(self):
"""Return not disabled NamespaceUser objects for this Namespace."""
return [nsu for nsu in self.namespace_users if not nsu.disabled]
@property
def enabled_users(self):
"""Return not disabled User objects for this Namespace."""
return [nsu.user for nsu in self.enabled_namespace_users]
@property
def roles(self):
"""Return dict with role name as key and list of Users as value."""
roles = defaultdict(list)
for ns_user in self.enabled_namespace_users:
roles[ns_user.role].append(ns_user.user)
return roles
@property
def owners(self):
"""Return a list of owner role User objects."""
return self.roles.get("owner", [])
@property
def moderators(self):
"""Return a list of moderator role User objects."""
return self.enabled_users
@property
def visible_roots(self):
return self.roots.filter(
Node.verified == True, Node.disabled == False
).order_by(Node.changed.desc())
@property
def visible_root_stats(self):
stats = {}
for root in self.visible_roots:
if root.title:
stats[root.path] = root.stats
elif root.uri:
stats[root.uri.data] = root.stats
return stats
@property
def nodes(self):
return (
get_nodes_who_share_roots_query(self.dbsession, self.roots)
.filter(Node.parent_id.isnot(None))
.order_by(Node.changed.desc())
)
def page_nodes(self, limit=100, offset=0):
if self.roots.count() == 0:
return []
query = get_nodes_who_share_roots_query(self.dbsession, self.roots).filter(
Node.disabled == False, Node.user_id != None
)
if self.hide_unless_approved:
query = query.filter(Node.approved == True)
if self.hide_unverified:
query = query.filter(Node.verified == True)
return query.order_by(Node.changed.desc()).limit(limit).offset(offset)
@property
def verified_nodes(self):
return self.nodes.filter(Node.verified == True)
@property
def disabled_nodes(self):
return self.nodes.filter(Node.disabled == True)
@property
def approved_nodes(self):
return self.nodes.filter(Node.approved == True, Node.disabled == False)
@property
def unapproved_nodes(self):
return self.nodes.filter(
or_(Node.approved == False, Node.approved.is_(None)), Node.disabled == False
)
@property
def visible_nodes(self):
return get_nodes_who_share_roots_query(self.dbsession, self.visible_roots)
@property
def owner_request_pending(self):
"""Is this Namespace due for a owner request verification?"""
if self.owner_request_timestamp:
delta = now_timestamp() - self.owner_request_timestamp
# 5 minutes.
if delta <= 300:
return False
return True
@property
def node_order(self):
if self.reverse_order:
return "newest-first"
return "oldest-first"
def update_owner_request_timestamp(self):
self.owner_request_timestamp = now_timestamp()
def clear_owner_request_timestamp(self):
self.owner_request_timestamp = None
def set_stylesheet(self, body):
self.stylesheet = body
self.stylesheet_timestamp = now_timestamp()
def set_stylesheet_embed(self, body):
self.stylesheet_embed = body
self.stylesheet_embed_timestamp = now_timestamp()
def is_owner(self, user):
"""Return True if given user owns this namespace, else False."""
if user and user.authenticated and user in self.owners:
return True
return False
def is_moderator(self, user):
"""Return True if given user moderates this namespace, else False."""
if user and user.authenticated and user in self.moderators:
return True
return False
def can_alter_node(self, node, user):
return self.is_moderator(user) or node.is_owner(user)
def can_see_node(self, node, user):
visible = True
if node.disabled:
visible = False
if self.hide_unless_approved and not node.approved:
visible = False
if self.hide_unverified and not node.verified:
visible = False
if user:
if self.is_moderator(user) or node.is_owner(user):
# owners can see node.
visible = True
return visible
@property
def dict_dump(self):
dump = {}
for root in self.roots:
if root.has_uri:
root_identifier = root.uri.data
else:
root_identifier = root.slug
root_dump = {
"name" : root_identifier,
"title" : root.title,
# TODO: fix when root.created becomes root.created_timestamp.
"timestamp" : root.created,
}
comments = []
for node in root.children:
comment = {
"date" : node.created_date,
# TODO: when root.created becomes root.created_timestamp.
"timestamp" : node.created,
"content" : node.data,
"author" : node.user.name,
"author_ip" : node.ip_address,
}
if self.subscription_type == "production":
comment["email"] = node.user.email
comments.append(comment)
root_dump["comments"] = comments
dump[root_identifier] = root_dump
return dump
def get_topsecret_namespaces(dbsession):
return dbsession.query(Namespace).all()
def get_namespace_by_name(dbsession, name):
if name:
return (
dbsession.query(Namespace)
.filter(Namespace.name == unicode(name))
.one_or_none()
)
def get_or_create_namespace(dbsession, name):
namespace = get_namespace_by_name(dbsession, name)
if not namespace:
namespace = Namespace(name)
# commit namespace to db right away to prevent detached instances.
# causes some trash namespaces to accumulate but better then errors.
dbsession.add(namespace)
dbsession.flush()
return namespace

View file

@ -0,0 +1,143 @@
from sqlalchemy import Boolean, Column, Unicode, BigInteger
from sqlalchemy.orm import relationship
import uuid
from .meta import Base, RBase
from .meta import UUIDType
from .meta import now_timestamp, foreign_key, get_object_by_id
import requests
import logging
log = logging.getLogger(__name__)
class NamespaceRequest(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), index=True)
namespace_id = Column(UUIDType, foreign_key("Namespace", "id"), index=True)
created_timestamp = Column(BigInteger, nullable=False, index=True)
# this is the endpoint uri to scrape looking for the namespace request id.
target = Column(Unicode(256), nullable=True)
verified = Column(Boolean, default=False)
user = relationship(
argument="User",
uselist=False,
lazy="joined",
back_populates="namespace_owner_requests",
)
namespace = relationship(
argument="Namespace",
uselist=False,
lazy="joined",
back_populates="namespace_owner_requests",
)
def __init__(self, user, namespace, target=None):
self.created_timestamp = now_timestamp()
self.id = uuid.uuid1()
self.user = user
self.namespace = namespace
self.target = target
def verify(self):
self.namespace.owner_request_timestamp = now_timestamp()
# set this namespace_request to verified.
self.verified = True
def unverify(self):
self.namespace.owner_request_timestamp = now_timestamp()
# set this namespace_request to unverified.
self.verified = False
def verify_target(self, target):
self.target = target
if self.scrape_target():
self.verify()
else:
self.unverify()
def scrape_target(self):
"""Scrape the given target for NamespaceRequest id
Return True if found else False.
"""
# TODO: this is a blocking external call to a resource we
# have no control over and should likely be a different service!
# https://github.com/russellballestrini/webwords
namespace_request_id = str(self.id)
if self.target:
# https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Request_fields
headers = {
"User-Agent": "remarkbox.com",
}
log.info(
"scraping target={} looking for uuid={}".format(
self.target, namespace_request_id
)
)
resp = requests.get(self.target, timeout=8.50)
if resp.ok:
if namespace_request_id in resp.text:
log.info(
"scraping target={} looking for uuid={} status=hit".format(
self.target,
namespace_request_id,
)
)
return True
log.info(
"scraping target={} looking for uuid={} status=miss".format(
self.target,
namespace_request_id,
)
)
return False
log.info(
"scraping target={} looking for uuid={} status={} reason={}".format(
self.target,
namespace_request_id,
resp.status_code,
resp.reason,
)
)
return False
def get_namespace_request_by_id(dbsession, namespace_request_id):
"""Try to get NamespaceRequest object by id or return None."""
return get_object_by_id(dbsession, namespace_request_id, NamespaceRequest)
def get_namespace_request(dbsession, user, namespace):
if user and namespace:
return (
dbsession.query(NamespaceRequest)
.filter(
NamespaceRequest.user_id == user.id,
NamespaceRequest.namespace_id == namespace.id,
)
.one_or_none()
)
def get_or_create_namespace_request(dbsession, user, namespace):
namespace_request = get_namespace_request(dbsession, user, namespace)
if namespace_request is None:
namespace_request = NamespaceRequest(user, namespace)
return namespace_request
def get_topsecret_namespace_requests(dbsession, limit=100, offset=0):
return (
dbsession.query(NamespaceRequest)
.order_by(NamespaceRequest.created_timestamp.desc())
.limit(limit)
.offset(offset)
)

View file

@ -0,0 +1,44 @@
from sqlalchemy import Integer, BigInteger, Boolean, Column, Unicode, UnicodeText, or_
from sqlalchemy.orm import relationship, backref
from sqlalchemy.ext.associationproxy import association_proxy
import uuid
from collections import defaultdict
from .meta import Base, RBase
from .meta import now_timestamp, foreign_key
from .meta import UUIDType
class NamespaceUser(RBase, Base):
user_id = Column(UUIDType, foreign_key("User", "id"), primary_key=True, index=True)
namespace_id = Column(
UUIDType, foreign_key("Namespace", "id"), primary_key=True, index=True
)
role = Column(Unicode(64), default=None)
# TODO: someday this should be renamed to created_timestamp
created = Column(BigInteger, nullable=False)
# TODO: someday this should be renamed to changed_timestamp
changed = Column(BigInteger, nullable=False)
disabled = Column(Boolean, default=False)
verified = Column(Boolean, default=False)
user = relationship(
argument="User",
backref=backref("user_namespaces", cascade="all, delete-orphan"),
)
namespace = relationship(
argument="Namespace",
backref=backref("namespace_users", cascade="all, delete-orphan"),
)
def __init__(self, role=None, namespace=None, user=None):
self.created = now_timestamp()
self.changed = now_timestamp()
self.role = role
self.namespace = namespace
self.user = user

673
remarkbox/models/node.py Normal file
View file

@ -0,0 +1,673 @@
from collections import (
OrderedDict,
deque,
)
from sqlalchemy import (
BigInteger, Boolean, Column, Integer, Unicode, UnicodeText, or_, func
)
from sqlalchemy.orm import relationship, backref
import uuid
from slugify import slugify
from remarkbox.lib import (
timestamp_to_datetime,
timestamp_to_ago_string,
timestamp_to_date_string,
)
from remarkbox.lib.render import markdown_to_html
from .meta import Base, RBase
from .meta import UUIDType
from .meta import now_timestamp, foreign_key, get_object_by_id
from .uri import get_or_create_uri
from .event import NodeEvent
import codecs
import logging
try:
unicode("")
except:
from six import u as unicode
log = logging.getLogger(__name__)
class Node(RBase, Base):
"""
Node class acts like a linked list to support nested:
Question -> Answers -> Comments
Thread -> Posts -> Remarks
The `parent_id` column is only used by children nodes.
The class will allow 3 layer nesting like StackOverflow or unlimited
nesting like reddit. The frontend code determines the particular
nesting strategy.
For more information checkout this section of the SQLAlchemy Docs:
http://docs.sqlalchemy.org/en/latest/orm/relationships.html#adjacency-list-relationships
"""
id = Column(UUIDType, primary_key=True, index=True)
root_id = Column(UUIDType, index=True, default=None)
parent_id = Column(UUIDType, foreign_key("Node", "id"), default=None)
namespace_id = Column(UUIDType, foreign_key("Namespace", "id"), default=None)
user_id = Column(UUIDType, foreign_key("User", "id"), default=None)
user_surrogate_id = Column(
UUIDType, foreign_key("UserSurrogate", "id"), default=None
)
title = Column(Unicode(256), default=None)
data = Column(UnicodeText, default=None)
data_html = Column(UnicodeText, default=None)
# the depth of this node in the thread's graph / tree.
graph_depth = Column(Integer, nullable=False, default=-1)
# TODO: someday this should be renamed to created_timestamp
created = Column(BigInteger, nullable=False)
# TODO: someday this should be renamed to changed_timestamp
changed = Column(BigInteger, nullable=False)
disabled_timestamp = Column(BigInteger)
disabled = Column(Boolean, default=False)
verified = Column(Boolean, default=False)
# a locked thread (root node) prevents new commenting.
locked = Column(Boolean, default=False)
# by default comments are approved. unless Namespace hide_unless_approved.
approved = Column(Boolean, default=True)
# is there a related Uri model to this node?
has_uri = Column(Boolean, default=False, nullable=False)
ip_address = Column(Unicode(45), default=None)
# lazy='joined' performs a left join to reduce queries, it's magic.
user = relationship(argument="User", lazy="joined")
# lazy='joined' performs a left join to reduce queries, it's magic.
user_surrogate = relationship(argument="UserSurrogate", lazy="joined")
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
children = relationship(
argument="Node",
lazy="dynamic",
order_by="Node.created",
foreign_keys=[parent_id],
backref=backref("parent", remote_side=[id]),
)
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
children_desc = relationship(
argument="Node",
lazy="dynamic",
order_by="desc(Node.created)",
foreign_keys=[parent_id],
)
votes = relationship(argument="Vote", backref="node", order_by="desc(Vote.created)")
# set uselist to false to create one-to-one relationship.
uri = relationship(argument="Uri", uselist=False, back_populates="node")
# set uselist to false to create one-to-one relationship.
namespace = relationship(argument="Namespace", uselist=False)
# lazy='joined' performs a left join to reduce queries, it's magic.
# this is used for denormalized stats like counts, etc.
cache = relationship(
argument="NodeCache", lazy="joined", back_populates="node", uselist=False
)
watchers = relationship(argument="Watcher", lazy="dynamic", back_populates="node")
events = relationship(argument="NodeEvent", lazy="dynamic", back_populates="node")
def __init__(self):
self.id = uuid.uuid1()
self.created = now_timestamp()
self.changed = self.created
def get_children(self, order_by="asc"):
if order_by == "desc":
return self.children_desc
return self.children
@property
def verified_children(self):
return self.children.filter(Node.verified == True)
@property
def unverified_children(self):
return self.children.filter(Node.verified == False)
@property
def path_to_root(self):
"""The path from this node to the root node."""
# TODO: memoize this result? how / when to deal with cache busting?
node = self
path_to_root = []
path_to_root.append(node)
while node.parent is not None:
node = node.parent
path_to_root.append(node)
return path_to_root
@property
def path_from_root(self):
"""The path from the root node to this node."""
return list(reversed(self.path_to_root))
@property
def root2(self):
"""The origin or top most parent node without a parent."""
if self.is_root:
return self
return self.path_to_root[-1]
@property
def root(self):
"""The origin or top most parent node without a parent."""
if self.is_root:
return self
return self.dbsession.query(Node).filter(Node.id == self.root_id).one()
@property
def is_root(self):
if not self.parent_id or not self.root_id or self.root_id == self.id:
return True
return False
def recompute_depth(self):
"""recompute the depth of a node in the thread."""
self.graph_depth = len(self.path_to_root) - 1
@property
def depth(self):
if self.graph_depth == -1:
self.recompute_depth()
return self.graph_depth
@property
def stats(self):
"""The denormalized stats for this node."""
from .node_cache import NodeCache
if self.cache is None:
self.cache = NodeCache(self.id)
return self.cache.stats
def score(self):
# this doesn't actually exist right now. I removed voting from this
# iteration of the codebase. maybe worth adding in at some point.
return sum([vote.value for vote in self.votes])
def children_order_by_score(self):
# TODO: this should be optimized, we should craft a query and have
# database to return results in the expected order instead of multiple
# queries. First we select children then we sort by selecting each
# vote for each child and sum the result.
return sorted(self.children, key=lambda x: x.score(), reverse=True)
def new_child(self):
"""create and return new child node of this node, in memory"""
child = Node()
child.parent = self
child.root_id = self.root_id
return child
def new_event(self, user, action):
"""create and return new node_event for this node, in memory"""
event = NodeEvent(node=self, user=user, action=action)
self.events.append(event)
return event
def avatar_uri(self, **kwargs):
"""Return invatar URI. Optionally return gravatar URI."""
u = self.user if self.user else self.user_surrogate
if self.disabled:
kwargs["text"] = "-"
kwargs["bg"] = "#aaaaaa"
if self.disabled or not self.verified:
return u.svgtar(**kwargs)
return u.avatar_uri(**kwargs)
@property
def created_timestamp(self):
return self.created
@property
def human_created_timestamp(self):
return timestamp_to_ago_string(self.created)
# TODO: take into account user's timezone?
@property
def created_date(self):
return timestamp_to_date_string(self.created)
# TODO: take into account user's timezone?
@property
def created_datetime(self):
return timestamp_to_datetime(self.created)
@property
def changed_timestamp(self):
return self.changed
@property
def human_changed_timestamp(self):
return timestamp_to_ago_string(self.changed)
# TODO: take into account user's timezone?
@property
def changed_date(self):
return timestamp_to_date_string(self.changed)
# TODO: take into account user's timezone?
@property
def changed_datetime(self):
return timestamp_to_datetime(self.changed)
@property
def title_ascii(self):
"""return the ascii version of the title, ignoring errors or None."""
if self.title:
return self.title.encode('ascii', 'ignore')
@property
def slug(self):
"""return slug from title or None"""
if self.title:
return slugify(self.title)
@property
def path(self):
"""return a node's path part of uri"""
if self.slug is not None:
return "/{}/{}".format(self.id, self.slug)
else:
return "/{}".format(self.id)
@property
def short_id(self):
"""return the base64 representation of UUID: 36 -> 22 unicode chars"""
base_64_id = codecs.encode(self.id.bytes, "base64")
base_64_id = base_64_id.rstrip(b"=\n").replace(b"/", b"_").replace(b"+", b"-")
return base_64_id.decode("utf-8")
@property
def was_edited(self):
return not self.created == self.changed
@property
def enabled(self):
return not self.disabled
def set_data(self, data, namespace=None):
if namespace is None:
namespace = self.root.namespace
self.data = data
self.data_html = markdown_to_html(data, namespace)
def _invalidate_cache(self):
if self.root.cache:
self.root.cache.invalidate()
def edit(self, data):
"""edit node."""
self.set_data(data)
self.changed = now_timestamp()
self._invalidate_cache()
def disable(self):
"""disable node."""
self.disabled = True
self.disabled_timestamp = now_timestamp()
self._invalidate_cache()
def enable(self):
"""enable node."""
self.disabled = False
self.disabled_timestamp = None
self._invalidate_cache()
def verify(self):
"""verify node."""
self.verified = True
self._invalidate_cache()
def unverify(self):
"""unverify node."""
self.verified = False
self._invalidate_cache()
def approve(self):
"""verify node."""
self.approved = True
self._invalidate_cache()
def deny(self):
"""unverify node."""
self.approved = False
self._invalidate_cache()
def visible(self):
if self.disabled:
return False
return True
def is_owner(self, user):
"""Return True if given user owns this node, else False."""
if user and user.authenticated and user == self.user:
return True
return False
def get_node_by_id(dbsession, node_id):
"""Try to get Node object by id or return None."""
return get_object_by_id(dbsession, node_id, Node)
def get_node_by_uri(dbsession, node_uri):
return get_or_create_uri(dbsession, node_uri).node
def create_root_node():
node = Node()
node.root_id = node.id
node.graph_depth = 0
return node
def get_or_create_node_by_uri(dbsession, node_uri, node_title=None):
"""Try to get Node object by uri or return new Node"""
uri = get_or_create_uri(dbsession, node_uri)
if uri.node is None:
# create new root node for external page (embed).
# imported here to prevent circular import.
from .namespace import get_or_create_namespace
uri.node = create_root_node()
uri.node.verified = True
uri.node.has_uri = True
uri.node.title = node_title
uri.node.namespace = get_or_create_namespace(dbsession, uri.parsed.hostname)
elif node_title:
# if the external page title has changed, update our database.
if node_title != uri.node.title:
uri.node.title = node_title
dbsession.add(uri.node)
dbsession.flush()
return uri.node
def get_topsecret_roots(dbsession):
"""Return all verified parent nodes by descending created order."""
return (
dbsession.query(Node)
.filter(Node.parent_id == None, Node.verified == True, Node.disabled == False)
.order_by(Node.changed.desc())
)
def get_topsecret_nodes(dbsession, limit=100, offset=0):
"""Return all nodes by descending created order."""
return (
dbsession.query(Node)
.filter(Node.disabled == False, Node.user_id != None)
.order_by(Node.changed.desc())
.limit(limit)
.offset(offset)
)
def get_root_nodes(dbsession):
"""Return a list of all root nodes (nodes without a parent.)"""
return (
dbsession.query(Node)
.filter(Node.parent_id == None)
.order_by(Node.changed.desc())
)
def get_root_nodes_by_keywords(dbsession, keywords, namespace=None):
# make dictionary mapping of root_node_ids to accumulated scores
root_scores = {}
# make dictionary mapping of root_node_ids to root_node_objects
root_nodes = {}
# a list of matched node objects.
nodes = []
if namespace:
node_query = namespace.visible_nodes
else:
node_query = get_nodes_who_share_roots_query(
dbsession, get_topsecret_roots(dbsession)
)
for keyword in keywords:
# extend nodes, with a list of nodes which match this keyword.
keyword_filter = Node.data.ilike("%{}%".format(keyword))
nodes.extend(node_query.filter(keyword_filter).all())
# accumulate scores and root node objects.
for node in nodes:
if node.root.id not in root_scores:
root_scores[node.root.id] = 0
root_scores[node.root.id] += 1
if node.root.id not in root_nodes:
root_nodes[node.root.id] = node.root
# sort the scores dictionary by value (score),
# but get the key (object id),
# reversed (highest score first)
sorted_root_ids = sorted(root_scores, key=root_scores.get, reverse=True)
# return root node objects in sorted order by score.
return [root_nodes[root_id] for root_id in sorted_root_ids]
def get_nodes_who_share_roots_query(dbsession, root_nodes):
# a giant n "or" query, where n is the number of root_nodes.
if root_nodes.count() == 0:
# return a "null" query object. still hits database...
# this allows us to pass around the query object.
from sqlalchemy.sql import text
return dbsession.query(Node).filter(text("0 == 1"))
return dbsession.query(Node).filter(or_(Node.root_id == n.id for n in root_nodes))
def get_nodes_who_share_roots(dbsession, root_nodes):
return get_nodes_who_share_roots_query(dbsession, root_nodes).all()
def get_nodes_who_share_root(dbsession, root_node, order="oldest-first"):
nodes = dbsession.query(Node).filter(Node.root_id == root_node.id)
if order == "oldest-first":
nodes = nodes.order_by(Node.created)
elif order == "newest-first":
nodes = nodes.order_by(Node.created.desc())
return nodes
def get_node_count_who_share_root(dbsession, root_node):
return (
dbsession.query(func.count("id"))
.select_from(Node)
.filter(Node.root_id == root_node.id)
.scalar()
)
def get_visible_node_count_who_share_root(dbsession, root_node):
return (
dbsession.query(func.count("id"))
.select_from(Node)
.filter(
Node.root_id == root_node.id, Node.disabled == False, Node.approved == True
)
.scalar()
)
def get_node_id_map(nodes):
"""return a dictionary of all nodes who share this root."""
node_id_map = {}
for node in nodes:
node_id_map[node.id] = node
return node_id_map
def get_graph_from_nodes(nodes):
"""infinite nested graph or tree of nodes."""
graph = OrderedDict()
# create all node keys in graph.
for node in nodes:
graph[node.id] = []
for node in nodes:
if node.parent is not None:
graph[node.parent.id].append(node.id)
return graph
def get_group_conversation_ids(nodes):
"""Return a list of node ids whose parent is root."""
if len(list(nodes)) == 0:
return []
root = nodes[0].root
return [node.id for node in nodes if node.parent and node.parent.id == root.id]
# Reference post:
# http://kmkeen.com/python-trees/2010-09-18-08-55-50-039.html
# strategy queue.
def flatten_graph(node_id, graph, include_given_node_id=False):
"""
Given a node_id and graph (or tree),
return a list of every descendant node_id, depth first order.
"""
flat_graph = []
to_crawl = deque([node_id])
while to_crawl:
current_id = to_crawl.pop()
flat_graph.append(current_id)
children_ids = graph[current_id]
# reverse so that we extend the queue in the expected order.
children_ids.reverse()
to_crawl.extend(children_ids)
if include_given_node_id:
return flat_graph
return flat_graph[1:]
def get_conversation_graph_from_nodes(nodes, graph=None, node_id_map=None, order="oldest-first"):
"""
Given a list of nodes, return a grouped conversation graph which is a
dictionary of ids where the keys are the top level conversation node ids
and the values are a list of all decendant node ids.
If you already have it computed, you may optionally provide an existing
graph (dictionary tree of node ids), and/or an existing node_id_map.
"""
def get_timestamp(node_id):
return node_id_map[node_id].created
if graph is None:
graph = get_graph_from_nodes(nodes)
if node_id_map is None:
node_id_map = get_node_id_map(nodes)
_reverse = False
if order == "newest-first":
_reverse = True
convo_graph = OrderedDict()
convo_ids = sorted(
get_group_conversation_ids(nodes),
key=get_timestamp,
reverse=_reverse
)
for convo_node_id in convo_ids:
descendant_ids = flatten_graph(
convo_node_id,
graph,
)
# sort by timestamp, oldest first.
convo_graph[convo_node_id] = sorted(
descendant_ids,
key=get_timestamp,
)
return convo_graph
def recompute_depth_on_all_node_objects(dbsession, chunk_lenth=500):
"""Only use this if a many nodes were moved and/or re-nested."""
#
# WARNING, this could take a long time and be computationally expensive
# both on the host running it, and the database, respectively.
#
import math
log.info("loading and querying nodes...")
node_count = dbsession.query(Node).count()
nodes = dbsession.query(Node).all()
log.info("node_count={}".format(node_count))
for i, node in enumerate(nodes):
# this is the computationally intense part.
node.recompute_depth()
dbsession.add(node)
if i % chunk_lenth == 0:
progress = int(math.floor((i / float(node_count)) * 100))
log.info(
"Flushing chunk={}/{}, chunk_lenth={} progress={}%".format(
i, node_count, chunk_lenth, progress
)
)
dbsession.flush()
# one final flush to database for the last partial chunk.
log.info(
"Flushing chunk={}/{}, chunk_lenth={} progress={}%".format(
i + 1, node_count, chunk_lenth, 100
)
)
dbsession.flush()

View file

@ -0,0 +1,88 @@
from sqlalchemy import BigInteger, Boolean, Column, UnicodeText
from sqlalchemy.orm import relationship, backref
import uuid
from .meta import Base, RBase
from .meta import UUIDType
from .meta import now_timestamp, foreign_key
import json
from .node import get_node_count_who_share_root, get_visible_node_count_who_share_root
class NodeCache(RBase, Base):
"""
The NodeCache class duplicates and denormalizes results of intensive
queries, like full table scans, into a lookup table indexed by the node_id.
You can think of this table and class as a cache for statistics.
Always invalidate before writing to the underlying normalized relations.
"""
id = Column(UUIDType, primary_key=True, index=True)
node_id = Column(UUIDType, foreign_key("Node", "id"), index=True, default=None)
valid = Column(Boolean, default=False)
updated_timestamp = Column(BigInteger, nullable=False)
# Statistics stored as JSON in the database.
json_stats = Column(UnicodeText, default=None)
# lazy='joined' performs a left join and reduces amount of queries
# from N different users to 1. It's magic.
node = relationship(
argument="Node",
lazy="joined",
back_populates="cache",
uselist=False,
foreign_keys=[node_id],
)
def __init__(self, node_id):
self.id = uuid.uuid1()
self.node_id = node_id
self.updated_timestamp = now_timestamp()
def _recompute_stats(self, new_stats=None):
# todo: this might make recomputations automatic / easier.
# https://sqlalchemy-utils.readthedocs.io/en/latest/aggregates.html
if new_stats is None:
new_stats = {}
if self.node.is_root:
new_stats["root"] = {}
# don't consider the root node itself in the counts, so minus 1.
new_stats["root"]["count"] = (
get_node_count_who_share_root(self.dbsession, self.node) - 1
)
new_stats["root"]["visible_count"] = (
get_visible_node_count_who_share_root(self.dbsession, self.node) - 1
)
self.valid = True
self.json_stats = json.dumps(new_stats)
self.updated_timestamp = now_timestamp()
def invalidate(self):
"""invalidate stats to force a full recomputation on next read request."""
self.valid = False
self.updated_timestamp = now_timestamp()
@property
def stats(self):
if not self.valid:
self._recompute_stats()
return json.loads(self.json_stats)
@stats.setter
def stats(self, new_stats):
self._recompute_stats(new_stats)
def invalidate_all_node_cache_objects(dbsession):
"""Only use this if you change the NodeCache stats JSON schema."""
dbsession.query(NodeCache).update(
{"valid": False, "updated_timestamp": now_timestamp()}
)
dbsession.flush()

View file

@ -0,0 +1,97 @@
from .meta import Base, RBase, UUIDType, foreign_key, get_object_by_id, now_timestamp
from sqlalchemy import Column, BigInteger, Boolean, Unicode, UnicodeText, Enum
from sqlalchemy.orm import relationship
import uuid
from remarkbox.lib import (
timestamp_to_datetime,
timestamp_to_ago_string,
timestamp_to_date_string,
)
class NodeEventNotification(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
# the Node this event is for.
node_event_id = Column(UUIDType, foreign_key("NodeEvent", "id"), index=True)
# the watcher which caused this notification to exist.
watcher_id = Column(UUIDType, foreign_key("Watcher", "id"), index=True)
# the user to notify.
user_id = Column(UUIDType, foreign_key("User", "id"), index=True)
# the type of action that was performed on Node.
created_timestamp = Column(BigInteger, nullable=False)
# the timestamp when the notification was last updated.
updated_timestamp = Column(BigInteger, nullable=False)
# was the notification sent yet?
sent = Column(Boolean, default=False)
# was the notification read yet?
# not really used for email, but could be useful for
# push notifications, web notifications, or browser notifications
read = Column(Boolean, default=False)
def __init__(self, node_event, watcher):
self.id = uuid.uuid1()
self.node_event = node_event
self.watcher = watcher
self.user = watcher.user
self.created_timestamp = now_timestamp()
self.updated_timestamp = now_timestamp()
# 1-to-1 relationships.
node_event = relationship(argument="NodeEvent", uselist=False, lazy="joined")
watcher = relationship(argument="Watcher", uselist=False, lazy="joined")
user = relationship(argument="User", uselist=False, lazy="joined")
@property
def frequency(self):
return self.watcher.notification_frequency
@property
def method(self):
return self.watcher.notification_method
# TODO: take into account user's timezone?
@property
def human_created_timestamp(self):
return timestamp_to_ago_string(self.created_timestamp)
@property
def created_date(self):
return timestamp_to_date_string(self.created_timestamp)
@property
def created_datetime(self):
return timestamp_to_datetime(self.created_timestamp)
@property
def human_changed_timestamp(self):
return timestamp_to_ago_string(self.changed_timestamp)
@property
def changed_date(self):
return timestamp_to_date_string(self.changed_timestamp)
@property
def changed_datetime(self):
return timestamp_to_datetime(self.changed_timestamp)
def get_node_event_notification_by_id(dbsession, node_event_notification_id):
"""Try to get NodeEventNotification object by id or return None."""
return get_object_by_id(dbsession, node_event_notfication_id, NodeEventNotification)
def get_topsecret_notifications(dbsession, limit=100, offset=0):
"""Return all NodeEventNotifications."""
return (
dbsession.query(NodeEventNotification)
.order_by(
NodeEventNotification.created_timestamp.desc(),
NodeEventNotification.user_id,
)
.limit(limit)
.offset(offset)
)

64
remarkbox/models/oauth.py Normal file
View file

@ -0,0 +1,64 @@
from .meta import Base, RBase, UUIDType, foreign_key, get_object_by_id, now_timestamp
from sqlalchemy import Column, BigInteger, Unicode, UnicodeText
from sqlalchemy.orm import relationship
import uuid
import json
try:
unicode("")
except:
from six import u as unicode
class Oauth(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
# the related user this oauth record belongs to.
user_id = Column(UUIDType, foreign_key("User", "id"), index=True)
# the related namespace this oauth record belongs to.
namespace_id = Column(UUIDType, foreign_key("Namespace", "id"), index=True)
# the name of the service (type of oauth record), for example slack.
service = Column(Unicode(16), nullable=False)
# the token provided after oauth dance used to communicate with 3rd party.
token = Column(Unicode(256), nullable=False)
# optional json_data without defined schema.
json_data = Column(UnicodeText, default=None)
# timestamp when Namespace owner authorized consent to connect with 3rd party oauth.
created_timestamp = Column(BigInteger, nullable=False)
# the last time this record was modified.
updated_timestamp = Column(BigInteger, nullable=False)
def __init__(self, user, namespace, service, token, data=None):
self.id = uuid.uuid1()
self.user = user
self.namespace = namespace
self.service = unicode(service)
self.token = unicode(token)
self.created_timestamp = now_timestamp()
self.data = data
# 1-to-1 relationships.
user = relationship(argument="User", uselist=False, lazy="joined")
namespace = relationship(argument="Namespace", uselist=False, lazy="joined")
@property
def data(self):
if self.json_data:
return json.loads(self.json_data)
return None
@data.setter
def data(self, new_data):
if new_data:
self.json_data = json.dumps(new_data)
else:
self.json_data = None
self.updated_timestamp = now_timestamp()
def get_oauth_by_id(dbsession, oauth_id):
"""Try to get Oauth object by id or return None"""
return get_object_by_id(dbsession, oauth_id, Oauth)

View file

@ -0,0 +1,39 @@
from sqlalchemy import BigInteger, Boolean, Column, Enum
from sqlalchemy.orm import relationship, backref
from sqlalchemy.ext.associationproxy import association_proxy
from .meta import Base, RBase
from .meta import now_timestamp, foreign_key
from .meta import UUIDType
class PayWhatYouCan(RBase, Base):
user_id = Column(UUIDType, foreign_key("User", "id"), primary_key=True, index=True)
# whether or not this is a one time charge or a yearly.
frequency = Column(Enum("once","yearly", name="frequency"), nullable=False)
# the amount in cents to charge this user.
amount = Column(BigInteger, nullable=False)
# the total contributions from this user in cents.
contributions = Column(BigInteger, default=0, nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
# 1-to-1 relationships.
user = relationship(argument="User", uselist=False, lazy="joined", back_populates="pay_what_you_can")
def __init__(self, user, frequency, amount):
self.created_timestamp = now_timestamp()
self.updated_timestamp = now_timestamp()
self.user = user
self.frequency = frequency
self.amount = amount
def update(self, frequency, amount):
if frequency != self.frequency:
self.frequency = frequency
self.updated_timestamp = now_timestamp()
if amount != self.amount:
self.amount = amount
self.updated_timestamp = now_timestamp()

139
remarkbox/models/uri.py Normal file
View file

@ -0,0 +1,139 @@
from .meta import Base, RBase
from .meta import foreign_key
from .meta import UUIDType
from sqlalchemy import Unicode, Column, BigInteger
from sqlalchemy.orm import relationship
import uuid
import miniuri
from remarkbox.lib import (
timestamp_to_datetime,
timestamp_to_ago_string,
timestamp_to_date_string,
)
from .meta import now_timestamp
import logging
try:
unicode("")
except:
from six import u as unicode
log = logging.getLogger(__name__)
class Uri(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
node_id = Column(UUIDType, foreign_key("Node", "id"))
accessed_timestamp = Column(BigInteger, nullable=False, default=0, index=True)
data = Column(Unicode(256), nullable=False, index=True, unique=True)
node = relationship(argument="Node", uselist=False, back_populates="uri")
def __init__(self, data=None):
self.id = uuid.uuid1()
self.data = unicode(data)
@property
def data_ascii(self):
return self.data.encode('ascii', 'ignore')
@property
def parsed(self):
return miniuri.Uri(self.data)
@property
def favicon(self):
favicon = self.parsed
favicon.path = "/favicon.ico"
return favicon
@property
def robots(self):
robots = self.parsed
robots.path = "/robots.txt"
return robots
# TODO: take into account user's timezone?
@property
def accessed_date(self):
return timestamp_to_date_string(self.accessed_timestamp)
# TODO: take into account user's timezone?
@property
def accessed_datetime(self):
return timestamp_to_datetime(self.accessed_timestamp)
def get_all_uris(dbsession):
return dbsession.query(Uri).order_by(Uri.access_timestamp.desc()).all()
def get_uri_by_uri(dbsession, external_uri):
"""Try to get Uri object by external_uri or return None"""
if external_uri:
return (
dbsession.query(Uri).filter(Uri.data == unicode(external_uri)).one_or_none()
)
def get_or_create_uri_by_uri(dbsession, external_uri):
uri = get_uri_by_uri(dbsession, external_uri)
if uri is None:
uri = Uri(external_uri)
return uri
def get_or_create_uri(dbsession, external_uri):
"""Try to get Uri object by external_uri or return a new Uri."""
# imported here to prevent circular import.
from .namespace import get_or_create_namespace
if external_uri.count("://") >= 2:
# example:
# https://webcache.googleusercontent.com/search?
# q=cache:pSxEzC1gfeIJ:https://www.crazygames.com/game/shellshockersio+&cd=2&hl=en&ct=clnk&gl=us
if "webcache.googleusercontent.com" in external_uri:
# deal with google web cache to get at the real uri.
# https://www.crazygames.com/game/shellshockersio
external_uri_parts = external_uri.split(":")
external_uri = ":".join(external_uri_parts[-2:])
external_uri = external_uri.split("+")[0]
else:
# ignore the query string without consulting Namespace.
# thread_uri=https://www.crazygames.com/game/happy-wheels?
# utm_source=https%3A%2F%2Fwww.crazygames.com%2Fgame%2Fhappy-wheels
external_uri = external_uri.split("?")[0]
elif "?" in external_uri:
# check if we should ignore query string.
log.info("parsing hostname with miniuri: {}".format(external_uri))
muri = miniuri.Uri(external_uri)
namespace = get_or_create_namespace(dbsession, muri.hostname)
if namespace.ignore_query_string:
# search for uri without query string.
external_uri = external_uri.split("?")[0]
# by default remove any anchor fragments from external uri.
# TODO: configurable from Namespace settings?
if "#" in external_uri:
# search for uri without fragment string.
external_uri = external_uri.split("#")[0]
# https://www.example.com/index.html -> https://www.example.com
# https://www.example.com/asdf/index.php -> https://www.example.com/asdf
# TODO: configurable from Namespace settings?
if "/index." in external_uri:
for extension in ["html", "php"]:
file_part = "/index.{}".format(extension)
if external_uri.endswith(file_part):
external_uri = external_uri.rstrip(file_part)
return get_or_create_uri_by_uri(dbsession, external_uri)

416
remarkbox/models/user.py Normal file
View file

@ -0,0 +1,416 @@
from sqlalchemy import BigInteger, Boolean, Column, Unicode, Enum, func, or_
from sqlalchemy.orm import relationship, backref
from sqlalchemy.ext.associationproxy import association_proxy
import uuid
import bcrypt
from remarkbox.lib import timestamp_to_ago_string, gravatar_client
from remarkbox.lib.svgtar import inline_svgtar
from .meta import NOTIFICATION_FREQUENCIES
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key, get_object_by_id
from .node import Node
from .namespace_request import NamespaceRequest
from .watcher import Watcher
from .notification import NodeEventNotification
from miscutils import generate_password
import logging
log = logging.getLogger(__name__)
try:
unicode("")
except:
from six import u as unicode
class UserSurrogate(RBase, Base):
"""
This class represents a user surrogate account, a stand in.
A user surrogate may not log in; has no email. It is not a "real"
User, but a surrogate, a ghost of a user imported from another system.
"""
id = Column(UUIDType, primary_key=True, index=True)
# the namespace which imported this user surrogate.
namespace_id = Column(UUIDType, foreign_key("Namespace", "id"), index=True)
# the display name.
name = Column(Unicode(64), nullable=False)
created_timestamp = Column(BigInteger, nullable=False)
namespace = relationship(argument="Namespace", uselist=False, lazy="joined")
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
nodes = relationship(
argument="Node",
lazy="dynamic",
order_by=Node.created.desc(),
back_populates="user_surrogate",
)
def __init__(self, name, namespace):
self.name = name
self.namespace_id = namespace.id
self.created_timestamp = now_timestamp()
self.id = uuid.uuid1()
def svgtar(self, **kwargs):
if "text" not in kwargs:
kwargs["text"] = self.name[:1]
return inline_svgtar(h=str(self.id), **kwargs)
def avatar_uri(self, **kwargs):
return self.svgtar(**kwargs)
class User(RBase, Base):
"""This class represents a user account"""
# was this User object authenticated?
authenticated = False
id = Column(UUIDType, primary_key=True, index=True)
name = Column(Unicode(64), unique=True, nullable=False)
email = Column(Unicode(64), unique=True, nullable=False)
# the email_id is a random string added to otp links to prevent an
# attacker from looping/triggering otp emails for a list of addresses.
# we only share this string with the owner of an email box. not-in-use...
email_id = Column(Unicode(8))
password = Column(Unicode(64))
password_timestamp = Column(BigInteger)
# TODO: someday this should be renamed to created_timestamp
created = Column(BigInteger, nullable=False)
gravatar = Column(Boolean, default=False)
verified = Column(Boolean, default=False)
disabled = Column(Boolean, default=False)
# automatically watch any threads I create.
auto_watch_threads_i_create = Column(Boolean, default=True, nullable=False)
# automatically watch any threads I participate in.
auto_watch_threads_i_participate = Column(Boolean, default=False, nullable=False)
# By default, when I watch a Thread, I want to be notified:
default_node_watcher_frequency = Column(
Enum(*NOTIFICATION_FREQUENCIES, name="frequency"),
default="daily",
nullable=False,
)
# example: cus_12345678AbCdEF but may be null.
stripe_id = Column(Unicode(18), unique=True, nullable=True)
votes = relationship(argument="Vote", backref="user", order_by="desc(Vote.created)")
# lazy='dynamic' returns a query object instead of collection.
# Reference: http://docs.sqlalchemy.org/en/latest/orm/collections.html
nodes = relationship(
argument="Node",
lazy="dynamic",
order_by=Node.created.desc(),
back_populates="user",
)
namespaces = association_proxy(
"user_namespaces", "namespace", creator=lambda n: NamespaceUser(namespace=n)
)
oauth_records = relationship(
argument="Oauth", lazy="dynamic", back_populates="user"
)
namespace_owner_requests = relationship(
argument="NamespaceRequest", lazy="dynamic", back_populates="user"
)
watchers = relationship(
argument="Watcher",
lazy="dynamic",
back_populates="user",
order_by=Watcher.created_timestamp,
cascade="save-update, merge, delete",
)
# 1-to-1 relationships.
pay_what_you_can = relationship(argument="PayWhatYouCan", uselist=False, lazy="joined")
@property
def node_watchers(self):
return self.watchers.filter(Watcher.type == "node")
@property
def namespace_watchers(self):
return self.watchers.filter(Watcher.type == "namespace")
@property
def reply_watchers(self):
return self.watchers.filter(Watcher.type == "reply")
@property
def reply_watcher(self):
"""for now we only support one reply_watcher in the UI."""
return self.watchers.filter(Watcher.type == "reply").one()
def get_watcher_by_id(self, watcher_id):
"""Return a Watcher object for by ID or None."""
return self.node_watchers.filter(Watcher.id == watcher_id).one_or_none()
def get_watcher_by_node_id(self, node_id):
"""Return a Watcher object for the node (thread) or None."""
return self.node_watchers.filter(Watcher.node_id == node_id).one_or_none()
def watch_node(self, node):
"""create and return new Watcher object for this node and user in memory"""
watcher = Watcher(
user=self,
type="node",
node=node,
notification_frequency=self.default_node_watcher_frequency,
)
self.watchers.append(watcher)
return watcher
def watch_namespace(self, namespace):
"""create and return new Watcher object for this namespace and user in memory"""
watcher = self.get_watcher_by_namespace_id(namespace.id)
if watcher:
return watcher
watcher = Watcher(
user=self,
type="namespace",
namespace=namespace,
notification_frequency="daily",
)
self.watchers.append(watcher)
return watcher
def get_watcher_by_namespace_id(self, namespace_id):
"""Return a Watcher object for the Namespace or None."""
return self.namespace_watchers.filter(
Watcher.namespace_id == namespace_id
).one_or_none()
def unwatch_namespace(self, namespace):
"""Stop watching a Namespace."""
watcher = self.get_watcher_by_namespace_id(namespace.id)
if watcher:
self.dbsession.delete(watcher)
self.dbsession.flush()
node_notifications = relationship(
argument="NodeEventNotification", lazy="dynamic", back_populates="user"
)
@property
def unsent_node_notifications(self):
return self.node_notifications.filter(NodeEventNotification.sent == False)
@property
def sent_node_notifications(self):
return self.node_notifications.filter(NodeEventNotification.sent == True)
@property
def verified_namespace_owner_requests(self):
return [nr for nr in self.namespace_owner_requests if nr.verified]
@property
def unverified_namespace_owner_requests(self):
return [nr for nr in self.namespace_owner_requests if not nr.verified]
@property
def verified_nodes(self):
return self.nodes.filter(Node.verified == True, Node.disabled == False)
@property
def unverified_nodes(self):
return self.nodes.filter(Node.verified == False, Node.disabled == False)
@property
def disabled_nodes(self):
return self.nodes.filter(Node.verified == True, Node.disabled == True)
@property
def unapproved_nodes(self):
return self.nodes.filter(or_(Node.approved == False, Node.approved.is_(None)))
def page_nodes(self, limit=100, offset=0):
if self.nodes.count() == 0:
return []
return (
self.nodes.filter(
Node.disabled == False, Node.verified == True, Node.user_id != None, Node.approved == True
)
.order_by(Node.changed.desc())
.limit(limit)
.offset(offset)
)
def __init__(self, email):
self.name = unicode(generate_password(size=8))
self.created = now_timestamp()
self.id = uuid.uuid1()
self.email = unicode(email)
self.email_id = unicode(self._generate_raw_password(8))
self.new_password()
# don't password throttle new User objects.
self.password_timestamp = 0
def _generate_raw_password(self, size=32):
"""Return a system generated password"""
return generate_password(size)
def new_password(self):
"""Generate and return raw password, store password hash into DB."""
raw_password = self._generate_raw_password()
# bcrypt works with bytes so we encode to utf-8.
self.password = bcrypt.hashpw(
raw_password.encode("utf-8"),
bcrypt.gensalt()
).decode("utf-8")
self.password_timestamp = now_timestamp()
return raw_password
def check_password(self, password):
"""Accept plain-text raw password, create hash, compare with DB."""
stored_hash = self.password
# bcrypt works with bytes so we encode to utf-8.
new_hash = bcrypt.hashpw(
password.encode("utf-8"),
stored_hash.encode("utf-8"),
).decode("utf-8")
log.info("new_hash={} stored_hash={}".format(new_hash, stored_hash))
if new_hash == stored_hash:
return True
return False
def throttle_password(self, needed_delta=90000):
"""Return True when throttled, else False"""
if self.password_timestamp_delta <= needed_delta:
return True
return False
@property
def password_timestamp_delta(self):
return now_timestamp() - self.password_timestamp
@property
def human_password_timestamp(self):
return timestamp_to_ago_string(self.password_timestamp)
@property
def human_timestamp(self):
return timestamp_to_ago_string(self.created)
def gravatar_uri(self, **kwargs):
return gravatar_client(self.email, **kwargs)
def svgtar(self, **kwargs):
if "text" not in kwargs:
kwargs["text"] = self.name[:1]
return inline_svgtar(h=str(self.id), **kwargs)
def avatar_uri(self, **kwargs):
if self.gravatar:
return self.gravatar_uri(**kwargs)
return self.svgtar(**kwargs)
def create_default_reply_watcher(self):
if self.reply_watchers.count() == 0:
reply_watcher = Watcher(self, "reply")
self.watchers.append(reply_watcher)
return reply_watcher
return self.reply_watchers.first()
def _user_by_name_query(dbsession, name):
"""query User by case insensitive name."""
return dbsession.query(User).filter(func.lower(User.name) == unicode(name.lower()))
def is_user_name_available(dbsession, name):
return not _user_by_name_query(dbsession, unicode(name)).count()
def generate_user_name(dbsession, desired_name=""):
size = 8
sep = ""
if desired_name:
size = 3
sep = "-"
name = sep.join([desired_name, generate_password(size)])
while is_user_name_available(dbsession, name) == False:
name = sep.join([desired_name, generate_password(size)])
return name
def is_user_name_valid(name):
"""Test if user name meets our criteria for validity."""
# The choices here are subject to change. Right now we allow dashes.
name = name.replace("-", "")
return name.isalnum()
def get_user_by_name(dbsession, name):
"""Try to get User object by name or return None"""
if name:
return _user_by_name_query(dbsession, name).one_or_none()
def get_user_by_id(dbsession, user_id):
"""Try to get User object by id or return None."""
return get_object_by_id(dbsession, user_id, User)
def get_user_by_email(dbsession, email):
"""Try to get User object by email or return None"""
if email:
return dbsession.query(User).filter(User.email == unicode(email)).one_or_none()
def get_or_create_user_by_email(dbsession, email):
"""Try to get User object by email or return new User"""
user = get_user_by_email(dbsession, email)
if user is None:
# create new User for unverified user.
user = User(email)
if not is_user_name_available(dbsession, user.name):
user.name = generate_user_name(dbsession)
return user
def get_user_surrogate_by_name(dbsession, name, namespace):
"""Try to get UserSurrogate object by case insensitive name or return None"""
if name:
return (
dbsession.query(UserSurrogate)
.filter(func.lower(User.name) == unicode(name.lower()))
.filter(UserSurrogate.namespace_id == namespace.id)
.one_or_none()
)
def get_or_create_user_surrogate_by_name(dbsession, name, namespace):
"""
Try to get UserSurrogate object by case insensitive name
or create and return a new UserSurrogate
"""
user_surrogate = get_user_surrogate_by_name(dbsession, name, namespace)
if user_surrogate is None:
user_surrogate = UserSurrogate(name=name, namespace=namespace)
return user_surrogate

30
remarkbox/models/vote.py Normal file
View file

@ -0,0 +1,30 @@
from sqlalchemy import Integer, BigInteger, Column
import uuid
from remarkbox.lib import timestamp_to_ago_string, timestamp_to_date_string
from .meta import Base, RBase
from .meta import now_timestamp, foreign_key
from .meta import UUIDType
class Vote(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"))
node_id = Column(UUIDType, foreign_key("Node", "id"))
created = Column(BigInteger, nullable=False)
value = Column(Integer, nullable=False)
def __init__(self):
self.id = uuid.uuid1()
self.created = now_timestamp()
@property
def human_timestamp(self):
return timestamp_to_ago_string(self.created)
# TODO: take into account user's timezone?
@property
def created_date(self):
return timestamp_to_date_string(self.created)

View file

@ -0,0 +1,80 @@
from .meta import WATCHER_TYPES, NOTIFICATION_FREQUENCIES, NOTIFICATION_METHODS
from .meta import Base, RBase, UUIDType, foreign_key, get_object_by_id, now_timestamp
from sqlalchemy import Column, BigInteger, Unicode, UnicodeText, Enum
from sqlalchemy.orm import relationship
import uuid
from .notification import NodeEventNotification
class Watcher(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
# the User watching the Node.
user_id = Column(UUIDType, foreign_key("User", "id"), index=True)
# the Node under watch.
node_id = Column(UUIDType, foreign_key("Node", "id"), index=True, nullable=True)
# the Namespace under watch.
namespace_id = Column(
UUIDType, foreign_key("Namespace", "id"), index=True, nullable=True
)
# the type of watcher object.
type = Column(Enum(*WATCHER_TYPES, name="type"))
# how will this watcher object notify?
notification_method = Column(
Enum(*NOTIFICATION_METHODS, name="notification_method"), default="email"
)
# how frequently should we notify of changes?
notification_frequency = Column(
Enum(*NOTIFICATION_FREQUENCIES, name="frequency"), default="daily"
)
# the point in time when the User started authorized consent to watch.
created_timestamp = Column(BigInteger, nullable=False)
# the last time this record was modified.
updated_timestamp = Column(BigInteger, nullable=False)
def __init__(
self,
user,
type,
notification_method="email",
notification_frequency="daily",
node=None,
namespace=None,
):
self.id = uuid.uuid1()
self.user = user
self.type = type
self.notification_method = notification_method
self.notification_frequency = notification_frequency
self.created_timestamp = now_timestamp()
self.updated_timestamp = now_timestamp()
self.node = node
self.namespace = namespace
# 1-to-1 relationships.
node = relationship(argument="Node", uselist=False, lazy="joined")
namespace = relationship(argument="Namespace", uselist=False, lazy="joined")
user = relationship(argument="User", uselist=False, lazy="joined")
notifications = relationship(
argument="NodeEventNotification",
lazy="dynamic",
cascade="save-update, merge, delete",
back_populates="watcher"
)
def unsent_notifications(self):
self.notifications.filter(NodeEventNotification.sent == False)
def new_notification(self, node_event):
notification = NodeEventNotification(watcher=self, node_event=node_event)
self.notifications.append(notification)
return notification
def get_watcher_by_id(dbsession, watcher_id):
"""Try to get Watcher object by id or return None."""
return get_object_by_id(dbsession, watcher_id, Watcher)

127
remarkbox/routes.py Normal file
View file

@ -0,0 +1,127 @@
def includeme(config):
# shared routes
config.add_static_view("static", "static", cache_max_age=3600)
config.add_route("favicon", "/favicon.ico")
config.add_route("robots", "/robots.txt")
config.add_route("embed-iframe", "/embed-iframe.txt")
config.add_route("embed-iframe-min", "/embed-iframe-min.txt")
# stripe: credit card storage and processing.
config.add_route("billing", "/billing")
config.add_route("add-card", "/billing/add-card")
config.add_route(
"confirm-update-card", "/billing/confirm-update-card/{action}/{card_id}"
)
config.add_route("update-card", "/billing/update-card")
config.add_route("pay-what-you-can", "/pay-what-you-can")
# slack: bot notifications and oauth.
config.add_route("oauth-slack", "/oauth/slack")
config.add_route("oauth-slack-delete", "/oauth/slack/delete")
# todo: if namespace.public == False:
# lock this routes down to only load for namespace owners
config.add_route("home", "/")
config.add_route("sitemap", "/all.sitemap.xml")
config.add_route("sitemap2", "/ns/{namespace}/all.sitemap.xml")
config.add_route("namespace-dump-to-json", "/ns/{namespace}/dump.json")
# allows us to dynamically generate CSS regarding nesting based on
# the avatar_size configured in config file (move to Namespace settings?)
config.add_route("dynamic-remarkbox-css", "/dynamic-remarkbox.css")
config.add_route("search", "/search")
config.add_route("setup", "/setup")
config.add_route("setup-namespace-request-cancel", "/nsr/cancel")
config.add_route("log-out", "/log-out")
config.add_route("preview-post", "/preview-post")
config.add_route("new", "/new")
config.add_route("watch", "/watch")
config.add_route("unwatch", "/unwatch")
config.add_route("update-watching", "/update-watching")
# lock and unlock a thread.
config.add_route("lock", "/lock")
config.add_route("unlock", "/unlock")
# Given a node_id redirect to the node's root.
config.add_route("redirect-to-root", "/r/{node_id}")
# superfly admins: locked down routes.
config.add_route("topsecret-namespace-requests", "/topsecret/namespace-requests")
config.add_route("topsecret-namespace-owners", "/topsecret/namespace-owners")
config.add_route("topsecret-namespaces", "/topsecret/namespaces")
config.add_route("topsecret-notifications", "/topsecret/notifications")
config.add_route("topsecret-nodes", "/topsecret/nodes")
config.add_route("topsecret", "/topsecret")
# embed routes:
config.add_route("embed-join-or-log-in", "/embed/ns/{namespace}/join-or-log-in")
config.add_route("embed-log-out", "/embed/ns/{namespace}/log-out")
config.add_route("embed-namespace-nodes", "/embed/ns/{namespace}/nodes")
config.add_route("embed-namespace-settings", "/embed/ns/{namespace}/settings")
config.add_route(
"embed-namespace-stylesheet", "/embed/ns/{namespace}/{filename}.css"
)
config.add_route("embed-namespace", "/embed/ns/{namespace}")
config.add_route("embed-user-settings", "/embed/ns/{namespace}/u/settings")
config.add_route("embed-user-watching", "/embed/ns/{namespace}/u/watching")
config.add_route("embed-user-notifications", "/embed/ns/{namespace}/u/notifications")
config.add_route("embed-user", "/embed/ns/{namespace}/u/{user_name}")
config.add_route("embed-verify", "/embed/ns/{namespace}/{node_id}/verify")
config.add_route("embed-disable", "/embed/ns/{namespace}/{node_id}/disable")
config.add_route("embed-enable", "/embed/ns/{namespace}/{node_id}/enable")
config.add_route("embed-approve", "/embed/ns/{namespace}/{node_id}/approve")
config.add_route("embed-deny", "/embed/ns/{namespace}/{node_id}/deny")
config.add_route("embed-edit", "/embed/ns/{namespace}/{node_id}/edit")
config.add_route("embed-reply", "/embed/ns/{namespace}/reply")
config.add_route("embed-reply2", "/embed/ns/{namespace}/{node_id}/reply")
config.add_route("embed-show-node2", "/embed/ns/{namespace}/{node_id}")
config.add_route("embed-show-count", "/embed/count")
config.add_route("embed-show-node", "/embed")
config.add_route("embed-show-node3", "/embed/{node_id}")
# basic routes:
config.add_route("basic-join-or-log-in", "/join-or-log-in")
config.add_route("basic-namespace-nodes", "/ns/{namespace}/nodes")
config.add_route("basic-namespace-settings", "/ns/{namespace}/settings")
config.add_route("basic-namespace-stats-json", "/ns/{namespace}/stats.json")
config.add_route("basic-namespace-stylesheet", "/ns/{namespace}/{filename}.css")
config.add_route("basic-namespace-threads-rss", "/ns/{namespace}.threads.xml")
config.add_route("basic-namespace-nodes-rss", "/ns/{namespace}.nodes.xml")
config.add_route(
"basic-namespace-pending-rss", "/ns/{namespace}.pending.{feed_key}.xml"
)
config.add_route("basic-namespace", "/ns/{namespace}")
config.add_route("basic-user-settings", "/u/settings")
config.add_route("basic-user-watching", "/u/watching")
config.add_route("basic-user-notifications", "/u/notifications")
config.add_route("basic-user", "/u/{user_name}")
config.add_route("basic-verify", "/{node_id}/verify")
config.add_route("basic-disable", "/{node_id}/disable")
config.add_route("basic-enable", "/{node_id}/enable")
config.add_route("basic-approve", "/{node_id}/approve")
config.add_route("basic-deny", "/{node_id}/deny")
config.add_route("basic-edit", "/{node_id}/edit")
config.add_route("basic-edit2", "/{node_id}/{slug:.*}/edit")
config.add_route("basic-reply", "/{node_id}/reply")
config.add_route("basic-reply2", "/{node_id}/{slug:.*}/reply")
config.add_route("basic-show-count", "/{node_id}/count")
config.add_route("basic-show-count2", "/{node_id}/{slug:.*}/count")
config.add_route("basic-show-node", "/{node_id}")
config.add_route("basic-show-node2", "/{node_id}/{slug:.*}") # must be last.

View file

@ -0,0 +1,7 @@
import argparse
def base_parser(description):
parser = argparse.ArgumentParser(description=description)
parser.add_argument("-c", "--config", default="development.ini")
return parser

View file

@ -0,0 +1,11 @@
Change Log
################
**5a5d0b68db78**:
add_root_id_field.py
**6bbc92ccc112**:
add_changed_column_to_node_table.py

View file

@ -0,0 +1,73 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
# from remarkbox import models
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
# target_metadata = None
from remarkbox.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,29 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
from sqlalchemy_utils import UUIDType as TempUUIDType
UUIDType = TempUUIDType(binary=False)
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,24 @@
"""Add ip_address column to rb_node
Revision ID: 00211ed642cc
Revises: fa8402aa1a00
Create Date: 2018-05-02 02:33:50.107923
"""
# revision identifiers, used by Alembic.
revision = "00211ed642cc"
down_revision = "fa8402aa1a00"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column("rb_node", sa.Column("ip_address", sa.String(45)))
def downgrade():
op.drop_column("rb_node", "ip_address")

View file

@ -0,0 +1,57 @@
"""create new node_cache table
Revision ID: 0208c10ea67f
Revises: 503a2cef2378
Create Date: 2018-03-28 13:31:34.018012
"""
# revision identifiers, used by Alembic.
revision = "0208c10ea67f"
down_revision = "503a2cef2378"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy_utils import UUIDType as TempUUIDType
UUIDType = TempUUIDType(binary=False)
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"rb_node_cache",
sa.Column("id", UUIDType, nullable=False),
sa.Column("node_id", UUIDType, nullable=True),
sa.Column("valid", sa.Boolean(), nullable=True),
sa.Column("updated_timestamp", sa.BigInteger(), nullable=False),
sa.Column("json_stats", sa.UnicodeText(), nullable=True),
sa.ForeignKeyConstraint(["node_id"], ["rb_node.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_rb_node_cache_id"), "rb_node_cache", ["id"], unique=False)
op.create_index(
op.f("ix_rb_node_cache_node_id"), "rb_node_cache", ["node_id"], unique=False
)
op.drop_table("rb_root_cache")
def downgrade():
op.create_table(
"rb_root_cache",
sa.Column("id", sa.CHAR(length=32), nullable=False),
sa.Column("root_id", sa.CHAR(length=32), nullable=True),
sa.Column("valid", sa.BOOLEAN(), nullable=True),
sa.Column("updated_timestamp", sa.BIGINT(), nullable=False),
sa.Column("json_stats", sa.TEXT(), nullable=True),
sa.CheckConstraint(u"valid IN (0, 1)"),
sa.ForeignKeyConstraint(["root_id"], [u"rb_node.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.drop_index(op.f("ix_rb_node_cache_node_id"), table_name="rb_node_cache")
op.drop_index(op.f("ix_rb_node_cache_id"), table_name="rb_node_cache")
op.drop_table("rb_node_cache")

View file

@ -0,0 +1,38 @@
"""autogenerated indicies.
Revision ID: 04e52337756e
Revises: 6a440d3921d2
Create Date: 2017-07-03 13:45:32.742934
"""
# revision identifiers, used by Alembic.
revision = "04e52337756e"
down_revision = "6a440d3921d2"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f("ix_Node_id"), "Node", ["id"], unique=False)
op.create_index(op.f("ix_Node_root_id"), "Node", ["root_id"], unique=False)
op.create_index(op.f("ix_Uri_data"), "Uri", ["data"], unique=False)
op.create_index(op.f("ix_Uri_id"), "Uri", ["id"], unique=False)
op.create_index(op.f("ix_User_id"), "User", ["id"], unique=False)
op.create_index(op.f("ix_Vote_id"), "Vote", ["id"], unique=False)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_Vote_id"), table_name="Vote")
op.drop_index(op.f("ix_User_id"), table_name="User")
op.drop_index(op.f("ix_Uri_id"), table_name="Uri")
op.drop_index(op.f("ix_Uri_data"), table_name="Uri")
op.drop_index(op.f("ix_Node_root_id"), table_name="Node")
op.drop_index(op.f("ix_Node_id"), table_name="Node")
### end Alembic commands ###

View file

@ -0,0 +1,26 @@
"""namespace ignore_query_string
Revision ID: 051f8b810f26
Revises: 3ee195d142fb
Create Date: 2019-02-20 10:27:29.804965
"""
# revision identifiers, used by Alembic.
revision = '051f8b810f26'
down_revision = '3ee195d142fb'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
from sqlalchemy.sql import expression
op.add_column('rb_namespace', sa.Column('ignore_query_string', sa.Boolean(), nullable=True, server_default=expression.false()))
def downgrade():
pass

View file

@ -0,0 +1,26 @@
"""namespace description
Revision ID: 1040ee4836af
Revises: cbdf3cd4ccba
Create Date: 2017-07-15 23:25:12.010472
"""
# revision identifiers, used by Alembic.
revision = "1040ee4836af"
down_revision = "cbdf3cd4ccba"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"Namespace", sa.Column("description", sa.Unicode(length=256), nullable=True)
)
def downgrade():
op.drop_column("Namespace", "description")

View file

@ -0,0 +1,26 @@
"""uri-accessed-timestamp
Revision ID: 184a349cd589
Revises: a38c12eeff2d
Create Date: 2020-04-20 22:13:20.339912
"""
# revision identifiers, used by Alembic.
revision = '184a349cd589'
down_revision = 'a38c12eeff2d'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('rb_uri', sa.Column('accessed_timestamp', sa.BigInteger(), nullable=False, server_default="0"))
op.create_index(op.f('ix_rb_uri_accessed_timestamp'), 'rb_uri', ['accessed_timestamp'], unique=False)
def downgrade():
op.drop_index(op.f('ix_rb_uri_accessed_timestamp'), table_name='rb_uri')
op.drop_column('rb_uri', 'accessed_timestamp')

View file

@ -0,0 +1,55 @@
"""loop over root nodes and create namespace
Revision ID: 256468780798
Revises: 98a90d2add21
Create Date: 2017-07-03 16:37:07.937232
"""
# revision identifiers, used by Alembic.
revision = "256468780798"
down_revision = "98a90d2add21"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm.exc import NoResultFound
from remarkbox.models import Node, Namespace
def upgrade():
bind = op.get_bind()
DBSession = sa.orm.Session(bind=bind)
def get_or_create_namespace(name):
try:
return DBSession.query(Namespace).filter(Namespace.name == name).one()
except NoResultFound:
namespace = Namespace(name)
DBSession.add(namespace)
DBSession.flush()
DBSession.commit()
return namespace
root_nodes = DBSession.query(Node).filter(Node.parent_id == None)
for root_node in root_nodes:
if root_node.uri:
namespace = get_or_create_namespace(root_node.uri.parsed.hostname)
else:
namespace = get_or_create_namespace("temp.namespace.com")
root_node.namespace_id = namespace.id
DBSession.add(root_node)
DBSession.flush()
DBSession.commit()
def downgrade():
pass

View file

@ -0,0 +1,42 @@
"""Node disabled_timestamp
Revision ID: 2e00b92495e4
Revises: 7a3a4755b4a8
Create Date: 2017-08-13 15:58:33.169624
"""
# revision identifiers, used by Alembic.
revision = "2e00b92495e4"
down_revision = "7a3a4755b4a8"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from remarkbox.models import Node, now_timestamp
def upgrade():
# add the new column.
op.add_column(
"Node", sa.Column("disabled_timestamp", sa.BigInteger(), nullable=True)
)
bind = op.get_bind()
DBSession = sa.orm.Session(bind=bind)
# get all disabled nodes.
nodes = DBSession.query(Node).filter(Node.disabled == True)
for node in nodes:
node.disabled_timestamp = now_timestamp()
DBSession.add(node)
DBSession.flush()
DBSession.commit()
def downgrade():
op.drop_column("Node", "disabled_timestamp")

View file

@ -0,0 +1,32 @@
"""add subscription_type to Namespace
Revision ID: 3ee195d142fb
Revises: 771ef7c868de
Create Date: 2019-02-16 14:23:03.175524
"""
# revision identifiers, used by Alembic.
revision = '3ee195d142fb'
down_revision = '771ef7c868de'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
'rb_namespace',
sa.Column(
'subscription_type',
sa.Enum('development', 'trial', 'production', name='subscription_type'),
nullable=False,
server_default='development'
)
)
def downgrade():
pass

View file

@ -0,0 +1,25 @@
"""add user surrogate columns
Revision ID: 40ded1163f95
Revises: c9793937e756
Create Date: 2019-03-07 08:35:54.261514
"""
# revision identifiers, used by Alembic.
revision = '40ded1163f95'
down_revision = 'c9793937e756'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from remarkbox.models import UUIDType
def upgrade():
op.add_column('rb_node', sa.Column('user_surrogate_id', UUIDType, nullable=True))
def downgrade():
pass

View file

@ -0,0 +1,27 @@
"""new namespace column 3
Revision ID: 43c1e5a99d4e
Revises: a738d6859f18
Create Date: 2017-11-11 10:35:40.930389
"""
# revision identifiers, used by Alembic.
revision = "43c1e5a99d4e"
down_revision = "a738d6859f18"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"rb_namespace",
sa.Column("placeholder_text", sa.Unicode(length=256), nullable=True),
)
def downgrade():
op.drop_column("rb_namespace", "placeholder_text")

View file

@ -0,0 +1,32 @@
"""new namespace column for stylesheet uris
Revision ID: 4aeda8e797ab
Revises: 43c1e5a99d4e
Create Date: 2018-01-28 16:11:09.507278
"""
# revision identifiers, used by Alembic.
revision = "4aeda8e797ab"
down_revision = "43c1e5a99d4e"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"rb_namespace",
sa.Column("stylesheet_embed_uri", sa.Unicode(length=256), nullable=True),
)
op.add_column(
"rb_namespace",
sa.Column("stylesheet_uri", sa.Unicode(length=256), nullable=True),
)
def downgrade():
op.drop_column("rb_namespace", "stylesheet_uri")
op.drop_column("rb_namespace", "stylesheet_embed_uri")

View file

@ -0,0 +1,48 @@
"""root-stats
Revision ID: 503a2cef2378
Revises: aa2dfb4decda
Create Date: 2018-03-25 22:20:39.436208
"""
# revision identifiers, used by Alembic.
revision = "503a2cef2378"
down_revision = "aa2dfb4decda"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy_utils import UUIDType as TempUUIDType
UUIDType = TempUUIDType(binary=False)
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"rb_root_cache",
sa.Column("id", UUIDType, nullable=False),
sa.Column("root_id", UUIDType, nullable=True),
sa.Column("valid", sa.Boolean(), nullable=True),
sa.Column("updated_timestamp", sa.BigInteger(), nullable=False),
sa.Column("json_stats", sa.UnicodeText(), nullable=True),
sa.ForeignKeyConstraint(["root_id"], ["rb_node.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_rb_root_cache_id"), "rb_root_cache", ["id"], unique=False)
op.create_index(
op.f("ix_rb_root_cache_root_id"), "rb_root_cache", ["root_id"], unique=False
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_rb_root_cache_root_id"), table_name="rb_root_cache")
op.drop_index(op.f("ix_rb_root_cache_id"), table_name="rb_root_cache")
op.drop_table("rb_root_cache")
# ### end Alembic commands ###

View file

@ -0,0 +1,42 @@
"""node data html column
Revision ID: 591800942adf
Revises: c97ddfcbc406
Create Date: 2017-09-01 21:42:39.429275
"""
# revision identifiers, used by Alembic.
revision = "591800942adf"
down_revision = "c97ddfcbc406"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from remarkbox.models import Node
from remarkbox.lib.render import markdown_to_html
def upgrade():
bind = op.get_bind()
DBSession = sa.orm.Session(bind=bind)
op.add_column("Node", sa.Column("data_html", sa.UnicodeText(), nullable=True))
nodes = DBSession.query(Node).all()
for node in nodes:
if node.data:
node.data_html = markdown_to_html(node.data, node.root.namespace)
DBSession.add(node)
DBSession.flush()
DBSession.commit()
def downgrade():
op.drop_column("Node", "data_html")

View file

@ -0,0 +1,26 @@
"""stylesheet_timestamp
Revision ID: 59592cf325fb
Revises: 256468780798
Create Date: 2017-07-06 00:30:42.512137
"""
# revision identifiers, used by Alembic.
revision = "59592cf325fb"
down_revision = "256468780798"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"Namespace", sa.Column("stylesheet_timestamp", sa.BigInteger(), nullable=True)
)
def downgrade():
op.drop_column("Namespace", "stylesheet_timestamp")

View file

@ -0,0 +1,40 @@
"""add root_id field
Revision ID: 5a5d0b68db78
Revises: 6bbc92ccc112
Create Date: 2017-01-14 20:59:39.701946
"""
# revision identifiers, used by Alembic.
revision = "5a5d0b68db78"
down_revision = "6bbc92ccc112"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from remarkbox.models import Node
def upgrade():
bind = op.get_bind()
DBSession = sa.orm.Session(bind=bind)
op.add_column("Node", sa.Column("root_id", sa.Integer, default=None))
nodes = DBSession.query(Node).all()
for node in nodes:
node.root_id = node.root.id
DBSession.add(node)
DBSession.flush()
DBSession.commit()
def downgrade():
# this will fail for sqlite3 databases...
op.drop_column("Node", "root_id")

View file

@ -0,0 +1,48 @@
"""Added email_id column to User table.
Revision ID: 6a440d3921d2
Revises: 5a5d0b68db78
Create Date: 2017-05-16 16:22:51.161059
"""
# revision identifiers, used by Alembic.
revision = "6a440d3921d2"
down_revision = "5a5d0b68db78"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from remarkbox.models import User
from remarkbox.models import now_timestamp
def upgrade():
bind = op.get_bind()
DBSession = sa.orm.Session(bind=bind)
op.add_column("User", sa.Column("email_id", sa.Unicode))
op.add_column(
"User",
sa.Column(
"password_timestamp", sa.BigInteger, server_default=str(now_timestamp())
),
)
users = DBSession.query(User).all()
for user in users:
user.email_id = user._generate_raw_password(8)
DBSession.add(user)
DBSession.flush()
DBSession.commit()
def downgrade():
# this will fail for sqlite3 databases...
op.drop_column("User", "email_id")
op.drop_column("User", "password_timestamp")

View file

@ -0,0 +1,31 @@
"""add changed column to node table
Revision ID: 6bbc92ccc112
Revises:
Create Date: 2016-09-04 11:41:20.795668
"""
# revision identifiers, used by Alembic.
revision = "6bbc92ccc112"
down_revision = None
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from time import time
def upgrade():
now = int(time() * 1000)
op.add_column(
"Node",
sa.Column("changed", sa.BigInteger, nullable=False, server_default=str(now)),
)
def downgrade():
# this will fail for sqlite3 databases...
op.drop_column("Node", "changed")

View file

@ -0,0 +1,24 @@
"""namespace group conversations
Revision ID: 6db39621f46c
Revises: f7b42b2024fb
Create Date: 2019-03-22 09:40:30.185446
"""
# revision identifiers, used by Alembic.
revision = '6db39621f46c'
down_revision = 'f7b42b2024fb'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('rb_namespace', sa.Column('group_conversations', sa.Boolean(), nullable=True))
def downgrade():
pass

View file

@ -0,0 +1,52 @@
"""notification columns to rb_user table
Revision ID: 6fc1d0cf350c
Revises: a26302bcfa25
Create Date: 2018-05-27 21:04:17.704776
"""
# revision identifiers, used by Alembic.
revision = "6fc1d0cf350c"
down_revision = "a26302bcfa25"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"rb_user",
sa.Column(
"auto_watch_threads_i_create",
sa.Boolean(),
nullable=False,
server_default="1",
),
)
op.add_column(
"rb_user",
sa.Column(
"auto_watch_threads_i_participate",
sa.Boolean(),
nullable=False,
server_default="0",
),
)
op.add_column(
"rb_user",
sa.Column(
"default_node_watcher_frequency",
sa.Enum("immediately", "daily", "weekly", name="frequency"),
nullable=False,
server_default="daily",
),
)
def downgrade():
op.drop_column("rb_user", "default_node_watcher_frequency")
op.drop_column("rb_user", "auto_watch_threads_i_participate")
op.drop_column("rb_user", "auto_watch_threads_i_create")

View file

@ -0,0 +1,31 @@
"""create graph_depth column on node model.
Revision ID: 76deff88cfd6
Revises: d1a0ab4f617f
Create Date: 2020-02-23 20:51:49.889183
"""
# revision identifiers, used by Alembic.
revision = '76deff88cfd6'
down_revision = 'd1a0ab4f617f'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
'rb_node',
sa.Column(
'graph_depth',
sa.Integer(),
nullable=False,
server_default="-1",
)
)
def downgrade():
op.drop_column('rb_node', 'graph_depth')

View file

@ -0,0 +1,31 @@
"""Added created_timestamp column to namespace_request
Revision ID: 771ef7c868de
Revises: 6fc1d0cf350c
Create Date: 2018-10-08 23:06:50.252729
"""
# revision identifiers, used by Alembic.
revision = '771ef7c868de'
down_revision = '6fc1d0cf350c'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from time import time
def now_timestamp():
return int(time() * 1000)
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('rb_namespace_request', sa.Column('created_timestamp', sa.BigInteger(), nullable=False, server_default=str(now_timestamp())))
op.create_index(op.f('ix_rb_namespace_request_created_timestamp'), 'rb_namespace_request', ['created_timestamp'], unique=False)
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('rb_namespace_request', 'created_timestamp')

View file

@ -0,0 +1,30 @@
"""add hide_unless_approved column
Revision ID: 7a3a4755b4a8
Revises: 1040ee4836af
Create Date: 2017-08-09 21:53:01.995660
"""
# revision identifiers, used by Alembic.
revision = "7a3a4755b4a8"
down_revision = "1040ee4836af"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.add_column(
"Namespace", sa.Column("hide_unless_approved", sa.Boolean(), nullable=True)
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_column("Namespace", "hide_unless_approved")
### end Alembic commands ###

View file

@ -0,0 +1,26 @@
"""namespace theme embed column
Revision ID: 87fbd2b4e06e
Revises: 591800942adf
Create Date: 2017-09-27 10:20:27.168937
"""
# revision identifiers, used by Alembic.
revision = "87fbd2b4e06e"
down_revision = "591800942adf"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"Namespace", sa.Column("theme_embed", sa.Unicode(length=256), nullable=True)
)
def downgrade():
op.drop_column("Namespace", "theme_embed")

View file

@ -0,0 +1,24 @@
"""namespace public column
Revision ID: 953d42406dbb
Revises: 59592cf325fb
Create Date: 2017-07-08 15:48:41.261312
"""
# revision identifiers, used by Alembic.
revision = "953d42406dbb"
down_revision = "59592cf325fb"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column("Namespace", sa.Column("public", sa.Boolean(), nullable=True))
def downgrade():
op.drop_column("Namespace", "public")

View file

@ -0,0 +1,36 @@
"""namespace model
Revision ID: 98a90d2add21
Revises: 04e52337756e
Create Date: 2017-07-03 16:13:09.160494
"""
# revision identifiers, used by Alembic.
revision = "98a90d2add21"
down_revision = "04e52337756e"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from remarkbox.models import UUIDType
def upgrade():
# we don't need to create the table.
# it will be created automatically during release.
# We do need to create the namespace_id column on the node table.
op.add_column(u"Node", sa.Column("namespace_id", UUIDType, nullable=True))
# this fails so I had to comment it out.
# op.create_foreign_key(None, 'Node', 'Namespace', ['namespace_id'], ['id'])
def downgrade():
op.drop_constraint(None, "Node", type_="foreignkey")
op.drop_column(u"Node", "namespace_id")
op.drop_index(op.f("ix_Namespace_name"), table_name="Namespace")
op.drop_index(op.f("ix_Namespace_id"), table_name="Namespace")
op.drop_table("Namespace")

View file

@ -0,0 +1,26 @@
"""stripe_id for User
Revision ID: 98ced5f6ff36
Revises: f5f88293428a
Create Date: 2017-10-19 10:23:30.236902
"""
# revision identifiers, used by Alembic.
revision = "98ced5f6ff36"
down_revision = "f5f88293428a"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"rb_user", sa.Column("stripe_id", sa.Unicode(length=18), nullable=True)
)
def downgrade():
op.drop_column("rb_user", "stripe_id")

View file

@ -0,0 +1,41 @@
"""Add created_timestamp column to Oauth class, add email_notify enum to Namespace
Revision ID: a26302bcfa25
Revises: 00211ed642cc
Create Date: 2018-05-26 19:18:53.858014
"""
# revision identifiers, used by Alembic.
revision = "a26302bcfa25"
down_revision = "00211ed642cc"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"rb_oauth",
sa.Column(
"created_timestamp",
sa.BigInteger(),
nullable=False,
server_default="1527378962438",
),
)
op.add_column(
"rb_namespace",
sa.Column(
"email_notify",
sa.Enum("immediately", "daily", "weekly", name="email_notify"),
nullable=True,
),
)
def downgrade():
op.drop_column("rb_oauth", "created_timestamp")
op.drop_column("rb_namespace", "email_notify")

View file

@ -0,0 +1,24 @@
"""locked-rb-node
Revision ID: a38c12eeff2d
Revises: 76deff88cfd6
Create Date: 2020-04-20 10:22:43.611168
"""
# revision identifiers, used by Alembic.
revision = 'a38c12eeff2d'
down_revision = '76deff88cfd6'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('rb_node', sa.Column('locked', sa.Boolean(), nullable=True))
def downgrade():
op.drop_column('rb_node', 'locked')

View file

@ -0,0 +1,37 @@
"""new namespace columns 2
Revision ID: a738d6859f18
Revises: 98ced5f6ff36
Create Date: 2017-11-06 00:05:09.284352
"""
# revision identifiers, used by Alembic.
revision = "a738d6859f18"
down_revision = "98ced5f6ff36"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"rb_namespace",
sa.Column("google_analytics_id", sa.Unicode(length=18), nullable=True),
)
op.add_column(
"rb_namespace",
sa.Column("no_nodes_text", sa.Unicode(length=256), nullable=True),
)
op.add_column(
"rb_namespace",
sa.Column("owner_request_timestamp", sa.BigInteger(), nullable=True),
)
def downgrade():
op.drop_column("rb_namespace", "owner_request_timestamp")
op.drop_column("rb_namespace", "no_nodes_text")
op.drop_column("rb_namespace", "google_analytics_id")

View file

@ -0,0 +1,26 @@
"""hide_powered_by column
Revision ID: aa2dfb4decda
Revises: 4aeda8e797ab
Create Date: 2018-03-15 15:44:31.591391
"""
# revision identifiers, used by Alembic.
revision = "aa2dfb4decda"
down_revision = "4aeda8e797ab"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"rb_namespace", sa.Column("hide_powered_by", sa.Boolean(), nullable=True)
)
def downgrade():
op.drop_column("rb_namespace", "hide_powered_by")

View file

@ -0,0 +1,33 @@
"""pay what you can table
Revision ID: b132ec968f0c
Revises: 184a349cd589
Create Date: 2021-01-31 09:47:47.220938
"""
# revision identifiers, used by Alembic.
revision = 'b132ec968f0c'
down_revision = '184a349cd589'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from sqlalchemy_utils import UUIDType as TempUUIDType
UUIDType = TempUUIDType(binary=False)
def upgrade():
op.create_table('rb_pay_what_you_can',
sa.Column('user_id', UUIDType, nullable=False),
sa.Column('frequency', sa.Enum('once', 'yearly', name='frequency'), nullable=False),
sa.Column('amount', sa.BigInteger(), nullable=False),
sa.Column('contributions', sa.BigInteger(), nullable=False),
sa.Column('created_timestamp', sa.BigInteger(), nullable=False),
sa.Column('updated_timestamp', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['rb_user.id'], ),
sa.PrimaryKeyConstraint('user_id')
)
op.create_index(op.f('ix_rb_pay_what_you_can_user_id'), 'rb_pay_what_you_can', ['user_id'], unique=False)

View file

@ -0,0 +1,31 @@
"""Namespace stylesheet_embed column
Revision ID: ba8e83a3b8fd
Revises: 87fbd2b4e06e
Create Date: 2017-10-01 11:00:26.447925
"""
# revision identifiers, used by Alembic.
revision = "ba8e83a3b8fd"
down_revision = "87fbd2b4e06e"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"Namespace", sa.Column("stylesheet_embed", sa.UnicodeText(), nullable=True)
)
op.add_column(
"Namespace",
sa.Column("stylesheet_embed_timestamp", sa.BigInteger(), nullable=True),
)
def downgrade():
op.drop_column("Namespace", "stylesheet_embed_timestamp")
op.drop_column("Namespace", "stylesheet_embed")

View file

@ -0,0 +1,38 @@
"""add root_id field
Revision ID: c9793937e756
Revises: 051f8b810f26
Create Date: 2017-01-14 20:59:39.701946
"""
# revision identifiers, used by Alembic.
revision = "c9793937e756"
down_revision = "051f8b810f26"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
from remarkbox.models import NamespaceRequest
def upgrade():
bind = op.get_bind()
DBSession = sa.orm.Session(bind=bind)
namespace_requests = DBSession.query(NamespaceRequest).all()
for namespace_request in namespace_requests:
if namespace_request.verified:
namespace = namespace_request.namespace
namespace.set_role_for_user(namespace_request.user, "owner")
DBSession.add(namespace)
DBSession.flush()
DBSession.commit()
def downgrade():
pass

View file

@ -0,0 +1,24 @@
"""approve column
Revision ID: c97ddfcbc406
Revises: 2e00b92495e4
Create Date: 2017-08-13 18:49:09.057118
"""
# revision identifiers, used by Alembic.
revision = "c97ddfcbc406"
down_revision = "2e00b92495e4"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column("Node", sa.Column("approved", sa.Boolean(), nullable=True))
def downgrade():
op.drop_column("Node", "approved")

View file

@ -0,0 +1,24 @@
"""avatar_size in namespace
Revision ID: cbdf3cd4ccba
Revises: 953d42406dbb
Create Date: 2017-07-15 12:45:52.158320
"""
# revision identifiers, used by Alembic.
revision = "cbdf3cd4ccba"
down_revision = "953d42406dbb"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column("Namespace", sa.Column("avatar_size", sa.Integer(), nullable=True))
def downgrade():
op.drop_column("Namespace", "avatar_size")

View file

@ -0,0 +1,24 @@
"""add has_uri to Node
Revision ID: d1a0ab4f617f
Revises: 6db39621f46c
Create Date: 2019-06-29 16:23:09.323221
"""
# revision identifiers, used by Alembic.
revision = 'd1a0ab4f617f'
down_revision = '6db39621f46c'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('rb_node', sa.Column('has_uri', sa.Boolean(), nullable=False, server_default="0"))
def downgrade():
op.drop_column('rb_node', 'has_uri')

View file

@ -0,0 +1,26 @@
"""add mathjax column to rb_namespace
Revision ID: f5f88293428a
Revises: ba8e83a3b8fd
Create Date: 2017-10-14 23:13:46.584415
"""
# revision identifiers, used by Alembic.
revision = "f5f88293428a"
down_revision = "ba8e83a3b8fd"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"rb_namespace", sa.Column("mathjax", sa.Boolean(), server_default=sa.false())
)
def downgrade():
op.drop_column("rb_namespace", "mathjax")

View file

@ -0,0 +1,24 @@
"""namespace reverse node order
Revision ID: f7b42b2024fb
Revises: 40ded1163f95
Create Date: 2019-03-19 11:17:31.427157
"""
# revision identifiers, used by Alembic.
revision = 'f7b42b2024fb'
down_revision = '40ded1163f95'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('rb_namespace', sa.Column('reverse_order', sa.Boolean(), nullable=True))
def downgrade():
pass

View file

@ -0,0 +1,26 @@
"""Add link_protection column to Namespace
Revision ID: fa8402aa1a00
Revises: 0208c10ea67f
Create Date: 2018-04-05 15:08:49.493735
"""
# revision identifiers, used by Alembic.
revision = "fa8402aa1a00"
down_revision = "0208c10ea67f"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
"rb_namespace", sa.Column("link_protection", sa.Boolean(), nullable=True)
)
def downgrade():
op.drop_column("rb_namespace", "link_protection")

View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# obtains all data tables from database
TS=`sqlite3 $1 "SELECT tbl_name FROM sqlite_master WHERE type='table' and tbl_name not like 'sqlite_%';"`
# exports each table to csv
for T in $TS; do
sqlite3 $1 <<!
.headers on
.mode csv
.output $T.csv
select * from $T;
!
done

View file

@ -0,0 +1,40 @@
import os
import sys
import transaction
from pyramid.paster import get_appsettings, setup_logging
from pyramid.scripts.common import parse_vars
from ..models.meta import Base
from ..models import get_engine, get_session_factory, get_tm_session
def usage(argv):
cmd = os.path.basename(argv[0])
print(
"usage: %s <config_uri> [var=value]\n"
'(example: "%s development.ini")' % (cmd, cmd)
)
sys.exit(1)
def main(argv=sys.argv):
if len(argv) < 2:
usage(argv)
config_uri = argv[1]
options = parse_vars(argv[2:])
setup_logging(config_uri)
settings = get_appsettings(config_uri, options=options)
engine = get_engine(settings)
Base.metadata.create_all(engine)
session_factory = get_session_factory(engine)
Base.metadata.create_all(engine)
session_factory = get_session_factory(engine)
with transaction.manager:
dbsession = get_tm_session(session_factory, transaction.manager)

View file

@ -0,0 +1,32 @@
from pyramid.paster import bootstrap, setup_logging
from remarkbox.models import invalidate_all_node_cache_objects
from . import base_parser
def main():
parser = base_parser("Recompute denormalized fields and caches.")
args = parser.parse_args()
setup_logging(args.config)
with bootstrap(args.config) as env, env["request"].tm as tm:
request = env["request"]
# invalidate all NodeCache objs to force recomputation.
invalidate_all_node_cache_objects(request.dbsession)
# get all Uri objects.
uris = m.uri.get_all_uris(request.dbsession)
# interate over all Uri objects.
for uri in uris:
if uri.node:
# modify the Uri's related Node.
uri.node.has_uri = True
# add the related Node object to the sqlalchemy session.
request.dbsession.add(uri.node)
# flush / commit all changes stored the the sqlachemy session.
request.dbsession.flush()

View file

@ -0,0 +1,155 @@
import json
from pyramid.paster import bootstrap, setup_logging
from ..models import (
User,
UserSurrogate,
generate_user_name,
get_user_by_email,
get_user_surrogate_by_name,
get_or_create_node_by_uri,
is_user_name_valid,
is_user_name_available,
)
from miscutils import generate_password
from . import base_parser
try:
unicode("")
except:
from six import u as unicode
def get_arg_parser():
parser = base_parser("import comments from JSON")
# parser.add_argument('--dry-run', action='store_true', default=False)
parser.add_argument('--user-surrogates', action='store_true', default=False)
parser.add_argument("--group")
parser.add_argument("json_path", help="path to JSON file")
return parser
def main():
parser = get_arg_parser()
args = parser.parse_args()
setup_logging(args.config)
# group = generate_password(3)
group = ""
if args.group:
group = args.group
with open(args.json_path) as f:
pages = json.load(f)
with bootstrap(args.config) as env, env["request"].tm as tm:
request = env["request"]
users = {}
user_surrogates = {}
nodes = {}
for slug, page_data in pages.items():
comments = page_data["comments"]
if len(comments) == 0:
# skipping because this page has no comments.
continue
uri = page_data["link"]
print("creating page for {}".format(uri))
root = get_or_create_node_by_uri(request.dbsession, uri)
request.dbsession.add(root)
request.dbsession.flush()
for comment in comments:
email = comment["email"]
user = None
user_surrogate = None
if not email:
if args.user_surrogates:
name = comment["author"]
if name not in user_surrogates:
user_surrogates[name] = UserSurrogate(
name,
root.namespace,
)
user_surrogate = user_surrogates[name]
request.dbsession.add(user_surrogate)
request.dbsession.flush()
else:
# an email may not be empty or None.
continue
elif email in users:
user = users[email]
else:
user = get_user_by_email(request.dbsession, email)
if user:
users[email] = user
print("found user for {} ({})".format(email, user.name))
else:
desired_name = (
comment["author"].replace(" ", "-").replace("_", "-")
)
if group:
desired_name = desired_name + "-" + group
if not is_user_name_valid(
desired_name
) or not is_user_name_available(
request.dbsession, desired_name
):
desired_name = generate_user_name(
request.dbsession, desired_name
)
user = User(email)
user.name = unicode(desired_name)
users[email] = user
print("created user for {} ({})".format(email, desired_name))
request.dbsession.add(user)
request.dbsession.flush()
node = root.new_child()
node.set_data(unicode(comment["content"]))
node.created = int(comment["timestamp"]) * 1000
node.changed = int(comment["timestamp"]) * 1000
if user is not None:
node.user = user
elif user_surrogate is not None:
node.user_surrogate = user_surrogate
node.verified = True
node.ip_address = comment.get("author_ip", None)
nodes[comment["id"]] = node
request.dbsession.add(nodes[comment["id"]])
request.dbsession.flush()
for comment in comments:
if comment["parent_id"]:
print(
"id {}, parent_id {}".format(
comment["id"], comment["parent_id"]
)
)
if nodes.get(comment["parent_id"]):
nodes[comment["id"]].parent_id = nodes[comment["parent_id"]].id
request.dbsession.add(nodes[comment["id"]])
request.dbsession.flush()
else:
print(
"parent_id ({}) is not found, skipping".format(
comment["parent_id"]
)
)

View file

@ -0,0 +1,12 @@
for file in $(ls *.csv)
do
table_name="${file%.*}"
column_names=$(head -n1 $file | sed -e 's/\r//g')
sql_statement="COPY $table_name ($column_names) FROM '/tmp/$file' WITH DELIMITER ',' CSV HEADER;"
# use this to echo out all the psql commands instead of running them.
echo "psql --dbname remarkbox -c \"$sql_statement\""
done
# this is an example importing a single table from a CSV file.
#psql --dbname remarkbox -c "COPY rb_user (id,name,email,password,created,gravatar,verified,disabled,admin,email_id,password_timestamp) FROM 'rb_user.csv' WITH DELIMITER ',' CSV HEADER;

View file

@ -0,0 +1,49 @@
from collections import defaultdict
from pyramid.paster import bootstrap, setup_logging
from ..models import get_namespace_by_name, get_nodes_who_share_root
from . import base_parser
def get_arg_parser():
parser = base_parser("Given a namespace find dupe uri roots and merge them.")
parser.add_argument("namespace")
return parser
def main():
parser = get_arg_parser()
args = parser.parse_args()
setup_logging(args.config)
uris = defaultdict(list)
with bootstrap(args.config) as env, env["request"].tm as tm:
request = env["request"]
namespace = get_namespace_by_name(request.dbsession, args.namespace)
if namespace is not None:
for root in namespace.roots:
if root.uri is None:
continue
uris[root.uri.data].append(root)
for uri, roots in uris.items():
if len(roots) == 2:
for child in roots[0].children:
child.parent_id = roots[1].id
request.dbsession.add(child)
nodes = get_nodes_who_share_root(request.dbsession, roots[0])
for node in nodes:
node.root_id = roots[1].id
request.dbsession.add(node)
request.dbsession.flush()
request.dbsession.delete(roots[0])
request.dbsession.delete(roots[0].uri)
request.dbsession.flush()
else:
print("no such namespace ({})".format(args.namespace))

View file

@ -0,0 +1,159 @@
from __future__ import print_function
from pyramid.paster import bootstrap, setup_logging
from ..models import get_or_create_namespace, get_namespace_by_name, get_nodes_who_share_roots
from ..models.meta import SUBSCRIPTION_TYPES
from . import base_parser
toggle = lambda x: not x
def get_arg_parser():
parser = base_parser("Modify a Namespace.")
parser.add_argument(
"--toggle-hide-unless-approved", action="store_true", default=False
)
parser.add_argument("--create", action="store_true", default=False)
parser.add_argument("--delete", action="store_true", default=False)
parser.add_argument("--delete-children-nodes", action="store_true", default=False, help="this is useful if you mess up an import.")
parser.add_argument("--info", action="store_true", default=False)
parser.add_argument("--subscription-type", choices=SUBSCRIPTION_TYPES)
parser.add_argument("namespace")
return parser
def main():
parser = get_arg_parser()
args = parser.parse_args()
setup_logging(args.config)
with bootstrap(args.config) as env, env["request"].tm as tm:
sp = tm.savepoint()
request = env["request"]
if args.create:
namespace = get_or_create_namespace(request.dbsession, args.namespace)
request.dbsession.add(namespace)
request.dbsession.flush()
else:
namespace = get_namespace_by_name(request.dbsession, args.namespace)
if not namespace:
print("Namespace '{}' not found.".format(args.namespace))
exit()
if args.info:
from collections import OrderedDict
namespace_dict = OrderedDict(
sorted(namespace.__dict__.items(), key=lambda t: t[0])
)
for key, value in namespace_dict.items():
print("{}: ".format(key), end="")
print(value)
print("owners:")
for user in namespace.owners:
print(" {}: {}".format(user.name, user.email))
print("moderators:")
for user in namespace.moderators:
print(" {}: {}".format(user.name, user.email))
exit()
elif args.subscription_type:
namespace.subscription_type = args.subscription_type
request.dbsession.add(namespace)
request.dbsession.flush()
elif args.toggle_hide_unless_approved:
namespace.hide_unless_approved = toggle(namespace.hide_unless_approved)
request.dbsession.add(namespace)
request.dbsession.flush()
elif args.delete_children_nodes:
nodes = get_nodes_who_share_roots(request.dbsession, namespace.roots)
# delete all children nodes.
for node in nodes:
if node.is_root:
# Skip root nodes to keep Uri and Watchers relations.
continue
request.dbsession.delete(node)
if (
raw_input("*** DANGER: Really delete Nodes forever? [yes, no]: ")
== "yes"
):
request.dbsession.flush()
print("Flushed transaction to database, the Nodes were completely destroyed!")
else:
sp.rollback()
print("Rolled back transaction, sheepishly refused to delete anything!")
elif args.delete:
nodes = get_nodes_who_share_roots(request.dbsession, namespace.roots)
# delete all nodes.
for node in nodes:
if node.events:
# delete all related NodeEvents.
for event in node.events:
request.dbsession.delete(event)
# delete node.
request.dbsession.delete(node)
for root in namespace.roots:
if root.uri:
# delete related Uri.
request.dbsession.delete(root.uri)
if root.cache:
# delete related NodeCache.
request.dbsession.delete(root.cache)
if root.watchers:
# delete all related NodeEventWatchers.
for watcher in root.watchers:
# delete all unsent NodeEventNotifications.
if watcher.unsent_notifications():
for notification in watcher.unsent_notifications():
request.dbsession.delete(notification)
request.dbsession.delete(watcher)
if namespace.watchers:
for watcher in namespace.watchers:
# delete all unsent NodeEventNotifications.
if watcher.unsent_notifications():
for notification in watcher.unsent_notifications():
request.dbsession.delete(notification)
request.dbsession.delete(watcher)
# disable all related NamespaceUser objects.
if namespace.namespace_users:
for nsu in namespace.namespace_users:
# delete related NamespaceUser.
request.dbsession.delete(nsu)
# delete all related OauthRecords.
if namespace.oauth_records:
for oauth_record in namespace.oauth_records:
request.dbsession.delete(oauth_record)
# finally delete Namespace.
request.dbsession.delete(namespace)
if (
raw_input("*** DANGER: Delete Namespace '{}' ({}) forever? [yes, no]: ".format(namespace.name, namespace.id))
== "yes"
):
request.dbsession.flush()
print("Flushed transaction to database, the Namespace was completely destroyed!")
else:
sp.rollback()
print("Rolled back transaction, sheepishly refused to delete anything!")

View file

@ -0,0 +1,176 @@
from pyramid.paster import bootstrap, setup_logging
from ..models import get_node_by_uri, get_node_by_id
from . import base_parser
# edit node.
from os import environ
from tempfile import NamedTemporaryFile
from subprocess import call
ANSI_ESCAPES = {
"HEADER": "\033[95m",
"OKBLUE": "\033[94m",
"OKGREEN": "\033[92m",
"WARNING": "\033[93m",
"FAIL": "\033[91m",
"DISABLED": "\033[37m",
"ENDC": "\033[0m",
"BOLD": "\033[1m",
"UNDERLINE": "\033[4m",
}
def ansi(msg, codes=None):
msg = str(msg)
if codes:
for code in codes:
msg = ANSI_ESCAPES[code] + msg
msg += ANSI_ESCAPES["ENDC"]
return msg
def color_node(node):
# if node.spam:
# return ansi(node.id, 'FAIL')
if node.disabled == True:
return ansi(node.id, codes=["DISABLED"])
elif node.verified == False:
return ansi(node.id, codes=["WARNING"])
else:
return ansi(node.id, codes=["OKGREEN"])
def traverse(node):
"""stolen from here: http://www.saltycrane.com/blog/2008/08/python-recursion-example-navigate-tree-data/"""
print(" " * traverse.level + str(color_node(node)))
for child in node.children:
traverse.level += 2
traverse(child)
traverse.level -= 2
def edit_node(node):
EDITOR = environ.get("EDITOR", "vim")
with NamedTemporaryFile(suffix=".md") as tf:
tf.write(node.data)
tf.flush()
call([EDITOR, tf.name])
# do the parsing with `tf` using regular File operations.
# for instance:
tf.seek(0)
new_node_data = tf.read()
if new_node_data and new_node_data != node.data:
node.data = new_node_data
def info_node(node):
from collections import OrderedDict
node_dict = OrderedDict(sorted(node.__dict__.items(), key=lambda t: t[0]))
for key, value in node_dict.items():
print("{}: {}".format(key, value))
if node.uri:
print(node.uri.data)
def get_arg_parser():
parser = base_parser("Modify a Node.")
parser.add_argument("-u", "--uri", type=unicode, default=None)
parser.add_argument("-i", "--id", type=unicode, default=None)
parser.add_argument("--show", default=False, action="store_true")
parser.add_argument("--info", default=False, action="store_true")
parser.add_argument("--move", default=False, metavar="NEW-PARENT-ID")
parser.add_argument("--edit", default=False, action="store_true")
parser.add_argument("--disable", default=False, action="store_true")
parser.add_argument("--enable", default=False, action="store_true")
parser.add_argument("--delete", default=False, action="store_true")
parser.add_argument("--spam", default=False, action="store_true")
return parser
def main():
parser = get_arg_parser()
args = parser.parse_args()
setup_logging(args.config)
if args.uri and args.id:
print("do not pass both -u and -i flags.")
exit(1)
action_flag_count = sum(
[
args.show,
args.info,
bool(args.move),
args.disable,
args.enable,
args.spam,
args.delete,
args.edit,
]
)
if action_flag_count != 1:
if action_flag_count == 0:
parser.print_help()
else:
print("only pass one action flag.")
exit(1)
with bootstrap(args.config) as env, env["request"].tm as tm:
request = env["request"]
node = None
if args.uri is not None:
node = get_node_by_uri(request.dbsession, args.uri)
elif args.id is not None:
node = get_node_by_id(request.dbsession, args.id)
if node is None:
print("No such, node. Check the id or uri.")
exit(1)
if args.show:
traverse.level = 1
traverse(node)
if args.info:
info_node(node)
if args.delete:
if (
raw_input("Delete node '{}' forever? [yes, no]: ".format(node.id))
== "yes"
):
request.dbsession.delete(node)
request.dbsession.flush()
else:
if args.move:
parent = get_node_by_id(request.dbsession, args.move)
if parent is None:
print(
"The new parent id ({}) does not exist.".format(args.new_parent)
)
exit(0)
node.parent_id = parent.id
node.root_id = parent.id
node.namespace = parent.namespace
if node.title:
node.title = ""
if args.edit:
edit_node(node)
if args.disable:
node.disable()
if args.enable:
node.enable()
request.dbsession.add(node)
request.dbsession.flush()

View file

@ -0,0 +1,55 @@
from pyramid.paster import bootstrap, setup_logging
from ..models import get_namespace_by_name
from . import base_parser
def get_arg_parser():
parser = base_parser("Modify all uris which fall under namespace.")
parser.add_argument("namespace")
parser.add_argument("--add-trailing-slash", action="store_true", default=False)
parser.add_argument("--https", action="store_true", default=False)
parser.add_argument("--http", action="store_true", default=False)
parser.add_argument("--move-namespace-domain")
return parser
def main():
parser = get_arg_parser()
args = parser.parse_args()
setup_logging(args.config)
with bootstrap(args.config) as env, env["request"].tm as tm:
request = env["request"]
namespace = get_namespace_by_name(request.dbsession, args.namespace)
if namespace is not None:
for root in namespace.roots:
if root.uri is None:
continue
# parsed miniuri object.
puri = root.uri.parsed
if args.add_trailing_slash and not root.uri.data.endswith("/"):
print("adding trailing slash to: {}".format(root.uri.data))
puri.path = puri.path + "/"
if args.move_namespace_domain and "." in args.move_namespace_domain:
namespace.name = args.move_namespace_domain
puri.hostname = args.move_namespace_domain
if args.http:
puri.scheme = "http"
if args.https:
puri.scheme = "https"
root.uri.data = puri.uri
request.dbsession.add(root.uri)
request.dbsession.flush()
else:
print("no such namespace ({})".format(args.namespace))

View file

@ -0,0 +1,83 @@
from pyramid.paster import bootstrap, setup_logging
from ..models import (
get_user_by_email,
get_namespace_by_name,
get_namespace_request,
is_user_name_valid,
is_user_name_available,
)
from . import base_parser
from six import u as unicode
def get_arg_parser():
parser = base_parser("Modify a user.")
parser.add_argument("-i", "--info", action="store_true", default=False)
parser.add_argument("email", type=unicode)
parser.add_argument("--namespace-remove", default=None, type=unicode)
parser.add_argument("--namespace-add", default=None, type=unicode)
parser.add_argument(
"--namespace-role", default=None, type=unicode, choices=[None, "owner"]
)
parser.add_argument("--change-user-name", type=unicode)
return parser
def main():
parser = get_arg_parser()
args = parser.parse_args()
setup_logging(args.config)
with bootstrap(args.config) as env, env["request"].tm as tm:
request = env["request"]
user = get_user_by_email(request.dbsession, args.email)
if user is None:
print("No such user found by email: {}.".format(args.email))
exit()
if args.info:
from collections import OrderedDict
user_dict = OrderedDict(sorted(user.__dict__.items(), key=lambda t: t[0]))
del user_dict["password"]
del user_dict["email_id"]
for key, value in user_dict.items():
print("{}: {}".format(key, value))
print("namespaces:")
for namespace in user.namespaces:
print(" - {}".format(namespace.name))
exit()
if args.namespace_add:
namespace_to_add = get_namespace_by_name(
request.dbsession, args.namespace_add
)
if namespace_to_add:
namespace_to_add.set_role_for_user(user, role=args.namespace_role)
if args.namespace_remove:
namespace_to_remove = get_namespace_by_name(
request.dbsession, args.namespace_remove
)
if namespace_to_remove:
namespace_request_to_remove = get_namespace_request(
request.dbsession, user, namespace_to_remove
)
if namespace_request_to_remove:
request.dbsession.delete(namespace_request_to_remove)
namespace_to_remove.clear_owner_request_timestamp()
namespace_to_remove.remove_user(user)
if args.change_user_name:
if is_user_name_valid(args.change_user_name) and is_user_name_available(
request.dbsession, args.change_user_name
):
user.name = args.change_user_name
request.dbsession.add(user)
request.dbsession.flush()

View file

@ -0,0 +1,21 @@
from pyramid.paster import bootstrap, setup_logging
from remarkbox.models import recompute_depth_on_all_node_objects
from . import base_parser
def main():
parser = base_parser(
"Recompute nested node.depth in relation their thread."
)
parser.add_argument("--chunk", type=int, default=500,)
args = parser.parse_args()
setup_logging(args.config)
with bootstrap(args.config) as env, env["request"].tm as tm:
request = env["request"]
# Run node.recompute_depth() on all Node objects.
# Flush all changes stored in the sqlalchemy session to database.
recompute_depth_on_all_node_objects(request.dbsession, args.chunk)

View file

@ -0,0 +1,29 @@
from pyramid.paster import bootstrap, setup_logging
from ..models import get_root_nodes, get_nodes_who_share_root
from . import base_parser
def main():
parser = base_parser(
"Safely approve all nodes, permitting namespace settings allow."
)
args = parser.parse_args()
setup_logging(args.config)
with bootstrap(args.config) as env, env["request"].tm as tm:
request = env["request"]
for root in get_root_nodes(request.dbsession):
if root.namespace is None:
# skip this root, it's namespace is None?
continue
if root.namespace.hide_unless_approved:
# skip this root, it's namespace is actively moderated!
continue
root.approved = True
request.dbsession.add(root)
for node in get_nodes_who_share_root(request.dbsession, root):
node.approved = True
request.dbsession.add(node)
request.dbsession.flush()

View file

@ -0,0 +1,19 @@
from pyramid.paster import bootstrap, setup_logging
from ..lib.notify import deliver_scheduled_notifications
from . import base_parser
def get_arg_parser():
parser = base_parser("Send Node Notification Digests.")
return parser
def main():
parser = get_arg_parser()
args = parser.parse_args()
setup_logging(args.config)
with bootstrap(args.config) as env:
deliver_scheduled_notifications(env["request"])

View file

@ -0,0 +1,678 @@
body {
overflow-x: hidden;
font-size: 1.0em;
line-height: 1.6;
font-weight: 400;
font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #222;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 0px;
margin-bottom: 0px;
font-size: 1.3em;
font-weight: 300;
}
h1 {font-size: 3.00rem;}
h2 {font-size: 2.50rem;}
h3 {font-size: 2.00rem;}
h4 {font-size: 1.75rem;}
h5 {font-size: 1.50rem;}
h6 {font-size: 1.25rem;}
.node-data h1,
.node-data h2,
.node-data h3,
.node-data h4,
.node-data h5,
.node-data h6 {
font-size: 1.50rem;
margin-bottom: .3em;
}
a, a:visited, a:hover {
color: #292929;
opacity: .75;
text-decoration: none;
font-weight: bold;
}
a:hover {
opacity: 1.0;
}
a.anchor {
display: block;
position: relative;
top: -150px;
visibility: hidden;
}
a.mod::after {
content: " mod ";
background-color: #292929;
color: white;
border-radius: 8px;
margin-left: 4px;
margin-right: 4px;
padding: 1px;
}
pre {
overflow: auto;
margin-top: 20px;
margin-bottom: 20px;
margin-left: 20px;
padding: 2%;
color: #000000;
font-size: 12px;
font-family: 'PT Mono', monospace;
line-height: 1.125em;
overflow-x: auto;
border-radius: 8px;
background-color: #FAFAFA;
}
label.close {
margin-right: 16px;
}
label {
display: block;
margin-bottom: .5rem;
font-weight: 600;
}
code {
padding: 0.2em;
margin: 0;
background-color: rgba(27,31,35,0.05);
border-radius: 3px;
}
blockquote {
font-style: italic;
margin-top: 10px;
margin-bottom: 10px;
margin-left: 15px;
padding-left: 15px;
border-left: 3px solid #ccc;
}
button.link,
.button {
color: #292929;
text-align: left;
padding: 0;
opacity: .80;
}
button,
button.link,
.button {
outline: none;
}
button.link {
border: none;
background: none;
}
button:hover,
button.link:hover,
.button:hover {
opacity: 1.0;
}
span.avoidwrap {
display:inline-block;
}
div.summary {
margin-top: 25px;
margin-bottom: 25px;
}
p.whats-next {
margin-top: 15px;
margin-bottom: 15px;
}
.node p {
margin-top: 0px;
margin-bottom: .5em;
}
form.node-action {
display: inline;
}
.node-actions {
margin-top: -.3em;
}
.preview {
border-top: 1px dotted #bbbbbb;
border-bottom: 1px dotted #bbbbbb;
padding-top: 16px;
padding-bottom: 16px;
margin-top: 0px;
margin-bottom: 16px;
margin-right: 5px;
margin-left: 5px;
line-height: 1.6;
}
.preview p {
margin-top: 15px;
margin-bottom: 15px;
line-height: 1.75;
}
.preview-raw-markdown {
display: block;
white-space: pre-wrap;
background-color: unset;
border-radius: unset;
border: unset;
border-width: 0px;
margin-top: 19px;
margin-bottom: 10px;
}
.powered-by {
text-align: center;
margin-bottom: 0px;
margin-top: 8px;
}
.nested-avatar {
margin-top: 6px;
}
.avatar {
border-radius: 50%;
-moz-border-radius: 50%;
-webkit-border-radius: 50%;
}
.user-settings-profile-pic {
}
.focused {
animation: fade 4s forwards;
background-color: rgba(237, 237, 237, 1);
}
@keyframes fade {
from {background-color:rgba(237, 237, 237, 1);}
to {background-color:rgba(237, 237, 237, 0);}
}
/* ALERT START */
#alerts {
z-index: 98;
}
.alert {
opacity: .25;
display: grid;
grid-template-columns: 1fr 50px;
grid-gap: 10px;
}
.alert-message {
text-align: center;
margin-top: 10px;
margin-bottom: 10px;
}
.alert .close {
text-align: right;
margin-top: 10px;
margin-bottom: 10px;
margin-right: 15px;
}
.alert-danger, .alert-error {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
}
.alert-warning {
color: #856404;
background-color: #fff3cd;
border-color: #ffeeba;
}
.alert-info, .alert-notice {
color: #0c5460;
background-color: #d1ecf1;
border-color: #bee5eb;
}
.alert-success {
color: #155724;
background-color: #d4edda;
border-color: #c3e6cb;
}
/* ALERT END */
.author-and-date, .status, .whats-next, .topic, .remark-count {
opacity: .50;
font-size: .80em;
}
.date:before {
content: "\00b7";
}
.question-mark-circle:before
{
content: '?';
display: inline-block;
font-family: sans-serif;
font-weight: bold;
text-align: center;
width: 1.8ex;
height: 1.8ex;
font-size: 1.4ex;
line-height: 1.8ex;
border-radius: 1.2ex;
margin-right: 4px;
padding: 1px;
color: #cccccc;
background: white;
border: 1px solid #cccccc;
text-decoration: none;
}
.question-mark-circle:hover:before
{
color: white;
background: #cccccc;
text-decoration: none;
}
.remark, .load-more, .action {
font-size: .75em;
margin-right: 8px;
font-weight: bold;
cursor: pointer;
}
.center {
text-align: center;
}
.load-more, .load-more:visited {
color: #336699;
}
.button-right {
float: right;
margin-left: 8px;
}
.button-max-width {
width: 100%;
}
.opacity-20 {opacity: .2 !important;}
.opacity-40 {opacity: .4 !important;}
.opacity-60 {opacity: .6 !important;}
.opacity-80 {opacity: .8 !important;}
.remark-box-div-main {
margin-bottom: 35px;
}
.remark-box-div {
display: none;
/* this is needed to prevent "jumping" jquery bug. */
overflow: hidden;
margin-top: 10px;
}
.edit-box-div {
display: none;
/* this is needed to prevent "jumping" jquery bug. */
overflow: hidden;
margin-top: 15px;
}
.my-namespaces-div {
display: none;
position: absolute;
background-color: #ffffff;
z-index: 1;
/* this is needed to prevent "jumping" jquery bug. */
overflow: hidden;
padding-top: 10px;
padding-bottom: 10px;
}
#remarkbox-footer {
font-size: .80em;
font-weight: bold;
}
#search {
display: inline;
}
#keywords {
width: 100%;
line-height: 12px;
margin-bottom: 0px;
text-align: center;
}
.common-text-input, .common-textarea {
width: 100%;
border-color: #bbbbbb;
border-style: solid;
border-width: 1px;
border-radius: 2px;
margin-top: 7px;
/* This allows padding on inputs without causing scrollbars */
padding: .5em;
box-sizing:border-box;
-moz-box-sizing:border-box;
-webkit-box-sizing:border-box;
-ms-box-sizing:border-box;
}
.common-textarea {
height: 120px;
}
.monospace {
font-family: monospace;
}
#email_input {}
#email2_input {
display: none;
margin-bottom: 0px;
}
/* Stripe form */
.StripeElement {
border-color: #bbbbbb;
border-style: solid;
border-width: 1px;
border-radius: 2px;
background-color: white;
width: 300px;
padding-top: 12px;
padding-bottom: 12px;
padding-left: 3px;
padding-right: 3px;
}
.StripeElement--focus {
box-shadow: 0 1px 3px 0 #cfd7df;
}
.StripeElement--invalid {
border-color: #fa755a;
}
.StripeElement--webkit-autofill {
background-color: #fefde5 !important;
}
/* media viewports */
.shown-on-phone {
display: none;
}
.shown-on-tablet {
display: none;
}
.remarkbox-menu {
font-size: .80em;
z-index: 98;
padding-top: 8px;
padding-bottom: 8px;
display: grid;
grid-template-columns: 1fr;
grid-gap: 10px;
}
.remarkbox-menu .user {
text-align: right;
padding: 8px;
padding-right: 48px;
}
.remarkbox-menu .home {
padding: 8px;
padding-left: 48px;
}
.remarkbox-sub-menu {
font-size: .80em;
}
div.user-watching {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 10px;
}
.call-to-action {
text-align: right;
}
@media screen and (max-width:568px) {
h1, h2, h3, h4, h5, h6 {font-size: 1.5em !important;}
.hidden-on-phone {display: none}
.shown-on-phone {display: inline}
.remarkbox-menu {
padding-top: 0px;
}
.remarkbox-menu .search {display: none;}
.remarkbox-menu .home {padding: 0px; text-align: center;}
.remarkbox-menu .user {padding: 0px; text-align: center;}
.call-to-action {padding: 0px;}
.thread {padding-left: 40px;}
}
@media screen and (min-width:568px) {
.navbar.hidden-on-tablet {display: none}
.hidden-on-tablet {display: none}
.shown-on-tablet {display: inline}
.remarkbox-menu {grid-template-columns: 1fr 1fr 1fr;}
.remarkbox-menu .home {padding: 8px; padding-left: 24px;}
.remarkbox-menu .user {padding: 8px; padding-right: 24px;}
.call-to-action {
grid-column: 1 / span 3;
}
}
@media screen and (min-width:750px) {
.remarkbox-menu {grid-template-columns: 1fr 1fr 1fr;}
.remarkbox-menu .home {padding: 8px; padding-left: 48px;}
.remarkbox-menu .user {padding: 8px; padding-right: 48px;}
.call-to-action {
grid-column: 1 / span 3;
}
}
/*
*
* burger phone menu
*
*/
.burger-checkbox{
position: fixed;
bottom: 20px;
right: 20px;
height: 20px;
opacity: 0;
}
.burger-label {
border-radius: 50%;
position: fixed;
bottom: 15px;
right: 20px;
height: 55px;
width: 55px;
z-index: 99;
}
.burger-label span {
position: absolute;
margin-left: 14px;
width: 26px;
height: 2px;
top: 50%;
margin-top: -1px;
left: 0;
display: block;
background: #020304;
transition: .5s;
}
.burger-label span:first-child {
top: 18px;
}
.burger-label span:last-child {
top: 38px;
}
.burger-label:hover {
cursor: pointer;
}
.burger-checkbox:checked + .burger-label span {
opacity: 0;
top: 50%;
}
.burger-checkbox:checked + .burger-label span:first-child {
opacity: 1;
transform: rotate(405deg);
}
.burger-checkbox:checked + .burger-label span:last-child {
opacity: 1;
transform: rotate(-405deg);
}
.burger-checkbox ~ .phone-menu {
background: white;
position: fixed;
bottom: 0;
right: 0;
width: 100%;
height: 0px;
z-index: 98;
transition: .25s;
transition-delay: .25s;
/*overflow-y: scroll;*/
}
.burger-checkbox ~ .phone-menu > h5 {
margin-top: 15px;
margin-bottom: 15px;
}
.burger-checkbox ~ .phone-menu > ul,
.burger-checkbox ~ .phone-menu > h5 {
text-align: center;
left: 10%;
right: 10%;
margin-bottom: 0px;
margin-top: 20px;
padding-top: 0px;
}
.burger-checkbox ~ .phone-menu > ul {
margin: 0px;
padding: 0px;
}
.burger-checkbox ~ .phone-menu > ul > li,
.burger-checkbox ~ .phone-menu > h5 {
opacity: 0;
transition: .25s;
transition-delay: 0s;
}
.burger-checkbox ~ .phone-menu > ul > li > a {
text-decoration: none;
font-size: 11px;
font-weight: 700;
font-family: sans-serif;
display: block;
padding: 0px;
}
.burger-checkbox:checked ~ .phone-menu {
height: 100%;
transition-delay: 0s;
}
.burger-checkbox:checked ~ .phone-menu > ul > li,
.burger-checkbox:checked ~ .phone-menu > h5 {
opacity: 1;
transition-delay: .25s;
}
.phone-menu ul {
list-style-type: none;
padding-left: 0px;
}
section.join-or-log-in {
margin-left: auto;
margin-right: auto;
max-width: 340px;
}
/*
*
* funnel pages style
*
*/
.funnel {
margin-top: 60px;
margin-left: auto;
margin-right: auto;
max-width: 340px;
}
section.step-1,
section.step-2,
section.step-3,
section.step-4,
section.active-card,
section.new-card,
section.payment-card,
section.join-or-log-in,
section.well
{
background-color: white;
padding: 10px 12px;
border-radius: 4px;
border: 1px solid transparent;
box-shadow: 0 1px 3px 0 #e6ebf1;
-webkit-transition: box-shadow 150ms ease;
transition: box-shadow 150ms ease;
text-align: center;
}
section.step-4 {
text-align: left;
}
input#namespace-domain {
margin-bottom: 10px;
text-align: center;
}

View file

@ -0,0 +1,38 @@
body {
margin-left: 0px;
margin-top: 0px;
margin-right: 0px;
margin-bottom: 16px;
/* This is a hack for iOS to prevent overflow on ipads and phones */
width: 1px;
min-width: 100%;
*width: 100%;
}
.remarkbox-menu {
grid-template-columns: 1fr;
font-size: 1em;
}
.remarkbox-menu .home {
display: none;
}
.remarkbox-menu .search {
display: none;
}
.remarkbox-menu .user {
text-align: right;
padding: 0px;
}
.call-to-action {
grid-column: 1;
}
.avatar-nav .avatar {
margin-top: -4px;
margin-left: 6px;
}

View file

@ -0,0 +1,61 @@
.hll { background-color: #ffffcc }
.c { color: #408080; font-style: italic } /* Comment */
.err { border: 1px solid #FF0000 } /* Error */
.k { color: #008000; font-weight: bold } /* Keyword */
.o { color: #666666 } /* Operator */
.cm { color: #408080; font-style: italic } /* Comment.Multiline */
.cp { color: #BC7A00 } /* Comment.Preproc */
.c1 { color: #408080; font-style: italic } /* Comment.Single */
.cs { color: #408080; font-style: italic } /* Comment.Special */
.gd { color: #A00000 } /* Generic.Deleted */
.ge { font-style: italic } /* Generic.Emph */
.gr { color: #FF0000 } /* Generic.Error */
.gh { color: #000080; font-weight: bold } /* Generic.Heading */
.gi { color: #00A000 } /* Generic.Inserted */
.go { color: #888888 } /* Generic.Output */
.gp { color: #000080; font-weight: bold } /* Generic.Prompt */
.gs { font-weight: bold } /* Generic.Strong */
.gu { color: #800080; font-weight: bold } /* Generic.Subheading */
.gt { color: #0044DD } /* Generic.Traceback */
.kc { color: #008000; font-weight: bold } /* Keyword.Constant */
.kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
.kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
.kp { color: #008000 } /* Keyword.Pseudo */
.kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
.kt { color: #B00040 } /* Keyword.Type */
.m { color: #666666 } /* Literal.Number */
.s { color: #BA2121 } /* Literal.String */
.na { color: #7D9029 } /* Name.Attribute */
.nb { color: #008000 } /* Name.Builtin */
.nc { color: #0000FF; font-weight: bold } /* Name.Class */
.no { color: #880000 } /* Name.Constant */
.nd { color: #AA22FF } /* Name.Decorator */
.ni { color: #999999; font-weight: bold } /* Name.Entity */
.ne { color: #D2413A; font-weight: bold } /* Name.Exception */
.nf { color: #0000FF } /* Name.Function */
.nl { color: #A0A000 } /* Name.Label */
.nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
.nt { color: #008000; font-weight: bold } /* Name.Tag */
.nv { color: #19177C } /* Name.Variable */
.ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
.w { color: #bbbbbb } /* Text.Whitespace */
.mf { color: #666666 } /* Literal.Number.Float */
.mh { color: #666666 } /* Literal.Number.Hex */
.mi { color: #666666 } /* Literal.Number.Integer */
.mo { color: #666666 } /* Literal.Number.Oct */
.sb { color: #BA2121 } /* Literal.String.Backtick */
.sc { color: #BA2121 } /* Literal.String.Char */
.sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
.s2 { color: #BA2121 } /* Literal.String.Double */
.se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
.sh { color: #BA2121 } /* Literal.String.Heredoc */
.si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
.sx { color: #008000 } /* Literal.String.Other */
.sr { color: #BB6688 } /* Literal.String.Regex */
.s1 { color: #BA2121 } /* Literal.String.Single */
.ss { color: #19177C } /* Literal.String.Symbol */
.bp { color: #008000 } /* Name.Builtin.Pseudo */
.vc { color: #19177C } /* Name.Variable.Class */
.vg { color: #19177C } /* Name.Variable.Global */
.vi { color: #19177C } /* Name.Variable.Instance */
.il { color: #666666 } /* Literal.Number.Integer.Long */

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,93 @@
// post_preview
// previewTimer must live outside the functions.
var previewTimer = null;
function previewAjax(textarea, div, show_raw = false, mathjax = false){
// set div to raw textarea while waiting for remote Markdown rendering.
if (show_raw) {
// bust HTML tags like <script> to prevent running evil code.
var busted_textarea = $('#'+textarea).val().replace(/&/g, '&amp;').replace(/</g,'&lt;');
// $('#' + div).html('<div class="preview-raw-markdown">'+busted_textarea+'</div>');
$('#' + div).html('<span class="preview-raw-markdown">'+busted_textarea+'</span>');
}
if (previewTimer) {
clearTimeout(previewTimer);
}
previewTimer = setTimeout(
function() { sendPreview( textarea, div, mathjax ); },
800
);
}
function sendPreview(textarea, div, mathjax=false){
$.ajaxSetup ({
cache: false
});
var url = '/preview-post';
var params = { 'data' : $('#'+textarea).val(), 'csrf_token' : csrf_token };
$( '#' + div ).load( url, params );
if (mathjax) {
setTimeout(function(){
// hack: block MathJax rerender for 50ms, give div chance to load.
MathJax.Hub.Queue(["Typeset",MathJax.Hub,div]);
}, 100);
}
}
// this toggles a dropdown.
function toggle(target, button, off_text, on_text="hide"){
if (!$('#' + target + ":visible").height()){
$('#' + target).slideDown("slow");
$('#' + button).text(on_text);
}
else {
$('#' + target).slideUp("slow");
$('#' + button).text(off_text);
}
}
$(document).ready( function() {
$('button.vote-up').click(
function(){
var post_id = $(this).closest('div').attr('id');
sendVote( post_id, 'up' );
}
);
$('button.vote-down').click(
function(){
var post_id = $(this).closest('div').attr('id');
sendVote( post_id, 'down' );
}
);
// make all elements with class alert fade into existance.
$('.alert').delay(100).animate({"opacity": "1"}, 2000);
// highlight a fragment hash div if it exsits.
if(window.location.hash) {
// Fragment exists, so target the node's div.
var fragment = '#node-data-' + window.location.hash.replace('#', '');
$(fragment).addClass("focused");
}
});
function sendVote( post_id, direction ){
var url = '/vote-post';
var params = { 'post_id' : post_id, 'direction' : direction, 'csrf_token' : csrf_token };
$.getJSON( url, params, function( r ){
if ( r['status'] ) {
$('#'+post_id).children('b').html(r['vote_sum']);
}
else {
$('#json').html(r['msg']);
}
}
);
}

View file

@ -0,0 +1 @@
{"version":3,"sources":["../src/ie8.polyfils.js"],"names":["Array","prototype","forEach","fun","this","TypeError","t","Object","len","length","thisArg","arguments","i","call","Function","bind","oThis","aArgs","slice","fToBind","fNOP","fBound","apply","concat","callback","O","k"],"mappings":";;AAOMA,MAAMC,UAAUC,UACrBF,MAAMC,UAAUC,QAAU,SAASC,GAClC,YACA,IAAa,SAATC,MAA4B,OAATA,MAAgC,kBAARD,GAAoB,KAAM,IAAIE,UAO7E,KAAK,GAJJC,GAAIC,OAAOH,MACXI,EAAMF,EAAEG,SAAW,EACnBC,EAAUC,UAAUF,QAAU,EAAIE,UAAU,GAAK,OAEzCC,EAAI,EAAOJ,EAAJI,EAASA,IACpBA,IAAKN,IACRH,EAAIU,KAAKH,EAASJ,EAAEM,GAAIA,EAAGN,KAK1BQ,SAASb,UAAUc,OACtBD,SAASb,UAAUc,KAAO,SAASC,GACjC,GAAoB,kBAATZ,MAGT,KAAM,IAAIC,WAAU,uEAGtB,IAAIY,GAAUjB,MAAMC,UAAUiB,MAAML,KAAKF,UAAW,GAChDQ,EAAUf,KACVgB,EAAU,aACVC,EAAU,WACR,MAAOF,GAAQG,MAAMlB,eAAgBgB,GAAOhB,KAAOY,EAC5CC,EAAMM,OAAOvB,MAAMC,UAAUiB,MAAML,KAAKF,aAMrD,OAHAS,GAAKnB,UAAYG,KAAKH,UACtBoB,EAAOpB,UAAY,GAAImB,GAEhBC,IAINrB,MAAMC,UAAUC,UACnBF,MAAMC,UAAUC,QAAU,SAASsB,EAAUd,GAC3C,GAAa,OAATN,KAAe,KAAM,IAAIC,WAAU,+BACvC,IAAwB,kBAAbmB,GAAyB,KAAM,IAAInB,WAAUmB,EAAW,qBAMnE,KAAK,GAHHC,GAAIlB,OAAOH,MACXI,EAAMiB,EAAEhB,SAAW,EAEZiB,EAAE,EAAQlB,EAAJkB,EAAUA,IACnBA,IAAKD,IACPD,EAASX,KAAKH,EAASe,EAAEC,GAAIA,EAAGD","file":"ie8.polyfils.min.js","sourcesContent":["/*\n * IE8 Polyfils for iframeResizer.js\n *\n * Public domain code - Mozilla Contributors\n * https://developer.mozilla.org/\n */\n\n if (!Array.prototype.forEach){\n\tArray.prototype.forEach = function(fun /*, thisArg */){\n\t\t\"use strict\";\n\t\tif (this === void 0 || this === null || typeof fun !== \"function\") throw new TypeError();\n\n\t\tvar\n\t\t\tt = Object(this),\n\t\t\tlen = t.length >>> 0,\n\t\t\tthisArg = arguments.length >= 2 ? arguments[1] : void 0;\n\n\t\tfor (var i = 0; i < len; i++)\n\t\t\tif (i in t)\n\t\t\t\tfun.call(thisArg, t[i], i, t);\n\t};\n}\n\n\nif (!Function.prototype.bind) {\n Function.prototype.bind = function(oThis) {\n if (typeof this !== 'function') {\n // closest thing possible to the ECMAScript 5\n // internal IsCallable function\n throw new TypeError('Function.prototype.bind - what is trying to be bound is not callable');\n }\n\n var aArgs = Array.prototype.slice.call(arguments, 1),\n fToBind = this,\n fNOP = function() {},\n fBound = function() {\n return fToBind.apply(this instanceof fNOP ? this : oThis,\n aArgs.concat(Array.prototype.slice.call(arguments)));\n };\n\n fNOP.prototype = this.prototype;\n fBound.prototype = new fNOP();\n\n return fBound;\n };\n}\n\nif (!Array.prototype.forEach) {\n Array.prototype.forEach = function(callback, thisArg) {\n if (this === null) throw new TypeError(' this is null or not defined');\n if (typeof callback !== 'function') throw new TypeError(callback + ' is not a function');\n\n var\n O = Object(this),\n len = O.length >>> 0;\n\n for (var k=0 ; k < len ; k++) {\n if (k in O)\n callback.call(thisArg, O[k], k, O);\n }\n };\n}\n\n\n"]}

View file

@ -0,0 +1,4 @@
// IE8 polyfils for iframeResizer.js
Array.prototype.forEach||(Array.prototype.forEach=function(a){"use strict";if(void 0===this||null===this||"function"!=typeof a)throw new TypeError;for(var b=Object(this),c=b.length>>>0,d=arguments.length>=2?arguments[1]:void 0,e=0;c>e;e++)e in b&&a.call(d,b[e],e,b)}),Function.prototype.bind||(Function.prototype.bind=function(a){if("function"!=typeof this)throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");var b=Array.prototype.slice.call(arguments,1),c=this,d=function(){},e=function(){return c.apply(this instanceof d?this:a,b.concat(Array.prototype.slice.call(arguments)))};return d.prototype=this.prototype,e.prototype=new d,e}),Array.prototype.forEach||(Array.prototype.forEach=function(a,b){if(null===this)throw new TypeError(" this is null or not defined");if("function"!=typeof a)throw new TypeError(a+" is not a function");for(var c=Object(this),d=c.length>>>0,e=0;d>e;e++)e in c&&a.call(b,c[e],e,c)});
//# sourceMappingURL=ie8.polyfils.map

Some files were not shown because too many files have changed in this diff Show more