├── woodwind ├── __init__.py ├── wsgi.py ├── static │ ├── logo.png │ ├── syndication-icons │ │ └── twitter.com.png │ ├── subscriptions.js │ ├── normalize.css.map │ ├── webaction.js │ ├── indieconfig.js │ ├── style.css.map │ ├── style.scss │ ├── normalize.css │ ├── normalize.scss │ └── style.css ├── templates │ ├── offline.jinja2 │ ├── subscribe.jinja2 │ ├── subscriptions_opml.xml │ ├── select-feed.jinja2 │ ├── settings_indie_config.jinja2 │ ├── settings_micropub.jinja2 │ ├── settings_action_urls.jinja2 │ ├── settings.jinja2 │ ├── feed.jinja2 │ ├── _reply.jinja2 │ ├── subscriptions.jinja2 │ ├── _entry.jinja2 │ └── base.jinja2 ├── __main__.py ├── extensions.py ├── websocket_server.py ├── sse_server.py ├── app.py ├── api.py ├── util.py ├── push.py ├── models.py ├── tasks.py └── views.py ├── timers.py ├── config.py.template ├── run.py ├── init_db.py ├── .gitignore ├── log.sh ├── vacuum.sql ├── woodwind-dev.ini ├── woodwind-sock.ini ├── frontend ├── manifest.json ├── package.json ├── sw.js ├── webaction.js ├── indieconfig.js └── feed.js ├── setup.py ├── deploy.sh ├── woodwind.ini ├── woodwind.cfg.template ├── NOTES.md ├── scripts ├── 20150318-clean-content.py └── 2015-03-26-normalize-urls.py ├── requirements.txt ├── fabfile.py ├── README.md └── LICENSE /woodwind/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import create_app 2 | 3 | __all__ = ['create_app'] 4 | -------------------------------------------------------------------------------- /woodwind/wsgi.py: -------------------------------------------------------------------------------- 1 | from . import create_app 2 | 3 | application = create_app('../woodwind.cfg') 4 | -------------------------------------------------------------------------------- /woodwind/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karadaisy/woodwind/HEAD/woodwind/static/logo.png -------------------------------------------------------------------------------- /woodwind/static/syndication-icons/twitter.com.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karadaisy/woodwind/HEAD/woodwind/static/syndication-icons/twitter.com.png -------------------------------------------------------------------------------- /timers.py: -------------------------------------------------------------------------------- 1 | from uwsgidecorators import timer 2 | from woodwind import tasks 3 | 4 | 5 | @timer(300) 6 | def tick(signum=None): 7 | tasks.q.enqueue(tasks.tick) 8 | -------------------------------------------------------------------------------- /config.py.template: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Config: 5 | SECRET_KEY = 'super secret key' 6 | SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.getcwd() + '/db.sqlite' 7 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | 4 | def main(): 5 | from woodwind.app import create_app 6 | app = create_app() 7 | app.run(debug=True, port=4000) 8 | 9 | 10 | main() 11 | -------------------------------------------------------------------------------- /woodwind/templates/offline.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja2" %} 2 | 3 | {% block login %}{% endblock login %} 4 | 5 | {% block body %} 6 | 7 | Offline, and it feels so good 8 | 9 | {% endblock body %} -------------------------------------------------------------------------------- /init_db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from woodwind import create_app 4 | from woodwind.extensions import db 5 | 6 | app = create_app() 7 | 8 | with app.app_context(): 9 | db.create_all() 10 | -------------------------------------------------------------------------------- /woodwind/__main__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['main'] 2 | 3 | 4 | def main(): 5 | from woodwind.app import create_app 6 | app = create_app() 7 | app.run(debug=True, port=4000) 8 | 9 | 10 | main() 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *~ 4 | .sass-cache 5 | .sass-cacheconfig.py 6 | __pycache__ 7 | celerybeat-schedule 8 | celerybeat-schedule* 9 | config.py 10 | venv 11 | woodwind.cfg 12 | localcert 13 | -------------------------------------------------------------------------------- /log.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | REMOTE_USER=kmahan 4 | HOST=orin.kylewm.com 5 | 6 | ssh -t $REMOTE_USER@$HOST bash -c "' 7 | 8 | set -x 9 | 10 | sudo tail -n 60 -f /var/log/upstart/woodwind.log 11 | '" 12 | -------------------------------------------------------------------------------- /vacuum.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM entry 2 | USING ( 3 | SELECT 4 | id, 5 | ROW_NUMBER() OVER (PARTITION BY feed_id ORDER BY retrieved DESC) AS row 6 | FROM entry 7 | ) AS numbered 8 | WHERE entry.id = numbered.id 9 | AND (row > 2000 OR retrieved < CURRENT_DATE - INTERVAL '365 days'); 10 | -------------------------------------------------------------------------------- /woodwind-dev.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | master=true 3 | processes=1 4 | #socket=/tmp/woodwind.sock 5 | #chmod-socket=666 6 | http-socket=:3000 7 | module=woodwind.wsgi 8 | import=timers 9 | attach-daemon=rqworker high low 10 | attach-daemon=python -m woodwind.websocket_server 11 | py-autoreload=3 12 | -------------------------------------------------------------------------------- /woodwind-sock.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | master=true 3 | processes=1 4 | socket=/tmp/woodwind.sock 5 | chmod-socket=666 6 | #http-socket=:3000 7 | module=woodwind.wsgi 8 | import=timers 9 | attach-daemon=rqworker high low 10 | attach-daemon=python -m woodwind.websocket_server 11 | py-autoreload=3 12 | -------------------------------------------------------------------------------- /woodwind/templates/subscribe.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja2" %} 2 | {% block body %} 3 |
4 | 5 | 6 |
7 | {% endblock body %} 8 | -------------------------------------------------------------------------------- /woodwind/templates/subscriptions_opml.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% for s in subscriptions %} 6 | 7 | {% endfor %} 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Woodwind", 3 | "short_name": "Woodwind", 4 | "start_url": "/", 5 | "scope": "/", 6 | "display": "standalone", 7 | "theme_color": "#9b6137", 8 | "background_color": "#9b6137", 9 | "icons": [{ 10 | "src": "/static/logo.png", 11 | "sizes": "512x512", 12 | "type": "image/png" 13 | }] 14 | } 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | setup(name='Woodwind', 6 | version='1.0.0', 7 | description='Stream-style indieweb reader', 8 | author='Kyle Mahan', 9 | author_email='kyle@kylewm.com', 10 | url='https://indieweb.org/Woodwind', 11 | packages=['woodwind']) 12 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | REMOTE_USER=kmahan 4 | HOST=orin.kylewm.com 5 | REMOTE_PATH=/srv/www/woodwind.xyz/woodwind 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 woodwind 16 | 17 | '" 18 | -------------------------------------------------------------------------------- /woodwind.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | master=true 3 | 4 | threads=2 5 | cheaper-algo=spare 6 | cheaper=2 7 | cheaper-initial=2 8 | workers=10 9 | 10 | socket=/tmp/woodwind.sock 11 | chmod-socket=666 12 | module=woodwind.wsgi 13 | import=timers 14 | 15 | #attach-daemon=venv/bin/rqworker high 16 | attach-daemon=venv/bin/python -m woodwind.websocket_server 17 | attach-daemon=venv/bin/rqworker high low 18 | -------------------------------------------------------------------------------- /woodwind.cfg.template: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = True 4 | # do not intercept redirects when using debug toolbar 5 | DEBUG_TB_INTERCEPT_REDIRECTS = False 6 | SECRET_KEY = 'super secret key' 7 | 8 | SQLALCHEMY_DATABASE_URI = 'postgres:///woodwind' 9 | PER_PAGE = 100 10 | 11 | # client secret and key for fetch twitter contexts from granary.appspot.com 12 | TWITTER_AU_KEY = '...' 13 | TWITTER_AU_SECRET = '...' 14 | 15 | SQLALCHEMY_TRACK_MODIFICATIONS = False 16 | -------------------------------------------------------------------------------- /woodwind/extensions.py: -------------------------------------------------------------------------------- 1 | from flask.ext.login import LoginManager 2 | from flask.ext.micropub import MicropubClient 3 | from flask.ext.sqlalchemy import SQLAlchemy 4 | from flask_debugtoolbar import DebugToolbarExtension 5 | 6 | 7 | db = SQLAlchemy() 8 | micropub = MicropubClient(client_id='https://woodwind.xyz/') 9 | login_mgr = LoginManager() 10 | login_mgr.login_view = 'views.index' 11 | #toolbar = DebugToolbarExtension() 12 | 13 | 14 | def init_app(app): 15 | db.init_app(app) 16 | micropub.init_app(app) 17 | login_mgr.init_app(app) 18 | # toolbar.init_app(app) 19 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | Clear out orphaned feeds 2 | 3 | ``` 4 | delete from entry_to_reply_context where entry_id in (select id from entry where feed_id in (select id from feed where not exists (select * from users_to_feeds where feed_id = feed.id))); 5 | delete from entry_to_reply_context where context_id in (select id from entry where feed_id in (select id from feed where not exists (select * from users_to_feeds where feed_id = feed.id))); 6 | delete from entry where feed_id in (select id from feed where not exists (select * from users_to_feeds where feed_id = feed.id)); 7 | delete from feed where not exists (select * from users_to_feeds where feed_id = feed.id); 8 | ``` 9 | -------------------------------------------------------------------------------- /scripts/20150318-clean-content.py: -------------------------------------------------------------------------------- 1 | from config import Config 2 | import sqlalchemy 3 | import sqlalchemy.orm 4 | from woodwind.models import Entry 5 | from woodwind import util 6 | 7 | engine = sqlalchemy.create_engine(Config.SQLALCHEMY_DATABASE_URI) 8 | Session = sqlalchemy.orm.sessionmaker(bind=engine) 9 | 10 | try: 11 | engine.execute('alter table entry add column content_cleaned text') 12 | except: 13 | pass 14 | 15 | try: 16 | session = Session() 17 | 18 | for entry in session.query(Entry).all(): 19 | print('processing', entry.id) 20 | entry.content_cleaned = util.clean(entry.content) 21 | 22 | session.commit() 23 | except: 24 | session.rollback() 25 | raise 26 | finally: 27 | session.close() 28 | -------------------------------------------------------------------------------- /woodwind/websocket_server.py: -------------------------------------------------------------------------------- 1 | import websockets 2 | import asyncio 3 | import asyncio_redis 4 | 5 | 6 | @asyncio.coroutine 7 | def handle_subscription(websocket, path): 8 | topic = yield from websocket.recv() 9 | redis = yield from asyncio_redis.Connection.create() 10 | ps = yield from redis.start_subscribe() 11 | yield from ps.subscribe(['woodwind_notify:' + topic]) 12 | while True: 13 | message = yield from ps.next_published() 14 | if not websocket.open: 15 | break 16 | yield from websocket.send(message.value) 17 | redis.close() 18 | 19 | 20 | asyncio.get_event_loop().run_until_complete( 21 | websockets.serve(handle_subscription, 'localhost', 8077)) 22 | asyncio.get_event_loop().run_forever() 23 | -------------------------------------------------------------------------------- /woodwind/templates/select-feed.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja2" %} 2 | {% block body %} 3 |
4 |
5 | 6 | 7 | {% for feed in feeds %} 8 |

9 | 11 | 15 |

16 | {% endfor %} 17 | 18 | 19 | 20 |
21 | 22 | 23 |
24 | {% endblock body %} 25 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "woodwind-fe", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "mocha" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/kylewm/woodwind.git" 12 | }, 13 | "keywords": [ 14 | "indieweb", 15 | "reader" 16 | ], 17 | "author": "Kyle Mahan", 18 | "license": "BSD-2-Clause", 19 | "bugs": { 20 | "url": "https://github.com/kylewm/woodwind/issues" 21 | }, 22 | "homepage": "https://github.com/kylewm/woodwind#readme", 23 | "devDependencies": { 24 | "babel": "^6.5.2", 25 | "babel-cli": "^6.9.0", 26 | "babel-preset-es2015": "^6.9.0", 27 | "mocha": "^2.5.3" 28 | }, 29 | "dependencies": { 30 | "jquery": "^2.2.4", 31 | "moment": "^2.13.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | asyncio-redis==0.14.2 2 | beautifulsoup4==4.6.0 3 | bleach==2.1.1 4 | blinker==1.4 5 | certifi==2015.04.28 # rq.filter: <=2015.04.28 6 | cffi==1.6.0 7 | click==6.6 8 | cryptography==2.1.3 9 | feedparser==5.2.1 10 | Flask==0.10.1 11 | Flask-DebugToolbar==0.10.0 12 | Flask-Login==0.3.2 13 | Flask-Micropub==0.2.8 14 | Flask-SQLAlchemy==2.1 15 | html5lib==0.999999999 16 | idna==2.1 17 | itsdangerous==0.24 18 | Jinja2==2.8 19 | MarkupSafe==0.23 20 | mf2py==1.0.5 21 | mf2util==0.4.2 22 | psycopg2==2.6.1 23 | pyasn1==0.1.9 24 | pycparser==2.14 25 | pyOpenSSL==16.0.0 26 | pyquerystring==1.0.2 27 | pytz==2016.4 28 | redis==2.10.5 29 | requests==2.10.0 30 | rq==0.5.6 31 | sgmllib3k==1.0.0 32 | six==1.10.0 33 | SQLAlchemy==1.0.13 34 | uWSGI==2.0.12 # rq.filter: <=2.0.12 35 | websockets==3.1 36 | Werkzeug==0.11.9 37 | wheel==0.29.0 38 | -------------------------------------------------------------------------------- /woodwind/templates/settings_indie_config.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja2" %} 2 | {% block body %} 3 |
4 |
5 | 6 |

Indie-Config

7 |

8 | Clicking an indie-action link will invoke your web+action handler if registered. See indie-config for details. 9 |

10 | {% set selectedActions = settings.get('indie-config-actions', []) %} 11 | 12 | {% for action in ['like', 'favorite', 'reply', 'repost', 'bookmark'] %} 13 |
18 | {% endfor %} 19 | 20 |
21 |
22 | {% endblock body %} 23 | -------------------------------------------------------------------------------- /frontend/sw.js: -------------------------------------------------------------------------------- 1 | var version = 'v2'; 2 | 3 | this.addEventListener('install', function (event) { 4 | event.waitUntil( 5 | caches.open(version).then(function (cache) { 6 | return cache.addAll([ 7 | '/static/logo.png', 8 | '/static/style.css', 9 | '/offline', 10 | ]) 11 | }) 12 | ); 13 | }) 14 | 15 | this.addEventListener('fetch', function (event) { 16 | console.log('caught fetch: ' + event) 17 | event.respondWith( 18 | caches.match(event.request) 19 | .then(function (response) { 20 | console.log('cache got response: ' + response) 21 | return response || fetch(event.request); 22 | }) 23 | .then(function (response) { 24 | console.log('fetch got response: ' + response) 25 | return response 26 | }) 27 | .catch(function (err) { 28 | return caches.match('/offline') 29 | }) 30 | ) 31 | }) -------------------------------------------------------------------------------- /fabfile.py: -------------------------------------------------------------------------------- 1 | from fabric.api import local, prefix, cd, run, env, lcd, sudo 2 | 3 | env.hosts = ['orin.kylewm.com'] 4 | 5 | REMOTE_PATH = '/srv/www/kylewm.com/woodwind' 6 | 7 | 8 | def commit(): 9 | local("git add -p") 10 | local("git diff-index --quiet HEAD || git commit") 11 | 12 | 13 | def push(): 14 | local("git push origin master") 15 | 16 | 17 | def pull(): 18 | with cd(REMOTE_PATH): 19 | run("git pull origin master") 20 | run("git submodule update") 21 | 22 | 23 | def push_remote(): 24 | with cd(REMOTE_PATH): 25 | run("git add -p") 26 | run("git diff-index --quiet HEAD || git commit") 27 | run("git push origin master") 28 | 29 | 30 | def restart(): 31 | with cd(REMOTE_PATH): 32 | with prefix("source venv/bin/activate"): 33 | run("pip install --upgrade -r requirements.txt") 34 | sudo("restart woodwind") 35 | 36 | 37 | def deploy(): 38 | commit() 39 | push() 40 | pull() 41 | restart() 42 | -------------------------------------------------------------------------------- /woodwind/sse_server.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | import asyncio 3 | import asyncio_redis 4 | 5 | 6 | @asyncio.coroutine 7 | def handle_subscription(request): 8 | topic = request.GET['topic'] 9 | response = web.StreamResponse() 10 | response.headers['Content-Type'] = 'text/event-stream' 11 | response.start(request) 12 | redis = yield from asyncio_redis.Connection.create() 13 | try: 14 | ps = yield from redis.start_subscribe() 15 | yield from ps.subscribe(['woodwind_notify:' + topic]) 16 | while True: 17 | message = yield from ps.next_published() 18 | response.write( 19 | 'data: {}\n\n'.format(message.value).encode('utf-8')) 20 | finally: 21 | redis.close() 22 | 23 | 24 | app = web.Application() 25 | app.router.add_route('GET', '/', handle_subscription) 26 | 27 | loop = asyncio.get_event_loop() 28 | srv = loop.run_until_complete( 29 | loop.create_server(app.make_handler(), '0.0.0.0', 8077)) 30 | loop.run_forever() 31 | -------------------------------------------------------------------------------- /woodwind/templates/settings_micropub.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja2" %} 2 | {% block body %} 3 |
4 | 5 |

Micropub

6 |

7 | Each post will have Like, Repost, and Reply buttons that will post content to your site directly via micropub. See Micropub for details. 8 |

9 |

10 | Configure micropub credentials. 11 |

12 | {% if current_user.micropub_endpoint or current_user.access_token %} 13 | 14 | 15 |

16 | Update Syndication Targets 17 |

18 |

19 | Reauthorize Micropub 20 |

21 |

22 | Revoke Credentials 23 |

24 | {% else %} 25 |

26 | Authorize Micropub 27 |

