├── .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 | """
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/