├── .gitignore ├── .gitmodules ├── .hgignore ├── .travis.yml ├── LICENSE ├── Procfile ├── README.md ├── alembic_migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 51d6e03ecba_initial_revision.py │ └── 93bd528a83_add_attachment_element.py ├── app.json ├── conftest.py ├── create_tables.py ├── deploy.sh ├── fabfile.py ├── init_db.py ├── install.py ├── migrations ├── 20141017-permalinks.py ├── 20141023-venues.py ├── 20141111-fold-up-locations.py ├── 20141130-eliminate-duplicate-tags.py ├── 20141204-add-avatar-suffix.py ├── 20141214-add_job_table.py ├── 20141214-add_mention_rsvp_column.py ├── 20150401_add_event_columns.py ├── 20150406_short_paths.py ├── 20150922-add-updated-column.sql └── 20151117-contacts_convert_social_ids_to_urls.py ├── pygments.css ├── redwind-dev.ini ├── redwind.cfg.heroku ├── redwind.cfg.template ├── redwind.ini ├── redwind ├── __init__.py ├── admin.py ├── auth.py ├── contexts.py ├── exporter.py ├── extensions.py ├── hooks.py ├── imageproxy.py ├── importer.py ├── maps.py ├── micropub.py ├── models.py ├── plugins │ ├── __init__.py │ ├── facebook.py │ ├── hentry_template.py │ ├── instagram.py │ ├── locations.py │ ├── posse.py │ ├── push.py │ ├── twitter.py │ ├── wm_receiver.py │ ├── wm_sender.py │ └── wordpress.py ├── services.py ├── static │ ├── admin │ │ ├── edit_checkin.js │ │ ├── edit_contact.js │ │ ├── edit_post.js │ │ ├── edit_venue.js │ │ ├── http.js │ │ └── util.js │ ├── cassis.js │ ├── img │ │ ├── cc-by.png │ │ ├── indiewebcamp-button.png │ │ └── users │ │ │ ├── kyle.jpg │ │ │ ├── nobody.png │ │ │ └── placeholder.png │ ├── indieconfig.js │ ├── kyle-mahan-public-key.asc │ ├── normalize.css │ ├── normalize.scss │ ├── pygments.css │ ├── style.css │ ├── style.scss │ ├── subscriptions.html │ ├── swirl_pattern.png │ ├── tag-it │ │ ├── css │ │ │ ├── jquery.tagit.css │ │ │ └── tagit.ui-zendesk.css │ │ └── js │ │ │ ├── tag-it.js │ │ │ └── tag-it.min.js │ └── webaction.js ├── tasks.py ├── templates │ ├── _checkin.jinja2 │ ├── _contexts.jinja2 │ ├── _location.jinja2 │ ├── _macros.jinja2 │ ├── _photos.jinja2 │ ├── _post_mentions.jinja2 │ ├── _syndication.jinja2 │ ├── admin │ │ ├── _contact.jinja2 │ │ ├── _context.jinja2 │ │ ├── _contexts.jinja2 │ │ ├── _nav.jinja2 │ │ ├── associate_credential.jinja2 │ │ ├── base.jinja2 │ │ ├── contact.html │ │ ├── contact.jinja2 │ │ ├── contacts.jinja2 │ │ ├── drafts.jinja2 │ │ ├── edit_article.jinja2 │ │ ├── edit_bookmark.jinja2 │ │ ├── edit_checkin.jinja2 │ │ ├── edit_contact.jinja2 │ │ ├── edit_event.jinja2 │ │ ├── edit_like.jinja2 │ │ ├── edit_note.jinja2 │ │ ├── edit_photo.jinja2 │ │ ├── edit_post.jinja2 │ │ ├── edit_post_all.jinja2 │ │ ├── edit_reply.jinja2 │ │ ├── edit_review.jinja2 │ │ ├── edit_share.jinja2 │ │ ├── edit_venue.jinja2 │ │ ├── login.jinja2 │ │ ├── mentions.jinja2 │ │ ├── merge_users.jinja2 │ │ ├── settings.jinja2 │ │ ├── share_on_facebook.jinja2 │ │ ├── share_on_twitter.jinja2 │ │ ├── venue.jinja2 │ │ └── venues.jinja2 │ ├── base.jinja2 │ ├── home.jinja2 │ ├── mentions.atom │ ├── posse │ │ ├── edit.jinja2 │ │ └── index.jinja2 │ ├── post.jinja2 │ ├── posts.atom │ ├── posts.jinja2 │ ├── tags.jinja2 │ ├── webmention.jinja2 │ ├── wm_received.jinja2 │ └── wm_status.jinja2 ├── util.py ├── views.py └── wsgi.py ├── requirements.txt ├── run.py ├── runtests.py ├── runtime.txt ├── scripts ├── convert_addressbook.py ├── convert_files_to_attachments.py ├── do_get_micropub_access_token.py ├── doimport.py ├── export_bookmarks.py ├── fix_non_utc_events.py └── update_static_resources.py ├── setup.cfg ├── setup.py ├── smartypants.py └── tests ├── conftest.py ├── context_test.py ├── image.jpg ├── testutil.py ├── util_test.py ├── views_test.py ├── wm_receiver_test.py └── wm_sender_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | test.db 3 | test.json 4 | redwind/static/uploads 5 | redwind/static/img/users/* 6 | !redwind/static/img/users/nobody*.png 7 | *~ 8 | build 9 | src 10 | venv 11 | TAGS 12 | *.log 13 | *.log.* 14 | _data 15 | _data.backup/ 16 | _mirror 17 | _archive 18 | redwind/skeletal/static/teamrobot-js 19 | .webassets-cache 20 | _resized 21 | redwind/static/css/site.css 22 | logs 23 | .env 24 | redwind/static/js/main.js 25 | redwind/templates/custom_* 26 | config.py 27 | .coverage 28 | htmlcov 29 | redwind/static/mirror 30 | redwind/static/map 31 | .sass-cache 32 | *.css.map 33 | _imageproxy 34 | *.orig 35 | *.egg 36 | db.sqlite 37 | *.egg-info 38 | redwind.cfg 39 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karadaisy/redwind/7ad807b5ab2dd74a8d470dbea9dd4baf5567d9c6/.gitmodules -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | .webassets-cache 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.4" 4 | install: 5 | - pip install --upgrade setuptools 6 | - pip install -r requirements.txt 7 | - pip install coveralls 8 | script: 9 | - python -m pytest tests --cov=redwind --cov-report term-missing 10 | after_success: 11 | - coveralls 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Simplifed BSD License 2 | 3 | Copyright (c) 2013 - 2015, Kyle Mahan 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are 8 | met: 9 | 10 | 1. Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in the 15 | documentation and/or other materials provided with the 16 | distribution. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: uwsgi uwsgi-heroku.ini 2 | -------------------------------------------------------------------------------- /alembic_migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /alembic_migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /alembic_migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | config = context.config 9 | 10 | # Interpret the config file for Python logging. 11 | # This line sets up loggers basically. 12 | fileConfig(config.config_file_name) 13 | 14 | # add your model's MetaData object here 15 | # for 'autogenerate' support 16 | # from myapp import mymodel 17 | # target_metadata = mymodel.Base.metadata 18 | from flask import current_app 19 | config.set_main_option('sqlalchemy.url', current_app.config.get('SQLALCHEMY_DATABASE_URI')) 20 | target_metadata = current_app.extensions['migrate'].db.metadata 21 | 22 | # other values from the config, defined by the needs of env.py, 23 | # can be acquired: 24 | # my_important_option = config.get_main_option("my_important_option") 25 | # ... etc. 26 | 27 | 28 | def run_migrations_offline(): 29 | """Run migrations in 'offline' mode. 30 | 31 | This configures the context with just a URL 32 | and not an Engine, though an Engine is acceptable 33 | here as well. By skipping the Engine creation 34 | we don't even need a DBAPI to be available. 35 | 36 | Calls to context.execute() here emit the given string to the 37 | script output. 38 | 39 | """ 40 | url = config.get_main_option("sqlalchemy.url") 41 | context.configure(url=url) 42 | 43 | with context.begin_transaction(): 44 | context.run_migrations() 45 | 46 | 47 | def run_migrations_online(): 48 | """Run migrations in 'online' mode. 49 | 50 | In this scenario we need to create an Engine 51 | and associate a connection with the context. 52 | 53 | """ 54 | engine = engine_from_config(config.get_section(config.config_ini_section), 55 | prefix='sqlalchemy.', 56 | poolclass=pool.NullPool) 57 | 58 | connection = engine.connect() 59 | context.configure(connection=connection, 60 | target_metadata=target_metadata, 61 | **current_app.extensions['migrate'].configure_args) 62 | 63 | try: 64 | with context.begin_transaction(): 65 | context.run_migrations() 66 | finally: 67 | connection.close() 68 | 69 | if context.is_offline_mode(): 70 | run_migrations_offline() 71 | else: 72 | run_migrations_online() 73 | 74 | -------------------------------------------------------------------------------- /alembic_migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /alembic_migrations/versions/51d6e03ecba_initial_revision.py: -------------------------------------------------------------------------------- 1 | """initial revision 2 | 3 | Revision ID: 51d6e03ecba 4 | Revises: None 5 | Create Date: 2015-06-04 21:58:29.508486 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '51d6e03ecba' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | from sqlalchemy.dialects import postgresql 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | pass 20 | ### end Alembic commands ### 21 | 22 | 23 | def downgrade(): 24 | ### commands auto generated by Alembic - please adjust! ### 25 | pass 26 | ### end Alembic commands ### 27 | -------------------------------------------------------------------------------- /alembic_migrations/versions/93bd528a83_add_attachment_element.py: -------------------------------------------------------------------------------- 1 | """add Attachment element 2 | 3 | Revision ID: 93bd528a83 4 | Revises: 51d6e03ecba 5 | Create Date: 2015-06-04 22:02:36.082013 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '93bd528a83' 11 | down_revision = '51d6e03ecba' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | from redwind import create_app 18 | from redwind import util 19 | from redwind import admin 20 | from redwind.models import Post, Attachment 21 | from redwind.extensions import db 22 | import os 23 | import datetime 24 | import random 25 | import string 26 | import mimetypes 27 | import shutil 28 | from flask import current_app 29 | 30 | 31 | def upgrade(): 32 | # commands auto generated by Alembic - please adjust! ### 33 | op.create_table( 34 | 'attachment', 35 | sa.Column('id', sa.Integer(), nullable=False, index=True), 36 | sa.Column('filename', sa.String(length=256), nullable=True), 37 | sa.Column('mimetype', sa.String(length=256), nullable=True), 38 | sa.Column('storage_path', sa.String(length=256), nullable=True), 39 | sa.Column('post_id', sa.Integer(), nullable=True), 40 | sa.ForeignKeyConstraint(['post_id'], ['post.id'], ), 41 | sa.PrimaryKeyConstraint('id')) 42 | # end Alembic commands ### 43 | 44 | 45 | def downgrade(): 46 | # commands auto generated by Alembic - please adjust! ### 47 | op.drop_table('attachment') 48 | # end Alembic commands ### 49 | 50 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Red Wind", 3 | "description": "IndieWeb-ready blog software written in Python and running on Flask.", 4 | "keywords": ["indieweb", "social", "blog"], 5 | "website": "https://indiewebcamp.com/Red_Wind", 6 | "repository": "https://github.com/kylewm/redwind", 7 | "scripts": { 8 | "postdeploy": [ 9 | "cp config.py.heroku config.py", 10 | "python install.py" 11 | ] 12 | }, 13 | "env": { 14 | "SECRET_KEY": { 15 | "description": "A secret key for verifying the integrity of signed cookies.", 16 | "generator": "secret" 17 | }, 18 | "URL": { 19 | "descrption": "The URL where the app will be deployed", 20 | "value": "http://appname.herokuapp.com" 21 | }, 22 | "TIMEZONE": { 23 | "description": "The user's location timezone", 24 | "value": "America/Los_Angeles" 25 | } 26 | }, 27 | "addons": [ 28 | "redistogo:nano", 29 | "heroku-postgresql:hobby-dev" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | # lets py.test set the proper PYTHONPATH 2 | -------------------------------------------------------------------------------- /create_tables.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from redwind import create_app 4 | from redwind.extensions import db 5 | from flask import current_app 6 | 7 | app = create_app() 8 | with app.app_context(): 9 | print('creating database tables for database', 10 | current_app.config['SQLALCHEMY_DATABASE_URI']) 11 | db.create_all() 12 | print('done creating database tables') 13 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | REMOTE_USER=kmahan 4 | HOST=orin.kylewm.com 5 | REMOTE_PATH=/srv/www/kylewm.com/redwind 6 | 7 | ssh $REMOTE_USER@$HOST bash -c "' 8 | 9 | set -x 10 | cd $REMOTE_PATH 11 | 12 | git pull origin master \ 13 | && source venv/bin/activate \ 14 | && pip install --upgrade -r requirements.txt \ 15 | && sudo restart redwind 16 | 17 | '" 18 | -------------------------------------------------------------------------------- /fabfile.py: -------------------------------------------------------------------------------- 1 | from fabric.api import local, prefix, cd, run, env, lcd, sudo 2 | import datetime 3 | 4 | env.hosts = ['orin.kylewm.com'] 5 | 6 | REMOTE_PATH = '/srv/www/kylewm.com/redwind' 7 | 8 | 9 | def backup(): 10 | backup_dir = '~/Backups/kylewm.com/{}/'.format( 11 | datetime.date.isoformat(datetime.date.today())) 12 | local('mkdir -p ' + backup_dir) 13 | local('scp orin.kylewm.com:kylewm.com.db ' + backup_dir) 14 | 15 | 16 | def commit(): 17 | local("git add -p") 18 | local("git diff-index --quiet HEAD || git commit") 19 | 20 | 21 | def push(): 22 | local("git push origin master") 23 | 24 | 25 | def pull(): 26 | with cd(REMOTE_PATH): 27 | run("git pull origin master") 28 | run("git submodule update") 29 | 30 | 31 | def restart(): 32 | with cd(REMOTE_PATH): 33 | with prefix("source venv/bin/activate"): 34 | run("pip install --upgrade -r requirements.txt") 35 | # run("uwsgi --reload /tmp/redwind.pid") 36 | # run("supervisorctl restart rw:*") 37 | sudo("restart redwind", shell=False) 38 | 39 | 40 | def tail(): 41 | sudo("tail -n 60 -f /var/log/upstart/redwind.log") 42 | 43 | 44 | def deploy(): 45 | commit() 46 | push() 47 | pull() 48 | restart() 49 | -------------------------------------------------------------------------------- /init_db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from redwind import create_app, models 4 | from redwind.extensions import db 5 | from flask import current_app 6 | 7 | app = create_app() 8 | with app.app_context(): 9 | print('''\ 10 | Welcome to the Red Wind installation script. This should only be used for 11 | to initialize a brand new database. Running this script against an existing 12 | database could destroy your data!''') 13 | 14 | print('creating database tables for database', current_app.config['SQLALCHEMY_DATABASE_URI']) 15 | db.create_all() 16 | print('done creating database tables') 17 | -------------------------------------------------------------------------------- /install.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from redwind import create_app, models 4 | from redwind.extensions import db 5 | from flask import current_app 6 | 7 | app = create_app() 8 | with app.app_context(): 9 | print('''\ 10 | Welcome to the Red Wind installation script. This should only be used for 11 | to initialize a brand new database. Running this script against an existing 12 | database could destroy your data!''') 13 | 14 | username = input('Your name: ').strip() 15 | twitter_username = input('Your Twitter username: ').strip() 16 | twitter_client_id = input('Twitter Client Key: ').strip() 17 | twitter_client_secret = input('Twitter Client Secret: ').strip() 18 | 19 | print('creating database tables for database', 20 | current_app.config['SQLALCHEMY_DATABASE_URI']) 21 | db.create_all() 22 | print('done creating database tables') 23 | 24 | bio = ''' 25 |
26 | New User is a brand new Red Wind user! 27 | Visit Settings to edit your bio. 28 | 31 |
32 | '''.format(twitter_username.lstrip('@')) 33 | 34 | print('setting default settings') 35 | defaults = [ 36 | ('Author Name', ''), 37 | ('Author Image', ''), 38 | ('Author Domain', ''), 39 | ('Author Bio', bio), 40 | ('Site Title', ''), 41 | ('Site URL', ''), 42 | ('Shortener URL', None), 43 | ('Push Hub', ''), 44 | ('Posts Per Page', 15), 45 | ('Twitter API Key', twitter_client_id), 46 | ('Twitter API Secret', twitter_client_secret), 47 | ('Twitter OAuth Token', ''), 48 | ('Twitter OAuth Token Secret', ''), 49 | ('Facebook App ID', ''), 50 | ('Facebook App Secret', ''), 51 | ('Facebook Access Token', ''), 52 | ('WordPress Client ID', ''), 53 | ('WordPress Client Secret', ''), 54 | ('WordPress Access Token', ''), 55 | ('Instagram Client ID', ''), 56 | ('Instagram Client Secret', ''), 57 | ('Instagram Access Token', ''), 58 | ('PGP Key URL', ''), 59 | ('Avatar Prefix', 'nobody'), 60 | ('Avatar Suffix', 'png'), 61 | ('Timezone', 'America/Los_Angeles'), 62 | ] 63 | 64 | for name, default in defaults: 65 | key = name.lower().replace(' ', '_') 66 | s = models.Setting.query.get(key) 67 | if not s: 68 | s = models.Setting() 69 | s.key = key 70 | s.name = name 71 | s.value = default 72 | db.session.add(s) 73 | 74 | user = models.User(name=username, admin=True) 75 | user.credentials.append(models.Credential(type='twitter', 76 | value=twitter_username)) 77 | db.session.commit() 78 | 79 | print('finished setting default settings') 80 | -------------------------------------------------------------------------------- /migrations/20141017-permalinks.py: -------------------------------------------------------------------------------- 1 | from redwind import app, db, util 2 | from redwind.models import Post 3 | import itertools 4 | 5 | db.engine.execute('alter table post add column historic_path varchar(256)') 6 | db.engine.execute('update post set historic_path = path') 7 | 8 | for post in Post.query.all(): 9 | print(post.historic_path) 10 | if not post.slug: 11 | post.slug = post.generate_slug() 12 | post.path = '{}/{:02d}/{}'.format(post.published.year, 13 | post.published.month, 14 | post.slug) 15 | 16 | db.session.commit() 17 | -------------------------------------------------------------------------------- /migrations/20141023-venues.py: -------------------------------------------------------------------------------- 1 | from redwind import db 2 | from redwind.models import Venue 3 | 4 | db.create_all() 5 | db.engine.execute( 6 | 'ALTER TABLE location ADD COLUMN venue_id INTEGER REFERENCES venue(id)') 7 | db.engine.execute( 8 | 'ALTER TABLE post ADD COLUMN venue_id INTEGER REFERENCES venue(id)') 9 | -------------------------------------------------------------------------------- /migrations/20141111-fold-up-locations.py: -------------------------------------------------------------------------------- 1 | """convert all Locations and Photos to JSON blobs and save them 2 | inside their owner 3 | """ 4 | import os 5 | import json 6 | from sqlalchemy import (create_engine, Table, Column, String, Integer, 7 | Float, Text, MetaData, select, ForeignKey, 8 | bindparam) 9 | 10 | 11 | engine = create_engine(os.environ.get('DATABASE_URL'), echo=True) 12 | 13 | # modify schema 14 | try: 15 | engine.execute('ALTER TABLE POST ADD COLUMN LOCATION TEXT') 16 | except: 17 | pass 18 | 19 | try: 20 | engine.execute('ALTER TABLE VENUE ADD COLUMN LOCATION TEXT') 21 | except: 22 | pass 23 | 24 | try: 25 | engine.execute('ALTER TABLE POST ADD COLUMN PHOTOS TEXT') 26 | except: 27 | pass 28 | 29 | 30 | conn = engine.connect() 31 | metadata = MetaData() 32 | 33 | locations = Table( 34 | 'Location', metadata, 35 | Column('id', Integer, primary_key=True), 36 | Column('latitude', Float), 37 | Column('longitude', Float), 38 | Column('name', String), 39 | Column('street_address', String), 40 | Column('extended_address', String), 41 | Column('locality', String), 42 | Column('region', String), 43 | Column('country_name', String), 44 | Column('postal_code', String), 45 | Column('country_code', String), 46 | Column('post_id', Integer, ForeignKey('post.id')), 47 | Column('venue_id', Integer, ForeignKey('venue.id')), 48 | ) 49 | 50 | photos = Table( 51 | 'photo', metadata, 52 | Column('id', Integer, primary_key=True), 53 | Column('filename', String), 54 | Column('caption', Text), 55 | Column('post_id', Integer, ForeignKey('post.id')), 56 | ) 57 | 58 | posts = Table( 59 | 'post', metadata, 60 | Column('id', Integer, primary_key=True), 61 | Column('location', Text), 62 | Column('photos', Text), 63 | ) 64 | 65 | venues = Table( 66 | 'venue', metadata, 67 | Column('id', Integer, primary_key=True), 68 | Column('location', Text), 69 | ) 70 | 71 | loc_attrs = [ 72 | locations.c.latitude, 73 | locations.c.longitude, 74 | locations.c.name, 75 | locations.c.street_address, 76 | locations.c.extended_address, 77 | locations.c.locality, 78 | locations.c.region, 79 | locations.c.country_name, 80 | locations.c.postal_code, 81 | locations.c.country_code, 82 | ] 83 | 84 | 85 | def migrate_post_locations(): 86 | posts_batch = [] 87 | for row in conn.execute( 88 | select([posts, locations], use_labels=True).select_from( 89 | posts.join(locations))): 90 | loc = json.dumps({ 91 | attr.name: row[attr] for attr in loc_attrs if row[attr] 92 | }) 93 | posts_batch.append({ 94 | 'post_id': row[posts.c.id], 95 | 'location': loc, 96 | }) 97 | 98 | update_posts = posts.update()\ 99 | .where(posts.c.id == bindparam('post_id'))\ 100 | .values(location=bindparam('location')) 101 | 102 | conn.execute(update_posts, posts_batch) 103 | 104 | 105 | def migrate_venue_locations(): 106 | venues_batch = [] 107 | for row in conn.execute( 108 | select([venues, locations], use_labels=True).select_from( 109 | venues.join(locations))): 110 | loc = json.dumps({ 111 | attr.name: row[attr] for attr in loc_attrs if row[attr] 112 | }) 113 | venues_batch.append({ 114 | 'venue_id': row[venues.c.id], 115 | 'location': loc, 116 | }) 117 | 118 | update_posts = venues.update()\ 119 | .where(venues.c.id == bindparam('venue_id'))\ 120 | .values(location=bindparam('location')) 121 | 122 | conn.execute(update_posts, venues_batch) 123 | 124 | 125 | def migrate_post_photos(): 126 | photo_map = {} 127 | photo_attrs = [ 128 | photos.c.caption, 129 | photos.c.filename, 130 | ] 131 | 132 | for row in conn.execute( 133 | select([posts, photos], use_labels=True).select_from( 134 | posts.join(photos))): 135 | post_id = row[posts.c.id] 136 | photo_json = { 137 | attr.name: row[attr] for attr in photo_attrs if row[attr] 138 | } 139 | photo_map.setdefault(post_id, []).append(photo_json) 140 | 141 | photo_batch = [] 142 | for post_id, photo_blob in photo_map.items(): 143 | photo_batch.append({ 144 | 'post_id': post_id, 145 | 'photos': json.dumps(photo_blob), 146 | }) 147 | 148 | update_photos = posts.update()\ 149 | .where(posts.c.id == bindparam('post_id'))\ 150 | .values(photos=bindparam('photos')) 151 | 152 | conn.execute(update_photos, photo_batch) 153 | 154 | 155 | migrate_post_locations() 156 | migrate_venue_locations() 157 | migrate_post_photos() 158 | -------------------------------------------------------------------------------- /migrations/20141130-eliminate-duplicate-tags.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | import os 4 | import json 5 | from sqlalchemy import (create_engine, Table, Column, String, Integer, 6 | Float, Text, MetaData, select, ForeignKey, 7 | bindparam, delete, and_) 8 | from config import Configuration 9 | 10 | engine = create_engine(Configuration.SQLALCHEMY_DATABASE_URI, echo=True) 11 | 12 | metadata = MetaData() 13 | 14 | tags = Table( 15 | 'tag', metadata, 16 | Column('id', Integer, primary_key=True), 17 | Column('name', String), 18 | ) 19 | 20 | posts = Table( 21 | 'post', metadata, 22 | Column('id', Integer, primary_key=True), 23 | ) 24 | 25 | posts_to_tags = Table( 26 | 'posts_to_tags', metadata, 27 | Column('tag_id', Integer, ForeignKey('tag.id')), 28 | Column('post_id', Integer, ForeignKey('post.id')), 29 | ) 30 | 31 | 32 | def eliminate_duplicates(conn): 33 | tag_map = {} 34 | update_batch = [] 35 | delete_batch = [] 36 | 37 | for row in conn.execute( 38 | select([posts, tags]).select_from( 39 | posts.join(posts_to_tags).join(tags) 40 | ).order_by(tags.c.id)): 41 | post_id = row[0] 42 | tag_id = row[1] 43 | tag_name = row[2] 44 | 45 | # possible duplicate 46 | if tag_name in tag_map: 47 | preexisting_tag_id = tag_map.get(tag_name) 48 | if preexisting_tag_id != tag_id: 49 | update_batch.append({ 50 | 'the_post_id': post_id, 51 | 'old_tag_id': tag_id, 52 | 'new_tag_id': preexisting_tag_id, 53 | }) 54 | delete_batch.append({ 55 | 'the_tag_id': tag_id, 56 | }) 57 | else: 58 | tag_map[tag_name] = tag_id 59 | 60 | print('update batch', update_batch) 61 | if update_batch: 62 | update_stmt = posts_to_tags.update().where( 63 | and_( 64 | posts_to_tags.c.post_id == bindparam('the_post_id'), 65 | posts_to_tags.c.tag_id == bindparam('old_tag_id') 66 | ) 67 | ).values(tag_id=bindparam('new_tag_id')) 68 | # print(update_stmt) 69 | # print(update_batch) 70 | conn.execute(update_stmt, update_batch) 71 | 72 | print('delete batch', delete_batch) 73 | if delete_batch: 74 | delete_stmt = tags.delete().where(tags.c.id == bindparam('the_tag_id')) 75 | # print(delete_stmt) 76 | conn.execute(delete_stmt, delete_batch) 77 | 78 | 79 | with engine.begin() as conn: 80 | eliminate_duplicates(conn) 81 | -------------------------------------------------------------------------------- /migrations/20141204-add-avatar-suffix.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from redwind import db, models 4 | 5 | s = models.Setting.query.get('avatar_suffix') 6 | if not s: 7 | print('Adding Avatar Suffix settings') 8 | s = models.Setting() 9 | s.key = 'avatar_suffix' 10 | s.name = 'Avatar Suffix' 11 | s.value = 'jpg' 12 | db.session.add(s) 13 | db.session.commit() 14 | else: 15 | print('Found Avatar Suffix settings. Skipping.') 16 | 17 | print('Done.') 18 | -------------------------------------------------------------------------------- /migrations/20141214-add_job_table.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | import os 4 | import json 5 | from sqlalchemy import (create_engine, Table, Column, String, Integer, 6 | PickleType, Boolean, DateTime, Float, Text, 7 | MetaData, select, ForeignKey, bindparam, 8 | delete, and_) 9 | from sqlalchemy.ext.declarative import declarative_base 10 | from sqlalchemy.sql import func 11 | from config import Configuration 12 | 13 | metadata = MetaData() 14 | Base = declarative_base(metadata=metadata) 15 | 16 | engine = create_engine(Configuration.SQLALCHEMY_DATABASE_URI, echo=True) 17 | 18 | 19 | 20 | class Job(Base): 21 | __tablename__ = 'job' 22 | 23 | id = Column(Integer, primary_key=True) 24 | created = Column(DateTime, default=func.now()) 25 | updated = Column(DateTime, onupdate=func.now()) 26 | key = Column(String(128)) 27 | params = Column(PickleType) 28 | result = Column(PickleType) 29 | complete = Column(Boolean) 30 | 31 | 32 | metadata.create_all(engine) 33 | -------------------------------------------------------------------------------- /migrations/20141214-add_mention_rsvp_column.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | import os 4 | import json 5 | from sqlalchemy import (create_engine, Table, Column, String, Integer, 6 | PickleType, Boolean, DateTime, Float, Text, 7 | MetaData, select, ForeignKey, bindparam, 8 | delete, and_) 9 | from sqlalchemy.ext.declarative import declarative_base 10 | from sqlalchemy.sql import func 11 | from config import Configuration 12 | 13 | engine = create_engine(Configuration.SQLALCHEMY_DATABASE_URI, echo=True) 14 | 15 | engine.execute('alter table mention add column rsvp varchar(32)') 16 | -------------------------------------------------------------------------------- /migrations/20150401_add_event_columns.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | import os 4 | import json 5 | from sqlalchemy import (create_engine, Table, Column, String, Integer, 6 | PickleType, Boolean, DateTime, Float, Text, 7 | MetaData, select, ForeignKey, bindparam, 8 | delete, and_) 9 | from sqlalchemy.ext.declarative import declarative_base 10 | from sqlalchemy.sql import func 11 | from config import Configuration 12 | 13 | engine = create_engine(Configuration.SQLALCHEMY_DATABASE_URI, echo=True) 14 | 15 | engine.execute('alter table post add column start_utc type timestamp') 16 | engine.execute('alter table post add column end_utc type timestamp') 17 | engine.execute('alter table post add column start_utcoffset type interval') 18 | engine.execute('alter table post add column end_utcoffset type interval') 19 | engine.execute('alter table post add column updated timestamp') 20 | 21 | 22 | -------------------------------------------------------------------------------- /migrations/20150406_short_paths.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | import os 4 | import json 5 | from sqlalchemy import (create_engine, Table, Column, String, Integer, 6 | PickleType, Boolean, DateTime, Float, Text, 7 | MetaData, select, ForeignKey, bindparam, 8 | delete, and_) 9 | from sqlalchemy.ext.declarative import declarative_base 10 | from sqlalchemy.sql import func 11 | 12 | from redwind import create_app, util 13 | from redwind.extensions import db 14 | from redwind.models import Post 15 | import itertools 16 | 17 | app = create_app() 18 | with app.app_context(): 19 | 20 | #print('adding table column') 21 | #db.engine.execute('alter table post add column short_path varchar(16)') 22 | 23 | short_paths = set() 24 | 25 | for post in Post.query.order_by(Post.published): 26 | if not post.short_path: 27 | short_base = '{}/{}'.format( 28 | util.tag_for_post_type(post.post_type), 29 | util.base60_encode(util.date_to_ordinal(post.published))) 30 | 31 | for idx in itertools.count(1): 32 | post.short_path = short_base + util.base60_encode(idx) 33 | if post.short_path not in short_paths: 34 | break 35 | 36 | short_paths.add(post.short_path) 37 | print(post.short_path + '\t' + post.path) 38 | 39 | db.session.commit() 40 | -------------------------------------------------------------------------------- /migrations/20150922-add-updated-column.sql: -------------------------------------------------------------------------------- 1 | alter table post add column updated timestamp; 2 | update post set updated=published where updated is null; 3 | -------------------------------------------------------------------------------- /migrations/20151117-contacts_convert_social_ids_to_urls.py: -------------------------------------------------------------------------------- 1 | from redwind import create_app 2 | from redwind.models import Contact 3 | from redwind.extensions import db 4 | 5 | app = create_app() 6 | with app.app_context(): 7 | for contact in Contact.query.all(): 8 | urls = [] 9 | if 'twitter' in contact.social: 10 | urls.append('https://twitter.com/%s' % contact.social['twitter']) 11 | if 'facebook' in contact.social: 12 | urls.append('https://www.facebook.com/%s' % contact.social['facebook']) 13 | print(urls) 14 | contact.social = urls 15 | db.session.commit() 16 | 17 | -------------------------------------------------------------------------------- /pygments.css: -------------------------------------------------------------------------------- 1 | .codehilite .hll { background-color: #ffffcc } 2 | .codehilite { background: #f8f8f8; } 3 | .codehilite .c { color: #8f5902; font-style: italic } /* Comment */ 4 | .codehilite .err { color: #a40000; border: 1px solid #ef2929 } /* Error */ 5 | .codehilite .g { color: #000000 } /* Generic */ 6 | .codehilite .k { color: #204a87; font-weight: bold } /* Keyword */ 7 | .codehilite .l { color: #000000 } /* Literal */ 8 | .codehilite .n { color: #000000 } /* Name */ 9 | .codehilite .o { color: #ce5c00; font-weight: bold } /* Operator */ 10 | .codehilite .x { color: #000000 } /* Other */ 11 | .codehilite .p { color: #000000; font-weight: bold } /* Punctuation */ 12 | .codehilite .cm { color: #8f5902; font-style: italic } /* Comment.Multiline */ 13 | .codehilite .cp { color: #8f5902; font-style: italic } /* Comment.Preproc */ 14 | .codehilite .c1 { color: #8f5902; font-style: italic } /* Comment.Single */ 15 | .codehilite .cs { color: #8f5902; font-style: italic } /* Comment.Special */ 16 | .codehilite .gd { color: #a40000 } /* Generic.Deleted */ 17 | .codehilite .ge { color: #000000; font-style: italic } /* Generic.Emph */ 18 | .codehilite .gr { color: #ef2929 } /* Generic.Error */ 19 | .codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ 20 | .codehilite .gi { color: #00A000 } /* Generic.Inserted */ 21 | .codehilite .go { color: #000000; font-style: italic } /* Generic.Output */ 22 | .codehilite .gp { color: #8f5902 } /* Generic.Prompt */ 23 | .codehilite .gs { color: #000000; font-weight: bold } /* Generic.Strong */ 24 | .codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ 25 | .codehilite .gt { color: #a40000; font-weight: bold } /* Generic.Traceback */ 26 | .codehilite .kc { color: #204a87; font-weight: bold } /* Keyword.Constant */ 27 | .codehilite .kd { color: #204a87; font-weight: bold } /* Keyword.Declaration */ 28 | .codehilite .kn { color: #204a87; font-weight: bold } /* Keyword.Namespace */ 29 | .codehilite .kp { color: #204a87; font-weight: bold } /* Keyword.Pseudo */ 30 | .codehilite .kr { color: #204a87; font-weight: bold } /* Keyword.Reserved */ 31 | .codehilite .kt { color: #204a87; font-weight: bold } /* Keyword.Type */ 32 | .codehilite .ld { color: #000000 } /* Literal.Date */ 33 | .codehilite .m { color: #0000cf; font-weight: bold } /* Literal.Number */ 34 | .codehilite .s { color: #4e9a06 } /* Literal.String */ 35 | .codehilite .na { color: #c4a000 } /* Name.Attribute */ 36 | .codehilite .nb { color: #204a87 } /* Name.Builtin */ 37 | .codehilite .nc { color: #000000 } /* Name.Class */ 38 | .codehilite .no { color: #000000 } /* Name.Constant */ 39 | .codehilite .nd { color: #5c35cc; font-weight: bold } /* Name.Decorator */ 40 | .codehilite .ni { color: #ce5c00 } /* Name.Entity */ 41 | .codehilite .ne { color: #cc0000; font-weight: bold } /* Name.Exception */ 42 | .codehilite .nf { color: #000000 } /* Name.Function */ 43 | .codehilite .nl { color: #f57900 } /* Name.Label */ 44 | .codehilite .nn { color: #000000 } /* Name.Namespace */ 45 | .codehilite .nx { color: #000000 } /* Name.Other */ 46 | .codehilite .py { color: #000000 } /* Name.Property */ 47 | .codehilite .nt { color: #204a87; font-weight: bold } /* Name.Tag */ 48 | .codehilite .nv { color: #000000 } /* Name.Variable */ 49 | .codehilite .ow { color: #204a87; font-weight: bold } /* Operator.Word */ 50 | .codehilite .w { color: #f8f8f8; text-decoration: underline } /* Text.Whitespace */ 51 | .codehilite .mf { color: #0000cf; font-weight: bold } /* Literal.Number.Float */ 52 | .codehilite .mh { color: #0000cf; font-weight: bold } /* Literal.Number.Hex */ 53 | .codehilite .mi { color: #0000cf; font-weight: bold } /* Literal.Number.Integer */ 54 | .codehilite .mo { color: #0000cf; font-weight: bold } /* Literal.Number.Oct */ 55 | .codehilite .sb { color: #4e9a06 } /* Literal.String.Backtick */ 56 | .codehilite .sc { color: #4e9a06 } /* Literal.String.Char */ 57 | .codehilite .sd { color: #8f5902; font-style: italic } /* Literal.String.Doc */ 58 | .codehilite .s2 { color: #4e9a06 } /* Literal.String.Double */ 59 | .codehilite .se { color: #4e9a06 } /* Literal.String.Escape */ 60 | .codehilite .sh { color: #4e9a06 } /* Literal.String.Heredoc */ 61 | .codehilite .si { color: #4e9a06 } /* Literal.String.Interpol */ 62 | .codehilite .sx { color: #4e9a06 } /* Literal.String.Other */ 63 | .codehilite .sr { color: #4e9a06 } /* Literal.String.Regex */ 64 | .codehilite .s1 { color: #4e9a06 } /* Literal.String.Single */ 65 | .codehilite .ss { color: #4e9a06 } /* Literal.String.Symbol */ 66 | .codehilite .bp { color: #3465a4 } /* Name.Builtin.Pseudo */ 67 | .codehilite .vc { color: #000000 } /* Name.Variable.Class */ 68 | .codehilite .vg { color: #000000 } /* Name.Variable.Global */ 69 | .codehilite .vi { color: #000000 } /* Name.Variable.Instance */ 70 | .codehilite .il { color: #0000cf; font-weight: bold } /* Literal.Number.Integer.Long */ -------------------------------------------------------------------------------- /redwind-dev.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | master=true 3 | processes=4 4 | threads=2 5 | #http=:5000 6 | socket=/tmp/uwsgi.sock 7 | chmod-socket=666 8 | module=redwind.wsgi:application 9 | attach-daemon=rqworker redwind:high redwind:low 10 | pidfile=/tmp/redwind.pid 11 | py-autoreload=3 12 | -------------------------------------------------------------------------------- /redwind.cfg.heroku: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Configuration(object): 5 | DEBUG = os.environ.get('REDWIND_DEBUG') == 'true' 6 | 7 | # do not intercept redirects when using debug toolbar 8 | DEBUG_TB_INTERCEPT_REDIRECTS = False 9 | 10 | # Some secret key used by Flask-Login 11 | SECRET_KEY = os.environ.get('SECRET_KEY') 12 | 13 | # Color theme for pygments syntax coloring (handled by the 14 | # codehilite plugin for Markdown) 15 | PYGMENTS_STYLE = 'tango' 16 | 17 | # schema to contact DB 18 | SQLALCHEMY_DATABASE_URI = 'postgres:///redwind' 19 | # SQLALCHEMY_DATABASE_URI = 'sqlite:////home/kmahan/kylewm.com.db' 20 | 21 | REDIS_URL = 'redis://localhost:6379' 22 | -------------------------------------------------------------------------------- /redwind.cfg.template: -------------------------------------------------------------------------------- 1 | DEBUG = False 2 | 3 | # do not intercept redirects when using debug toolbar 4 | DEBUG_TB_INTERCEPT_REDIRECTS = False 5 | 6 | # Some secret key used by Flask-Login 7 | SECRET_KEY = 'UseASecretKeyOtherThanThisOne' 8 | 9 | # Color theme for pygments syntax coloring (handled by the 10 | # codehilite plugin for Markdown) 11 | PYGMENTS_STYLE = 'tango' 12 | 13 | # schema to contact DB 14 | SQLALCHEMY_DATABASE_URI = 'postgres:///redwind' 15 | # SQLALCHEMY_DATABASE_URI = 'sqlite:////home/kmahan/kylewm.com.db' 16 | 17 | # the theme to use 18 | # DEFAULT_THEME = 'plain' 19 | 20 | # Pubsubhubbub hub to ping when a post is created or edited. 21 | PUSH_HUB = 'https://pubsubhubbub.superfeedr.com' 22 | 23 | # Use the Pushover service to send push notifications to your mobile device 24 | # PUSHOVER_USER = '...' 25 | # PUSHOVER_TOKEN = '...' 26 | 27 | UPLOAD_PATH = '/srv/www/redwind/Uploads' 28 | IMAGEPROXY_PATH = '/srv/www/redwind/ImageProxy' 29 | -------------------------------------------------------------------------------- /redwind.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | master=true 3 | socket=/tmp/uwsgi.sock 4 | chmod-socket=666 5 | module=redwind.wsgi:application 6 | 7 | threads=2 8 | cheaper-algo=spare 9 | cheaper=2 10 | cheaper-initial=2 11 | workers=10 12 | 13 | attach-daemon=venv/bin/rqworker redwind:high redwind:low 14 | -------------------------------------------------------------------------------- /redwind/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from logging import StreamHandler, Formatter 3 | from logging.handlers import SMTPHandler, RotatingFileHandler 4 | import logging 5 | import importlib 6 | 7 | MAIL_FORMAT = '''\ 8 | Message type: %(levelname)s 9 | Location: %(pathname)s:%(lineno)d 10 | Module: %(module)s 11 | Function: %(funcName)s 12 | Time: %(asctime)s 13 | 14 | Message: 15 | 16 | %(message)s 17 | ''' 18 | 19 | 20 | def create_app(config_file='../redwind.cfg', is_queue=False): 21 | from redwind import extensions 22 | from redwind.views import views 23 | from redwind.admin import admin 24 | from redwind.services import services 25 | from redwind.micropub import micropub 26 | from redwind.imageproxy import imageproxy 27 | 28 | app = Flask(__name__) 29 | app.config.from_pyfile(config_file) 30 | app.config['CONFIG_FILE'] = config_file 31 | 32 | app.jinja_env.trim_blocks = True 33 | app.jinja_env.lstrip_blocks = True 34 | app.jinja_env.add_extension('jinja2.ext.autoescape') 35 | app.jinja_env.add_extension('jinja2.ext.with_') 36 | app.jinja_env.add_extension('jinja2.ext.i18n') 37 | 38 | extensions.init_app(app) 39 | 40 | if app.config.get('PROFILE'): 41 | from werkzeug.contrib.profiler import ProfilerMiddleware 42 | f = open('logs/profiler.log', 'w') 43 | app.wsgi_app = ProfilerMiddleware( 44 | app.wsgi_app, f, restrictions=[60], 45 | sort_by=('cumtime', 'tottime', 'ncalls')) 46 | 47 | # logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) 48 | if app.debug: 49 | app.logger.setLevel(logging.ERROR) 50 | else: 51 | app.logger.setLevel(logging.DEBUG) 52 | stream_handler = StreamHandler() 53 | # stream_handler = RotatingFileHandler(app.config.get('QUEUE_LOG' if is_queue else 'APP_LOG'), maxBytes=10000, backupCount=1) 54 | formatter = logging.Formatter( 55 | '%(asctime)s - %(name)s - %(levelname)s - %(message)s') 56 | stream_handler.setFormatter(formatter) 57 | app.logger.addHandler(stream_handler) 58 | 59 | recipients = app.config.get('ADMIN_EMAILS') 60 | if recipients: 61 | error_handler = SMTPHandler( 62 | 'localhost', 'Redwind ', 63 | recipients, 'redwind error') 64 | error_handler.setLevel(logging.ERROR) 65 | error_handler.setFormatter(Formatter(MAIL_FORMAT)) 66 | app.logger.addHandler(error_handler) 67 | 68 | app.register_blueprint(views) 69 | app.register_blueprint(admin) 70 | app.register_blueprint(services) 71 | app.register_blueprint(micropub) 72 | app.register_blueprint(imageproxy) 73 | 74 | for plugin in [ 75 | 'facebook', 76 | 'instagram', 77 | 'locations', 78 | 'push', 79 | 'twitter', 80 | 'wm_receiver', 81 | 'wm_sender', 82 | 'wordpress', 83 | 'posse', 84 | ]: 85 | # app.logger.info('loading plugin module %s', plugin) 86 | module = importlib.import_module('redwind.plugins.' + plugin) 87 | try: 88 | module.register(app) 89 | except: 90 | app.logger.warn('no register method for plugin module %s', plugin) 91 | 92 | return app 93 | -------------------------------------------------------------------------------- /redwind/auth.py: -------------------------------------------------------------------------------- 1 | from .extensions import login_mgr 2 | from .models import User, Credential 3 | from flask import current_app 4 | 5 | 6 | @login_mgr.user_loader 7 | def load_user(id): 8 | try: 9 | if isinstance(id, int): 10 | return User.query.get(id) 11 | else: 12 | cred = Credential.query.filter_by(type='indieauth', value=id).first() 13 | return cred and cred.user 14 | except: 15 | current_app.logger.exception('loading current user') 16 | -------------------------------------------------------------------------------- /redwind/contexts.py: -------------------------------------------------------------------------------- 1 | from . import hooks 2 | from . import util 3 | from .extensions import db 4 | from .models import Context 5 | 6 | import bs4 7 | import mf2py 8 | import mf2util 9 | 10 | from flask import current_app 11 | 12 | 13 | def fetch_contexts(post): 14 | do_fetch_context(post, 'reply_contexts', post.in_reply_to) 15 | do_fetch_context(post, 'repost_contexts', post.repost_of) 16 | do_fetch_context(post, 'like_contexts', post.like_of) 17 | do_fetch_context(post, 'bookmark_contexts', post.bookmark_of) 18 | 19 | 20 | def do_fetch_context(post, context_attr, urls): 21 | current_app.logger.debug("fetching urls %s", urls) 22 | old_contexts = getattr(post, context_attr) 23 | new_contexts = [create_context(url) for url in urls] 24 | 25 | for old in old_contexts: 26 | if old not in new_contexts: 27 | db.session.delete(old) 28 | 29 | for new_context in new_contexts: 30 | db.session.add(new_context) 31 | 32 | setattr(post, context_attr, new_contexts) 33 | db.session.commit() 34 | 35 | 36 | def extract_ogp_context(context, doc, url): 37 | """ Gets Open Graph Protocol data from the given document 38 | See http://indiewebcamp.com/The-Open-Graph-protocol 39 | """ 40 | soup = bs4.BeautifulSoup(doc) 41 | 42 | # extract ogp data 43 | ogp_title = soup.find('meta', {'property': 'og:title'}) 44 | ogp_image = soup.find('meta', {'property': 'og:image'}) 45 | ogp_site = soup.find('meta', {'property': 'og:site_name'}) 46 | ogp_url = soup.find('meta', {'property': 'og:url'}) 47 | ogp_content = soup.find('meta', {'property': 'og:description'}) 48 | 49 | # import the title if mf2 didn't get a title *or* content 50 | if ogp_title and not context.title and not context.content: 51 | context.title = ogp_title.get('content') 52 | 53 | if ogp_image and not context.author_image: 54 | context.author_image = ogp_image.get('content') 55 | 56 | if ogp_site and not context.author_name: 57 | context.author_name = ogp_site.get('content') 58 | 59 | if ogp_url and not context.permalink: 60 | context.permalink = ogp_url.get('content') 61 | 62 | if ogp_content and not context.content: 63 | context.content = ogp_content.get('content') 64 | context.content_plain = ogp_content.get('content') 65 | # remove the title if they are the same 66 | if context.title == context.content: 67 | context.title = None 68 | 69 | return context 70 | 71 | 72 | def extract_mf2_context(context, doc, url): 73 | """ Gets Microformats2 data from the given document 74 | """ 75 | cached_mf2 = {} 76 | 77 | # used by authorship algorithm 78 | def fetch_mf2(url): 79 | if url in cached_mf2: 80 | return cached_mf2[url] 81 | p = mf2py.parse(url=url) 82 | cached_mf2[url] = p 83 | return p 84 | 85 | blob = mf2py.parse(doc=doc, url=url) 86 | cached_mf2[url] = blob 87 | 88 | if blob: 89 | current_app.logger.debug('parsed successfully by mf2py: %s', url) 90 | entry = mf2util.interpret(blob, url, fetch_mf2_func=fetch_mf2) 91 | if entry: 92 | current_app.logger.debug( 93 | 'parsed successfully by mf2util: %s', url) 94 | published = entry.get('published') 95 | content = util.clean_foreign_html(entry.get('content', '')) 96 | content_plain = util.format_as_text( 97 | content, link_fn=lambda a: a) 98 | 99 | title = entry.get('name') 100 | if title and len(title) > 512: 101 | # FIXME is there a db setting to do this automatically? 102 | title = title[:512] 103 | author_name = entry.get('author', {}).get('name', '') 104 | author_image = entry.get('author', {}).get('photo') 105 | 106 | permalink = entry.get('url') 107 | if not permalink or not isinstance(permalink, str): 108 | permalink = url 109 | 110 | context.url = url 111 | context.permalink = permalink 112 | context.author_name = author_name 113 | context.author_url = entry.get('author', {}).get('url', '') 114 | context.author_image = author_image 115 | context.content = content 116 | context.content_plain = content_plain 117 | context.published = published 118 | context.title = title 119 | 120 | return context 121 | 122 | 123 | def extract_default_context(context, response, url): 124 | """ Gets default information if not all info is retrieved 125 | """ 126 | context = Context() if not context else context 127 | 128 | if not context.url or not context.permalink: 129 | current_app.logger.debug('getting default url info: %s', url) 130 | context.url = context.permalink = url 131 | 132 | if not context.title and not context.content: 133 | current_app.logger.debug('getting default title info: %s', url) 134 | if response: 135 | html = response.text 136 | soup = bs4.BeautifulSoup(html) 137 | 138 | if soup.title: 139 | context.title = soup.title.string 140 | 141 | return context 142 | 143 | 144 | def create_context(url): 145 | for context in hooks.fire('create-context', url): 146 | if context: 147 | return context 148 | 149 | context = None 150 | response = None 151 | try: 152 | response = util.fetch_html(url) 153 | response.raise_for_status() 154 | 155 | context = Context.query.filter_by(url=url).first() 156 | current_app.logger.debug( 157 | 'checked for pre-existing context for this url: %s', context) 158 | 159 | if not context: 160 | context = Context() 161 | 162 | context.url = context.permalink = url 163 | 164 | context = extract_mf2_context( 165 | context=context, 166 | doc=response.text, 167 | url=url 168 | ) 169 | context = extract_ogp_context( 170 | context=context, 171 | doc=response.text, 172 | url=url 173 | ) 174 | except: 175 | current_app.logger.exception( 176 | 'Could not fetch context for url %s, received response %s', 177 | url, response) 178 | 179 | context = extract_default_context( 180 | context=context, 181 | response=response, 182 | url=url 183 | ) 184 | 185 | return context 186 | -------------------------------------------------------------------------------- /redwind/exporter.py: -------------------------------------------------------------------------------- 1 | from .models import Setting, Post, Contact, Venue 2 | import datetime 3 | 4 | 5 | def export_all(): 6 | return { 7 | 'settings': [export_setting(s) for s in Setting.query.all()], 8 | 'venues': [export_venue(v) for v in Venue.query.all()], 9 | 'contacts': [export_contact(c) for c in Contact.query.all()], 10 | 'posts': [export_post(p) for p in Post.query.all()], 11 | } 12 | 13 | def export_datetime(dt): 14 | if dt: 15 | if dt.tzinfo: 16 | dt = dt.astimezone(datetime.timezone.utc) 17 | dt = dt.replace(tzinfo=None) 18 | return dt.strftime('%Y-%m-%dT%H:%M:%S') 19 | 20 | def export_setting(s): 21 | return { 22 | 'key': s.key, 23 | 'name': s.name, 24 | 'value': s.value, 25 | } 26 | 27 | 28 | def export_contact(c): 29 | return { 30 | 'name': c.name, 31 | 'nicks': [ n.name for n in c.nicks ], 32 | 'image': c.image, 33 | 'url': c.url, 34 | 'social': c.social, 35 | } 36 | 37 | 38 | def export_venue(v): 39 | return { 40 | 'name': v.name, 41 | 'location': v.location, 42 | 'slug': v.slug, 43 | } 44 | 45 | 46 | def export_post(p): 47 | return { 48 | 'path': p.path, 49 | 'historic_path': p.historic_path, 50 | 'post_type': p.post_type, 51 | 'draft': p.draft, 52 | 'deleted': p.deleted, 53 | 'hidden': p.hidden, 54 | 'redirect': p.redirect, 55 | 'tags': [t.name for t in p.tags], 56 | 'audience': p.audience, 57 | 'in_reply_to': p.in_reply_to, 58 | 'repost_of': p.repost_of, 59 | 'like_of': p.like_of, 60 | 'bookmark_of': p.bookmark_of, 61 | 'reply_contexts': [export_context(c) for c in p.reply_contexts], 62 | 'like_contexts': [export_context(c) for c in p.like_contexts], 63 | 'repost_contexts': [export_context(c) for c in p.repost_contexts], 64 | 'bookmark_contexts': [export_context(c) for c in p.bookmark_contexts], 65 | 'title': p.title, 66 | 'published': export_datetime(p.published), 67 | 'slug': p.slug, 68 | 'syndication': p.syndication, 69 | 'location': p.location, 70 | 'photos': p.photos, 71 | 'venue': p.venue.slug if p.venue else None, 72 | 'mentions': [export_mention(m) for m in p.mentions], 73 | 'content': p.content, 74 | 'content_html': p.content_html, 75 | } 76 | 77 | 78 | def export_context(c): 79 | return { 80 | 'url': c.url, 81 | 'permalink': c.permalink, 82 | 'author_name': c.author_name, 83 | 'author_url': c.author_url, 84 | 'author_image': c.author_image, 85 | 'content': c.content, 86 | 'content_plain': c.content_plain, 87 | 'published': export_datetime(c.published), 88 | 'title': c.title, 89 | 'syndication': c.syndication, 90 | } 91 | 92 | 93 | def export_mention(m): 94 | return { 95 | 'url': m.url, 96 | 'permalink': m.permalink, 97 | 'author_name': m.author_name, 98 | 'author_url': m.author_url, 99 | 'author_image': m.author_image, 100 | 'content': m.content, 101 | 'content_plain': m.content_plain, 102 | 'published': export_datetime(m.published), 103 | 'title': m.title, 104 | 'syndication': m.syndication, 105 | 'reftype': m.reftype, 106 | } 107 | -------------------------------------------------------------------------------- /redwind/extensions.py: -------------------------------------------------------------------------------- 1 | from flask.ext.sqlalchemy import SQLAlchemy 2 | from flask.ext.login import LoginManager 3 | 4 | db = SQLAlchemy() 5 | 6 | 7 | # toolbar = DebugToolbarExtension(app) 8 | login_mgr = LoginManager() 9 | login_mgr.login_view = 'admin.login' 10 | 11 | 12 | def init_app(app): 13 | db.init_app(app) 14 | login_mgr.init_app(app) 15 | -------------------------------------------------------------------------------- /redwind/hooks.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | actions = {} 4 | 5 | 6 | def register(hook, action): 7 | #app.logger.debug('registering hook %s -> %s', hook, action) 8 | actions.setdefault(hook, []).append(action) 9 | 10 | 11 | def fire(hook, *args, **kwargs): 12 | #app.logger.debug('firing hook %s', hook) 13 | return [action(*args, **kwargs) 14 | for action in actions.get(hook, [])] 15 | -------------------------------------------------------------------------------- /redwind/imageproxy.py: -------------------------------------------------------------------------------- 1 | from redwind import util 2 | from flask import request, abort, send_file, url_for, make_response, \ 3 | Blueprint, escape, current_app 4 | from requests.exceptions import HTTPError 5 | import datetime 6 | import hashlib 7 | import hmac 8 | import json 9 | import os 10 | import shutil 11 | import sys 12 | import urllib.parse 13 | 14 | imageproxy = Blueprint('imageproxy', __name__) 15 | 16 | 17 | def construct_url(url, size=None, external=False): 18 | from redwind.models import get_settings 19 | if not url or 'PILBOX_URL' not in current_app.config: 20 | return url 21 | url = urllib.parse.urljoin(get_settings().site_url, url) 22 | query = [('url', url)] 23 | if size: 24 | query += [('w', size), ('h', size), ('mode', 'clip')] 25 | else: 26 | query += [('op', 'noop')] 27 | querystring = urllib.parse.urlencode(query) 28 | if 'PILBOX_KEY' in current_app.config: 29 | h = hmac.new(current_app.config['PILBOX_KEY'].encode(), 30 | querystring.encode(), hashlib.sha1) 31 | querystring += '&sig=' + h.hexdigest() 32 | proxy_url = current_app.config['PILBOX_URL'] + '?' + querystring 33 | if external: 34 | proxy_url = urllib.parse.urljoin(get_settings().site_url, proxy_url) 35 | return proxy_url 36 | 37 | 38 | @imageproxy.app_template_filter('imageproxy') 39 | def imageproxy_filter(src, side=None, external=False): 40 | return escape( 41 | construct_url(src, side and str(side), external)) 42 | -------------------------------------------------------------------------------- /redwind/importer.py: -------------------------------------------------------------------------------- 1 | from .extensions import db 2 | from .models import Setting, Post, Contact, Venue, Tag, Nick, Mention, Context 3 | import datetime 4 | 5 | 6 | def truncate(string, length): 7 | if string: 8 | return string[:length] 9 | 10 | def import_all(blob): 11 | tags = {} 12 | venues = {} 13 | db.session.add_all([import_setting(s) for s in blob['settings']]) 14 | db.session.add_all([import_venue(v, venues) for v in blob['venues']]) 15 | db.session.add_all([import_contact(c) for c in blob['contacts']]) 16 | db.session.add_all([import_post(p, tags, venues) for p in blob['posts']]) 17 | db.session.commit() 18 | 19 | 20 | def import_datetime(dt): 21 | if dt: 22 | return datetime.datetime.strptime(dt, '%Y-%m-%dT%H:%M:%S') 23 | 24 | def import_setting(blob): 25 | s = Setting() 26 | s.key = blob['key'] 27 | s.name = blob['name'] 28 | s.value = blob['value'] 29 | return s 30 | 31 | 32 | def import_contact(blob): 33 | c = Contact() 34 | c.name = blob['name'] 35 | c.nicks = [Nick(name=name) for name in blob['nicks']] 36 | c.image = blob['image'] 37 | c.url = blob['url'] 38 | c.social = blob['social'] 39 | return c 40 | 41 | 42 | def import_venue(blob, venues): 43 | v = Venue() 44 | v.name = blob['name'] 45 | v.location = blob['location'] 46 | v.slug = blob['slug'] 47 | venues[v.slug] = v 48 | return v 49 | 50 | 51 | def import_post(blob, tags, venues): 52 | def import_tag(tag_name): 53 | tag = tags.get(tag_name) 54 | if not tag: 55 | tag = Tag(tag_name) 56 | tags[tag_name] = tag 57 | return tag 58 | 59 | def lookup_venue(slug): 60 | if slug: 61 | return venues.get(slug) 62 | 63 | p = Post(blob['post_type']) 64 | p.path = blob['path'] 65 | p.historic_path = blob['historic_path'] 66 | p.draft = blob['draft'] 67 | p.deleted = blob['deleted'] 68 | p.hidden = blob['hidden'] 69 | p.redirect = blob['redirect'] 70 | p.tags = [import_tag(t) for t in blob['tags']] 71 | p.audience = blob['audience'] 72 | p.in_reply_to = blob['in_reply_to'] 73 | p.repost_of = blob['repost_of'] 74 | p.like_of = blob['like_of'] 75 | p.bookmark_of = blob['bookmark_of'] 76 | p.reply_contexts = [import_context(c) for c in blob['reply_contexts']] 77 | p.like_contexts = [import_context(c) for c in blob['like_contexts']] 78 | p.repost_contexts = [import_context(c) for c in blob['repost_contexts']] 79 | p.bookmark_contexts = [import_context(c) for c in blob['bookmark_contexts']] 80 | p.title = blob['title'] 81 | p.published = import_datetime(blob['published']) 82 | p.slug = blob['slug'] 83 | p.syndication = blob['syndication'] 84 | p.location = blob['location'] 85 | p.photos = blob['photos'] 86 | p.venue = lookup_venue(blob['venue']) 87 | p.mentions = [import_mention(m) for m in blob['mentions']] 88 | p.content = blob['content'] 89 | p.content_html = blob['content_html'] 90 | return p 91 | 92 | 93 | def import_context(blob): 94 | c = Context() 95 | c.url = blob['url'] 96 | c.permalink = blob['permalink'] 97 | c.author_name = truncate(blob['author_name'], 128) 98 | c.author_url = blob['author_url'] 99 | c.author_image = blob['author_image'] 100 | c.content = blob['content'] 101 | c.content_plain = blob['content_plain'] 102 | c.published = import_datetime(blob['published']) 103 | c.title = truncate(blob['title'], 512) 104 | c.syndication = blob['syndication'] 105 | return c 106 | 107 | 108 | def import_mention(blob): 109 | m = Mention() 110 | m.url = blob['url'] 111 | m.permalink = blob['permalink'] 112 | m.author_name = truncate(blob['author_name'], 128) 113 | m.author_url = blob['author_url'] 114 | m.author_image = blob['author_image'] 115 | m.content = blob['content'] 116 | m.content_plain = blob['content_plain'] 117 | m.published = import_datetime(blob['published']) 118 | m.title = truncate(blob['title'], 512) 119 | m.syndication = blob['syndication'] 120 | m.reftype = blob['reftype'] 121 | return m 122 | -------------------------------------------------------------------------------- /redwind/maps.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generate static map images 3 | """ 4 | from redwind import imageproxy 5 | import urllib.parse 6 | 7 | # get_map_image(600, 400, 33, -88, 13, []) 8 | # get_map_image(600, 400, 33, -88, 13, [Marker(33, -88)]) 9 | 10 | 11 | class Marker: 12 | def __init__(self, lat, lng, icon='dot-small-blue'): 13 | self.lat = lat 14 | self.lng = lng 15 | self.icon = icon 16 | 17 | 18 | def get_map_image(width, height, maxzoom, markers): 19 | # create the URL 20 | args = [ 21 | ('width', width), 22 | ('height', height), 23 | ('maxzoom', maxzoom), 24 | ('basemap', 'streets'), 25 | ] + [ 26 | ('marker[]', 'lat:{};lng:{};icon:{}'.format(m.lat, m.lng, m.icon)) 27 | for m in markers 28 | ] 29 | 30 | return imageproxy.construct_url( 31 | 'http://static-maps.kylewm.com/img.php?' 32 | + urllib.parse.urlencode(args)) 33 | -------------------------------------------------------------------------------- /redwind/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karadaisy/redwind/7ad807b5ab2dd74a8d470dbea9dd4baf5567d9c6/redwind/plugins/__init__.py -------------------------------------------------------------------------------- /redwind/plugins/hentry_template.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def fill(author_url=None, author_name=None, author_image=None, pub_date=None, 4 | title=None, content=None, permalink=None): 5 | 6 | html = """
\n""" 7 | if author_name or author_image or author_url: 8 | html += """\n" 20 | 21 | if title: 22 | html += """

{}

\n""".format(title) 23 | 24 | if pub_date: 25 | html += """\n""".format(pub_date) 26 | 27 | if content: 28 | html += """
{}
\n""".format("e-content" if title else "p-name e-content", content) 29 | 30 | if permalink: 31 | html += """permalink
\n""".format(permalink) 32 | 33 | html += "
\n" 34 | return html 35 | -------------------------------------------------------------------------------- /redwind/plugins/locations.py: -------------------------------------------------------------------------------- 1 | from flask import request, jsonify, Blueprint, current_app 2 | from redwind import hooks 3 | from redwind import views 4 | from redwind.extensions import db 5 | from redwind.models import Post, Venue 6 | from redwind.tasks import get_queue, async_app_context 7 | import json 8 | import requests 9 | 10 | 11 | locations = Blueprint('locations', __name__) 12 | 13 | 14 | def register(app): 15 | app.register_blueprint(locations) 16 | hooks.register('post-saved', reverse_geocode) 17 | hooks.register('venue-saved', reverse_geocode_venue) 18 | 19 | 20 | def reverse_geocode(post, args): 21 | get_queue().enqueue(do_reverse_geocode_post, post.id, current_app.config['CONFIG_FILE']) 22 | 23 | 24 | def reverse_geocode_venue(venue, args): 25 | get_queue().enqueue(do_reverse_geocode_venue, venue.id, current_app.config['CONFIG_FILE']) 26 | 27 | 28 | def do_reverse_geocode_post(postid, app_config): 29 | with async_app_context(app_config): 30 | post = Post.load_by_id(postid) 31 | if not post: 32 | return 33 | if post.location and 'latitude' in post.location \ 34 | and 'longitude' in post.location: 35 | adr = do_reverse_geocode(post.location['latitude'], 36 | post.location['longitude']) 37 | # copy the dict so that the ORM recognizes 38 | # that it changed 39 | post.location = dict(post.location) 40 | post.location.update(adr) 41 | db.session.commit() 42 | 43 | 44 | def do_reverse_geocode_venue(venueid, app_config): 45 | with async_app_context(app_config): 46 | venue = Venue.query.get(venueid) 47 | if venue.location and 'latitude' in venue.location \ 48 | and 'longitude' in venue.location: 49 | adr = do_reverse_geocode(venue.location['latitude'], 50 | venue.location['longitude']) 51 | # copy the dict so the ORM actually recognizes 52 | # that it changed 53 | venue.location = dict(venue.location) 54 | venue.location.update(adr) 55 | venue.update_slug(views.geo_name(venue.location, as_html=False)) 56 | db.session.commit() 57 | 58 | 59 | def do_reverse_geocode(lat, lng): 60 | def region(adr): 61 | if adr.get('country_code') == 'us': 62 | return adr.get('state') or adr.get('county') 63 | else: 64 | return adr.get('county') or adr.get('state') 65 | 66 | current_app.logger.debug('reverse geocoding with nominatum') 67 | r = requests.get('http://nominatim.openstreetmap.org/reverse', 68 | params={ 69 | 'lat': lat, 70 | 'lon': lng, 71 | 'format': 'json' 72 | }) 73 | r.raise_for_status() 74 | 75 | data = json.loads(r.text) 76 | current_app.logger.debug( 77 | 'received response %s', json.dumps(data, indent=True)) 78 | 79 | adr = data.get('address', {}) 80 | 81 | # hat-tip https://gist.github.com/barnabywalters/8318401 82 | return { 83 | 'street_address': adr.get('road'), 84 | 'extended_address': adr.get('suburb'), 85 | 'locality': (adr.get('hamlet') 86 | or adr.get('village') 87 | or adr.get('town') 88 | or adr.get('city') 89 | or adr.get('locality') 90 | or adr.get('suburb') 91 | or adr.get('county')), 92 | 'region': region(adr), 93 | 'country_name': adr.get('country'), 94 | 'postal_code': adr.get('postcode'), 95 | 'country_code': adr.get('country_code'), 96 | } 97 | 98 | 99 | @locations.route('/services/geocode') 100 | def reverse_geocode_service(): 101 | lat = request.args.get('latitude') 102 | lng = request.args.get('longitude') 103 | return jsonify(do_reverse_geocode(lat, lng)) 104 | -------------------------------------------------------------------------------- /redwind/plugins/posse.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, request, flash, redirect, url_for 2 | from flask import current_app 3 | from flask.ext.login import current_user, login_required 4 | from flask.ext.micropub import MicropubClient 5 | 6 | import mf2py 7 | import mf2util 8 | import requests 9 | 10 | 11 | from redwind import hooks 12 | from redwind import util 13 | from redwind.models import get_settings, Post, PosseTarget 14 | from redwind.extensions import db 15 | from redwind.tasks import get_queue, async_app_context 16 | 17 | 18 | posse = Blueprint('posse', __name__, url_prefix='/posse',) 19 | micropub = MicropubClient(client_id='https://github.com/kylewm/redwind') 20 | 21 | 22 | def register(app): 23 | app.register_blueprint(posse) 24 | micropub.init_app(app) 25 | hooks.register('post-saved', syndicate) 26 | 27 | 28 | @posse.context_processor 29 | def inject_settings_variable(): 30 | return { 31 | 'settings': get_settings() 32 | } 33 | 34 | 35 | @posse.route('/') 36 | @login_required 37 | def index(): 38 | return render_template('posse/index.jinja2') 39 | 40 | 41 | @posse.route('/add', methods=['POST']) 42 | @login_required 43 | def add(): 44 | me = request.form.get('me') 45 | state = '|'.join((request.form.get('style', ''), 46 | request.form.get('name', ''))) 47 | return micropub.authorize(me, scope='post', state=state) 48 | 49 | 50 | @posse.route('/callback') 51 | @micropub.authorized_handler 52 | def callback(info): 53 | if info.error: 54 | flash('Micropub failure: {}'.format(info.error)) 55 | else: 56 | flash('Micropub success! Authorized {}'.format(info.me)) 57 | 58 | p = mf2py.parse(url=info.me) 59 | 60 | current_app.logger.debug('found author info %s', info.me) 61 | target = PosseTarget( 62 | uid=info.me, 63 | name=info.me, 64 | style='microblog', 65 | micropub_endpoint=info.micropub_endpoint, 66 | access_token=info.access_token) 67 | 68 | current_user.posse_targets.append(target) 69 | db.session.commit() 70 | return redirect(url_for('.edit', target_id=target.id)) 71 | 72 | 73 | @posse.route('/edit', methods=['GET', 'POST']) 74 | @login_required 75 | def edit(): 76 | if request.method == 'GET': 77 | target_id = request.args.get('target_id') 78 | target = PosseTarget.query.get(target_id) 79 | return render_template('posse/edit.jinja2', target=target) 80 | 81 | target_id = request.form.get('target_id') 82 | target = PosseTarget.query.get(target_id) 83 | 84 | for prop in [ 85 | 'name', 'style', 86 | 'user_name', 'user_url', 'user_photo', 87 | 'service_name', 'service_url', 'service_photo', 88 | ]: 89 | setattr(target, prop, request.form.get(prop)) 90 | 91 | db.session.commit() 92 | return redirect(url_for('.edit', target_id=target_id)) 93 | 94 | 95 | @posse.route('/delete', methods=['POST']) 96 | @login_required 97 | def delete(): 98 | target_id = request.form.get('target_id') 99 | target = PosseTarget.query.get(target_id) 100 | db.session.delete(target) 101 | db.session.commit() 102 | return redirect(url_for('.index')) 103 | 104 | 105 | def syndicate(post, args): 106 | syndto = args.getlist('syndicate-to') 107 | for target in PosseTarget.query.filter(PosseTarget.uid.in_(syndto)): 108 | current_app.logger.debug('enqueuing task to posse to %s', target.uid) 109 | get_queue().enqueue(do_syndicate, post.id, target.id, 110 | current_app.config['CONFIG_FILE']) 111 | 112 | 113 | def do_syndicate(post_id, target_id, app_config): 114 | with async_app_context(app_config): 115 | post = Post.query.get(post_id) 116 | target = PosseTarget.query.get(target_id) 117 | 118 | current_app.logger.debug( 119 | 'posseing %s to target %s', post.path, target.uid) 120 | 121 | data = {'access_token': target.access_token} 122 | files = None 123 | 124 | if post.repost_of: 125 | data['repost-of'] = post.repost_of[0] 126 | if post.like_of: 127 | data['like-of'] = post.like_of[0] 128 | if post.in_reply_to: 129 | data['in-reply-to'] = post.in_reply_to[0] 130 | 131 | if post.post_type == 'review': 132 | item = post.item or {} 133 | data['item[name]'] = data['item'] = item.get('name') 134 | data['item[author]'] = item.get('author') 135 | data['rating'] = post.rating 136 | data['description'] = data['description[markdown]'] = data['description[value]'] = post.content 137 | data['description[html]'] = post.content_html 138 | else: 139 | data['name'] = post.title 140 | data['content'] = data['content[markdown]'] = data['content[value]'] = post.content 141 | data['content[html]'] = post.content_html 142 | 143 | data['url'] = (post.shortlink if target.style == 'microblog' 144 | else post.permalink) 145 | 146 | if post.post_type == 'photo' and post.attachments: 147 | if len(post.attachments) == 1: 148 | a = post.attachments[0] 149 | files = {'photo': (a.filename, open(a.disk_path, 'rb'), 150 | a.mimetype)} 151 | else: 152 | files = [('photo[]', (a.filename, open(a.disk_path, 'rb'), 153 | a.mimetype)) for a in post.attachments] 154 | 155 | data['location'] = post.get_location_as_geo_uri() 156 | data['place-name'] = post.venue and post.venue.name 157 | 158 | categories = [tag.name for tag in post.tags] 159 | for person in post.people: 160 | categories.append(person.url) 161 | if person.social: 162 | categories += person.social 163 | data['category[]'] = categories 164 | 165 | resp = requests.post(target.micropub_endpoint, 166 | data=util.trim_nulls(data), files=files) 167 | resp.raise_for_status() 168 | current_app.logger.debug( 169 | 'received response from posse endpoint: code=%d, headers=%s, body=%s', 170 | resp.status_code, resp.headers, resp.text) 171 | 172 | post.add_syndication_url(resp.headers['Location']) 173 | db.session.commit() 174 | -------------------------------------------------------------------------------- /redwind/plugins/push.py: -------------------------------------------------------------------------------- 1 | from flask import url_for, current_app, Config 2 | from redwind import hooks 3 | from redwind.tasks import get_queue 4 | import requests 5 | 6 | 7 | def register(app): 8 | #app.register_blueprint(push) 9 | hooks.register('post-saved', send_notifications) 10 | 11 | 12 | def send_notifications(post, args): 13 | if not post.hidden and not post.draft and 'PUSH_HUB' in current_app.config: 14 | urls = [ 15 | url_for('views.index', _external=True), 16 | url_for('views.index', feed='atom', _external=True), 17 | ] 18 | get_queue().enqueue(publish, urls, current_app.config['PUSH_HUB']) 19 | 20 | 21 | def publish(urls, push_hub): 22 | if push_hub: 23 | print('sending PuSH notification to', urls) 24 | data = {'hub.mode': 'publish', 'hub.url': urls} 25 | response = requests.post(push_hub, data) 26 | if response.status_code == 204: 27 | print('successfully sent PuSH notification.', 28 | response, response.text) 29 | else: 30 | print('unexpected response from PuSH hub', 31 | response, response.text) 32 | -------------------------------------------------------------------------------- /redwind/plugins/wordpress.py: -------------------------------------------------------------------------------- 1 | from redwind import hooks 2 | from redwind.tasks import get_queue, async_app_context 3 | from redwind.models import Post, Setting, get_settings 4 | from redwind.extensions import db 5 | 6 | from flask.ext.login import login_required 7 | from flask import ( 8 | request, redirect, url_for, Blueprint, current_app, make_response, 9 | ) 10 | 11 | import requests 12 | import urllib.request 13 | import urllib.parse 14 | 15 | wordpress = Blueprint('wordpress', __name__) 16 | 17 | API_TOKEN_URL = 'https://public-api.wordpress.com/oauth2/token' 18 | API_AUTHORIZE_URL = 'https://public-api.wordpress.com/oauth2/authorize' 19 | API_AUTHENTICATE_URL = 'https://public-api.wordpress.com/oauth2/authenticate' 20 | API_SITE_URL = 'https://public-api.wordpress.com/rest/v1.1/sites/{}' 21 | API_POST_URL = 'https://public-api.wordpress.com/rest/v1.1/sites/{}/posts/{}' 22 | API_NEW_LIKE_URL = 'https://public-api.wordpress.com/rest/v1.1/sites/{}/posts/{}/likes/new' 23 | API_NEW_REPLY_URL = 'https://public-api.wordpress.com/rest/v1.1/sites/{}/posts/{}/replies/new' 24 | API_ME_URL = 'https://public-api.wordpress.com/rest/v1.1/me' 25 | 26 | 27 | def register(app): 28 | app.register_blueprint(wordpress) 29 | hooks.register('post-saved', send_to_wordpress) 30 | 31 | 32 | @wordpress.route('/install_wordpress') 33 | @login_required 34 | def install(): 35 | settings = [ 36 | Setting(key='wordpress_client_id', name='WordPress Client ID'), 37 | Setting(key='wordpress_client_secret', name='WordPress Client Secret'), 38 | Setting(key='wordpress_access_token', name='WordPress Access Token'), 39 | ] 40 | 41 | for s in settings: 42 | if not Setting.query.get(s.key): 43 | db.session.add(s) 44 | db.session.commit() 45 | 46 | return 'Success' 47 | 48 | 49 | @wordpress.route('/authorize_wordpress') 50 | @login_required 51 | def authorize_wordpress(): 52 | from redwind.extensions import db 53 | redirect_uri = url_for('.authorize_wordpress', _external=True) 54 | 55 | code = request.args.get('code') 56 | if code: 57 | r = requests.post(API_TOKEN_URL, data={ 58 | 'client_id': get_settings().wordpress_client_id, 59 | 'redirect_uri': redirect_uri, 60 | 'client_secret': get_settings().wordpress_client_secret, 61 | 'code': code, 62 | 'grant_type': 'authorization_code', 63 | }) 64 | 65 | if r.status_code // 100 != 2: 66 | return make_response( 67 | 'Code: {}. Message: {}'.format(r.status_code, r.text), 68 | r.status_code) 69 | 70 | payload = r.json() 71 | 72 | access_token = payload.get('access_token') 73 | Setting.query.get('wordpress_access_token').value = access_token 74 | db.session.commit() 75 | return redirect(url_for('admin.edit_settings')) 76 | else: 77 | return redirect(API_AUTHORIZE_URL + '?' + urllib.parse.urlencode({ 78 | 'client_id': get_settings().wordpress_client_id, 79 | 'redirect_uri': redirect_uri, 80 | 'response_type': 'code', 81 | 'scope': 'global', 82 | })) 83 | 84 | 85 | def send_to_wordpress(post, args): 86 | if 'wordpress' in args.getlist('syndicate-to'): 87 | get_queue().enqueue(do_send_to_wordpress, post.id, current_app.config['CONFIG_FILE']) 88 | 89 | 90 | def do_send_to_wordpress(post_id, app_config): 91 | with async_app_context(app_config): 92 | post = Post.load_by_id(post_id) 93 | 94 | if post.like_of: 95 | for url in post.like_of: 96 | try_post_like(url, post) 97 | 98 | elif post.in_reply_to: 99 | for url in post.in_reply_to: 100 | try_post_reply(url, post) 101 | 102 | 103 | def try_post_like(url, post): 104 | current_app.logger.debug('wordpress. posting like to %s', url) 105 | myid = find_my_id() 106 | siteid, postid = find_post_id(url) 107 | current_app.logger.debug( 108 | 'wordpress. posting like to site-id %d, post-id %d', siteid, postid) 109 | if myid and siteid and postid: 110 | endpoint = API_NEW_LIKE_URL.format(siteid, postid) 111 | current_app.logger.debug('wordpress: POST to endpoint %s', endpoint) 112 | r = requests.post(endpoint, headers={ 113 | 'authorization': 'Bearer ' + get_settings().wordpress_access_token, 114 | }) 115 | r.raise_for_status() 116 | if r.json().get('success'): 117 | wp_url = '{}#liked-by-{}'.format(url, myid) 118 | post.add_syndication_url(wp_url) 119 | db.session.commit() 120 | return wp_url 121 | 122 | current_app.logger.error( 123 | 'failed to post wordpress like. response: %r: %r', r, r.text) 124 | 125 | 126 | def try_post_reply(url, post): 127 | current_app.logger.debug('wordpress. posting reply to %s', url) 128 | myid = find_my_id() 129 | siteid, postid = find_post_id(url) 130 | current_app.logger.debug( 131 | 'wordpress. posting reply to site-id %d, post-id %d', siteid, postid) 132 | if myid and siteid and postid: 133 | endpoint = API_NEW_REPLY_URL.format(siteid, postid) 134 | current_app.logger.debug('wordpress: POST to endpoint %s', endpoint) 135 | r = requests.post(endpoint, headers={ 136 | 'authorization': 'Bearer ' + get_settings().wordpress_access_token, 137 | }, data={ 138 | 'content': post.content_html, 139 | }) 140 | r.raise_for_status() 141 | wp_url = r.json().get('URL') 142 | if wp_url: 143 | post.add_syndication_url(wp_url) 144 | db.session.commit() 145 | return wp_url 146 | 147 | current_app.logger.error( 148 | 'failed to post wordpress reply. response: %r: %r', r, r.text) 149 | 150 | 151 | def find_my_id(): 152 | r = requests.get(API_ME_URL, headers={ 153 | 'authorization': 'Bearer ' + get_settings().wordpress_access_token, 154 | }) 155 | r.raise_for_status() 156 | return r.json().get('ID') 157 | 158 | 159 | def find_post_id(url): 160 | p = urllib.parse.urlparse(url) 161 | 162 | slug = list(filter(None, p.path.split('/')))[-1] 163 | 164 | r = requests.get(API_POST_URL.format(p.netloc, 'slug:' + slug)) 165 | r.raise_for_status() 166 | blob = r.json() 167 | 168 | return blob.get('site_ID'), blob.get('ID') 169 | -------------------------------------------------------------------------------- /redwind/services.py: -------------------------------------------------------------------------------- 1 | from . import contexts 2 | from . import util 3 | from .models import Venue 4 | from .views import geo_name 5 | 6 | from bs4 import BeautifulSoup 7 | from flask import request, jsonify, redirect, url_for, Blueprint, current_app, render_template 8 | import datetime 9 | import mf2py 10 | import mf2util 11 | import requests 12 | import sys 13 | import urllib 14 | 15 | services = Blueprint('services', __name__) 16 | 17 | USER_AGENT = 'Red Wind (https://github.com/kylewm/redwind)' 18 | 19 | @services.route('/services/fetch_profile') 20 | def fetch_profile(): 21 | url = request.args.get('url') 22 | if not url: 23 | return """ 24 | 25 |

Fetch Profile

26 |
27 | 28 |
""" 29 | 30 | try: 31 | name = None 32 | image = None 33 | 34 | d = mf2py.Parser(url=url).to_dict() 35 | 36 | relmes = d['rels'].get('me', []) 37 | 38 | # check for h-feed 39 | hfeed = next((item for item in d['items'] 40 | if 'h-feed' in item['type']), None) 41 | if hfeed: 42 | authors = hfeed.get('properties', {}).get('author') 43 | images = hfeed.get('properties', {}).get('photo') 44 | if authors: 45 | if isinstance(authors[0], dict): 46 | name = authors[0].get('properties', {}).get('name') 47 | image = authors[0].get('properties', {}).get('photo') 48 | else: 49 | name = authors[0] 50 | if images and not image: 51 | image = images[0] 52 | 53 | # check for top-level h-card 54 | for item in d['items']: 55 | if 'h-card' in item.get('type', []): 56 | if not name: 57 | name = item.get('properties', {}).get('name') 58 | if not image: 59 | image = item.get('properties', {}).get('photo') 60 | 61 | return jsonify({ 62 | 'name': name, 63 | 'image': image, 64 | 'social': relmes, 65 | }) 66 | 67 | except BaseException as e: 68 | resp = jsonify({'error': str(e)}) 69 | resp.status_code = 400 70 | return resp 71 | 72 | 73 | @services.route('/services/nearby') 74 | def nearby_venues(): 75 | lat = float(request.args.get('latitude')) 76 | lng = float(request.args.get('longitude')) 77 | venues = Venue.query.all() 78 | 79 | venues.sort(key=lambda venue: (float(venue.location['latitude']) - lat) ** 2 80 | + (float(venue.location['longitude']) - lng) ** 2) 81 | 82 | return jsonify({ 83 | 'venues': [{ 84 | 'id': venue.id, 85 | 'name': venue.name, 86 | 'latitude': venue.location['latitude'], 87 | 'longitude': venue.location['longitude'], 88 | 'geocode': geo_name(venue.location), 89 | } for venue in venues[:10]] 90 | }) 91 | 92 | 93 | @services.route('/services/fetch_context') 94 | def fetch_context_service(): 95 | results = [] 96 | for url in request.args.getlist('url[]'): 97 | ctx = contexts.create_context(url) 98 | results.append({ 99 | 'title': ctx.title, 100 | 'permalink': ctx.permalink, 101 | 'html': render_template( 102 | 'admin/_context.jinja2', type=request.args.get('type'), 103 | context=ctx), 104 | }) 105 | 106 | return jsonify({'contexts': results}) 107 | 108 | 109 | @services.route('/yt/