28 | {% endif %} 29 |
30 | {% endblock body %} 31 | -------------------------------------------------------------------------------- /woodwind/static/subscriptions.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | 3 | $(".feed-details").css({display: "none"}); 4 | 5 | $(".show-details").click(function(evt) { 6 | evt.preventDefault(); 7 | var target = $(this).data("target"); 8 | $("#" + target).css({display: "inherit"}); 9 | $(this).css({display: "none"}); 10 | }); 11 | 12 | $("form.edit-subscription").submit(function(evt) { 13 | var form = $(this); 14 | evt.preventDefault(); 15 | $(".save-status", form).html("Saving…"); 16 | $.post(form.attr('action'), form.serialize(), function success(){ 17 | $(".save-status", form).html("Saved."); 18 | }).fail(function failure() { 19 | $(".save-status", form).html("Save Failed!"); 20 | }); 21 | }); 22 | 23 | $("form.poll-now").submit(function(evt) { 24 | var form = $(this); 25 | evt.preventDefault(); 26 | 27 | $(".poll-status", form).html(""); 28 | 29 | $.post(form.attr('action'), form.serialize(), function success(){ 30 | $(".poll-status", form).html("Poll Requested."); 31 | }).fail(function failure() { 32 | $(".poll-status", form).html("Polling Failed!"); 33 | }); 34 | 35 | }); 36 | 37 | }); 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Woodwind 2 | ======== 3 | 4 | [![Requirements Status](https://requires.io/github/kylewm/woodwind/requirements.svg?branch=master)](https://requires.io/github/kylewm/woodwind/requirements/?branch=master) 5 | 6 | A minimum viable stream-style feed reader. 7 | 8 | Supports mf2 h-feed and xml feeds (thanks to Universal Feed Parser). 9 | 10 | Installation 11 | ---------- 12 | 13 | How to run your own instance of Woodwind. You'll first need to make 14 | sure you have *Postgres* and *Redis* installed and running. 15 | 16 | ```bash 17 | git clone https://github.com/kylewm/woodwind.git 18 | cd woodwind 19 | ``` 20 | 21 | Set up the virtualenv and install dependencies. 22 | 23 | ```bash 24 | virtualenv --python=/usr/bin/python3 venv 25 | source venv/bin/activate 26 | pip install -r requirements.txt 27 | ``` 28 | 29 | Copy woodwind.cfg.template to woodwind.cfg and edit it to check the 30 | Postgres connection string. 31 | 32 | Then create database tables and run Woodwind. 33 | 34 | ```bash 35 | # create the postgres database 36 | createdb woodwind 37 | # copy and edit the configuration file 38 | cp woodwind.cfg.template woodwind.cfg 39 | nano woodwind.cfg 40 | # create the database tables 41 | python init_db.py 42 | # finally run the application 43 | uwsgi woodwind-dev.ini 44 | ``` 45 | 46 | Now visit localhost:3000, and you should see the login screen! 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Simplifed BSD License 2 | 3 | Copyright (c) 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. -------------------------------------------------------------------------------- /woodwind/templates/settings_action_urls.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja2" %} 2 | {% block body %} 3 |
4 |
5 | 6 |

Action URLs

7 |

8 | Manually configure your own web action handlers. The placeholder {url} will be replaced with the permalink URL of each entry. 9 |

10 |
11 | {% set actionUrls = settings.get('action-urls', {}) %} 12 | {% for action, url in actionUrls %} 13 | 14 | 15 | {% endfor %} 16 | 17 | 18 | 19 |
20 |
21 | 22 |
23 |
24 | 25 |
26 |

27 |
28 |
29 | 36 | {% endblock body %} 37 | -------------------------------------------------------------------------------- /woodwind/templates/settings.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja2" %} 2 | {% block body %} 3 |
4 |
5 | {% set reply_method = settings.get('reply-method') %} 6 |

Reply Mechanism

7 |

8 | 9 | 10 | Each post will have Like, Repost, and Reply buttons that will post content to your site directly via micropub. See Micropub for details. 11 |

12 |

13 | 14 | 15 | Clicking an indie-action link will invoke your web+action handler if registered. See indie-config for details. 16 |

17 |

18 | 19 | 20 | Manually configure your own web action handlers. The placeholder {url} will be replaced with the permalink URL of each entry. 21 |

22 | 23 |
24 |
25 | 26 | {% endblock body %} 27 | -------------------------------------------------------------------------------- /woodwind/templates/feed.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja2" %} 2 | {% block head %} 3 | 4 | {% if ws_topic %} 5 | 6 | {% endif %} 7 | 8 | 9 | {% if current_user and current_user.settings 10 | and current_user.settings.get('reply-method') == 'indie-config' %} 11 | 12 | 13 | {% endif %} 14 | 15 | {% endblock head %} 16 | 17 | {% block header %} 18 | {% if current_user.is_authenticated %} 19 |
20 | 21 |
22 | {% endif %} 23 | 24 | {% if all_tags %} 25 | 26 | {% for tag in all_tags|sort %} 27 | {{ tag }}{% if not loop.last %}, {% endif %} 28 | {% endfor %} 29 | {% endif %} 30 | 31 | {% endblock header %} 32 | 33 | {% block body %} 34 | 37 | 38 |
39 |
40 | 41 | {% for entry in entries %} 42 | {% include '_entry.jinja2' with context %} 43 | {% endfor %} 44 | 45 | {% if entries and not solo %} 46 | 49 | {% endif %} 50 | 51 | 52 | 53 | {% endblock body %} 54 | -------------------------------------------------------------------------------- /woodwind/app.py: -------------------------------------------------------------------------------- 1 | from raven.contrib.flask import Sentry 2 | from woodwind import extensions 3 | from woodwind.api import api 4 | from woodwind.push import push 5 | from woodwind.views import views 6 | import flask 7 | import logging 8 | import logging.handlers 9 | import sys 10 | 11 | 12 | MAIL_FORMAT = '''\ 13 | Message type: %(levelname)s 14 | Location: %(pathname)s:%(lineno)d 15 | Module: %(module)s 16 | Function: %(funcName)s 17 | Time: %(asctime)s 18 | 19 | Message: 20 | 21 | %(message)s 22 | ''' 23 | 24 | sentry = Sentry() 25 | 26 | 27 | def create_app(config_path='../woodwind.cfg'): 28 | app = flask.Flask('woodwind') 29 | app.config.from_pyfile(config_path) 30 | configure_logging(app) 31 | extensions.init_app(app) 32 | app.register_blueprint(views) 33 | app.register_blueprint(api) 34 | app.register_blueprint(push) 35 | return app 36 | 37 | 38 | def configure_logging(app): 39 | if app.debug: 40 | return 41 | 42 | app.logger.setLevel(logging.DEBUG) 43 | 44 | sentry.init_app(app, dsn=app.config.get('SENTRY_DSN'), logging=True, level=logging.WARNING) 45 | 46 | handler = logging.StreamHandler(sys.stdout) 47 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 48 | handler.setFormatter(formatter) 49 | app.logger.addHandler(handler) 50 | 51 | recipients = app.config.get('ADMIN_EMAILS') 52 | if recipients: 53 | error_handler = logging.handlers.SMTPHandler( 54 | 'localhost', 'Woodwind ', 55 | recipients, 'woodwind error') 56 | error_handler.setLevel(logging.ERROR) 57 | error_handler.setFormatter(logging.Formatter(MAIL_FORMAT)) 58 | app.logger.addHandler(error_handler) 59 | -------------------------------------------------------------------------------- /scripts/2015-03-26-normalize-urls.py: -------------------------------------------------------------------------------- 1 | from config import Config 2 | from woodwind import util 3 | from woodwind.models import Feed 4 | import requests 5 | import sqlalchemy 6 | import sqlalchemy.orm 7 | import sys 8 | 9 | engine = sqlalchemy.create_engine(Config.SQLALCHEMY_DATABASE_URI) 10 | Session = sqlalchemy.orm.sessionmaker(bind=engine) 11 | 12 | def follow_redirects(): 13 | try: 14 | session = Session() 15 | feeds = session.query(Feed).all() 16 | for feed in feeds: 17 | print('fetching', feed.feed) 18 | try: 19 | r = requests.head(feed.feed, allow_redirects=True) 20 | if feed.feed != r.url: 21 | print('urls differ', feed.feed, r.url) 22 | feed.feed = r.url 23 | except: 24 | print('error', sys.exc_info()[0]) 25 | session.commit() 26 | except: 27 | session.rollback() 28 | raise 29 | finally: 30 | session.close() 31 | 32 | def dedupe(): 33 | try: 34 | session = Session() 35 | feeds = session.query(Feed).order_by(Feed.id).all() 36 | 37 | removed = set() 38 | 39 | for f1 in feeds: 40 | if f1.id in removed: 41 | continue 42 | 43 | for f2 in feeds: 44 | if f2.id in removed: 45 | continue 46 | 47 | if f1.id < f2.id and f1.feed == f2.feed: 48 | print('dedupe', f1.feed, f1.id, f2.id) 49 | f1.users += f2.users 50 | f2.users.clear() 51 | removed.add(f2.id) 52 | session.delete(f2) 53 | session.commit() 54 | except: 55 | session.rollback() 56 | raise 57 | finally: 58 | session.close() 59 | 60 | dedupe() 61 | -------------------------------------------------------------------------------- /woodwind/static/normalize.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": "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", 4 | "sources": ["normalize.scss"], 5 | "names": [], 6 | "file": "normalize.css" 7 | } -------------------------------------------------------------------------------- /woodwind/api.py: -------------------------------------------------------------------------------- 1 | import flask 2 | import flask.ext.login as flask_login 3 | import requests 4 | from woodwind import util 5 | 6 | api = flask.Blueprint('api', __name__) 7 | 8 | 9 | @api.route('/publish', methods=['POST']) 10 | def publish(): 11 | action = flask.request.form.get('action') 12 | target = flask.request.form.get('target') 13 | content = flask.request.form.get('content') 14 | syndicate_to = flask.request.form.getlist('syndicate-to[]') 15 | 16 | if syndicate_to: 17 | syndicate_to = [util.html_unescape(id) for id in syndicate_to] 18 | 19 | data = { 20 | 'h': 'entry', 21 | 'syndicate-to[]': syndicate_to, 22 | 'access_token': flask_login.current_user.access_token, 23 | } 24 | 25 | if action.startswith('rsvp-'): 26 | data['in-reply-to'] = target 27 | data['content'] = content 28 | data['rsvp'] = action.split('-', 1)[-1] 29 | elif action == 'like': 30 | data['like-of'] = target 31 | elif action == 'repost': 32 | data['repost-of'] = target 33 | else: 34 | data['in-reply-to'] = target 35 | data['content'] = content 36 | 37 | resp = requests.post( 38 | flask_login.current_user.micropub_endpoint, data=data, headers={ 39 | 'Authorization': 'Bearer {}'.format( 40 | flask_login.current_user.access_token), 41 | }) 42 | 43 | return flask.jsonify({ 44 | 'code': resp.status_code, 45 | 'content': resp.text, 46 | 'content-type': resp.headers.get('content-type'), 47 | 'location': resp.headers.get('location'), 48 | }) 49 | 50 | 51 | @api.route('/_forward', methods=['GET', 'POST']) 52 | def forward_request(): 53 | if flask.request.method == 'GET': 54 | args = flask.request.args.copy() 55 | url = args.pop('_url') 56 | result = requests.get(url, params=args) 57 | else: 58 | data = flask.request.form.copy() 59 | url = data.pop('_url') 60 | result = requests.post(url, data=data) 61 | 62 | return flask.jsonify({ 63 | 'code': result.status_code, 64 | 'content': result.text, 65 | 'content-type': result.headers.get('content-type'), 66 | 'location': result.headers.get('location'), 67 | }) 68 | -------------------------------------------------------------------------------- /frontend/webaction.js: -------------------------------------------------------------------------------- 1 | /*jslint browser: true, plusplus: true, vars: true, indent: 2 */ 2 | (function () { 3 | 'use strict'; 4 | 5 | var loadingClassRegexp = /(^|\s)indieconfig-loading(\s|$)/; 6 | 7 | var doTheAction = function (indieConfig) { 8 | var href, action, anchors; 9 | 10 | // Don't block the tag anymore as the queued action is now handled 11 | this.className = this.className.replace(loadingClassRegexp, ' '); 12 | 13 | // Pick the correct endpoint for the correct action 14 | action = this.getAttribute('do'); 15 | href = indieConfig[action]; 16 | 17 | // If no endpoint is found, try the URL of the first a-tag within it 18 | if (!href) { 19 | anchors = this.getElementsByTagName('a'); 20 | if (anchors[0]) { 21 | href = anchors[0].href; 22 | } 23 | } 24 | 25 | // We have found an endpoint! 26 | if (href) { 27 | //Resolve a relative target 28 | var target = document.createElement('a'); 29 | target.href = this.getAttribute('with'); 30 | target = target.href; 31 | 32 | // Insert the target into the endpoint 33 | href = href.replace('{url}', encodeURIComponent(target || window.location.href)); 34 | 35 | // And redirect to it 36 | window.open( href, '_blank'); 37 | } 38 | }; 39 | 40 | // Event handler for a click on an indie-action tag 41 | var handleTheAction = function (e) { 42 | // Prevent the default of eg. any a-tag fallback within the indie-action tag 43 | e.preventDefault(); 44 | 45 | // Make sure this tag hasn't already been queued for the indieconfig-load 46 | if (!loadingClassRegexp.test(this.className)) { 47 | this.className += ' indieconfig-loading'; 48 | // Set "doTheAction" to be called when the indie-config has been loaded 49 | window.loadIndieConfig(doTheAction.bind(this)); 50 | } 51 | }; 52 | 53 | // Once the page is loased add click event listeners to all indie-action tags 54 | window.addEventListener('DOMContentLoaded', function () { 55 | var actions = document.querySelectorAll('indie-action'), 56 | i, 57 | length = actions.length; 58 | 59 | for (i = 0; i < length; i++) { 60 | actions[i].addEventListener('click', handleTheAction); 61 | } 62 | }); 63 | }()); 64 | -------------------------------------------------------------------------------- /woodwind/static/webaction.js: -------------------------------------------------------------------------------- 1 | /*jslint browser: true, plusplus: true, vars: true, indent: 2 */ 2 | (function () { 3 | 'use strict'; 4 | 5 | var loadingClassRegexp = /(^|\s)indieconfig-loading(\s|$)/; 6 | 7 | var doTheAction = function (indieConfig) { 8 | var href, action, anchors; 9 | 10 | // Don't block the tag anymore as the queued action is now handled 11 | this.className = this.className.replace(loadingClassRegexp, ' '); 12 | 13 | // Pick the correct endpoint for the correct action 14 | action = this.getAttribute('do'); 15 | href = indieConfig[action]; 16 | 17 | // If no endpoint is found, try the URL of the first a-tag within it 18 | if (!href) { 19 | anchors = this.getElementsByTagName('a'); 20 | if (anchors[0]) { 21 | href = anchors[0].href; 22 | } 23 | } 24 | 25 | // We have found an endpoint! 26 | if (href) { 27 | //Resolve a relative target 28 | var target = document.createElement('a'); 29 | target.href = this.getAttribute('with'); 30 | target = target.href; 31 | 32 | // Insert the target into the endpoint 33 | href = href.replace('{url}', encodeURIComponent(target || window.location.href)); 34 | 35 | // And redirect to it 36 | window.open( href, '_blank'); 37 | } 38 | }; 39 | 40 | // Event handler for a click on an indie-action tag 41 | var handleTheAction = function (e) { 42 | // Prevent the default of eg. any a-tag fallback within the indie-action tag 43 | e.preventDefault(); 44 | 45 | // Make sure this tag hasn't already been queued for the indieconfig-load 46 | if (!loadingClassRegexp.test(this.className)) { 47 | this.className += ' indieconfig-loading'; 48 | // Set "doTheAction" to be called when the indie-config has been loaded 49 | window.loadIndieConfig(doTheAction.bind(this)); 50 | } 51 | }; 52 | 53 | // Once the page is loased add click event listeners to all indie-action tags 54 | window.addEventListener('DOMContentLoaded', function () { 55 | var actions = document.querySelectorAll('indie-action'), 56 | i, 57 | length = actions.length; 58 | 59 | for (i = 0; i < length; i++) { 60 | actions[i].addEventListener('click', handleTheAction); 61 | } 62 | }); 63 | }()); 64 | -------------------------------------------------------------------------------- /frontend/indieconfig.js: -------------------------------------------------------------------------------- 1 | /*jslint browser: true, plusplus: true, vars: true, indent: 2 */ 2 | window.loadIndieConfig = (function () { 3 | 'use strict'; 4 | 5 | // Indie-Config Loading script 6 | // by Pelle Wessman, voxpelli.com 7 | // MIT-licensed 8 | // https://indieweb.org/indie-config 9 | 10 | var config, configFrame, configTimeout, 11 | callbacks = [], 12 | handleConfig, parseConfig; 13 | 14 | // When the configuration has been loaded – deregister all loading mechanics and call all callbacks 15 | handleConfig = function () { 16 | config = config || {}; 17 | 18 | configFrame.parentNode.removeChild(configFrame); 19 | configFrame = undefined; 20 | 21 | window.removeEventListener('message', parseConfig); 22 | 23 | clearTimeout(configTimeout); 24 | 25 | while (callbacks[0]) { 26 | callbacks.shift()(config); 27 | } 28 | }; 29 | 30 | // When we receive a message, check if the source is right and try to parse it 31 | parseConfig = function (message) { 32 | var correctSource = (configFrame && message.source === configFrame.contentWindow); 33 | 34 | if (correctSource && config === undefined) { 35 | try { 36 | config = JSON.parse(message.data); 37 | } catch (ignore) {} 38 | 39 | handleConfig(); 40 | } 41 | }; 42 | 43 | return function (callback) { 44 | // If the config is already loaded, call callback right away 45 | if (config) { 46 | callback(config); 47 | return; 48 | } 49 | 50 | // Otherwise add the callback to the queue 51 | callbacks.push(callback); 52 | 53 | // Are we already trying to load the Indie-Config, then wait 54 | if (configFrame) { 55 | return; 56 | } 57 | 58 | // Create the iframe that will load the Indie-Config 59 | configFrame = document.createElement('iframe'); 60 | configFrame.src = 'web+action:load'; 61 | document.getElementsByTagName('body')[0].appendChild(configFrame); 62 | configFrame.style.display = 'none'; 63 | 64 | // Listen for messages so we will catch the Indie-Config message 65 | window.addEventListener('message', parseConfig); 66 | 67 | // And if no such Indie-Config message has been loaded in a while, abort the loading 68 | configTimeout = setTimeout(handleConfig, 3000); 69 | }; 70 | }()); 71 | -------------------------------------------------------------------------------- /woodwind/static/indieconfig.js: -------------------------------------------------------------------------------- 1 | /*jslint browser: true, plusplus: true, vars: true, indent: 2 */ 2 | window.loadIndieConfig = (function () { 3 | 'use strict'; 4 | 5 | // Indie-Config Loading script 6 | // by Pelle Wessman, voxpelli.com 7 | // MIT-licensed 8 | // https://indieweb.org/indie-config 9 | 10 | var config, configFrame, configTimeout, 11 | callbacks = [], 12 | handleConfig, parseConfig; 13 | 14 | // When the configuration has been loaded – deregister all loading mechanics and call all callbacks 15 | handleConfig = function () { 16 | config = config || {}; 17 | 18 | configFrame.parentNode.removeChild(configFrame); 19 | configFrame = undefined; 20 | 21 | window.removeEventListener('message', parseConfig); 22 | 23 | clearTimeout(configTimeout); 24 | 25 | while (callbacks[0]) { 26 | callbacks.shift()(config); 27 | } 28 | }; 29 | 30 | // When we receive a message, check if the source is right and try to parse it 31 | parseConfig = function (message) { 32 | var correctSource = (configFrame && message.source === configFrame.contentWindow); 33 | 34 | if (correctSource && config === undefined) { 35 | try { 36 | config = JSON.parse(message.data); 37 | } catch (ignore) {} 38 | 39 | handleConfig(); 40 | } 41 | }; 42 | 43 | return function (callback) { 44 | // If the config is already loaded, call callback right away 45 | if (config) { 46 | callback(config); 47 | return; 48 | } 49 | 50 | // Otherwise add the callback to the queue 51 | callbacks.push(callback); 52 | 53 | // Are we already trying to load the Indie-Config, then wait 54 | if (configFrame) { 55 | return; 56 | } 57 | 58 | // Create the iframe that will load the Indie-Config 59 | configFrame = document.createElement('iframe'); 60 | configFrame.src = 'web+action:load'; 61 | document.getElementsByTagName('body')[0].appendChild(configFrame); 62 | configFrame.style.display = 'none'; 63 | 64 | // Listen for messages so we will catch the Indie-Config message 65 | window.addEventListener('message', parseConfig); 66 | 67 | // And if no such Indie-Config message has been loaded in a while, abort the loading 68 | configTimeout = setTimeout(handleConfig, 3000); 69 | }; 70 | }()); 71 | -------------------------------------------------------------------------------- /woodwind/templates/_reply.jinja2: -------------------------------------------------------------------------------- 1 | {% set settings = current_user.settings or {} %} 2 | {% set reply_method = settings.get('reply-method') %} 3 | 4 | {% if reply_method == 'micropub' and current_user.micropub_endpoint %} 5 |
6 | 7 | 8 | {% if entry.get_property('event') %} 9 |
10 | 11 | 12 | 13 |
14 | {% endif %} 15 | 16 |
17 | 18 | 19 | 20 | 21 |
22 | 32 |
33 |
34 |
35 | {% elif reply_method == 'indie-config' %} 36 | {% for action in settings.get('indie-config-actions', []) %} 37 | 38 | {{ action | capitalize }} 39 | 40 | {% endfor %} 41 | {% elif reply_method == 'action-urls' %} 42 | {% for action, url in settings.get('action-urls', []) %} 43 | {{ action | capitalize }} 44 | {% endfor %} 45 | {% endif %} 46 | -------------------------------------------------------------------------------- /woodwind/util.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import re 3 | from xml.sax import saxutils 4 | 5 | from flask import current_app 6 | from redis import StrictRedis 7 | import bleach 8 | import requests 9 | 10 | redis = StrictRedis() 11 | 12 | bleach.ALLOWED_TAGS += [ 13 | 'a', 'img', 'p', 'br', 'marquee', 'blink', 14 | 'audio', 'video', 'source', 'table', 'tbody', 'td', 'tr', 'div', 'span', 15 | 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 16 | ] 17 | 18 | bleach.ALLOWED_ATTRIBUTES.update({ 19 | 'img': ['src', 'alt', 'title'], 20 | 'audio': ['preload', 'controls', 'src'], 21 | 'video': ['preload', 'controls', 'src', 'poster'], 22 | 'source': ['type', 'src'], 23 | 'td': ['colspan'], 24 | }) 25 | 26 | USER_AGENT = 'Woodwind (https://github.com/kylewm/woodwind)' 27 | 28 | 29 | def requests_get(url, **kwargs): 30 | lastresp = redis.get('resp:' + url) 31 | if lastresp: 32 | lastresp = pickle.loads(lastresp) 33 | 34 | headers = kwargs.setdefault('headers', {}) 35 | headers['User-Agent'] = USER_AGENT 36 | 37 | if lastresp: 38 | if 'Etag' in lastresp.headers: 39 | headers['If-None-Match'] = lastresp.headers['Etag'] 40 | if 'Last-Modified' in lastresp.headers: 41 | headers['If-Modified-Since'] = lastresp.headers['Last-Modified'] 42 | 43 | if 'timeout' not in kwargs: 44 | kwargs['timeout'] = (9.1, 30) 45 | 46 | current_app.logger.debug('fetching %s with args %s', url, kwargs) 47 | resp = requests.get(url, **kwargs) 48 | 49 | current_app.logger.debug('fetching %s got response %s', url, resp) 50 | if resp.status_code == 304: 51 | return lastresp 52 | if resp.status_code // 100 == 2: 53 | redis.setex('resp:' + url, 24 * 3600, pickle.dumps(resp)) 54 | return resp 55 | 56 | 57 | def clean(text): 58 | """Strip script tags and other possibly dangerous content 59 | """ 60 | if text is not None: 61 | text = re.sub('', '', text, flags=re.DOTALL) 62 | return bleach.clean(text, strip=True) 63 | 64 | 65 | def html_escape(text): 66 | # https://wiki.python.org/moin/EscapingHtml 67 | return saxutils.escape(text, {'"': '"', "'": '''}) 68 | 69 | 70 | def html_unescape(text): 71 | # https://wiki.python.org/moin/EscapingHtml 72 | return saxutils.unescape(text, {'"': '"', ''': "'"}) 73 | -------------------------------------------------------------------------------- /woodwind/templates/subscriptions.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja2" %} 2 | 3 | {% block head %} 4 | 5 | {% endblock head %} 6 | 7 | {% block header %} 8 | {% if current_user.is_authenticated %} 9 |
10 | 11 |
12 | {% endif %} 13 | {% endblock header %} 14 | 15 | {% block body %} 16 | 17 |
18 | {{ subscriptions | count }} subscriptions 19 | 20 |
21 | 22 | Export as OPML 23 |
24 |
25 | 26 | {% for s in subscriptions %} 27 | 28 |
29 | {% if s.feed.failure_count > 0 %} 30 |
31 | Last {{ s.feed.failure_count }} Attempt(s) Failed 32 |
33 | {% endif %} 34 |
{{ s.name }} checked {{s.feed.last_checked | relative_time}} 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 48 | 49 | 50 |
51 | 52 |
53 |
Details 54 |
    55 |
  • Last checked: {{s.feed.last_checked | relative_time}}
  • 56 |
  • Last updated: {{s.feed.last_updated | relative_time}}
  • 57 |
  • Last response: {{s.feed.last_response | e}}
  • 58 |
  • PuSH hub: {{s.feed.push_hub}}
  • 59 |
  • PuSH topic: {{s.feed.push_topic}}
  • 60 |
  • PuSH verified: {{s.feed.push_verified}}
  • 61 |
  • PuSH last ping: {{s.feed.last_pinged | relative_time}}
  • 62 |
  • PuSH expiry: {{s.feed.push_expiry | relative_time}}
  • 63 |
64 |
65 |
66 | 67 |
68 |
69 | 70 | 71 |
72 | 73 |
74 | 75 | 76 |
77 | View Posts 78 | 79 | 80 |
81 | {% endfor %} 82 | 83 | {% endblock body %} 84 | {% block foot %} 85 | 86 | {% endblock foot %} 87 | -------------------------------------------------------------------------------- /woodwind/templates/_entry.jinja2: -------------------------------------------------------------------------------- 1 | {% for context in entry.reply_context %} 2 |
3 |
4 | {% if context.author_photo %} 5 | 6 | {% endif %} 7 | {% if context.author_name %} 8 | {{ context.author_name }} - 9 | {% endif %} 10 | {{ context.permalink | domain_for_url }} 11 |
12 | {% if context.title %} 13 |

{{ context.title|e }}

14 | {% endif %} 15 | {% if context.content %} 16 |
17 | {{ context.content_cleaned | proxy_all | add_preview }} 18 |
19 | {% endif %} 20 | 27 |
28 | {% endfor %} 29 | 30 |
31 |
32 | {% if entry.author_photo %} 33 | 34 | {% endif %} 35 | {% if entry.author_name %} 36 | {{ entry.author_name }} - 37 | {% endif %} 38 | {% if entry.subscription %} 39 | {{ entry.subscription.name }} 40 | 41 | more from this feed 42 | 43 | {% endif %} 44 |
45 | {% if entry.title %} 46 |

{{ entry.title|e }}

47 | {% endif %} 48 |
49 | {% if entry.get_property('event') %} 50 |

51 | {% if entry.get_property('start') %} 52 | start: {{ entry.get_property('start') }} 53 | {% endif %} 54 |
55 | {% if entry.get_property('end') %} 56 | end: {{ entry.get_property('end') }} 57 | {% endif %} 58 |

59 | {% endif %} 60 | 61 | {% set photo = entry.get_property('photo') %} 62 | {% if photo and (not entry.content or ' 64 | 65 | 66 | {% endif %} 67 | 68 | {% set ofs = ['like', 'bookmark', 'repost', 'listen'] %} 69 | {% for of in ofs %} 70 | {% set properties = entry.get_property(of + "-of") %} 71 | {% if properties %} 72 |
73 | {% for property in properties %} 74 |

75 | {% if of == "like" %} 76 | Liked: 77 | {% else %} 78 | {{ of | title }}ed: 79 | {% endif %} 80 | {{ property }} 81 |

82 | {% endfor %} 83 |
84 | {% endif %} 85 | {% endfor %} 86 | 87 | {% if entry.content %} 88 |
89 | {{ entry.content_cleaned | proxy_all | add_preview }} 90 |
91 | {% endif %} 92 | 93 | 119 |
120 |
121 | -------------------------------------------------------------------------------- /woodwind/templates/base.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Woodwind 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% block head %}{% endblock %} 20 | 21 | 22 |
23 | {% if current_user.is_authenticated %} 24 |
25 | 26 | 27 | Woodwind 28 | 29 | 44 |
45 | 46 | {% else %} 47 |

48 | 49 | Woodwind 50 |

51 | {% endif %} 52 | 53 | {% for message in get_flashed_messages() %} 54 |
{{ message }}
55 | {% endfor %} 56 | 57 | {% block login %} 58 | 59 | {% if not current_user.is_authenticated %} 60 |
61 | 62 | 63 | 64 |
65 | Your Woodwind account is tied to your personal domain name. Check out the IndieWeb's Getting Started page for details. 66 | {% endif %} 67 | 68 | {% endblock login %} 69 | 70 | {% block header %}{% endblock %} 71 | 72 |
73 | 74 |
75 | {% block body %}{% endblock %} 76 |
77 | {% block foot %}{% endblock %} 78 | 79 | 82 | 83 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /woodwind/push.py: -------------------------------------------------------------------------------- 1 | from . import tasks 2 | from .extensions import db 3 | from .models import Feed 4 | from flask import Blueprint, request, abort, current_app, make_response 5 | import datetime 6 | import hmac 7 | 8 | 9 | push = Blueprint('push', __name__) 10 | 11 | 12 | @push.route('/_notify/', methods=['GET', 'POST']) 13 | def notify(feed_id): 14 | current_app.logger.debug( 15 | 'received PuSH notification for feed id %d', feed_id) 16 | feed = Feed.query.get(feed_id) 17 | 18 | current_app.logger.debug('processing PuSH notification for feed %r', feed) 19 | if request.method == 'GET': 20 | # verify subscribe or unsusbscribe 21 | mode = request.args.get('hub.mode') 22 | topic = request.args.get('hub.topic') 23 | challenge = request.args.get('hub.challenge') 24 | lease_seconds = request.args.get('hub.lease_seconds') 25 | current_app.logger.debug( 26 | 'PuSH verification. feed=%r, mode=%s, topic=%s, ' 27 | 'challenge=%s, lease_seconds=%s', 28 | feed, mode, topic, challenge, lease_seconds) 29 | 30 | if mode == 'subscribe': 31 | if not feed: 32 | current_app.logger.warn( 33 | 'could not find feed corresponding to %d', feed_id) 34 | return make_response('no feed with id %d' % feed_id, 400) 35 | 36 | if topic != feed.push_topic: 37 | current_app.logger.warn( 38 | 'feed topic (%s) does not match subscription request (%s)', 39 | feed.push_topic, topic) 40 | return make_response( 41 | 'topic %s does not match subscription request %s' % (feed.push_topic, topic), 400) 42 | 43 | current_app.logger.debug( 44 | 'PuSH verify subscribe for feed=%r, topic=%s', feed, topic) 45 | feed.push_verified = True 46 | if lease_seconds: 47 | feed.push_expiry = datetime.datetime.utcnow() \ 48 | + datetime.timedelta(seconds=int(lease_seconds)) 49 | db.session.commit() 50 | return challenge 51 | 52 | elif mode == 'unsubscribe': 53 | if not feed or topic != feed.push_topic: 54 | current_app.logger.debug( 55 | 'PuSH verify unsubscribe for feed=%r, topic=%s', feed, topic) 56 | return challenge 57 | else: 58 | current_app.logger.debug( 59 | 'PuSH denying unsubscribe for feed=%r, topic=%s', feed, topic) 60 | return make_response('unsubscribe denied', 400) 61 | 62 | elif mode: 63 | current_app.logger.debug('PuSH request with unknown mode %s', mode) 64 | return make_response('unrecognized hub.mode=%s' % mode, 400) 65 | 66 | else: 67 | current_app.logger.debug('PuSH request with no mode') 68 | return make_response('missing requred parameter hub.mode', 400) 69 | 70 | if not feed: 71 | current_app.logger.warn( 72 | 'could not find feed corresponding to %d', feed_id) 73 | return make_response('no feed with id %d' % feed_id, 400) 74 | 75 | # could it be? an actual push notification!? 76 | current_app.logger.debug( 77 | 'received PuSH ping for %r; content size: %d', feed, len(request.data)) 78 | 79 | # try to process fat pings 80 | content = None 81 | content_type = None 82 | signature = request.headers.get('X-Hub-Signature') 83 | if signature and feed.push_secret and request.data: 84 | expected = 'sha1=' + hmac.new(feed.push_secret.encode('utf-8'), 85 | msg=request.data, digestmod='sha1').hexdigest() 86 | if expected != signature: 87 | current_app.logger.warn( 88 | 'X-Hub-Signature (%s) did not match expected (%s)', 89 | signature, expected) 90 | return make_response('', 204) 91 | current_app.logger.info('Good X-Hub-Signature!') 92 | content_type = request.headers.get('Content-Type') 93 | current_app.logger.info('PuSH content type: %s', content_type) 94 | content = request.data.decode('utf-8') 95 | 96 | tasks.q_high.enqueue(tasks.update_feed, feed.id, 97 | content=content, content_type=content_type, 98 | is_polling=False) 99 | feed.last_pinged = datetime.datetime.utcnow() 100 | db.session.commit() 101 | return make_response('', 204) 102 | -------------------------------------------------------------------------------- /frontend/feed.js: -------------------------------------------------------------------------------- 1 | if (navigator.serviceWorker) { 2 | navigator.serviceWorker.register('./sw.js') 3 | } 4 | 5 | $(function(){ 6 | function updateTimestamps() { 7 | $(".permalink time").each(function() { 8 | var absolute = $(this).attr('datetime'); 9 | var formatted = moment.utc(absolute).fromNow(); 10 | $(this).text(formatted); 11 | }) 12 | } 13 | 14 | function clickOlderLink(evt) { 15 | evt.preventDefault(); 16 | $.get(this.href, function(result) { 17 | var $newElements = $("article,.pager", $(result)); 18 | $(".pager").replaceWith($newElements); 19 | $newElements.each(function () { 20 | twttr.widgets.load(this); 21 | }); 22 | attachListeners(); 23 | }); 24 | } 25 | 26 | function submitMicropubForm(evt) { 27 | evt.preventDefault(); 28 | 29 | var button = this; 30 | var form = $(button).closest('form'); 31 | var replyArea = form.parent(); 32 | var endpoint = form.attr('action'); 33 | var responseArea = $('.micropub-response', replyArea); 34 | var formData = form.serializeArray(); 35 | formData.push({name: button.name, value: button.value}); 36 | 37 | $.post( 38 | endpoint, 39 | formData, 40 | function(result) { 41 | if (Math.floor(result.code / 100) == 2) { 42 | responseArea.html('Success!'); 43 | $("textarea", form).val(""); 44 | 45 | if (button.value === 'rsvp-yes') { 46 | $(".rsvps", form).html('✓ Going'); 47 | } else if (button.value === 'rsvp-maybe') { 48 | $(".rsvps", form).html('? Interested'); 49 | } else if (button.value === 'rsvp-no') { 50 | $(".rsvps", form).html('✗ Not Going'); 51 | } 52 | 53 | } else { 54 | responseArea.html('Failure'); 55 | } 56 | }, 57 | 'json' 58 | ); 59 | 60 | 61 | responseArea.html('Posting…'); 62 | } 63 | 64 | function attachListeners() { 65 | $("#older-link").off('click').click(clickOlderLink); 66 | $(".micropub-form button[type='submit']").off('click').click(submitMicropubForm); 67 | 68 | // Post by ctrl/cmd + enter in the text area 69 | $(".micropub-form textarea.content").keyup(function(e) { 70 | if ((e.ctrlKey || e.metaKey) && (e.keyCode == 13 || e.keyCode == 10)) { 71 | var button = $(e.target).closest('form').find('button[value=reply]'); 72 | button[0].click(); 73 | } 74 | }); 75 | 76 | $(".micropub-form .content").focus(function (evt) { 77 | $(this).animate({ height: "4em" }, 200); 78 | var $target = $(evt.target); 79 | }); 80 | } 81 | 82 | 83 | function clickUnfoldLink(evt) { 84 | $('#fold').after($('#fold').children()) 85 | $('#unfold-link').hide(); 86 | } 87 | 88 | 89 | function foldNewEntries(entries) { 90 | $('#fold').prepend(entries.join('\n')); 91 | attachListeners(); 92 | $('#unfold-link').text($('#fold>article:not(.reply-context)').length + " New Posts"); 93 | $('#unfold-link').off('click').click(clickUnfoldLink); 94 | $('#unfold-link').show(); 95 | 96 | // load twitter embeds 97 | twttr.widgets.load($('#fold').get(0)); 98 | } 99 | 100 | // topic will be user:id or feed:id 101 | function webSocketSubscribe(topic) { 102 | if ('WebSocket' in window) { 103 | var ws = new WebSocket(window.location.origin 104 | .replace(/http:\/\//, 'ws://') 105 | .replace(/https:\/\//, 'wss://') 106 | + '/_updates'); 107 | 108 | ws.onopen = function(event) { 109 | // send the topic 110 | console.log('subscribing to topic: ' + topic); 111 | ws.send(topic); 112 | }; 113 | ws.onmessage = function(event) { 114 | var data = JSON.parse(event.data); 115 | foldNewEntries(data.entries); 116 | }; 117 | } 118 | } 119 | 120 | attachListeners(); 121 | 122 | $(document).on("keypress", function(e) { 123 | if (e.which === 46) { 124 | clickUnfoldLink(); 125 | } 126 | }); 127 | 128 | if (WS_TOPIC) { 129 | webSocketSubscribe(WS_TOPIC); 130 | } 131 | 132 | updateTimestamps(); 133 | window.setInterval(updateTimestamps, 60 * 1000); 134 | 135 | }); 136 | -------------------------------------------------------------------------------- /woodwind/static/style.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": "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", 4 | "sources": ["normalize.scss","style.scss"], 5 | "names": [], 6 | "file": "style.css" 7 | } -------------------------------------------------------------------------------- /woodwind/models.py: -------------------------------------------------------------------------------- 1 | from .extensions import db 2 | 3 | from sqlalchemy.dialects.postgresql import JSON 4 | import uuid 5 | 6 | 7 | entry_to_reply_context = db.Table( 8 | 'entry_to_reply_context', db.Model.metadata, 9 | db.Column('entry_id', db.Integer, db.ForeignKey('entry.id'), index=True), 10 | db.Column('context_id', db.Integer, db.ForeignKey('entry.id'), index=True)) 11 | 12 | 13 | class User(db.Model): 14 | id = db.Column(db.Integer, primary_key=True) 15 | url = db.Column(db.String(256)) 16 | # domain = db.Column(db.String(256)) 17 | micropub_endpoint = db.Column(db.String(512)) 18 | access_token = db.Column(db.String(512)) 19 | settings = db.Column(JSON) 20 | 21 | # Flask-Login integration 22 | @property 23 | def is_authenticated(self): 24 | return True 25 | 26 | @property 27 | def is_active(self): 28 | return True 29 | 30 | @property 31 | def is_anonymous(self): 32 | return False 33 | 34 | def get_id(self): 35 | return self.url 36 | 37 | def get_setting(self, key, default=None): 38 | if self.settings is None: 39 | return default 40 | return self.settings.get(key, default) 41 | 42 | def set_setting(self, key, value): 43 | if self.settings is None: 44 | self.settings = {} 45 | else: 46 | self.settings = dict(self.settings) 47 | self.settings[key] = value 48 | 49 | def __eq__(self, other): 50 | if type(other) is type(self): 51 | return self.domain == other.domain 52 | return False 53 | 54 | def __repr__(self): 55 | return ''.format(self.domain) 56 | 57 | 58 | class Feed(db.Model): 59 | id = db.Column(db.Integer, primary_key=True) 60 | # the name of this feed 61 | name = db.Column(db.String(256)) 62 | # url that we subscribed to; periodically check if the feed url 63 | # has changed 64 | origin = db.Column(db.String(512)) 65 | # url of the feed itself 66 | feed = db.Column(db.String(512)) 67 | # html, xml, etc. 68 | type = db.Column(db.String(64)) 69 | # last time this feed returned new data 70 | last_updated = db.Column(db.DateTime) 71 | # last time we checked this feed 72 | last_checked = db.Column(db.DateTime) 73 | etag = db.Column(db.String(512)) 74 | 75 | push_hub = db.Column(db.String(512)) 76 | push_topic = db.Column(db.String(512)) 77 | push_verified = db.Column(db.Boolean) 78 | push_expiry = db.Column(db.DateTime) 79 | push_secret = db.Column(db.String(200)) 80 | last_pinged = db.Column(db.DateTime) 81 | 82 | last_response = db.Column(db.Text) 83 | failure_count = db.Column(db.Integer, default=0) 84 | 85 | def get_feed_code(self): 86 | return self.feed # binascii.hexlify(self.feed.encode()) 87 | 88 | def get_or_create_push_secret(self): 89 | if not self.push_secret: 90 | self.push_secret = uuid.uuid4().hex 91 | return self.push_secret 92 | 93 | def __repr__(self): 94 | return ''.format(self.name, self.feed) 95 | 96 | 97 | class Subscription(db.Model): 98 | id = db.Column(db.Integer, primary_key=True) 99 | user_id = db.Column(db.Integer, db.ForeignKey(User.id), index=True) 100 | feed_id = db.Column(db.Integer, db.ForeignKey(Feed.id), index=True) 101 | 102 | # user-editable name of this subscribed feed 103 | name = db.Column(db.String(256)) 104 | tags = db.Column(db.String(256)) 105 | # exclude from the front page 106 | exclude = db.Column(db.Boolean, default=False) 107 | 108 | user = db.relationship(User, backref='subscriptions') 109 | feed = db.relationship(Feed, backref='subscriptions') 110 | 111 | 112 | class Entry(db.Model): 113 | id = db.Column(db.Integer, primary_key=True) 114 | feed_id = db.Column(db.Integer, db.ForeignKey(Feed.id), index=True) 115 | feed = db.relationship(Feed, backref='entries') 116 | published = db.Column(db.DateTime) 117 | updated = db.Column(db.DateTime) 118 | deleted = db.Column(db.DateTime) 119 | retrieved = db.Column(db.DateTime, index=True) 120 | uid = db.Column(db.String(512)) 121 | permalink = db.Column(db.String(512), index=True) 122 | author_name = db.Column(db.String(512)) 123 | author_url = db.Column(db.String(512)) 124 | author_photo = db.Column(db.String(512)) 125 | title = db.Column(db.Text) 126 | content = db.Column(db.Text) 127 | content_cleaned = db.Column(db.Text) 128 | # other properties 129 | properties = db.Column(JSON) 130 | reply_context = db.relationship( 131 | 'Entry', secondary='entry_to_reply_context', 132 | primaryjoin=id == entry_to_reply_context.c.entry_id, 133 | secondaryjoin=id == entry_to_reply_context.c.context_id) 134 | 135 | def __init__(self, *args, **kwargs): 136 | super().__init__(*args, **kwargs) 137 | self.subscription = None 138 | self.properties = {} 139 | self._syndicated_copies = [] 140 | 141 | def get_property(self, key, default=None): 142 | return self.properties.get(key, default) 143 | 144 | def set_property(self, key, value): 145 | self.properties[key] = value 146 | 147 | def __repr__(self): 148 | return ''.format(self.title, (self.content or '')[:140]) 149 | -------------------------------------------------------------------------------- /woodwind/static/style.scss: -------------------------------------------------------------------------------- 1 | @import "normalize"; 2 | 3 | 4 | $title-font: Helvetica, Arial, sans-serif; 5 | /*$body-font: Georgia, Times New Roman, serif;*/ 6 | $body-font: $title-font; 7 | 8 | /* Subtlety of Hue */ 9 | $lunar-green: #484A47; 10 | $pine-glade: #C1CE96; 11 | $athens-gray: #ECEBF0; 12 | $sirocco: #687D77; 13 | $armadillo: #353129; 14 | 15 | $box-shadow: 0 0 2px $sirocco; 16 | 17 | 18 | body { 19 | font: 12pt $body-font; 20 | line-height: 1.4em; 21 | background: $athens-gray; 22 | /*background: #f4f4f4;*/ 23 | padding-top: 1em; 24 | } 25 | 26 | 27 | a { 28 | color: #336699; 29 | text-decoration: none; 30 | } 31 | 32 | a:hover { 33 | color: #993366; 34 | } 35 | 36 | 37 | p { 38 | &:first-child { 39 | margin-top:0; 40 | } 41 | &:last-child { 42 | margin-bottom:0; 43 | } 44 | } 45 | 46 | header, main, .footer { 47 | max-width: 800px; 48 | margin: 0 auto; 49 | } 50 | 51 | header { 52 | margin-bottom: 1em; 53 | } 54 | 55 | .footer { 56 | font-size: 0.8em; 57 | text-align: center; 58 | } 59 | 60 | ul#navigation { 61 | list-style-type: none; 62 | margin: 0; 63 | padding: 0; 64 | float: right; 65 | 66 | li { 67 | display: inline-block; 68 | padding: 3px; 69 | } 70 | } 71 | 72 | .button-link { 73 | text-align: center; 74 | margin: 1em 0; 75 | 76 | a { 77 | padding: 0.5em; 78 | background-color: $armadillo; 79 | color: $athens-gray; 80 | border: 1px solid $athens-gray; 81 | border-radius: 4px; 82 | display: inline-block; 83 | } 84 | } 85 | 86 | #fold { 87 | display: none; 88 | } 89 | 90 | #unfold-link { 91 | display: none; 92 | } 93 | 94 | article { 95 | margin-bottom: 1em; 96 | box-shadow: $box-shadow; 97 | background-color: white; 98 | border-radius: 3px; 99 | padding: 0.5em; 100 | 101 | &.reply-context { 102 | margin-bottom: 0; 103 | background-color: #f3f3f3; 104 | color: #555; 105 | .content img { 106 | max-height: 240px; 107 | max-width: 240px; 108 | } 109 | } 110 | 111 | div { 112 | overflow: auto; 113 | 114 | /*p:first-child { 115 | margin-top: 0; 116 | } 117 | p:last-child { 118 | margin-bottom: 0; 119 | }*/ 120 | } 121 | 122 | img, video { 123 | max-width: 100%; 124 | } 125 | 126 | header { 127 | img { 128 | vertical-align: middle; 129 | margin: inherit; 130 | display: inline; 131 | max-width: 1.2em; 132 | max-height: 1.2em; 133 | min-width: inherit; 134 | min-height: inherit; 135 | } 136 | 137 | color: $lunar-green; 138 | border-bottom: 1px solid $sirocco; 139 | margin-bottom: 0.5em; 140 | overflow: auto; 141 | } 142 | 143 | footer { 144 | margin-top: 0.5em; 145 | margin-bottom: 0; 146 | } 147 | 148 | h1 { 149 | font-size: 1.2em; 150 | font-weight: bold; 151 | } 152 | } 153 | 154 | label { 155 | font-weight: bold; 156 | display: inline-block; 157 | margin: 5px 0 2px 0; 158 | max-width: 100%; 159 | } 160 | 161 | textarea, input[type="text"], input[type="url"] { 162 | width: 100%; 163 | margin: 0.25em 0; 164 | 165 | background-image: none; 166 | border: 1px solid #ccc; 167 | border-radius: 4px; 168 | box-shadow: 0x 1px 1px rgba(0, 0, 0, 0.075) inset; 169 | 170 | &.input-25 { 171 | width: 23%; 172 | } 173 | &.input-50 { 174 | width: 48%; 175 | } 176 | &.input-75 { 177 | width: 73%; 178 | } 179 | } 180 | 181 | button { 182 | border: 1px solid rgb(204, 204, 204); 183 | background: #efefef; 184 | color: #003366; 185 | border-radius: 4px; 186 | padding: 4px 10px; 187 | margin: 5px 5px 5px 0; 188 | box-shadow: 0 1px 1px #ccc; 189 | &:hover { 190 | background: white; 191 | } 192 | &:active { 193 | box-shadow: 0 0; 194 | margin: 6px 4px 4px 1px; 195 | } 196 | } 197 | 198 | 199 | .micropub-form { 200 | .content { 201 | height: 1em; 202 | width: calc(100% - 1.4em - 96px); 203 | padding: 3px; 204 | margin: 0; 205 | vertical-align: middle; 206 | } 207 | 208 | button { 209 | background-color: #eee; 210 | border-radius: 3px; 211 | vertical-align: middle; 212 | border: 0; 213 | &.small { 214 | width: 24px; 215 | height: 24px; 216 | padding: 4px; 217 | line-height: 1; 218 | } 219 | } 220 | 221 | .rsvps { 222 | text-align: center; 223 | } 224 | } 225 | 226 | .syndication-toggles { 227 | 228 | } 229 | 230 | .syndication-toggle { 231 | display: inline-block; 232 | 233 | input { 234 | display: none; 235 | } 236 | 237 | label { 238 | display: inline-block; 239 | vertical-align: middle; 240 | padding: 4px; 241 | border-radius: 3px; 242 | background-color: #eee; 243 | margin: 0; 244 | text-align: left; 245 | font-weight: normal; 246 | font-size: 1em; 247 | color: #666; 248 | cursor: pointer; 249 | 250 | img { 251 | max-height: 16px; 252 | max-width: 16px; 253 | } 254 | } 255 | 256 | input:checked + label { 257 | background-color: #337AB7; 258 | color: #fff; 259 | } 260 | } 261 | 262 | 263 | .reply-area { 264 | margin-top: 0.5em; 265 | 266 | .reply-link { 267 | display: inline-block; 268 | padding: 0.2em; 269 | border: 1px solid $sirocco; 270 | border-radius: 4px; 271 | background-color: $athens-gray; 272 | text-decoration: none; 273 | color: $lunar-green; 274 | min-width: 50px; 275 | text-align: center; 276 | } 277 | } 278 | 279 | @media only screen and (max-width: 800px) { 280 | article { 281 | header { 282 | img { 283 | vertical-align: text-middle; 284 | margin: inherit; 285 | display: inline; 286 | max-width: 1.2em; 287 | max-height: 1.2em; 288 | min-width: inherit; 289 | min-height: inherit; 290 | } 291 | } 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /woodwind/static/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | /** 3 | * 1. Set default font family to sans-serif. 4 | * 2. Prevent iOS text size adjust after orientation change, without disabling 5 | * user zoom. 6 | */ 7 | html { 8 | font-family: sans-serif; 9 | /* 1 */ 10 | -ms-text-size-adjust: 100%; 11 | /* 2 */ 12 | -webkit-text-size-adjust: 100%; 13 | /* 2 */ } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | body { 19 | margin: 0; } 20 | 21 | /* HTML5 display definitions 22 | ========================================================================== */ 23 | /** 24 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 25 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 26 | * and Firefox. 27 | * Correct `block` display not defined for `main` in IE 11. 28 | */ 29 | article, 30 | aside, 31 | details, 32 | figcaption, 33 | figure, 34 | footer, 35 | header, 36 | hgroup, 37 | main, 38 | menu, 39 | nav, 40 | section, 41 | summary { 42 | display: block; } 43 | 44 | /** 45 | * 1. Correct `inline-block` display not defined in IE 8/9. 46 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 47 | */ 48 | audio, 49 | canvas, 50 | progress, 51 | video { 52 | display: inline-block; 53 | /* 1 */ 54 | vertical-align: baseline; 55 | /* 2 */ } 56 | 57 | /** 58 | * Prevent modern browsers from displaying `audio` without controls. 59 | * Remove excess height in iOS 5 devices. 60 | */ 61 | audio:not([controls]) { 62 | display: none; 63 | height: 0; } 64 | 65 | /** 66 | * Address `[hidden]` styling not present in IE 8/9/10. 67 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 68 | */ 69 | [hidden], 70 | template { 71 | display: none; } 72 | 73 | /* Links 74 | ========================================================================== */ 75 | /** 76 | * Remove the gray background color from active links in IE 10. 77 | */ 78 | a { 79 | background-color: transparent; } 80 | 81 | /** 82 | * Improve readability when focused and also mouse hovered in all browsers. 83 | */ 84 | a:active, 85 | a:hover { 86 | outline: 0; } 87 | 88 | /* Text-level semantics 89 | ========================================================================== */ 90 | /** 91 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 92 | */ 93 | abbr[title] { 94 | border-bottom: 1px dotted; } 95 | 96 | /** 97 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 98 | */ 99 | b, 100 | strong { 101 | font-weight: bold; } 102 | 103 | /** 104 | * Address styling not present in Safari and Chrome. 105 | */ 106 | dfn { 107 | font-style: italic; } 108 | 109 | /** 110 | * Address variable `h1` font-size and margin within `section` and `article` 111 | * contexts in Firefox 4+, Safari, and Chrome. 112 | */ 113 | h1 { 114 | font-size: 2em; 115 | margin: 0.67em 0; } 116 | 117 | /** 118 | * Address styling not present in IE 8/9. 119 | */ 120 | mark { 121 | background: #ff0; 122 | color: #000; } 123 | 124 | /** 125 | * Address inconsistent and variable font size in all browsers. 126 | */ 127 | small { 128 | font-size: 80%; } 129 | 130 | /** 131 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 132 | */ 133 | sub, 134 | sup { 135 | font-size: 75%; 136 | line-height: 0; 137 | position: relative; 138 | vertical-align: baseline; } 139 | 140 | sup { 141 | top: -0.5em; } 142 | 143 | sub { 144 | bottom: -0.25em; } 145 | 146 | /* Embedded content 147 | ========================================================================== */ 148 | /** 149 | * Remove border when inside `a` element in IE 8/9/10. 150 | */ 151 | img { 152 | border: 0; } 153 | 154 | /** 155 | * Correct overflow not hidden in IE 9/10/11. 156 | */ 157 | svg:not(:root) { 158 | overflow: hidden; } 159 | 160 | /* Grouping content 161 | ========================================================================== */ 162 | /** 163 | * Address margin not present in IE 8/9 and Safari. 164 | */ 165 | figure { 166 | margin: 1em 40px; } 167 | 168 | /** 169 | * Address differences between Firefox and other browsers. 170 | */ 171 | hr { 172 | -moz-box-sizing: content-box; 173 | box-sizing: content-box; 174 | height: 0; } 175 | 176 | /** 177 | * Contain overflow in all browsers. 178 | */ 179 | pre { 180 | overflow: auto; } 181 | 182 | /** 183 | * Address odd `em`-unit font size rendering in all browsers. 184 | */ 185 | code, 186 | kbd, 187 | pre, 188 | samp { 189 | font-family: monospace, monospace; 190 | font-size: 1em; } 191 | 192 | /* Forms 193 | ========================================================================== */ 194 | /** 195 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 196 | * styling of `select`, unless a `border` property is set. 197 | */ 198 | /** 199 | * 1. Correct color not being inherited. 200 | * Known issue: affects color of disabled elements. 201 | * 2. Correct font properties not being inherited. 202 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 203 | */ 204 | button, 205 | input, 206 | optgroup, 207 | select, 208 | textarea { 209 | color: inherit; 210 | /* 1 */ 211 | font: inherit; 212 | /* 2 */ 213 | margin: 0; 214 | /* 3 */ } 215 | 216 | /** 217 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 218 | */ 219 | button { 220 | overflow: visible; } 221 | 222 | /** 223 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 224 | * All other form control elements do not inherit `text-transform` values. 225 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 226 | * Correct `select` style inheritance in Firefox. 227 | */ 228 | button, 229 | select { 230 | text-transform: none; } 231 | 232 | /** 233 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 234 | * and `video` controls. 235 | * 2. Correct inability to style clickable `input` types in iOS. 236 | * 3. Improve usability and consistency of cursor style between image-type 237 | * `input` and others. 238 | */ 239 | button, 240 | html input[type="button"], 241 | input[type="reset"], 242 | input[type="submit"] { 243 | -webkit-appearance: button; 244 | /* 2 */ 245 | cursor: pointer; 246 | /* 3 */ } 247 | 248 | /** 249 | * Re-set default cursor for disabled elements. 250 | */ 251 | button[disabled], 252 | html input[disabled] { 253 | cursor: default; } 254 | 255 | /** 256 | * Remove inner padding and border in Firefox 4+. 257 | */ 258 | button::-moz-focus-inner, 259 | input::-moz-focus-inner { 260 | border: 0; 261 | padding: 0; } 262 | 263 | /** 264 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 265 | * the UA stylesheet. 266 | */ 267 | input { 268 | line-height: normal; } 269 | 270 | /** 271 | * It's recommended that you don't attempt to style these elements. 272 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 273 | * 274 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 275 | * 2. Remove excess padding in IE 8/9/10. 276 | */ 277 | input[type="checkbox"], 278 | input[type="radio"] { 279 | box-sizing: border-box; 280 | /* 1 */ 281 | padding: 0; 282 | /* 2 */ } 283 | 284 | /** 285 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 286 | * `font-size` values of the `input`, it causes the cursor style of the 287 | * decrement button to change from `default` to `text`. 288 | */ 289 | input[type="number"]::-webkit-inner-spin-button, 290 | input[type="number"]::-webkit-outer-spin-button { 291 | height: auto; } 292 | 293 | /** 294 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 295 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 296 | * (include `-moz` to future-proof). 297 | */ 298 | input[type="search"] { 299 | -webkit-appearance: textfield; 300 | /* 1 */ 301 | -moz-box-sizing: content-box; 302 | -webkit-box-sizing: content-box; 303 | /* 2 */ 304 | box-sizing: content-box; } 305 | 306 | /** 307 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 308 | * Safari (but not Chrome) clips the cancel button when the search input has 309 | * padding (and `textfield` appearance). 310 | */ 311 | input[type="search"]::-webkit-search-cancel-button, 312 | input[type="search"]::-webkit-search-decoration { 313 | -webkit-appearance: none; } 314 | 315 | /** 316 | * Define consistent border, margin, and padding. 317 | */ 318 | fieldset { 319 | border: 1px solid #c0c0c0; 320 | margin: 0 2px; 321 | padding: 0.35em 0.625em 0.75em; } 322 | 323 | /** 324 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 325 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 326 | */ 327 | legend { 328 | border: 0; 329 | /* 1 */ 330 | padding: 0; 331 | /* 2 */ } 332 | 333 | /** 334 | * Remove default vertical scrollbar in IE 8/9/10/11. 335 | */ 336 | textarea { 337 | overflow: auto; } 338 | 339 | /** 340 | * Don't inherit the `font-weight` (applied by a rule above). 341 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 342 | */ 343 | optgroup { 344 | font-weight: bold; } 345 | 346 | /* Tables 347 | ========================================================================== */ 348 | /** 349 | * Remove most spacing between table cells. 350 | */ 351 | table { 352 | border-collapse: collapse; 353 | border-spacing: 0; } 354 | 355 | td, 356 | th { 357 | padding: 0; } 358 | 359 | /*# sourceMappingURL=normalize.css.map */ 360 | -------------------------------------------------------------------------------- /woodwind/static/normalize.scss: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } 428 | -------------------------------------------------------------------------------- /woodwind/static/style.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | /** 3 | * 1. Set default font family to sans-serif. 4 | * 2. Prevent iOS text size adjust after orientation change, without disabling 5 | * user zoom. 6 | */ 7 | html { 8 | font-family: sans-serif; 9 | /* 1 */ 10 | -ms-text-size-adjust: 100%; 11 | /* 2 */ 12 | -webkit-text-size-adjust: 100%; 13 | /* 2 */ } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | body { 19 | margin: 0; } 20 | 21 | /* HTML5 display definitions 22 | ========================================================================== */ 23 | /** 24 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 25 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 26 | * and Firefox. 27 | * Correct `block` display not defined for `main` in IE 11. 28 | */ 29 | article, 30 | aside, 31 | details, 32 | figcaption, 33 | figure, 34 | footer, 35 | header, 36 | hgroup, 37 | main, 38 | menu, 39 | nav, 40 | section, 41 | summary { 42 | display: block; } 43 | 44 | /** 45 | * 1. Correct `inline-block` display not defined in IE 8/9. 46 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 47 | */ 48 | audio, 49 | canvas, 50 | progress, 51 | video { 52 | display: inline-block; 53 | /* 1 */ 54 | vertical-align: baseline; 55 | /* 2 */ } 56 | 57 | /** 58 | * Prevent modern browsers from displaying `audio` without controls. 59 | * Remove excess height in iOS 5 devices. 60 | */ 61 | audio:not([controls]) { 62 | display: none; 63 | height: 0; } 64 | 65 | /** 66 | * Address `[hidden]` styling not present in IE 8/9/10. 67 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 68 | */ 69 | [hidden], 70 | template { 71 | display: none; } 72 | 73 | /* Links 74 | ========================================================================== */ 75 | /** 76 | * Remove the gray background color from active links in IE 10. 77 | */ 78 | a { 79 | background-color: transparent; } 80 | 81 | /** 82 | * Improve readability when focused and also mouse hovered in all browsers. 83 | */ 84 | a:active, 85 | a:hover { 86 | outline: 0; } 87 | 88 | /* Text-level semantics 89 | ========================================================================== */ 90 | /** 91 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 92 | */ 93 | abbr[title] { 94 | border-bottom: 1px dotted; } 95 | 96 | /** 97 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 98 | */ 99 | b, 100 | strong { 101 | font-weight: bold; } 102 | 103 | /** 104 | * Address styling not present in Safari and Chrome. 105 | */ 106 | dfn { 107 | font-style: italic; } 108 | 109 | /** 110 | * Address variable `h1` font-size and margin within `section` and `article` 111 | * contexts in Firefox 4+, Safari, and Chrome. 112 | */ 113 | h1 { 114 | font-size: 2em; 115 | margin: 0.67em 0; } 116 | 117 | /** 118 | * Address styling not present in IE 8/9. 119 | */ 120 | mark { 121 | background: #ff0; 122 | color: #000; } 123 | 124 | /** 125 | * Address inconsistent and variable font size in all browsers. 126 | */ 127 | small { 128 | font-size: 80%; } 129 | 130 | /** 131 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 132 | */ 133 | sub, 134 | sup { 135 | font-size: 75%; 136 | line-height: 0; 137 | position: relative; 138 | vertical-align: baseline; } 139 | 140 | sup { 141 | top: -0.5em; } 142 | 143 | sub { 144 | bottom: -0.25em; } 145 | 146 | /* Embedded content 147 | ========================================================================== */ 148 | /** 149 | * Remove border when inside `a` element in IE 8/9/10. 150 | */ 151 | img { 152 | border: 0; } 153 | 154 | /** 155 | * Correct overflow not hidden in IE 9/10/11. 156 | */ 157 | svg:not(:root) { 158 | overflow: hidden; } 159 | 160 | /* Grouping content 161 | ========================================================================== */ 162 | /** 163 | * Address margin not present in IE 8/9 and Safari. 164 | */ 165 | figure { 166 | margin: 1em 40px; } 167 | 168 | /** 169 | * Address differences between Firefox and other browsers. 170 | */ 171 | hr { 172 | -moz-box-sizing: content-box; 173 | box-sizing: content-box; 174 | height: 0; } 175 | 176 | /** 177 | * Contain overflow in all browsers. 178 | */ 179 | pre { 180 | overflow: auto; } 181 | 182 | /** 183 | * Address odd `em`-unit font size rendering in all browsers. 184 | */ 185 | code, 186 | kbd, 187 | pre, 188 | samp { 189 | font-family: monospace, monospace; 190 | font-size: 1em; } 191 | 192 | /* Forms 193 | ========================================================================== */ 194 | /** 195 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 196 | * styling of `select`, unless a `border` property is set. 197 | */ 198 | /** 199 | * 1. Correct color not being inherited. 200 | * Known issue: affects color of disabled elements. 201 | * 2. Correct font properties not being inherited. 202 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 203 | */ 204 | button, 205 | input, 206 | optgroup, 207 | select, 208 | textarea { 209 | color: inherit; 210 | /* 1 */ 211 | font: inherit; 212 | /* 2 */ 213 | margin: 0; 214 | /* 3 */ } 215 | 216 | /** 217 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 218 | */ 219 | button { 220 | overflow: visible; } 221 | 222 | /** 223 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 224 | * All other form control elements do not inherit `text-transform` values. 225 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 226 | * Correct `select` style inheritance in Firefox. 227 | */ 228 | button, 229 | select { 230 | text-transform: none; } 231 | 232 | /** 233 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 234 | * and `video` controls. 235 | * 2. Correct inability to style clickable `input` types in iOS. 236 | * 3. Improve usability and consistency of cursor style between image-type 237 | * `input` and others. 238 | */ 239 | button, 240 | html input[type="button"], 241 | input[type="reset"], 242 | input[type="submit"] { 243 | -webkit-appearance: button; 244 | /* 2 */ 245 | cursor: pointer; 246 | /* 3 */ } 247 | 248 | /** 249 | * Re-set default cursor for disabled elements. 250 | */ 251 | button[disabled], 252 | html input[disabled] { 253 | cursor: default; } 254 | 255 | /** 256 | * Remove inner padding and border in Firefox 4+. 257 | */ 258 | button::-moz-focus-inner, 259 | input::-moz-focus-inner { 260 | border: 0; 261 | padding: 0; } 262 | 263 | /** 264 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 265 | * the UA stylesheet. 266 | */ 267 | input { 268 | line-height: normal; } 269 | 270 | /** 271 | * It's recommended that you don't attempt to style these elements. 272 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 273 | * 274 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 275 | * 2. Remove excess padding in IE 8/9/10. 276 | */ 277 | input[type="checkbox"], 278 | input[type="radio"] { 279 | box-sizing: border-box; 280 | /* 1 */ 281 | padding: 0; 282 | /* 2 */ } 283 | 284 | /** 285 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 286 | * `font-size` values of the `input`, it causes the cursor style of the 287 | * decrement button to change from `default` to `text`. 288 | */ 289 | input[type="number"]::-webkit-inner-spin-button, 290 | input[type="number"]::-webkit-outer-spin-button { 291 | height: auto; } 292 | 293 | /** 294 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 295 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 296 | * (include `-moz` to future-proof). 297 | */ 298 | input[type="search"] { 299 | -webkit-appearance: textfield; 300 | /* 1 */ 301 | -moz-box-sizing: content-box; 302 | -webkit-box-sizing: content-box; 303 | /* 2 */ 304 | box-sizing: content-box; } 305 | 306 | /** 307 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 308 | * Safari (but not Chrome) clips the cancel button when the search input has 309 | * padding (and `textfield` appearance). 310 | */ 311 | input[type="search"]::-webkit-search-cancel-button, 312 | input[type="search"]::-webkit-search-decoration { 313 | -webkit-appearance: none; } 314 | 315 | /** 316 | * Define consistent border, margin, and padding. 317 | */ 318 | fieldset { 319 | border: 1px solid #c0c0c0; 320 | margin: 0 2px; 321 | padding: 0.35em 0.625em 0.75em; } 322 | 323 | /** 324 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 325 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 326 | */ 327 | legend { 328 | border: 0; 329 | /* 1 */ 330 | padding: 0; 331 | /* 2 */ } 332 | 333 | /** 334 | * Remove default vertical scrollbar in IE 8/9/10/11. 335 | */ 336 | textarea { 337 | overflow: auto; } 338 | 339 | /** 340 | * Don't inherit the `font-weight` (applied by a rule above). 341 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 342 | */ 343 | optgroup { 344 | font-weight: bold; } 345 | 346 | /* Tables 347 | ========================================================================== */ 348 | /** 349 | * Remove most spacing between table cells. 350 | */ 351 | table { 352 | border-collapse: collapse; 353 | border-spacing: 0; } 354 | 355 | td, 356 | th { 357 | padding: 0; } 358 | 359 | /*$body-font: Georgia, Times New Roman, serif;*/ 360 | /* Subtlety of Hue */ 361 | body { 362 | font: 12pt Helvetica, Arial, sans-serif; 363 | line-height: 1.4em; 364 | background: #ecebf0; 365 | /*background: #f4f4f4;*/ 366 | padding-top: 1em; } 367 | 368 | a { 369 | color: #336699; 370 | text-decoration: none; } 371 | 372 | a:hover { 373 | color: #993366; } 374 | 375 | p:first-child { 376 | margin-top: 0; } 377 | p:last-child { 378 | margin-bottom: 0; } 379 | 380 | header, main, .footer { 381 | max-width: 800px; 382 | margin: 0 auto; } 383 | 384 | header { 385 | margin-bottom: 1em; } 386 | 387 | .footer { 388 | font-size: 0.8em; 389 | text-align: center; } 390 | 391 | ul#navigation { 392 | list-style-type: none; 393 | margin: 0; 394 | padding: 0; 395 | float: right; } 396 | ul#navigation li { 397 | display: inline-block; 398 | padding: 3px; } 399 | 400 | .button-link { 401 | text-align: center; 402 | margin: 1em 0; } 403 | .button-link a { 404 | padding: 0.5em; 405 | background-color: #353129; 406 | color: #ecebf0; 407 | border: 1px solid #ecebf0; 408 | border-radius: 4px; 409 | display: inline-block; } 410 | 411 | #fold { 412 | display: none; } 413 | 414 | #unfold-link { 415 | display: none; } 416 | 417 | article { 418 | margin-bottom: 1em; 419 | box-shadow: 0 0 2px #687d77; 420 | background-color: white; 421 | border-radius: 3px; 422 | padding: 0.5em; } 423 | article.reply-context { 424 | margin-bottom: 0; 425 | background-color: #f3f3f3; 426 | color: #555; } 427 | article.reply-context .content img { 428 | max-height: 240px; 429 | max-width: 240px; } 430 | article div { 431 | overflow: auto; 432 | /*p:first-child { 433 | margin-top: 0; 434 | } 435 | p:last-child { 436 | margin-bottom: 0; 437 | }*/ } 438 | article img, article video { 439 | max-width: 100%; } 440 | article header { 441 | color: #484a47; 442 | border-bottom: 1px solid #687d77; 443 | margin-bottom: 0.5em; 444 | overflow: auto; } 445 | article header img { 446 | vertical-align: middle; 447 | margin: inherit; 448 | display: inline; 449 | max-width: 1.2em; 450 | max-height: 1.2em; 451 | min-width: inherit; 452 | min-height: inherit; } 453 | article footer { 454 | margin-top: 0.5em; 455 | margin-bottom: 0; } 456 | article h1 { 457 | font-size: 1.2em; 458 | font-weight: bold; } 459 | 460 | label { 461 | font-weight: bold; 462 | display: inline-block; 463 | margin: 5px 0 2px 0; 464 | max-width: 100%; } 465 | 466 | textarea, input[type="text"], input[type="url"] { 467 | width: 100%; 468 | margin: 0.25em 0; 469 | background-image: none; 470 | border: 1px solid #ccc; 471 | border-radius: 4px; 472 | box-shadow: 0x 1px 1px rgba(0, 0, 0, 0.075) inset; } 473 | textarea.input-25, input[type="text"].input-25, input[type="url"].input-25 { 474 | width: 23%; } 475 | textarea.input-50, input[type="text"].input-50, input[type="url"].input-50 { 476 | width: 48%; } 477 | textarea.input-75, input[type="text"].input-75, input[type="url"].input-75 { 478 | width: 73%; } 479 | 480 | button { 481 | border: 1px solid #cccccc; 482 | background: #efefef; 483 | color: #003366; 484 | border-radius: 4px; 485 | padding: 4px 10px; 486 | margin: 5px 5px 5px 0; 487 | box-shadow: 0 1px 1px #ccc; } 488 | button:hover { 489 | background: white; } 490 | button:active { 491 | box-shadow: 0 0; 492 | margin: 6px 4px 4px 1px; } 493 | 494 | .micropub-form .content { 495 | height: 1em; 496 | width: calc(100% - 1.4em - 96px); 497 | padding: 3px; 498 | margin: 0; 499 | vertical-align: middle; } 500 | .micropub-form button { 501 | background-color: #eee; 502 | border-radius: 3px; 503 | vertical-align: middle; 504 | border: 0; } 505 | .micropub-form button.small { 506 | width: 24px; 507 | height: 24px; 508 | padding: 4px; 509 | line-height: 1; } 510 | .micropub-form .rsvps { 511 | text-align: center; } 512 | 513 | .syndication-toggle { 514 | display: inline-block; } 515 | .syndication-toggle input { 516 | display: none; } 517 | .syndication-toggle label { 518 | display: inline-block; 519 | vertical-align: middle; 520 | padding: 4px; 521 | border-radius: 3px; 522 | background-color: #eee; 523 | margin: 0; 524 | text-align: left; 525 | font-weight: normal; 526 | font-size: 1em; 527 | color: #666; 528 | cursor: pointer; } 529 | .syndication-toggle label img { 530 | max-height: 16px; 531 | max-width: 16px; } 532 | .syndication-toggle input:checked + label { 533 | background-color: #337AB7; 534 | color: #fff; } 535 | 536 | .reply-area { 537 | margin-top: 0.5em; } 538 | .reply-area .reply-link { 539 | display: inline-block; 540 | padding: 0.2em; 541 | border: 1px solid #687d77; 542 | border-radius: 4px; 543 | background-color: #ecebf0; 544 | text-decoration: none; 545 | color: #484a47; 546 | min-width: 50px; 547 | text-align: center; } 548 | 549 | @media only screen and (max-width: 800px) { 550 | article header img { 551 | vertical-align: text-middle; 552 | margin: inherit; 553 | display: inline; 554 | max-width: 1.2em; 555 | max-height: 1.2em; 556 | min-width: inherit; 557 | min-height: inherit; } } 558 | -------------------------------------------------------------------------------- /woodwind/tasks.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from flask import current_app, url_for 3 | from redis import StrictRedis 4 | from woodwind import util 5 | from woodwind.extensions import db 6 | from woodwind.models import Feed, Entry 7 | import sqlalchemy 8 | import bs4 9 | import datetime 10 | import feedparser 11 | import itertools 12 | import json 13 | import mf2py 14 | import mf2util 15 | import re 16 | import requests 17 | import rq 18 | import sys 19 | import time 20 | import traceback 21 | import urllib.parse 22 | 23 | # normal update interval for polling feeds 24 | UPDATE_INTERVAL = datetime.timedelta(hours=1) 25 | # update interval when polling feeds that are push verified 26 | UPDATE_INTERVAL_PUSH = datetime.timedelta(days=1) 27 | 28 | TWITTER_RE = re.compile( 29 | r'https?://(?:www\.|mobile\.)?twitter\.com/(\w+)/status(?:es)?/(\w+)') 30 | TAG_RE = re.compile(r']*?>') 31 | COMMENT_RE = re.compile(r'') 32 | JAM_RE = re.compile( 33 | '\s*\u266b (?:https?://)?[a-z0-9._\-]+\.[a-z]{2,9}(?:/\S*)?') 34 | 35 | AUDIO_ENCLOSURE_TMPL = '

' 37 | VIDEO_ENCLOSURE_TMPL = '

' 39 | 40 | redis = StrictRedis() 41 | q_high = rq.Queue('high', connection=redis) 42 | q = rq.Queue('low', connection=redis) 43 | 44 | 45 | _app = None 46 | 47 | class Mf2Fetcher: 48 | def __init__(self): 49 | self.cache = {} 50 | 51 | def __call__(self, url): 52 | if url in self.cache: 53 | return self.cache[url] 54 | p = mf2py.parse(url=url) 55 | self.cache[url] = p 56 | return p 57 | 58 | 59 | @contextmanager 60 | def flask_app(): 61 | global _app 62 | if _app is None: 63 | from woodwind import create_app 64 | _app = create_app() 65 | with _app.app_context(): 66 | try: 67 | yield _app 68 | except: 69 | _app.logger.exception('Unhandled exception') 70 | 71 | 72 | def tick(): 73 | """Checks all feeds to see if any of them are ready for an update. 74 | Makes use of uWSGI timers to run every 5 minutes, without needing 75 | a separate process to fire ticks. 76 | """ 77 | def should_update(feed, now): 78 | if not feed.last_checked: 79 | return True 80 | 81 | if not feed.subscriptions: 82 | return False 83 | 84 | if feed.failure_count > 8: 85 | update_interval = datetime.timedelta(days=1) 86 | elif feed.failure_count > 4: 87 | update_interval = datetime.timedelta(hours=8) 88 | elif feed.failure_count > 2: 89 | update_interval = datetime.timedelta(hours=4) 90 | else: 91 | update_interval = UPDATE_INTERVAL 92 | 93 | # PuSH feeds don't need to poll very frequently 94 | if feed.push_verified: 95 | update_interval = max(update_interval, UPDATE_INTERVAL_PUSH) 96 | 97 | return now - feed.last_checked > update_interval 98 | 99 | with flask_app(): 100 | now = datetime.datetime.utcnow() 101 | current_app.logger.info('Tick {}'.format(now)) 102 | for feed in Feed.query.all(): 103 | current_app.logger.debug( 104 | 'Feed %s last checked %s', feed, feed.last_checked) 105 | if should_update(feed, now): 106 | q.enqueue(update_feed, feed.id) 107 | 108 | 109 | def update_feed(feed_id, content=None, 110 | content_type=None, is_polling=True): 111 | 112 | def is_expected_content_type(feed_type): 113 | if not content_type: 114 | return True 115 | if feed_type == 'html': 116 | return content_type == 'text/html' 117 | if feed_type == 'xml': 118 | return content_type in [ 119 | 'application/rss+xml', 120 | 'application/atom+xml', 121 | 'application/rdf+xml', 122 | 'application/xml', 123 | 'text/xml', 124 | ] 125 | 126 | with flask_app() as app: 127 | feed = Feed.query.get(feed_id) 128 | current_app.logger.info('Updating {}'.format(str(feed)[:32])) 129 | 130 | now = datetime.datetime.utcnow() 131 | 132 | new_entries = [] 133 | updated_entries = [] 134 | reply_pairs = [] 135 | 136 | fetch_mf2 = Mf2Fetcher() 137 | try: 138 | if content and is_expected_content_type(feed.type): 139 | current_app.logger.info('using provided content. size=%d', 140 | len(content)) 141 | else: 142 | current_app.logger.info('fetching feed: %s', str(feed)[:32]) 143 | 144 | try: 145 | response = util.requests_get(feed.feed) 146 | except: 147 | feed.last_response = 'exception while retrieving: {}'.format( 148 | sys.exc_info()[0]) 149 | feed.failure_count += 1 150 | return 151 | 152 | if response.status_code // 100 != 2: 153 | current_app.logger.warn( 154 | 'bad response from %s. %r: %r', feed.feed, response, 155 | response.text) 156 | feed.last_response = 'bad response while retrieving: {}: {}'.format( 157 | response, response.text) 158 | feed.failure_count += 1 159 | return 160 | 161 | feed.failure_count = 0 162 | feed.last_response = 'success: {}'.format(response) 163 | 164 | if is_polling: 165 | check_push_subscription(feed, response) 166 | content = get_response_content(response) 167 | 168 | # backfill if this is the first pull 169 | backfill = len(feed.entries) == 0 170 | if feed.type == 'xml': 171 | result = process_xml_feed_for_new_entries( 172 | feed, content, backfill, now) 173 | elif feed.type == 'html': 174 | result = process_html_feed_for_new_entries( 175 | feed, content, backfill, now, fetch_mf2) 176 | else: 177 | result = [] 178 | 179 | # realize list, only look at the first 30 entries 180 | result = list(itertools.islice(result, 30)) 181 | 182 | old_entries = {} 183 | all_uids = [e.uid for e in result] 184 | if all_uids: 185 | for entry in (Entry.query 186 | .filter(Entry.feed == feed, 187 | Entry.uid.in_(all_uids)) 188 | .order_by(Entry.id.desc())): 189 | old_entries[entry.uid] = entry 190 | 191 | for entry in result: 192 | old = old_entries.get(entry.uid) 193 | current_app.logger.debug( 194 | 'entry for uid %s: %s', entry.uid, 195 | 'found' if old else 'not found') 196 | 197 | # have we seen this post before 198 | if not old: 199 | current_app.logger.debug('this is a new post, saving a new entry') 200 | # set a default value for published if none is provided 201 | entry.published = entry.published or now 202 | in_reply_tos = entry.get_property('in-reply-to', []) 203 | db.session.add(entry) 204 | feed.entries.append(entry) 205 | 206 | new_entries.append(entry) 207 | for irt in in_reply_tos: 208 | reply_pairs.append((entry, irt)) 209 | 210 | elif not is_content_equal(old, entry): 211 | current_app.logger.debug('this post content has changed, updating entry') 212 | 213 | entry.published = entry.published or old.published 214 | in_reply_tos = entry.get_property('in-reply-to', []) 215 | # we're updating an old entriy, use the original 216 | # retrieved time 217 | entry.retrieved = old.retrieved 218 | old.feed = None # feed.entries.remove(old) 219 | # punt on deleting for now, learn about cascade 220 | # and stuff later 221 | # session.delete(old) 222 | db.session.add(entry) 223 | feed.entries.append(entry) 224 | 225 | updated_entries.append(entry) 226 | for irt in in_reply_tos: 227 | reply_pairs.append((entry, irt)) 228 | 229 | else: 230 | current_app.logger.debug( 231 | 'skipping previously seen post %s', old.permalink) 232 | 233 | fetch_reply_contexts(reply_pairs, now, fetch_mf2) 234 | db.session.commit() 235 | except: 236 | db.session.rollback() 237 | raise 238 | 239 | finally: 240 | if is_polling: 241 | feed.last_checked = now 242 | if new_entries or updated_entries: 243 | feed.last_updated = now 244 | db.session.commit() 245 | 246 | if new_entries: 247 | notify_feed_updated(app, feed_id, new_entries) 248 | 249 | 250 | def check_push_subscription(feed, response): 251 | def send_request(mode, hub, topic): 252 | hub = urllib.parse.urljoin(feed.feed, hub) 253 | topic = urllib.parse.urljoin(feed.feed, topic) 254 | callback = url_for('push.notify', feed_id=feed.id, _external=True) 255 | current_app.logger.debug( 256 | 'sending %s request for hub=%r, topic=%r, callback=%r', 257 | mode, hub, topic, callback) 258 | r = requests.post(hub, data={ 259 | 'hub.mode': mode, 260 | 'hub.topic': topic, 261 | 'hub.callback': callback, 262 | 'hub.secret': feed.get_or_create_push_secret(), 263 | 'hub.verify': 'sync', # backcompat with 0.3 264 | }) 265 | current_app.logger.debug('%s response %r', mode, r) 266 | 267 | expiry = feed.push_expiry 268 | old_hub = feed.push_hub 269 | old_topic = feed.push_topic 270 | hub = response.links.get('hub', {}).get('url') 271 | topic = response.links.get('self', {}).get('url') 272 | 273 | current_app.logger.debug('link headers. links=%s, hub=%s, topic=%s', 274 | response.links, hub, topic) 275 | if not hub or not topic: 276 | # try to find link rel elements 277 | if feed.type == 'html': 278 | soup = bs4.BeautifulSoup(get_response_content(response)) 279 | if not hub: 280 | hub_link = soup.find('link', rel='hub') 281 | hub = hub_link and hub_link.get('href') 282 | if not topic: 283 | self_link = soup.find('link', rel='self') 284 | topic = self_link and self_link.get('href') 285 | elif feed.type == 'xml': 286 | parsed = feedparser.parse(get_response_content(response)) 287 | links = parsed.feed.get('links') 288 | if links: 289 | if not hub: 290 | hub = next((link['href'] for link in links 291 | if 'hub' in link['rel']), None) 292 | if not topic: 293 | topic = next((link['href'] for link in links 294 | if 'self' in link['rel']), None) 295 | 296 | if ((expiry and expiry - datetime.datetime.utcnow() 297 | <= UPDATE_INTERVAL_PUSH) 298 | or hub != old_hub or topic != old_topic or not feed.push_verified): 299 | current_app.logger.debug('push subscription expired or hub/topic changed') 300 | 301 | feed.push_hub = hub 302 | feed.push_topic = topic 303 | feed.push_verified = False 304 | feed.push_expiry = None 305 | db.session.commit() 306 | 307 | if old_hub and old_topic and hub != old_hub and topic != old_topic: 308 | current_app.logger.debug('unsubscribing hub=%s, topic=%s', old_hub, old_topic) 309 | send_request('unsubscribe', old_hub, old_topic) 310 | 311 | if hub and topic: 312 | current_app.logger.debug('subscribing hub=%s, topic=%s', hub, topic) 313 | send_request('subscribe', hub, topic) 314 | 315 | db.session.commit() 316 | 317 | 318 | def notify_feed_updated(app, feed_id, entries): 319 | """Render the new entries and publish them to redis 320 | """ 321 | from flask import render_template 322 | import flask.ext.login as flask_login 323 | current_app.logger.debug('notifying feed updated: %s', feed_id) 324 | 325 | feed = Feed.query.get(feed_id) 326 | for s in feed.subscriptions: 327 | with app.test_request_context(): 328 | flask_login.login_user(s.user, remember=True) 329 | rendered = [] 330 | for e in entries: 331 | e.subscription = s 332 | rendered.append(render_template('_entry.jinja2', entry=e)) 333 | 334 | message = json.dumps({ 335 | 'user': s.user.id, 336 | 'feed': feed.id, 337 | 'subscription': s.id, 338 | 'entries': rendered, 339 | }) 340 | 341 | topics = [] 342 | if not s.exclude: 343 | topics.append('user:{}'.format(s.user.id)) 344 | topics.append('subsc:{}'.format(s.id)) 345 | 346 | for topic in topics: 347 | redis.publish('woodwind_notify:{}'.format(topic), message) 348 | 349 | 350 | def is_content_equal(e1, e2): 351 | """The criteria for determining if an entry that we've seen before 352 | has been updated. If any of these fields have changed, we'll scrub the 353 | old entry and replace it with the updated one. 354 | """ 355 | def normalize(content): 356 | """Strip HTML tags, added to prevent a specific case where Wordpress 357 | syntax highlighting (crayon) generates slightly different 358 | markup every time it's called. 359 | """ 360 | if content: 361 | content = TAG_RE.sub('', content) 362 | content = COMMENT_RE.sub('', content) 363 | return content 364 | 365 | return ( 366 | e1.title == e2.title and 367 | normalize(e1.content) == normalize(e2.content) and 368 | e1.author_name == e2.author_name and 369 | e1.author_url == e2.author_url and 370 | e1.author_photo == e2.author_photo and 371 | e1.properties == e2.properties and 372 | e1.published == e2.published and 373 | e1.updated == e2.updated and 374 | e1.deleted == e2.deleted 375 | ) 376 | 377 | 378 | def process_xml_feed_for_new_entries(feed, content, backfill, now): 379 | current_app.logger.debug('fetching xml feed: %s', str(feed)[:32]) 380 | parsed = feedparser.parse(content, response_headers={ 381 | 'content-location': feed.feed, 382 | }) 383 | feed_props = parsed.get('feed', {}) 384 | default_author_url = feed_props.get('author_detail', {}).get('href') 385 | default_author_name = feed_props.get('author_detail', {}).get('name') 386 | default_author_photo = feed_props.get('logo') 387 | 388 | current_app.logger.debug('found %d entries', len(parsed.entries)) 389 | 390 | # work from the bottom up (oldest first, usually) 391 | for p_entry in reversed(parsed.entries): 392 | current_app.logger.debug('processing entry %s', str(p_entry)[:32]) 393 | permalink = p_entry.get('link') 394 | uid = p_entry.get('id') or permalink 395 | 396 | if not uid: 397 | continue 398 | 399 | if 'updated_parsed' in p_entry and p_entry.updated_parsed: 400 | try: 401 | updated = datetime.datetime.fromtimestamp( 402 | time.mktime(p_entry.updated_parsed)) 403 | except: 404 | current_app.logger.debug('mktime failed with updated timestamp: %v', p_entry.updated_parsed) 405 | else: 406 | updated = None 407 | 408 | if 'published_parsed' in p_entry and p_entry.published_parsed: 409 | try: 410 | published = datetime.datetime.fromtimestamp( 411 | time.mktime(p_entry.published_parsed)) 412 | except: 413 | current_app.logger.debug('mktime failed with published timestamp: %v', p_entry.published_parsed) 414 | published = updated 415 | else: 416 | published = updated 417 | 418 | retrieved = now 419 | if backfill and published: 420 | retrieved = published 421 | 422 | title = p_entry.get('title') 423 | 424 | content = None 425 | content_list = p_entry.get('content') 426 | if content_list: 427 | content = content_list[0].value 428 | else: 429 | content = p_entry.get('summary') 430 | 431 | if title and content: 432 | title_trimmed = title.rstrip('...').rstrip('…') 433 | if content.startswith(title_trimmed): 434 | title = None 435 | 436 | for link in p_entry.get('links', []): 437 | link_type = link.get('type') 438 | if link_type in ['audio/mpeg', 'audio/mp3']: 439 | audio = AUDIO_ENCLOSURE_TMPL.format(href=link.get('href')) 440 | content = (content or '') + audio 441 | if link_type in ['video/x-m4v', 'video/x-mp4', 'video/mp4']: 442 | video = VIDEO_ENCLOSURE_TMPL.format(href=link.get('href')) 443 | content = (content or '') + video 444 | 445 | yield Entry( 446 | published=published, 447 | updated=updated, 448 | uid=uid, 449 | permalink=permalink, 450 | retrieved=retrieved, 451 | title=p_entry.get('title'), 452 | content=content, 453 | content_cleaned=util.clean(content), 454 | author_name=p_entry.get('author_detail', {}).get('name') or 455 | default_author_name, 456 | author_url=p_entry.get('author_detail', {}).get('href') or 457 | default_author_url, 458 | author_photo=default_author_photo or 459 | fallback_photo(feed.origin)) 460 | 461 | 462 | def process_html_feed_for_new_entries(feed, content, backfill, now, fetch_mf2_func): 463 | # strip noscript tags before parsing, since we definitely aren't 464 | # going to preserve js 465 | content = re.sub(']*>', '', content, flags=re.IGNORECASE) 466 | 467 | # look for a element 468 | doc = bs4.BeautifulSoup(content, 'html5lib') 469 | base_el = doc.find('base') 470 | base_href = base_el.get('href') if base_el else None 471 | 472 | parsed = mf2util.interpret_feed( 473 | mf2py.parse(doc, feed.feed), 474 | source_url=feed.feed, base_href=base_href, 475 | fetch_mf2_func=fetch_mf2_func) 476 | hfeed = parsed.get('entries', []) 477 | 478 | for hentry in hfeed: 479 | current_app.logger.debug('building entry: %s', hentry.get('url')) 480 | entry = hentry_to_entry(hentry, feed, backfill, now) 481 | if entry: 482 | current_app.logger.debug('built entry: %s', entry.permalink) 483 | yield entry 484 | 485 | 486 | def hentry_to_entry(hentry, feed, backfill, now): 487 | def normalize_datetime(dt): 488 | if (dt and hasattr(dt, 'year') and hasattr(dt, 'month') and 489 | hasattr(dt, 'day')): 490 | # make sure published is in UTC and strip the timezone 491 | if hasattr(dt, 'tzinfo') and dt.tzinfo: 492 | return dt.astimezone(datetime.timezone.utc).replace( 493 | tzinfo=None) 494 | # convert datetime.date to datetime.datetime 495 | elif not hasattr(dt, 'hour'): 496 | return datetime.datetime(year=dt.year, month=dt.month, 497 | day=dt.day) 498 | 499 | permalink = url = hentry.get('url') 500 | uid = hentry.get('uid') or url 501 | if not uid: 502 | return 503 | 504 | # hentry = mf2util.interpret(mf2py.Parser(url=url).to_dict(), url) 505 | # permalink = hentry.get('url') or url 506 | # uid = hentry.get('uid') or uid 507 | 508 | # TODO repost = next(iter(hentry.get('repost-of', [])), None) 509 | 510 | title = hentry.get('name') 511 | content = hentry.get('content') 512 | summary = hentry.get('summary') 513 | 514 | if not content and summary: 515 | content = '{}

Read more'.format( 516 | summary, permalink) 517 | 518 | if not content and hentry.get('type') == 'entry': 519 | content = title 520 | title = None 521 | 522 | published = normalize_datetime(hentry.get('published')) 523 | updated = normalize_datetime(hentry.get('updated')) 524 | deleted = normalize_datetime(hentry.get('deleted')) 525 | 526 | # retrieved time is now unless we're backfilling old posts 527 | retrieved = now 528 | if backfill and published and published < retrieved: 529 | retrieved = published 530 | 531 | author = hentry.get('author', {}) 532 | author_name = author.get('name') 533 | author_photo = author.get('photo') 534 | author_url = author.get('url') 535 | 536 | if author_name and len(author_name) > Entry.author_name.property.columns[0].type.length: 537 | author_name = None 538 | if author_photo and len(author_photo) > Entry.author_photo.property.columns[0].type.length: 539 | author_photo = None 540 | if author_url and len(author_url) > Entry.author_url.property.columns[0].type.length: 541 | author_url = None 542 | 543 | entry = Entry( 544 | uid=uid, 545 | retrieved=retrieved, 546 | permalink=permalink, 547 | published=published, 548 | updated=updated, 549 | deleted=deleted, 550 | title=title, 551 | content=content, 552 | content_cleaned=util.clean(content), 553 | author_name=author_name, 554 | author_photo=author_photo or (feed and fallback_photo(feed.origin)), 555 | author_url=author_url) 556 | 557 | # complex properties, convert from list of complex objects to a 558 | # list of URLs 559 | for prop in ('in-reply-to', 'like-of', 'repost-of'): 560 | values = hentry.get(prop) 561 | if values: 562 | entry.set_property(prop, [value['url'] for value in values 563 | if 'url' in value]) 564 | 565 | # simple properties, just transfer them over wholesale 566 | for prop in ('syndication', 'location', 'photo'): 567 | value = hentry.get(prop) 568 | if value: 569 | entry.set_property(prop, value) 570 | 571 | if 'start-str' in hentry: 572 | entry.set_property('start', hentry.get('start-str')) 573 | 574 | if 'end-str' in hentry: 575 | entry.set_property('end', hentry.get('end-str')) 576 | 577 | # set a flag for events so we can show RSVP buttons 578 | if hentry.get('type') == 'event': 579 | entry.set_property('event', True) 580 | 581 | # does it look like a jam? 582 | plain = hentry.get('content-plain') 583 | if plain and JAM_RE.match(plain): 584 | entry.set_property('jam', True) 585 | 586 | current_app.logger.debug('entry properties %s', entry.properties) 587 | return entry 588 | 589 | 590 | def fetch_reply_contexts(reply_pairs, now, fetch_mf2_func): 591 | old_contexts = {} 592 | in_reply_tos = [url for _, url in reply_pairs] 593 | if in_reply_tos: 594 | for entry in (Entry.query 595 | .join(Entry.feed) 596 | .filter(Entry.permalink.in_(in_reply_tos), 597 | Feed.type == 'html')): 598 | old_contexts[entry.permalink] = entry 599 | 600 | for entry, in_reply_to in reply_pairs: 601 | context = old_contexts.get(in_reply_to) 602 | if not context: 603 | current_app.logger.info('fetching in-reply-to: %s', in_reply_to) 604 | try: 605 | proxied_reply_url = proxy_url(in_reply_to) 606 | parsed = mf2util.interpret( 607 | mf2py.parse(url=proxied_reply_url), in_reply_to, 608 | fetch_mf2_func=fetch_mf2_func) 609 | if parsed: 610 | context = hentry_to_entry(parsed, None, False, now) 611 | except requests.exceptions.RequestException as err: 612 | current_app.logger.warn( 613 | '%s fetching reply context: %s for entry: %s', 614 | type(err).__name__, proxied_reply_url, entry.permalink) 615 | 616 | if context: 617 | db.session.add(context) 618 | entry.reply_context.append(context) 619 | 620 | 621 | def proxy_url(url): 622 | if ('TWITTER_AU_KEY' in current_app.config and 623 | 'TWITTER_AU_SECRET' in current_app.config): 624 | # swap out the a-u url for twitter urls 625 | match = TWITTER_RE.match(url) 626 | if match: 627 | proxy_url = ( 628 | 'https://twitter-activitystreams.appspot.com/@me/@all/@app/{}?' 629 | .format(match.group(2)) + urllib.parse.urlencode({ 630 | 'format': 'html', 631 | 'access_token_key': 632 | current_app.config['TWITTER_AU_KEY'], 633 | 'access_token_secret': 634 | current_app.config['TWITTER_AU_SECRET'], 635 | })) 636 | current_app.logger.debug('proxied twitter url %s', proxy_url) 637 | return proxy_url 638 | return url 639 | 640 | 641 | def fallback_photo(url): 642 | """Use favatar to find an appropriate photo for any URL""" 643 | domain = urllib.parse.urlparse(url).netloc 644 | return 'http://www.google.com/s2/favicons?domain=' + domain 645 | 646 | 647 | def get_response_content(response): 648 | # if no charset is provided in the headers, figure out the 649 | # encoding from the content 650 | if 'charset' not in response.headers.get('content-type', ''): 651 | encodings = requests.utils.get_encodings_from_content(response.text) 652 | if encodings: 653 | response.encoding = encodings[0] 654 | return response.text 655 | -------------------------------------------------------------------------------- /woodwind/views.py: -------------------------------------------------------------------------------- 1 | from . import tasks, util 2 | from .extensions import db, login_mgr, micropub 3 | from .models import Feed, Entry, User, Subscription 4 | import flask.ext.login as flask_login 5 | 6 | import base64 7 | import bs4 8 | import datetime 9 | import feedparser 10 | import flask 11 | import hashlib 12 | import hmac 13 | import mf2py 14 | import mf2util 15 | import pyquerystring 16 | import requests 17 | import re 18 | import urllib 19 | import sqlalchemy 20 | import sqlalchemy.sql.expression 21 | 22 | IMAGE_TAG_RE = re.compile(r']*) src="(https?://[^">]+)"') 23 | 24 | 25 | views = flask.Blueprint('views', __name__) 26 | 27 | @views.route('/offline') 28 | def offline(): 29 | return flask.render_template('offline.jinja2') 30 | 31 | 32 | @views.route('/') 33 | def index(): 34 | page = int(flask.request.args.get('page', 1)) 35 | entry_tups = [] 36 | ws_topic = None 37 | solo = False 38 | all_tags = set() 39 | now = datetime.datetime.now() 40 | 41 | if flask_login.current_user.is_authenticated: 42 | for subsc in flask_login.current_user.subscriptions: 43 | if subsc.tags: 44 | all_tags.update(subsc.tags.split()) 45 | 46 | per_page = flask.current_app.config.get('PER_PAGE', 30) 47 | offset = (page - 1) * per_page 48 | 49 | entry_query = db.session.query(Entry, Subscription)\ 50 | .options( 51 | sqlalchemy.orm.subqueryload(Entry.feed), 52 | sqlalchemy.orm.subqueryload(Entry.reply_context))\ 53 | .join(Entry.feed)\ 54 | .join(Feed.subscriptions)\ 55 | .join(Subscription.user)\ 56 | .filter(User.id == flask_login.current_user.id)\ 57 | .filter(db.or_(Entry.deleted == None, 58 | Entry.deleted >= now))\ 59 | .order_by(Entry.published.desc()) 60 | 61 | if 'entry' in flask.request.args: 62 | entry_url = flask.request.args.get('entry') 63 | entry_tup = entry_query.filter(Entry.permalink == entry_url)\ 64 | .order_by(Entry.retrieved.desc())\ 65 | .first() 66 | if not entry_tup: 67 | flask.abort(404) 68 | entry_tups = [entry_tup] 69 | solo = True 70 | else: 71 | if 'tag' in flask.request.args: 72 | tag = flask.request.args.get('tag') 73 | entry_query = entry_query.filter( 74 | Subscription.tags.like('%{}%'.format(tag))) 75 | elif 'subscription' in flask.request.args: 76 | subsc_id = flask.request.args.get('subscription') 77 | subsc = Subscription.query.get(subsc_id) 78 | if not subsc: 79 | flask.abort(404) 80 | entry_query = entry_query.filter(Subscription.id == subsc_id) 81 | ws_topic = 'subsc:{}'.format(subsc.id) 82 | elif 'jam' in flask.request.args: 83 | entry_query = entry_query.filter( 84 | sqlalchemy.sql.expression.cast(Entry.properties['jam'], sqlalchemy.TEXT) == 'true') 85 | else: 86 | entry_query = entry_query.filter(Subscription.exclude == False) 87 | ws_topic = 'user:{}'.format(flask_login.current_user.id) 88 | 89 | entry_query = entry_query.order_by(Entry.retrieved.desc(), 90 | Entry.published.desc())\ 91 | .offset(offset).limit(per_page) 92 | entry_tups = entry_query.all() 93 | 94 | # stick the subscription into the entry. 95 | # FIXME this is hacky 96 | entries = [] 97 | for entry, subsc in entry_tups: 98 | entry.subscription = subsc 99 | entries.append(entry) 100 | 101 | entries = dedupe_copies(entries) 102 | resp = flask.make_response( 103 | flask.render_template('feed.jinja2', entries=entries, page=page, 104 | ws_topic=ws_topic, solo=solo, 105 | all_tags=all_tags)) 106 | resp.headers['Cache-control'] = 'max-age=0' 107 | return resp 108 | 109 | 110 | @views.route('/install') 111 | def install(): 112 | db.create_all() 113 | return 'Success!' 114 | 115 | 116 | @views.route('/subscriptions') 117 | @flask_login.login_required 118 | def subscriptions(): 119 | subscs = Subscription\ 120 | .query\ 121 | .filter_by(user_id=flask_login.current_user.id)\ 122 | .options(sqlalchemy.orm.subqueryload(Subscription.feed))\ 123 | .order_by(db.func.lower(Subscription.name))\ 124 | .all() 125 | 126 | return flask.render_template('subscriptions.jinja2', 127 | subscriptions=subscs) 128 | 129 | 130 | @views.route('/subscriptions_opml.xml') 131 | @flask_login.login_required 132 | def subscriptions_opml(): 133 | subscs = Subscription\ 134 | .query\ 135 | .filter_by(user_id=flask_login.current_user.id)\ 136 | .options(sqlalchemy.orm.subqueryload(Subscription.feed))\ 137 | .order_by(db.func.lower(Subscription.name))\ 138 | .all() 139 | template = flask.render_template('subscriptions_opml.xml', 140 | subscriptions=subscs) 141 | response = flask.make_response(template) 142 | response.headers['Content-Type'] = 'application/xml' 143 | return response 144 | 145 | @views.route('/settings', methods=['GET', 'POST']) 146 | @flask_login.login_required 147 | def settings(): 148 | settings = flask_login.current_user.settings or {} 149 | if flask.request.method == 'GET': 150 | return flask.render_template('settings.jinja2', settings=settings) 151 | 152 | settings = dict(settings) 153 | reply_method = flask.request.form.get('reply-method') 154 | settings['reply-method'] = reply_method 155 | flask_login.current_user.settings = settings 156 | db.session.commit() 157 | 158 | next_page = '.settings' 159 | if reply_method == 'micropub': 160 | next_page = '.settings_micropub' 161 | elif reply_method == 'indie-config': 162 | next_page = '.settings_indie_config' 163 | elif reply_method == 'action-urls': 164 | next_page = '.settings_action_urls' 165 | 166 | return flask.redirect(flask.url_for(next_page)) 167 | 168 | 169 | @views.route('/settings/micropub') 170 | @flask_login.login_required 171 | def settings_micropub(): 172 | settings = flask_login.current_user.settings or {} 173 | return flask.render_template('settings_micropub.jinja2', settings=settings) 174 | 175 | 176 | @views.route('/settings/indie-config', methods=['GET', 'POST']) 177 | @flask_login.login_required 178 | def settings_indie_config(): 179 | settings = flask_login.current_user.settings or {} 180 | 181 | if flask.request.method == 'GET': 182 | return flask.render_template('settings_indie_config.jinja2', 183 | settings=settings) 184 | 185 | settings = dict(settings) 186 | settings['indie-config-actions'] = flask.request.form.getlist( 187 | 'indie-config-action') 188 | flask_login.current_user.settings = settings 189 | print('new settings: ', settings) 190 | db.session.commit() 191 | return flask.redirect(flask.url_for('.index')) 192 | 193 | 194 | @views.route('/settings/action-urls', methods=['GET', 'POST']) 195 | @flask_login.login_required 196 | def settings_action_urls(): 197 | settings = flask_login.current_user.settings or {} 198 | if flask.request.method == 'GET': 199 | return flask.render_template('settings_action_urls.jinja2', 200 | settings=settings) 201 | 202 | settings = dict(settings) 203 | zipped = zip( 204 | flask.request.form.getlist('action'), 205 | flask.request.form.getlist('action-url')) 206 | settings['action-urls'] = [[k, v] for k, v in zipped if k and v] 207 | flask_login.current_user.settings = settings 208 | db.session.commit() 209 | return flask.redirect(flask.url_for('.index')) 210 | 211 | 212 | @views.route('/update_feed', methods=['POST']) 213 | @flask_login.login_required 214 | def update_feed(): 215 | feed_id = flask.request.form.get('id') 216 | tasks.q.enqueue(tasks.update_feed, feed_id) 217 | return flask.redirect(flask.url_for('.subscriptions')) 218 | 219 | 220 | @views.route('/update_all', methods=['POST']) 221 | @flask_login.login_required 222 | def update_all(): 223 | for s in flask_login.current_user.subscriptions: 224 | tasks.q.enqueue(tasks.update_feed, s.feed.id) 225 | return flask.redirect(flask.url_for('.subscriptions')) 226 | 227 | 228 | @views.route('/unsubscribe', methods=['POST']) 229 | @flask_login.login_required 230 | def unsubscribe(): 231 | subsc_id = flask.request.form.get('id') 232 | subsc = Subscription.query.get(subsc_id) 233 | db.session.delete(subsc) 234 | db.session.commit() 235 | flask.flash('Unsubscribed {}'.format(subsc.name)) 236 | return flask.redirect(flask.url_for('.subscriptions')) 237 | 238 | 239 | @views.route('/edit_subscription', methods=['POST']) 240 | @flask_login.login_required 241 | def edit_subscription(): 242 | subsc_id = flask.request.form.get('id') 243 | subsc_name = flask.request.form.get('name') 244 | subsc_tags = flask.request.form.get('tags') 245 | 246 | subsc = Subscription.query.get(subsc_id) 247 | if subsc_name: 248 | subsc.name = subsc_name 249 | if subsc_tags: 250 | tag_list = re.split(r'(?:\s|,)+', subsc_tags) 251 | subsc.tags = ' '.join(t.strip() for t in tag_list if t.strip()) 252 | else: 253 | subsc.tags = None 254 | subsc.exclude = flask.request.form.get('exclude') == 'true' 255 | 256 | db.session.commit() 257 | flask.flash('Edited {}'.format(subsc.name)) 258 | return flask.redirect(flask.url_for('.subscriptions')) 259 | 260 | 261 | @views.route('/logout') 262 | def logout(): 263 | flask_login.logout_user() 264 | return flask.redirect(flask.url_for('.index')) 265 | 266 | 267 | @views.route('/login', methods=['POST']) 268 | def login(): 269 | me = flask.request.form.get('me') 270 | if not me or me == 'http://': 271 | flask.flash('Sign in with your personal web address.') 272 | return flask.redirect(flask.url_for('.index')) 273 | 274 | return micropub.authenticate( 275 | me=me, next_url=flask.request.form.get('next')) 276 | 277 | 278 | @views.route('/login-callback') 279 | @micropub.authenticated_handler 280 | def login_callback(resp): 281 | if not resp.me: 282 | flask.flash(util.html_escape('Login error: ' + resp.error)) 283 | return flask.redirect(flask.url_for('.index')) 284 | 285 | if resp.error: 286 | flask.flash(util.html_escape('Warning: ' + resp.error)) 287 | 288 | user = load_user(resp.me) 289 | if not user: 290 | user = User(url=resp.me) 291 | db.session.add(user) 292 | 293 | db.session.commit() 294 | flask_login.login_user(user, remember=True) 295 | update_micropub_syndicate_to() 296 | return flask.redirect(resp.next_url or flask.url_for('.index')) 297 | 298 | 299 | @views.route('/authorize') 300 | @flask_login.login_required 301 | def authorize(): 302 | return micropub.authorize( 303 | me=flask_login.current_user.url, 304 | next_url=flask.request.args.get('next'), 305 | scope='post') 306 | 307 | 308 | @views.route('/micropub-callback') 309 | @micropub.authorized_handler 310 | def micropub_callback(resp): 311 | if not resp.me or resp.error: 312 | flask.flash(util.html_escape('Authorize error: ' + resp.error)) 313 | return flask.redirect(flask.url_for('.index')) 314 | 315 | user = load_user(resp.me) 316 | if not user: 317 | flask.flash(util.html_escape('Unknown user for url: ' + resp.me)) 318 | return flask.redirect(flask.url_for('.index')) 319 | 320 | user.micropub_endpoint = resp.micropub_endpoint 321 | user.access_token = resp.access_token 322 | db.session.commit() 323 | update_micropub_syndicate_to() 324 | 325 | flask.flash('Logged in as ' + user.url) 326 | return flask.redirect(resp.next_url or flask.url_for('.index')) 327 | 328 | 329 | @flask_login.login_required 330 | @views.route('/micropub-update') 331 | def micropub_update(): 332 | update_micropub_syndicate_to() 333 | 334 | syndicate_to = flask_login.current_user.get_setting('syndicate-to', []) 335 | 336 | flask.flash('Updated syndication targets: {}'.format(', '.join([ 337 | t.get('name') if isinstance(t, dict) else t for t in syndicate_to]))) 338 | 339 | return flask.redirect(flask.request.args.get('next') 340 | or flask.url_for('.index')) 341 | 342 | 343 | @flask_login.login_required 344 | def update_micropub_syndicate_to(): 345 | 346 | def adapt_expanded(targets): 347 | """Backcompat support for old-style "syndicate-to-expanded" properties, 348 | e.g., 349 | { 350 | "id": "twitter::kylewmahan", 351 | "name": "@kylewmahan", 352 | "service": "Twitter" 353 | } 354 | """ 355 | if targets: 356 | return [{ 357 | 'uid': t.get('id'), 358 | 'name': '{} on {}'.format(t.get('name'), t.get('service')), 359 | } for t in targets] 360 | return targets 361 | 362 | endpt = flask_login.current_user.micropub_endpoint 363 | token = flask_login.current_user.access_token 364 | if not endpt or not token: 365 | return 366 | resp = util.requests_get(endpt, params={ 367 | 'q': 'syndicate-to', 368 | }, headers={ 369 | 'Authorization': 'Bearer ' + token, 370 | 'Accept': 'application/json', 371 | }) 372 | if resp.status_code // 100 != 2: 373 | flask.current_app.logger.warn( 374 | 'Unexpected response querying micropub endpoint %s: %s', 375 | resp, resp.text) 376 | return 377 | 378 | flask.current_app.logger.debug('syndicate-to response: %s %s', 379 | resp, resp.text) 380 | 381 | content_type = resp.headers['content-type'] 382 | if content_type: 383 | content_type = content_type.split(';', 1)[0] 384 | 385 | try: 386 | if content_type == 'application/json': 387 | blob = resp.json() 388 | syndicate_tos = adapt_expanded(blob.get('syndicate-to-expanded')) 389 | if not syndicate_tos: 390 | syndicate_tos = blob.get('syndicate-to') 391 | 392 | else: # try to parse query string 393 | syndicate_tos = pyquerystring.parse(resp.text).get('syndicate-to', []) 394 | if isinstance(syndicate_tos, list): 395 | syndicate_tos = list(syndicate_tos) 396 | 397 | flask_login.current_user.set_setting('syndicate-to', syndicate_tos) 398 | db.session.commit() 399 | except ValueError as e: 400 | flask.flash('Could not parse syndicate-to response: {}'.format(e)) 401 | 402 | 403 | @views.route('/deauthorize') 404 | @flask_login.login_required 405 | def deauthorize(): 406 | flask_login.current_user.micropub_endpoint = None 407 | flask_login.current_user.access_token = None 408 | db.session.commit() 409 | return flask.redirect(flask.request.args.get('next') 410 | or flask.url_for('.index')) 411 | 412 | 413 | @login_mgr.user_loader 414 | def load_user(url): 415 | alt = url.rstrip('/') if url.endswith('/') else url + '/' 416 | return User.query.filter( 417 | (User.url == url) | (User.url == alt)).first() 418 | 419 | 420 | @views.route('/subscribe', methods=['GET', 'POST']) 421 | @flask_login.login_required 422 | def subscribe(): 423 | origin = (flask.request.form.get('origin') 424 | or flask.request.args.get('origin')) 425 | if origin: 426 | type = None 427 | feed = None 428 | typed_feed = flask.request.form.get('feed') 429 | if typed_feed: 430 | type, feed = typed_feed.split('|', 1) 431 | else: 432 | feeds = find_possible_feeds(origin) 433 | if not feeds: 434 | flask.flash('No feeds found for: ' + origin) 435 | return flask.redirect(flask.url_for('.index')) 436 | if len(feeds) > 1: 437 | return flask.render_template( 438 | 'select-feed.jinja2', origin=origin, feeds=feeds) 439 | feed = feeds[0]['feed'] 440 | type = feeds[0]['type'] 441 | new_feed = add_subscription(origin, feed, type) 442 | flask.flash('Successfully subscribed to: {}'.format(new_feed.name)) 443 | return flask.redirect(flask.url_for('.index')) 444 | 445 | if flask.request.method == 'POST': 446 | flask.abort(400) 447 | 448 | return flask.render_template('subscribe.jinja2') 449 | 450 | 451 | def add_subscription(origin, feed_url, type, tags=None): 452 | feed = Feed.query.filter_by(feed=feed_url, type=type).first() 453 | 454 | if not feed: 455 | name = None 456 | if type == 'html': 457 | flask.current_app.logger.debug('mf2py parsing %s', feed_url) 458 | resp = util.requests_get(feed_url) 459 | feed_text = resp.text if 'charset' in resp.headers.get('content-type', '') else resp.content 460 | parsed = mf2util.interpret_feed( 461 | mf2py.parse(doc=feed_text, url=feed_url), feed_url) 462 | name = parsed.get('name') 463 | elif type == 'xml': 464 | flask.current_app.logger.debug('feedparser parsing %s', feed_url) 465 | parsed = feedparser.parse(feed_url, agent=util.USER_AGENT) 466 | if parsed.feed: 467 | name = parsed.feed.get('title') 468 | else: 469 | flask.current_app.logger.error('unknown feed type %s', type) 470 | flask.abort(400) 471 | 472 | if not name: 473 | p = urllib.parse.urlparse(origin) 474 | name = p.netloc + p.path 475 | feed = Feed(name=name[:140], origin=origin, feed=feed_url, type=type) 476 | 477 | if feed: 478 | db.session.add(feed) 479 | 480 | flask_login.current_user.subscriptions.append( 481 | Subscription(feed=feed, name=feed.name, tags=tags)) 482 | 483 | db.session.commit() 484 | # go ahead and update the fed 485 | tasks.q.enqueue(tasks.update_feed, feed.id) 486 | return feed 487 | 488 | 489 | def find_possible_feeds(origin): 490 | # scrape an origin source to find possible alternative feeds 491 | try: 492 | resp = util.requests_get(origin) 493 | except requests.exceptions.RequestException as e: 494 | flask.flash('Error fetching source {}'.format(repr(e))) 495 | flask.current_app.logger.warn( 496 | 'Subscribe failed for %s with error %s', origin, repr(e)) 497 | return None 498 | 499 | feeds = [] 500 | 501 | xml_feed_types = [ 502 | 'application/rss+xml', 503 | 'application/atom+xml', 504 | 'application/rdf+xml', 505 | 'application/xml', 506 | 'text/xml', 507 | ] 508 | xml_mime_types = xml_feed_types + [ 509 | 'text/xml', 510 | 'text/rss+xml', 511 | 'text/atom+xml', 512 | ] 513 | html_feed_types = [ 514 | 'text/html', 515 | 'application/xhtml+xml', 516 | ] 517 | 518 | content_type = resp.headers['content-type'] 519 | content_type = content_type.split(';', 1)[0].strip() 520 | if content_type in xml_mime_types: 521 | feeds.append({ 522 | 'origin': origin, 523 | 'feed': origin, 524 | 'type': 'xml', 525 | 'title': 'untitled xml feed', 526 | }) 527 | 528 | elif content_type in html_feed_types: 529 | parsed = mf2py.parse(doc=resp.text, url=origin) 530 | # if text/html, then parse and look for h-entries 531 | hfeed = mf2util.interpret_feed(parsed, origin) 532 | if hfeed.get('entries'): 533 | ftitle = hfeed.get('name') or 'untitled h-feed' 534 | feeds.append({ 535 | 'origin': origin, 536 | 'feed': resp.url, 537 | 'type': 'html', 538 | 'title': ftitle[:140] 539 | }) 540 | 541 | # look for link="feed" 542 | for furl in parsed.get('rels', {}).get('feed', []): 543 | fprops = parsed.get('rel-urls', {}).get(furl, {}) 544 | if not fprops.get('type') or fprops.get('type') in html_feed_types: 545 | feeds.append({ 546 | 'origin': origin, 547 | 'feed': furl, 548 | 'type': 'html', 549 | 'title': fprops.get('title'), 550 | }) 551 | 552 | # then look for link rel="alternate" 553 | for link in parsed.get('alternates', []): 554 | if link.get('type') in xml_feed_types: 555 | feeds.append({ 556 | 'origin': origin, 557 | 'feed': link.get('url'), 558 | 'type': 'xml', 559 | 'title': link.get('title'), 560 | }) 561 | 562 | return feeds 563 | 564 | 565 | @views.app_template_filter() 566 | def prettify_url(url): 567 | parsed = urllib.parse.urlparse(url) 568 | if parsed.path: 569 | return parsed.netloc + parsed.path 570 | return parsed.netloc 571 | 572 | 573 | @views.app_template_filter() 574 | def domain_for_url(url): 575 | parsed = urllib.parse.urlparse(url) 576 | return parsed.netloc 577 | 578 | 579 | @views.app_template_filter() 580 | def favicon_for_url(url): 581 | return '//www.google.com/s2/favicons?' + urllib.parse.urlencode({ 582 | 'domain': url, 583 | }) 584 | 585 | 586 | @views.app_template_filter() 587 | def relative_time(dt): 588 | if dt: 589 | now = datetime.datetime.utcnow() 590 | diff = now - dt 591 | zero = datetime.timedelta(0) 592 | 593 | if diff == zero: 594 | pretty = 'Right now' 595 | elif diff > zero: 596 | years = diff.days // 365 597 | hours = diff.seconds // 60 // 60 598 | minutes = diff.seconds // 60 599 | 600 | if years > 1: 601 | pretty = str(years) + ' years ago' 602 | elif diff.days == 1: 603 | pretty = 'A day ago' 604 | elif diff.days > 1: 605 | pretty = str(diff.days) + ' days ago' 606 | elif hours == 1: 607 | pretty = 'An hour ago' 608 | elif hours > 1: 609 | pretty = str(hours) + ' hours ago' 610 | elif minutes == 1: 611 | pretty = 'A minute ago' 612 | elif minutes > 1: 613 | pretty = str(minutes) + ' minutes ago' 614 | else: 615 | pretty = str(diff.seconds) + ' seconds ago' 616 | else: 617 | diff = abs(diff) 618 | years = diff.days // 365 619 | hours = diff.seconds // 60 // 60 620 | minutes = diff.seconds // 60 621 | 622 | if years > 1: 623 | pretty = str(years) + ' years from now' 624 | elif diff.days == 1: 625 | pretty = 'A day from now' 626 | elif diff.days > 1: 627 | pretty = str(diff.days) + ' days from now' 628 | elif hours == 1: 629 | pretty = 'An hour from now' 630 | elif hours > 1: 631 | pretty = str(hours) + ' hours from now' 632 | elif minutes == 1: 633 | pretty = 'A minute from now' 634 | elif minutes > 1: 635 | pretty = str(minutes) + ' minutes from now' 636 | else: 637 | pretty = str(diff.seconds) + ' seconds from now' 638 | 639 | return ''.format(dt.isoformat(), pretty) 640 | 641 | 642 | @views.app_template_filter() 643 | def isoformat(dt): 644 | return dt and dt.isoformat() 645 | 646 | 647 | @views.app_template_filter() 648 | def add_preview(content): 649 | """If a post ends with the URL of a known media source (youtube, 650 | instagram, etc.), add the content inline. 651 | """ 652 | if not content or any('<' + tag in content for tag in ( 653 | 'img', 'iframe', 'embed', 'audio', 'video')): 654 | # don't add a preview to a post that already has one 655 | return content 656 | 657 | # flatten links and strip tags 658 | flat = content 659 | flat = re.sub(r']*href="([^"]+)"[^>]*>[^<]*', r'\1', flat) 660 | flat = re.sub(r']*>', '', flat) 661 | flat = flat.strip() 662 | 663 | instagram_regex = r'https?://(?:www\.)?instagram.com/p/[\w\-]+/?' 664 | vimeo_regex = r'https?://(?:www\.)?vimeo.com/(\d+)/?' 665 | youtube_regex = r'https?://(?:www\.)?youtube.com/watch\?v=([\w\-]+)' 666 | youtube_short_regex = r'https://youtu.be/([\w\-]+)' 667 | twitter_regex = r'https?://(?:www\.)?twitter.com/(\w+)/status/(\d+)' 668 | 669 | m = re.search(instagram_regex, flat) 670 | if m: 671 | ig_url = m.group(0) 672 | media_url = urllib.parse.urljoin(ig_url, 'media/?size=l') 673 | return '{}'.format( 674 | content, ig_url, media_url) 675 | 676 | m = re.search(vimeo_regex, flat) 677 | if m: 678 | # vimeo_url = m.group(0) 679 | vimeo_id = m.group(1) 680 | return ( 681 | '{}' 684 | ).format(content, vimeo_id) 685 | 686 | m = re.search(youtube_regex, flat) 687 | if not m: 688 | m = re.search(youtube_short_regex, content) 689 | 690 | if m: 691 | youtube_id = m.group(1) 692 | return ( 693 | '{}' 696 | ).format(content, youtube_id) 697 | 698 | m = re.search(twitter_regex + '$', flat) 699 | if m: 700 | tweet_url = m.group() 701 | return content + ( 702 | '' 704 | ).format(tweet_url) 705 | 706 | return content 707 | 708 | 709 | @views.app_template_filter() 710 | def proxy_image(url): 711 | proxy_url = flask.current_app.config.get('IMAGEPROXY_URL') 712 | proxy_key = flask.current_app.config.get('IMAGEPROXY_KEY') 713 | if proxy_url and proxy_key: 714 | sig = base64.urlsafe_b64encode( 715 | hmac.new(proxy_key.encode(), url.encode(), hashlib.sha256).digest() 716 | ).decode() 717 | return '/'.join((proxy_url.rstrip('/'), 's' + sig, url)) 718 | 719 | pilbox_url = flask.current_app.config.get('PILBOX_URL') 720 | pilbox_key = flask.current_app.config.get('PILBOX_KEY') 721 | if pilbox_url and pilbox_key: 722 | query = urllib.parse.urlencode({'url': url, 'op': 'noop'}) 723 | sig = hmac.new(pilbox_key.encode(), query.encode(), hashlib.sha1).hexdigest() 724 | query += '&sig=' + sig 725 | return pilbox_url + '?' + query 726 | 727 | camo_url = flask.current_app.config.get('CAMO_URL') 728 | camo_key = flask.current_app.config.get('CAMO_KEY') 729 | if camo_url and camo_key: 730 | digest = hmac.new(camo_key.encode(), url.encode(), hashlib.sha1).hexdigest() 731 | return (urllib.parse.urljoin(camo_url, digest) 732 | + '?url=' + urllib.parse.quote_plus(url)) 733 | return url 734 | 735 | 736 | @views.app_template_filter() 737 | def proxy_all(content): 738 | def repl(m): 739 | attrs = m.group(1) 740 | url = m.group(2) 741 | url = url.replace('&', '&') 742 | return '