├── usocial ├── __init__.py ├── controllers │ ├── __init__.py │ ├── api.py │ ├── account.py │ └── feed.py ├── static │ ├── favicon.ico │ ├── webfonts │ │ ├── fa-solid-900.eot │ │ ├── fa-solid-900.ttf │ │ ├── fa-brands-400.eot │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff │ │ ├── fa-brands-400.woff2 │ │ ├── fa-regular-400.eot │ │ ├── fa-regular-400.ttf │ │ ├── fa-regular-400.woff │ │ ├── fa-solid-900.woff │ │ ├── fa-solid-900.woff2 │ │ └── fa-regular-400.woff2 │ ├── style.css │ ├── mu.svg │ └── utils.js ├── templates │ ├── add_website.html │ ├── login.html │ ├── password.html │ ├── account.html │ ├── search_podcasts.html │ ├── base.html │ └── items.html ├── forms.py ├── scripts │ └── experiments │ │ ├── crawl_nownownow.py │ │ └── keywords.py ├── payments.py ├── main.py └── models.py ├── migrations ├── README ├── versions │ ├── 01f04eb3cb6d_initial_migration.py │ └── 7d065f861dd3_item_url_unique_per_feed.py ├── script.py.mako ├── alembic.ini └── env.py ├── .gitignore ├── requirements.txt ├── setup.py ├── TODO.md ├── docker-compose.yml ├── config.py ├── create-manifest.sh ├── Dockerfile ├── start.sh ├── .github └── workflows │ ├── tag.yml │ └── push.yml ├── README.md └── LICENSE /usocial/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /usocial/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .pytest_cache 3 | *.egg-info 4 | venv 5 | instance/ 6 | -------------------------------------------------------------------------------- /usocial/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/favicon.ico -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-solid-900.eot -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-brands-400.eot -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-brands-400.woff -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-regular-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-regular-400.eot -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-regular-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-regular-400.woff -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-solid-900.woff -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /usocial/static/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibz/usocial/HEAD/usocial/static/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | babel 2 | bs4 3 | feedparsley 4 | Flask 5 | Flask-Bcrypt 6 | Flask-Cors 7 | Flask-JWT-Extended 8 | Flask-Migrate 9 | Flask-WTF 10 | lnd-grpc-client 11 | pytest 12 | python-podcastindex 13 | requests 14 | wheel -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | requirements = None 4 | with open('requirements.txt', 'r') as r: 5 | requirements = [l.strip() for l in r.readlines()] 6 | 7 | setup( 8 | name='usocial', 9 | packages=['usocial'], 10 | include_package_data=True, 11 | install_requires=requirements, 12 | ) 13 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * Ability to play podcasts on the server (this could be used for example when running `usocial` on a home server, like [Umbrel](https://getumbrel.com/), that is connected to an audio system) 2 | * Detect when a website supports [Lightning Address](https://lightningaddress.com/) and make it easy to send one-time or recurring donations 3 | * Support more features of the ["podcast" namespace](https://github.com/Podcastindex-org/podcast-namespace) 4 | -------------------------------------------------------------------------------- /usocial/templates/add_website.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 |
5 | 6 | {{ form.hidden_tag() }} 7 | {{ form.url.label }} 8 | {{ form.url }} 9 | 10 |
11 |
12 | {% endblock %} -------------------------------------------------------------------------------- /migrations/versions/01f04eb3cb6d_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration. 2 | 3 | Revision ID: 01f04eb3cb6d 4 | Revises: 5 | Create Date: 2022-02-09 09:44:37.537462 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = '01f04eb3cb6d' 13 | down_revision = None 14 | branch_labels = None 15 | depends_on = None 16 | 17 | def upgrade(): 18 | pass 19 | 20 | def downgrade(): 21 | pass 22 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /usocial/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import PasswordField, SelectField, StringField 3 | 4 | class LoginForm(FlaskForm): 5 | username = StringField("Username") 6 | password = PasswordField("Password") 7 | 8 | class NewPasswordForm(FlaskForm): 9 | new_password = PasswordField("Password") 10 | repeat_new_password = PasswordField("Repeat") 11 | 12 | class FollowWebsiteForm(FlaskForm): 13 | url = StringField("http://") 14 | 15 | class FollowFeedForm(FlaskForm): 16 | url = SelectField("Feed") 17 | 18 | class SearchPodcastForm(FlaskForm): 19 | keywords = StringField("Keywords") 20 | -------------------------------------------------------------------------------- /usocial/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 | {{ form.hidden_tag() }} 5 | 6 | {% if not skip_username %} 7 | 8 | 9 | 10 | 11 | {% endif %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
{{ form.username.label }}{{ form.username }}
{{ form.password.label }}{{ form.password }}
21 |
22 | {% endblock %} -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | web: 5 | image: ghcr.io/ibz/usocial:master-buster 6 | restart: on-failure 7 | stop_grace_period: 1m 8 | ports: 9 | - 8448:5000 10 | volumes: 11 | - ${LND_DATA_DIR}:/lnd:ro 12 | - ${APP_DATA_DIR}/data:/instance 13 | environment: 14 | USOCIAL_JOB: "WEB" 15 | APP_PASSWORD: "${APP_PASSWORD}" 16 | LND_IP: "${LND_IP}" 17 | LND_GRPC_PORT: ${LND_GRPC_PORT} 18 | LND_DIR: "/lnd" 19 | fetcher: 20 | depends_on: 21 | - web 22 | image: ghcr.io/ibz/usocial:master-buster 23 | restart: on-failure 24 | stop_grace_period: 1m 25 | volumes: 26 | - ${APP_DATA_DIR}/data:/instance 27 | environment: 28 | USOCIAL_JOB: "FETCH_FEEDS" 29 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | DEBUG = False 5 | BCRYPT_LOG_ROUNDS = 13 6 | PROPAGATE_EXCEPTIONS = False 7 | SQLALCHEMY_TRACK_MODIFICATIONS = False 8 | JWT_TOKEN_LOCATION = 'cookies' 9 | JWT_ACCESS_TOKEN_EXPIRES = datetime.timedelta(days=1) 10 | JWT_CSRF_CHECK_FORM = True 11 | JWT_ACCESS_CSRF_FIELD_NAME = 'jwt_csrf_token' 12 | 13 | PODCASTINDEX_API_KEY = 'ZBJFW42QYWT6C8PWB7EZ' 14 | PODCASTINDEX_API_SECRET = 'dyHyKXSpJjn4J4rXYpj^DPsWNLjJ2j#VjJ38st9Q' 15 | 16 | SQLALCHEMY_DATABASE_URI = 'sqlite:///../instance/usocial.db' 17 | 18 | USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:80.0) Gecko/20100101 Firefox/80.0" 19 | 20 | LND_IP = os.environ.get("LND_IP") 21 | LND_GRPC_PORT = os.environ.get("LND_GRPC_PORT") 22 | LND_DIR = os.environ.get("LND_DIR") 23 | 24 | DEFAULT_USER_PASSWORD = os.environ.get("DEFAULT_USER_PASSWORD") 25 | 26 | VERSION = 'dev' 27 | BUILD = '?' 28 | -------------------------------------------------------------------------------- /create-manifest.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | IMAGE_NAME=${1} 4 | VERSION=${2} 5 | BASE_IMAGE=${3} 6 | FINAL_NAME="${IMAGE_NAME}:${VERSION}-${BASE_IMAGE}" 7 | MANIFEST_NAME="${FINAL_NAME}" 8 | 9 | declare -a architectures=("amd64" "arm64") 10 | 11 | for architecture in "${architectures[@]}"; do 12 | echo "Pulling ${VERSION} for ${architecture}..." 13 | docker pull "${FINAL_NAME}-${architecture}" 14 | done 15 | 16 | echo "Creating manifest list..." 17 | for architecture in "${architectures[@]}"; do 18 | echo " ${FINAL_NAME}-${architecture}" 19 | done | xargs docker manifest create "${MANIFEST_NAME}" 20 | 21 | for architecture in "${architectures[@]}"; do 22 | echo "Annotating manifest for ${architecture}..." 23 | docker manifest annotate "${MANIFEST_NAME}" "${FINAL_NAME}-${architecture}" --arch ${architecture} --os linux 24 | done 25 | 26 | echo "Pushing manifest list..." 27 | docker manifest push --purge ${MANIFEST_NAME} 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-buster 2 | 3 | ARG version 4 | 5 | RUN apt-get update && apt-get install -y cron sudo 6 | 7 | COPY requirements.txt / 8 | ENV PYTHONPATH "${PYTHONPATH}:/" 9 | RUN pip install --upgrade pip \ 10 | && pip install --no-cache-dir -r /requirements.txt 11 | 12 | COPY ./usocial /usocial 13 | COPY ./migrations /usocial/migrations 14 | COPY config.py start.sh / 15 | RUN chmod +x /start.sh 16 | 17 | RUN echo "VERSION = '${version}'" >> /config.py \ 18 | && echo "BUILD = '"`date +%Y%m%d`"'" >> /config.py 19 | 20 | ENV INSTANCE_PATH=/instance 21 | VOLUME ["/instance"] 22 | 23 | RUN groupadd -r usocial --gid=1000 && useradd -r -g usocial --uid=1000 --create-home --shell /bin/bash usocial 24 | 25 | RUN touch /var/log/cron.log \ 26 | && chown usocial:usocial /var/log/cron.log \ 27 | && echo 'usocial ALL=NOPASSWD: /usr/sbin/cron' >> /etc/sudoers 28 | 29 | USER usocial 30 | 31 | WORKDIR /usocial 32 | 33 | EXPOSE 5000 34 | 35 | CMD [ "/start.sh" ] 36 | -------------------------------------------------------------------------------- /usocial/templates/password.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 | 5 | {{ form.hidden_tag() }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
Note: Leave empty to remove the password!
{{ form.new_password.label }}{{ form.new_password }}
{{ form.repeat_new_password.label }}{{ form.repeat_new_password }}
21 |
22 | {% endblock %} -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic,flask_migrate 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /migrations/versions/7d065f861dd3_item_url_unique_per_feed.py: -------------------------------------------------------------------------------- 1 | """Item URL unique per feed, not globally. 2 | 3 | Revision ID: 7d065f861dd3 4 | Revises: 01f04eb3cb6d 5 | Create Date: 2022-03-17 23:53:50.263688 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '7d065f861dd3' 14 | down_revision = '01f04eb3cb6d' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | naming_convention = { 19 | "uq": 20 | "uq_%(table_name)s_%(column_0_name)s", 21 | } 22 | 23 | def upgrade(): 24 | with op.batch_alter_table("items", recreate='always', naming_convention=naming_convention) as batch_op: 25 | batch_op.drop_constraint('uq_items_url', type_='unique') 26 | batch_op.create_unique_constraint('uq_items_feed_id', ['feed_id', 'url']) 27 | 28 | def downgrade(): 29 | with op.batch_alter_table("items", recreate='always', naming_convention=naming_convention) as batch_op: 30 | batch_op.create_unique_constraint('uq_items_url', ['url']) 31 | batch_op.drop_constraint('uq_items_feed_id', type_='unique') 32 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | set -e 4 | 5 | setup_web () { 6 | if [ ! -f /instance/config.py ]; then 7 | echo "SECRET_KEY = '"`python -c 'import os;print(os.urandom(12).hex())'`"'" > /instance/config.py 8 | fi 9 | if [ ! -f /instance/usocial.db ]; then 10 | FLASK_APP=main DEFAULT_USER_PASSWORD=${APP_PASSWORD} flask create-db 11 | FLASK_APP=main DEFAULT_USER_PASSWORD=${APP_PASSWORD} flask db stamp 12 | fi 13 | 14 | FLASK_APP=main flask db upgrade 15 | } 16 | 17 | do_job_web () { 18 | setup_web 19 | python ./main.py 20 | } 21 | 22 | setup_fetch_feeds () { 23 | if ! crontab -l ; then 24 | echo "*/10 * * * * cd /usocial && FLASK_APP=main /usr/local/bin/flask fetch-feeds >> /var/log/cron.log 2>&1" > /home/usocial/usocial-cron 25 | chmod 0644 /home/usocial/usocial-cron 26 | crontab /home/usocial/usocial-cron 27 | fi 28 | } 29 | 30 | do_job_fetch_feeds () { 31 | setup_fetch_feeds 32 | sudo cron && tail -f /var/log/cron.log 33 | } 34 | 35 | case "$USOCIAL_JOB" in 36 | "WEB") do_job_web ;; 37 | "FETCH_FEEDS") do_job_fetch_feeds ;; 38 | esac 39 | -------------------------------------------------------------------------------- /usocial/templates/account.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |

Karma

4 |

Podcast value

5 | 6 | 7 | {% for action_name, amount in paid_value_amounts %} 8 | 9 | {% endfor %} 10 |
Total played{{ played_value }} minutes
Total {{ action_name }}{{ amount }} sats
11 | 12 |

Account

13 | 14 | 15 | 16 | {% if not (only_default_user and not user.password) %} 17 | 18 | {% endif %} 19 |
{{ user.username }} {% if user.password %}{% else %}{% endif %}set password
API key{{ user.fever_api_key }}
log out
20 | 21 |

App

22 | 23 | 24 | 25 | 26 | 27 |
Version{{ version }}
Build{{ build }}
Websiteusocial.me
LND{% if lnd_info %}connected to node {{ lnd_info.identity_pubkey }}{% else %}not connected{% endif %}
28 | {% endblock %} -------------------------------------------------------------------------------- /usocial/templates/search_podcasts.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 |
5 | 6 | {{ form.hidden_tag() }} 7 | {{ form.keywords.label }} 8 | {{ form.keywords }} 9 | 10 |
11 |
12 | 13 | {% for feed in podcastindex_feeds %} 14 | 15 | 18 | 30 | 31 | 32 | {% endfor %} 33 |
16 | 17 | 19 | {{ feed.title }} ({{ feed.domain }}) 20 | 21 | 22 | 23 | 26 |
27 | {% for cat in feed.categories %}{{ cat }} {% endfor %} 28 |
29 |
34 | {% endblock %} -------------------------------------------------------------------------------- /usocial/controllers/api.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, request 2 | 3 | from usocial import models 4 | from usocial.main import db, csrf 5 | 6 | api_blueprint = Blueprint('api', __name__) 7 | 8 | @csrf.exempt 9 | @api_blueprint.route('/api', methods=['POST']) 10 | def api(): 11 | res = {'api_version': "1"} 12 | 13 | if request.form.get('api_key'): 14 | user = models.User.query.filter_by(fever_api_key=request.form['api_key']).one_or_none() 15 | else: 16 | user = models.User.query.filter_by(username=models.User.DEFAULT_USERNAME, password=None).one_or_none() 17 | if not user: 18 | res['auth'] = 0 19 | return jsonify(res) 20 | res['auth'] = 1 21 | 22 | if 'feeds' in request.args: 23 | group_ids = [g.id for g in models.Group.query.filter_by(user_id=user.id).all()] 24 | feed_ids = [fg.feed_id for fg in models.FeedGroup.query.filter(models.FeedGroup.group_id.in_(group_ids)).all()] 25 | res['feeds'] = [{ 26 | 'id': f.id, 27 | 'title': f.title, 28 | 'url': f.url, 29 | 'site_url': f.homepage_url, 30 | 'last_updated_on_time': int(f.updated_at.timestamp()) if f.updated_at else 0 31 | } 32 | for f in models.Feed.query.filter(models.Feed.id.in_(feed_ids)).all()] 33 | if 'items' in request.args: 34 | res['items'] = [{ 35 | 'id': i.id, 36 | 'feed_id': i.feed_id, 37 | 'title': i.title, 38 | 'url': i.url, 39 | 'html': i.content_from_feed, 40 | 'created_on_time': int(i.updated_at.timestamp()), 41 | 'is_read': int(ui.read), 42 | 'is_saved': int(ui.liked) 43 | } 44 | for i, ui in db.session.query(models.Item, models.UserItem).select_from(models.Item).join(models.UserItem).filter(models.UserItem.user == user).all()] 45 | 46 | return jsonify(res) 47 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Build images on tag 2 | 3 | permissions: 4 | packages: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - v[0-9]+.[0-9]+.[0-9]+ 10 | - v[0-9]+.[0-9]+.[0-9]+-* 11 | 12 | jobs: 13 | build: 14 | name: Build image 15 | runs-on: ubuntu-20.04 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | architecture: 20 | - "arm64" 21 | - "amd64" 22 | base: 23 | - "buster" 24 | 25 | steps: 26 | - name: Checkout project 27 | uses: actions/checkout@v2 28 | 29 | - name: Set env variables 30 | run: | 31 | echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV 32 | echo "IMAGE_NAME=${GITHUB_REPOSITORY#*/}" >> $GITHUB_ENV 33 | - name: Login to GitHub Container Registry 34 | uses: docker/login-action@v1 35 | with: 36 | registry: ghcr.io 37 | username: ${{ github.repository_owner }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Set up QEMU 41 | uses: docker/setup-qemu-action@v1 42 | 43 | - name: Setup Docker buildx action 44 | uses: docker/setup-buildx-action@v1 45 | 46 | - name: Run Docker buildx 47 | run: | 48 | docker buildx build --platform linux/${{ matrix.architecture }} \ 49 | --tag ghcr.io/${{ github.repository_owner }}/${IMAGE_NAME}:${TAG}-${{ matrix.base }}-${{ matrix.architecture }} --output "type=registry" \ 50 | --build-arg version=${TAG} \ 51 | ./ 52 | create-manifest: 53 | name: Create manifest 54 | runs-on: ubuntu-20.04 55 | needs: build 56 | strategy: 57 | matrix: 58 | base: 59 | - "buster" 60 | 61 | steps: 62 | - name: Checkout project 63 | uses: actions/checkout@v2 64 | 65 | - name: Set env variables 66 | run: | 67 | echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV 68 | echo "IMAGE_NAME=${GITHUB_REPOSITORY#*/}" >> $GITHUB_ENV 69 | - name: Login to GitHub Container Registry 70 | uses: docker/login-action@v1 71 | with: 72 | registry: ghcr.io 73 | username: ${{ github.repository_owner }} 74 | password: ${{ secrets.GITHUB_TOKEN }} 75 | 76 | - name: Create final manifest 77 | run: ./create-manifest.sh "ghcr.io/${{ github.repository_owner }}/${IMAGE_NAME}" "${TAG}" "${{ matrix.base }}" 78 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Build images on push 2 | 3 | permissions: 4 | packages: write 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: Build image 14 | runs-on: ubuntu-20.04 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | architecture: 19 | - "arm64" 20 | - "amd64" 21 | base: 22 | - "buster" 23 | 24 | steps: 25 | - name: Checkout project 26 | uses: actions/checkout@v2 27 | 28 | - name: Set env variables 29 | run: | 30 | echo "BRANCH=$(echo ${GITHUB_REF#refs/heads/} | sed 's/\//-/g')" >> $GITHUB_ENV 31 | echo "IMAGE_NAME=${GITHUB_REPOSITORY#*/}" >> $GITHUB_ENV 32 | - name: Login to GitHub Container Registry 33 | uses: docker/login-action@v1 34 | with: 35 | registry: ghcr.io 36 | username: ${{ github.repository_owner }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Set up QEMU 40 | uses: docker/setup-qemu-action@v1 41 | 42 | - name: Setup Docker buildx action 43 | uses: docker/setup-buildx-action@v1 44 | 45 | - name: Run Docker buildx 46 | run: | 47 | docker buildx build --platform linux/${{ matrix.architecture }} \ 48 | --tag ghcr.io/${{ github.repository_owner }}/${IMAGE_NAME}:${BRANCH}-${{ matrix.base }}-${{ matrix.architecture }} --output "type=registry" \ 49 | --build-arg version=${BRANCH} \ 50 | ./ 51 | create-manifest: 52 | name: Create manifest 53 | runs-on: ubuntu-20.04 54 | needs: build 55 | strategy: 56 | matrix: 57 | base: 58 | - "buster" 59 | 60 | steps: 61 | - name: Checkout project 62 | uses: actions/checkout@v2 63 | 64 | - name: Set env variables 65 | run: | 66 | echo "BRANCH=$(echo ${GITHUB_REF#refs/heads/} | sed 's/\//-/g')" >> $GITHUB_ENV 67 | echo "IMAGE_NAME=${GITHUB_REPOSITORY#*/}" >> $GITHUB_ENV 68 | - name: Login to GitHub Container Registry 69 | uses: docker/login-action@v1 70 | with: 71 | registry: ghcr.io 72 | username: ${{ github.repository_owner }} 73 | password: ${{ secrets.GITHUB_TOKEN }} 74 | 75 | - name: Create final manifest 76 | run: ./create-manifest.sh "ghcr.io/${{ github.repository_owner }}/${IMAGE_NAME}" "${BRANCH}" "${{ matrix.base }}" -------------------------------------------------------------------------------- /usocial/static/style.css: -------------------------------------------------------------------------------- 1 | a:link, a:visited { color: black; text-decoration: none; } 2 | body { font-family: Verdana, Geneva, Tahoma, sans-serif; overflow: scroll; } 3 | 4 | .item-liked-1.item-hidden-0 { background-color: #D3DFC1; } 5 | .item-hidden-1 { background-color: #DAB2BA; } 6 | #navbar { background-color: #C4E38A; } 7 | .error { background-color: #DAB2BA; } 8 | .item-liked-1 .like-link { display: none; } 9 | .item-liked-0 .like-link { display: inline; } 10 | .item-liked-1 .unlike-link { display: inline; } 11 | .item-liked-0 .unlike-link { display: none; } 12 | .item-hidden-1 .hide-link { display: none; } 13 | .item-hidden-0 .hide-link { display: inline; } 14 | .item-hidden-1 .unhide-link { display: inline; } 15 | .item-hidden-0 .unhide-link { display: none; } 16 | .feed-followed-1 .follow-link { display: none; } 17 | .feed-followed-0 .follow-link { display: inline; } 18 | .feed-followed-1 .unfollow-link { display: inline; } 19 | .feed-followed-0 .unfollow-link { display: none; } 20 | .main-row { color: black; } 21 | .extra-row { color: gray; } 22 | .split-panels { width: 100%; } 23 | .left-panel, .right-panel { vertical-align: top; } 24 | .feeds-table { width: 100%; border-spacing: 0; } 25 | .feed-actions { width: 5%; } 26 | .items-table { width: 100%; border-spacing: 0; } 27 | .fullWidthTable { width: 100%; } 28 | .item-row td:nth-child(2) { text-align: end; } 29 | .item-row:hover, .item-active { background-color: #EBECCD; } 30 | .feed-row:hover, .feed-active { background-color: #EBECCD; } 31 | .form-row td:nth-child(1) { text-align: end; } 32 | .form-row td:nth-child(2) { text-align: left; } 33 | #feed-details { border: 1px solid; width: 100%; } 34 | #podcastPlayer { width: 100%; } 35 | 36 | @media screen and (max-width: 768px) { 37 | .left-panel, .right-panel { display: block; width: 100%; } 38 | } 39 | 40 | @media screen and (min-width: 768px) { 41 | .left-panel { width: 40%; } .right-panel { width: 60%; } 42 | } 43 | 44 | /***** BEGIN dark mode *****/ 45 | /***** https://ar.al/2021/08/24/implementing-dark-mode-in-a-handful-of-lines-of-css-with-css-filters/ *****/ 46 | @media (prefers-color-scheme: dark) { 47 | body { filter: invert(100%) hue-rotate(180deg); } 48 | 49 | /* Firefox workaround: Set the background colour for the html element separately. */ 50 | html { background-color: #111; } 51 | 52 | /* Do not invert media (revert the invert). */ 53 | img, video, iframe, audio { filter: invert(100%) hue-rotate(180deg); } 54 | 55 | /* Re-enable code block backgrounds. */ 56 | pre { filter: invert(6%); } 57 | 58 | /* Improve contrast on list item markers. */ 59 | li::marker { color: #666; } 60 | } 61 | /***** END dark mode *****/ 62 | -------------------------------------------------------------------------------- /usocial/scripts/experiments/crawl_nownownow.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | from datetime import datetime 3 | import requests 4 | import sys 5 | from urllib.parse import urlparse 6 | 7 | from feedparsley import parse_feed, extract_feed_links 8 | 9 | from usocial import models 10 | from usocial.main import db 11 | 12 | import config 13 | 14 | HEADERS = {'User-Agent': config.USER_AGENT} 15 | 16 | def get_links(): 17 | r = requests.get('https://nownownow.com/', headers=HEADERS) 18 | soup = BeautifulSoup(r.text, 'html.parser') 19 | links = [] 20 | for li in soup.find_all('li'): 21 | link = None 22 | url = li.find('div', attrs={'class': 'url'}) 23 | if url: 24 | link = url.find('a') 25 | else: 26 | name = li.find('div', attrs={'class': 'name'}) 27 | if name: 28 | link = name.find('a') 29 | links.append(link.attrs['href']) 30 | return links 31 | 32 | def parse_now_page(url, content): 33 | alt_links = extract_feed_links(url, content) 34 | 35 | print("Found %s alt links: %s" % (len(alt_links), ', '.join([l[0] for l in alt_links]))) 36 | 37 | for alt_url, _ in alt_links: 38 | feed = models.Feed.query.filter_by(url=alt_url).first() 39 | if feed: 40 | print("SKIP") 41 | return 42 | feed = models.Feed(url=alt_url) 43 | parsed_feed = parse_feed(alt_url) 44 | if not parsed_feed: 45 | print("FEED FAIL") 46 | continue 47 | if not parsed_feed['items']: 48 | print("EMPTY FEED") 49 | continue 50 | feed.update(parsed_feed) 51 | db.session.add(feed) 52 | db.session.commit() 53 | if parsed_feed: 54 | new_items, updated_items = feed.update_items(parsed_feed) 55 | db.session.add(feed) 56 | for item in new_items + updated_items: 57 | db.session.add(item) 58 | db.session.commit() 59 | return # NOTE: we only save the 1st valid feed 60 | 61 | def main(): 62 | start_at = 0 63 | if len(sys.argv) > 1: 64 | start_at = int(sys.argv[1]) 65 | 66 | links = get_links() 67 | print(f"Found {len(links)} now links") 68 | 69 | for i, link in enumerate(links): 70 | if i < start_at: 71 | print("SKIP") 72 | continue 73 | 74 | print(f"Processing link {i}: {link}") 75 | now_page = None 76 | try: 77 | now_page = requests.get(link, headers=HEADERS) 78 | if now_page.status_code != 200: 79 | print("FAIL") 80 | continue 81 | except requests.exceptions.RequestException as e: 82 | print("ERR") 83 | continue 84 | 85 | parse_now_page(link, now_page.text) 86 | 87 | if __name__ == '__main__': 88 | main() 89 | -------------------------------------------------------------------------------- /usocial/scripts/experiments/keywords.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | import nltk 3 | from nltk.corpus import stopwords 4 | from nltk.stem.porter import PorterStemmer 5 | from nltk.tokenize import RegexpTokenizer 6 | from nltk.stem.wordnet import WordNetLemmatizer 7 | import re 8 | from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer 9 | 10 | from usocial import models 11 | 12 | 13 | def extract_topn_from_vector(feature_names, sorted_items, topn=10): 14 | """get the feature names and tf-idf score of top n items""" 15 | sorted_items = sorted_items[:topn] 16 | score_vals = [] 17 | feature_vals = [] 18 | for idx, score in sorted_items: 19 | score_vals.append(round(score, 3)) 20 | feature_vals.append(feature_names[idx]) 21 | results = {} 22 | for idx in range(len(feature_vals)): 23 | results[feature_vals[idx]]=score_vals[idx] 24 | return results 25 | 26 | def sort_coo(coo_matrix): 27 | tuples = zip(coo_matrix.col, coo_matrix.data) 28 | return sorted(tuples, key=lambda x: (x[1], x[0]), reverse=True) 29 | 30 | def main(): 31 | stop_words = set(stopwords.words("english")) 32 | feed_items = {} 33 | for feed in models.Feed.query.all(): 34 | for item in feed.items: 35 | if item.content_from_feed: 36 | # TODO: check lang 37 | feed_items.setdefault(feed.id, []).append(item) 38 | corpus = {} 39 | for feed_id, items in feed_items.items(): 40 | feed_texts = [] 41 | for item in items: 42 | soup = BeautifulSoup(item.content_from_feed, 'html.parser') 43 | text = re.sub('[^a-zA-Z]', ' ', soup.text) 44 | text = text.lower() 45 | text = re.sub("</?.*?>", " <> ", text) 46 | text = re.sub("(\\d|\\W)+", " ", text) 47 | text = text.split() 48 | ps = PorterStemmer() # TODO: ? 49 | lem = WordNetLemmatizer() 50 | text = [lem.lemmatize(word) for word in text if word not in stop_words] 51 | text = " ".join(text) 52 | feed_texts.append(text) 53 | corpus[feed_id] = " ".join(feed_texts) 54 | cv = CountVectorizer(max_df=0.8, stop_words=stop_words, max_features=10000, ngram_range=(1, 3)) 55 | X = cv.fit_transform(list(corpus.values())) 56 | tfidf_transformer = TfidfTransformer(smooth_idf=True, use_idf=True) 57 | tfidf_transformer.fit(X) 58 | feature_names = cv.get_feature_names() 59 | 60 | for feed_id, doc in corpus.items(): 61 | tf_idf_vector = tfidf_transformer.transform(cv.transform([doc])) 62 | sorted_items = sort_coo(tf_idf_vector.tocoo()) 63 | keywords = extract_topn_from_vector(feature_names, sorted_items, 5) 64 | feed = models.Feed.query.filter_by(id=feed_id).first() 65 | print(f"Title: {feed.title}\nURL: {feed.url}\nKeywords: {keywords}") 66 | 67 | if __name__ == '__main__': 68 | main() 69 | -------------------------------------------------------------------------------- /usocial/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% if feed %}{{ feed.title }} :: {% endif %}usocial :: the anti-social network 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 19 |
20 | 21 | 22 | 41 | 42 | 43 | 54 | 55 | 56 | 59 | 60 |
44 | {% with messages = get_flashed_messages() %} 45 | {% if messages %} 46 | 47 | {% for message in messages %} 48 | 49 | {% endfor %} 50 |
{{ message }}
51 | {% endif %} 52 | {% endwith %} 53 |
57 | {% block content %}{% endblock %} 58 |
61 |
62 | 63 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from flask import current_app 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | logger = logging.getLogger('alembic.env') 18 | 19 | # add your model's MetaData object here 20 | # for 'autogenerate' support 21 | # from myapp import mymodel 22 | # target_metadata = mymodel.Base.metadata 23 | config.set_main_option( 24 | 'sqlalchemy.url', 25 | str(current_app.extensions['migrate'].db.get_engine().url).replace( 26 | '%', '%%')) 27 | target_metadata = current_app.extensions['migrate'].db.metadata 28 | 29 | # other values from the config, defined by the needs of env.py, 30 | # can be acquired: 31 | # my_important_option = config.get_main_option("my_important_option") 32 | # ... etc. 33 | 34 | 35 | def run_migrations_offline(): 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, target_metadata=target_metadata, literal_binds=True 50 | ) 51 | 52 | with context.begin_transaction(): 53 | context.run_migrations() 54 | 55 | 56 | def run_migrations_online(): 57 | """Run migrations in 'online' mode. 58 | 59 | In this scenario we need to create an Engine 60 | and associate a connection with the context. 61 | 62 | """ 63 | 64 | # this callback is used to prevent an auto-migration from being generated 65 | # when there are no changes to the schema 66 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 67 | def process_revision_directives(context, revision, directives): 68 | if getattr(config.cmd_opts, 'autogenerate', False): 69 | script = directives[0] 70 | if script.upgrade_ops.is_empty(): 71 | directives[:] = [] 72 | logger.info('No changes in schema detected.') 73 | 74 | connectable = current_app.extensions['migrate'].db.get_engine() 75 | 76 | with connectable.connect() as connection: 77 | context.configure( 78 | connection=connection, 79 | target_metadata=target_metadata, 80 | process_revision_directives=process_revision_directives, 81 | **current_app.extensions['migrate'].configure_args 82 | ) 83 | 84 | with context.begin_transaction(): 85 | context.run_migrations() 86 | 87 | 88 | if context.is_offline_mode(): 89 | run_migrations_offline() 90 | else: 91 | run_migrations_online() 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This project has been discontinued 2 | 3 | ## usocial (2018 - 2022) 4 | 5 | Most open source projects are simply forgotten and die a slow and lonely death. 6 | 7 | **usocial** was different. Same same, but different. 8 | 9 | It started as a personal RSS feed reader, evolved into a Podcasting 2.0 client, got released on Umbrel OS... 10 | 11 | You could run usocial on your own Umbrel personal server, follow your favourite blogs, subscribe to your favourite podcasts... 12 | 13 | **usocial** would connect to your own Lightning node and, while listening to a podcast episode, you could send sats directly to the podcaster. The payment would even automatically be split, according to the podcaster's desire, and go to different recipients. 14 | 15 | It had a terrible UI, but it worked beautifully. It was my way of keeping up to date with podcasts and blogs and tipping creators. 16 | 17 | Then, something happened. 18 | 19 | **usocial** didn't die, it just evolved into something else: **[Servus](https://github.com/servuscms/servus)** (2022-). 20 | 21 | I realized that more important than following blogs and podcasts is publishing your own content. Only *after* there is a solid way for anyone to self-host their web site and **publish** content will there be a need for a self-hosted way to **subscribe** to content. 22 | 23 | I used to be a fan of Jekyll, but I realized that it is not for the mere mortals to use. I hated WP, which I had used since 2005 or so. WP was more user-friendly than Jekyll and other SSGes, but it just did not click with me. 24 | 25 | I had written a few CMSes before (2008-2012), mostly trying to host my photoblog in a pre-Flickr era and to build a sort-of online travel log. See [nuages](https://github.com/ibz/nuages), [tzadik](https://github.com/ibz/tzadik), [feather](https://github.com/ibz/feather) and [travelist](https://github.com/ibz/travelist). 26 | 27 | Then it all clicked. The missing piece was a CMS. I could take a lot of ideas from Jekyll, while trying to keep the usability of WP. 28 | 29 | That is how [Servus](https://github.com/servuscms/servus) was born and that was the end of usocial. 30 | 31 | It didn't die, it just evolved. 32 | 33 | ## Setting up the development environment 34 | 35 | 1. Clone the repo 36 | 37 | `git clone https://github.com/ibz/usocial.git && cd usocial` 38 | 39 | 1. Set up a venv 40 | 41 | ``` 42 | python3 -m venv venv 43 | source venv/bin/activate 44 | pip install --upgrade pip 45 | pip install -e . 46 | ``` 47 | 48 | 1. Create an "instance" directory which will store your database and config file. 49 | 50 | `mkdir instance` 51 | 1. Generate a secret key (this is required by Flask for [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery) protection) 52 | 53 | ```echo "SECRET_KEY = '"`python3 -c 'import os;print(os.urandom(12).hex())'`"'" > instance/config.py``` 54 | 1. Export the environment variables (`FLASK_APP` is required, `FLASK_ENV` makes Flask automatically restart when you edit a file) 55 | 56 | `export FLASK_APP=usocial.main FLASK_ENV=development` 57 | 1. Create the database (this will also create the default user, "me", without a password) 58 | 59 | `flask create-db` 60 | 61 | 1. Run the app locally 62 | 63 | `flask run` 64 | -------------------------------------------------------------------------------- /usocial/payments.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha256 2 | import json 3 | import os 4 | import secrets 5 | 6 | from lndgrpc import LNDClient 7 | from lndgrpc.compiled.lightning_pb2 import Payment, PaymentFailureReason 8 | 9 | from usocial.main import app 10 | 11 | import config 12 | 13 | KEYSEND_PREIMAGE = 5482373484 14 | PODCAST = 7629169 15 | 16 | APP_NAME = 'usocial' 17 | 18 | class PaymentFailed(Exception): 19 | def __init__(self, custom_records, message): 20 | self.custom_records = custom_records 21 | super().__init__(message) 22 | 23 | def get_lnd_client(): 24 | if not all([config.LND_IP, config.LND_GRPC_PORT, config.LND_DIR]): 25 | return None 26 | return LNDClient("%s:%s" % (config.LND_IP, config.LND_GRPC_PORT), 27 | macaroon_filepath=os.path.join(config.LND_DIR, "data/chain/bitcoin/mainnet/admin.macaroon"), 28 | cert_filepath=os.path.join(config.LND_DIR, "tls.cert")) 29 | 30 | def get_lnd_info(): 31 | lnd = get_lnd_client() 32 | if not lnd: 33 | return None 34 | return lnd.get_info() 35 | 36 | def get_podcast_tlv(value_msat, user, action, feed, item=None, ts=None): 37 | tlv = { 38 | 'value_msat': value_msat, 39 | 'sender_name': user.username, 40 | 'action': action, 41 | 'podcast': feed.title, 42 | 'url': feed.url, 43 | 'app_name': APP_NAME, 44 | } 45 | if item: 46 | tlv['episode'] = item.title 47 | if ts is not None: 48 | tlv['ts'] = ts 49 | return tlv 50 | 51 | def send_payment(recipient, amount_msat, podcast_tlv): 52 | custom_records = {} 53 | if podcast_tlv: 54 | custom_records[PODCAST] = podcast_tlv 55 | total_tlv_value_msat = sum(t['value_msat'] for t in (podcast_tlv if isinstance(podcast_tlv, list) else [podcast_tlv])) 56 | if total_tlv_value_msat != amount_msat: 57 | app.logger.warn("Sum of values described in TLV (%s) does not match the actual amount sent (%s)." % (total_tlv_value_msat, amount_msat)) 58 | if recipient.custom_key: 59 | try: 60 | custom_key = int(recipient.custom_key) 61 | except ValueError: 62 | custom_key = recipient.custom_key 63 | if custom_key not in custom_records: 64 | custom_records[custom_key] = recipient.custom_value or "" 65 | 66 | lnd = get_lnd_client() 67 | if not lnd: 68 | raise PaymentFailed(custom_records, "LND not configured.") 69 | 70 | encoded_custom_records = {} 71 | for k, v in custom_records.items(): 72 | if isinstance(v, dict) or isinstance(v, list): 73 | encoded_custom_records[k] = json.dumps(v).encode('utf-8') 74 | elif isinstance(v, str): 75 | encoded_custom_records[k] = v.encode('utf-8') 76 | else: 77 | encoded_custom_records[k] = v 78 | 79 | preimage = secrets.token_bytes(32) 80 | encoded_custom_records[KEYSEND_PREIMAGE] = preimage 81 | 82 | app.logger.info("Sending %s (%s sats) to %s. Custom records: %s." % (amount_msat, amount_msat / 1000, recipient.address, custom_records)) 83 | 84 | ret = lnd.send_payment_v2(dest=bytes.fromhex(recipient.address), amt_msat=amount_msat, 85 | dest_custom_records=encoded_custom_records, 86 | payment_hash=sha256(preimage).digest(), 87 | timeout_seconds=10, fee_limit_msat=100000, max_parts=1, final_cltv_delta=144) 88 | 89 | app.logger.debug(ret) 90 | 91 | if not ret: 92 | raise PaymentFailed(custom_records, "Nothing returned.") 93 | has_success = False 94 | for r in ret: 95 | if r.status == Payment.FAILED: 96 | raise PaymentFailed(custom_records, "Payment failed: %s." % PaymentFailureReason.Name(r.failure_reason)) 97 | if r.status == Payment.SUCCEEDED: 98 | has_success = True 99 | if not has_success: 100 | raise PaymentFailed(custom_records, "No success reported.") 101 | -------------------------------------------------------------------------------- /usocial/controllers/account.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | 3 | from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for 4 | from flask_jwt_extended import create_access_token, create_refresh_token, current_user, get_jwt_identity, set_access_cookies, set_refresh_cookies, unset_jwt_cookies, verify_jwt_in_request 5 | from flask_jwt_extended.exceptions import NoAuthorizationError 6 | from sqlalchemy.exc import IntegrityError 7 | 8 | from usocial import forms, models as m, payments 9 | from usocial.main import app, db, jwt_required 10 | 11 | import config 12 | 13 | account_blueprint = Blueprint('account', __name__) 14 | 15 | def login_success(user): 16 | response = redirect(url_for('feed.items', liked=False)) 17 | set_access_cookies(response, create_access_token(identity=user.username)) 18 | set_refresh_cookies(response, create_refresh_token(identity=user.username)) 19 | return response 20 | 21 | def only_default_user(): 22 | if m.User.query.count() == 1: 23 | return m.User.query.filter_by(username=m.User.DEFAULT_USERNAME).one_or_none() 24 | 25 | def login_default_user(): 26 | default_user = only_default_user() 27 | if default_user and not default_user.password: 28 | return login_success(default_user) 29 | 30 | @account_blueprint.route('/', methods=['GET']) 31 | def index(): 32 | try: 33 | verify_jwt_in_request() 34 | return redirect(url_for('feed.items', liked=False)) 35 | except NoAuthorizationError: 36 | return login_default_user() or redirect(url_for('account.login')) 37 | 38 | @account_blueprint.route('/account', methods=['GET']) 39 | @jwt_required 40 | def account(): 41 | q = db.session.query(m.UserItem).filter_by(user_id=current_user.id) 42 | sum_q = q.statement.with_only_columns([ 43 | db.func.coalesce(db.func.sum(m.UserItem.stream_value_played), 0), 44 | db.func.coalesce(db.func.sum(m.UserItem.stream_value_paid), 0)]) 45 | played_value, paid_value = q.session.execute(sum_q).one() 46 | paid_value_amounts = m.Action.get_total_amounts(current_user) 47 | 48 | return render_template('account.html', user=current_user, 49 | played_value=played_value, paid_value=paid_value, paid_value_amounts=paid_value_amounts, 50 | only_default_user=only_default_user(), 51 | version=config.VERSION, build=config.BUILD, 52 | lnd_info=payments.get_lnd_info()) 53 | 54 | @account_blueprint.route('/account/login', methods=['GET', 'POST']) 55 | def login(): 56 | if request.method == 'GET': 57 | if current_user: 58 | return redirect(url_for('feed.items', liked=False)) 59 | else: 60 | return login_default_user() or render_template('login.html', user=None, skip_username=bool(only_default_user()), form=forms.LoginForm()) 61 | 62 | username = request.form['username'] if not only_default_user() else m.User.DEFAULT_USERNAME 63 | password = request.form['password'] 64 | user = m.User.query.filter_by(username=username).first() 65 | success = False 66 | if not user: 67 | app.logger.info("User not found: %s", username) 68 | else: 69 | if not user.password and not password: 70 | app.logger.info("Login success no auth: %s", username) 71 | success = True 72 | if user.verify_password(password): 73 | app.logger.info("Login success password: %s", username) 74 | success = True 75 | if success: 76 | return login_success(user) 77 | else: 78 | flash("Incorrect credentials.") 79 | return redirect(url_for('account.login')) 80 | 81 | @account_blueprint.route('/account/password', methods=['GET', 'POST']) 82 | @jwt_required 83 | def password(): 84 | if request.method == 'GET': 85 | return render_template('password.html', user=current_user, 86 | form=forms.NewPasswordForm(), 87 | jwt_csrf_token=request.cookies.get('csrf_access_token')) 88 | else: 89 | if request.form['new_password'] != request.form['repeat_new_password']: 90 | flash("Passwords don't match") 91 | return redirect(url_for('account.password')) 92 | current_user.set_password(request.form['new_password']) 93 | flash("Your password was changed") 94 | db.session.add(current_user) 95 | db.session.commit() 96 | return redirect(url_for('account.account')) 97 | 98 | @account_blueprint.route('/account/logout', methods=['GET']) 99 | def logout(): 100 | response = redirect(url_for('account.login')) 101 | unset_jwt_cookies(response) 102 | return response 103 | 104 | @account_blueprint.route('/account/volume', methods=['POST']) 105 | @jwt_required 106 | def update_volume(): 107 | current_user.audio_volume = float(request.form['value']) 108 | db.session.add(current_user) 109 | db.session.commit() 110 | return jsonify(ok=True) 111 | 112 | @account_blueprint.route('/account/timezone', methods=['POST']) 113 | @jwt_required 114 | def update_timezone(): 115 | try: 116 | current_user.timezone = pytz.timezone(request.form['value']).zone 117 | except pytz.exceptions.UnknownTimeZoneError: 118 | return "Invalid timezone.", 400 119 | db.session.add(current_user) 120 | db.session.commit() 121 | return jsonify(ok=True) 122 | -------------------------------------------------------------------------------- /usocial/main.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from functools import wraps 3 | import os 4 | import signal 5 | import sys 6 | 7 | import click 8 | from flask import Flask, redirect, url_for 9 | from flask.cli import with_appcontext 10 | from flask_bcrypt import Bcrypt 11 | from flask_cors import CORS 12 | from flask_jwt_extended import create_access_token, JWTManager, set_access_cookies, verify_jwt_in_request 13 | from flask_jwt_extended.exceptions import NoAuthorizationError 14 | from flask_migrate import Migrate 15 | from flask_sqlalchemy import SQLAlchemy 16 | from flask_wtf.csrf import CSRFProtect 17 | import podcastindex 18 | from sqlalchemy.exc import IntegrityError 19 | 20 | from logging.config import dictConfig 21 | 22 | dictConfig({ 23 | 'version': 1, 24 | 'formatters': { 25 | 'default': { 26 | 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s' 27 | } 28 | }, 29 | 'root': { 30 | 'level': 'DEBUG', 31 | } 32 | }) 33 | 34 | class MyFlask(Flask): 35 | def __init__(self, import_name): 36 | instance_path = os.environ.get('INSTANCE_PATH') 37 | 38 | super().__init__(import_name, instance_path=instance_path, instance_relative_config=True) 39 | 40 | self.initialized = False 41 | 42 | def __call__(self, environ, start_response): 43 | if not self.initialized: 44 | from usocial.controllers.account import account_blueprint 45 | app.register_blueprint(account_blueprint) 46 | from usocial.controllers.api import api_blueprint 47 | app.register_blueprint(api_blueprint) 48 | from usocial.controllers.feed import feed_blueprint 49 | app.register_blueprint(feed_blueprint) 50 | self.initialized = True 51 | return super().__call__(environ, start_response) 52 | 53 | app = MyFlask(__name__) 54 | app.config.from_object('config') 55 | app.config.from_pyfile('config.py') 56 | 57 | CORS(app) 58 | csrf = CSRFProtect(app) 59 | 60 | bcrypt = Bcrypt(app) 61 | db = SQLAlchemy(app) 62 | jwt = JWTManager(app) 63 | 64 | from usocial import models as m 65 | 66 | migrate = Migrate(app, db) 67 | 68 | def get_podcastindex(): 69 | import config 70 | return podcastindex.init({'api_key': config.PODCASTINDEX_API_KEY, 'api_secret': config.PODCASTINDEX_API_SECRET}) 71 | 72 | @app.template_filter('autoversion') 73 | def autoversion_filter(filename): 74 | fullpath = os.path.join(os.path.dirname(os.path.realpath(__file__)), filename[1:]) 75 | try: 76 | timestamp = str(int(os.path.getmtime(fullpath))) 77 | except OSError: 78 | return filename 79 | newfilename = "{0}?v={1}".format(filename, timestamp) 80 | return newfilename 81 | 82 | @app.cli.command("create-db") 83 | @with_appcontext 84 | def create_db(): 85 | db.create_all() 86 | 87 | db.session.add(m.User.create_default_user()) 88 | db.session.commit() 89 | 90 | @app.cli.command("create-user") 91 | @click.argument("username") 92 | @with_appcontext 93 | def create_user(username): 94 | try: 95 | db.session.add(m.User(username)) 96 | db.session.commit() 97 | except IntegrityError: 98 | print("User already exists.") 99 | sys.exit(1) 100 | 101 | @app.cli.command("fetch-feeds") 102 | @with_appcontext 103 | def fetch_feeds(): 104 | signal.signal(signal.SIGTERM, lambda _, __: sys.exit(0)) 105 | from feedparsley import parse_feed 106 | for feed_id, in list(m.Feed.query.with_entities(m.Feed.id).all()): 107 | try: 108 | feed = db.session.query(m.Feed).get(feed_id) 109 | parsed_feed = parse_feed(feed.url) 110 | 111 | feed.fetched_at = datetime.utcnow() 112 | if not parsed_feed: 113 | feed.fetch_failed = True 114 | else: 115 | feed.fetch_failed = False 116 | new_items_count = 0 117 | users_count = 0 118 | new_items, updated_items = feed.update_items(parsed_feed) 119 | value_from_index = None 120 | if new_items: 121 | new_items_count = len(new_items) 122 | for user in m.User.query.join(m.Group).join(m.FeedGroup).join(m.Feed).filter(m.FeedGroup.feed == feed): 123 | users_count += 1 124 | for item in new_items: 125 | db.session.add(m.UserItem(user=user, item=item)) 126 | if feed.is_podcast: 127 | feed_from_index = get_podcastindex().podcastByFeedUrl(feed.url) 128 | value_from_index = feed_from_index.get('feed', {}).get('value') if feed_from_index else None 129 | feed.update_value_spec(parsed_feed['value_spec'], parsed_feed['value_recipients'], value_from_index) 130 | feed.update(parsed_feed) 131 | db.session.commit() 132 | app.logger.info(f"Feed fetched: {feed.url}. New items: {new_items_count}. Affected users: {users_count}.") 133 | except Exception as e: 134 | app.logger.exception(e) 135 | db.session.rollback() 136 | feed = m.Feed.query.get(feed_id) 137 | feed.fetched_at = datetime.utcnow() 138 | feed.fetch_failed = True 139 | db.session.add(feed) 140 | db.session.commit() 141 | 142 | @jwt.token_verification_failed_loader 143 | def no_jwt(): 144 | return redirect(url_for('account.login')) 145 | 146 | @jwt.expired_token_loader 147 | def jwt_token_expired(_jwt_header, jwt_data): 148 | identity = jwt_data["sub"] 149 | response = redirect(url_for('feed.items', liked=False)) 150 | set_access_cookies(response, create_access_token(identity=identity)) 151 | return response 152 | 153 | @jwt.user_lookup_loader 154 | def load_user(_jwt_header, jwt_data): 155 | identity = jwt_data["sub"] 156 | from usocial import models 157 | return models.User.query.filter_by(username=identity).one_or_none() 158 | 159 | def jwt_required_wrapper(refresh): 160 | def jwt_required(fn): 161 | @wraps(fn) 162 | def wrapper(*args, **kwargs): 163 | try: 164 | verify_jwt_in_request(refresh) 165 | except NoAuthorizationError: 166 | return no_jwt() 167 | return fn(*args, **kwargs) 168 | return wrapper 169 | return jwt_required 170 | 171 | jwt_required = jwt_required_wrapper(False) 172 | refresh_jwt_required = jwt_required_wrapper(True) 173 | 174 | if __name__ == '__main__': 175 | signal.signal(signal.SIGTERM, lambda _, __: sys.exit(0)) 176 | app.run(host='0.0.0.0', port=5000) 177 | -------------------------------------------------------------------------------- /usocial/templates/items.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | 4 | {% if not liked %} 5 | + + 6 | {% endif %} 7 | 8 | {% set feed_value_spec = feed.value_spec %} 9 | 10 |
11 | 12 | 168 | 169 |
13 | 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 32 | {% for feed in feeds %} 33 | {% if counts[feed.id] %} 34 | 37 | 45 | 48 | 51 | 52 | 53 | 54 | {% endif %} 55 | {% endfor %} 56 |
18 | All 19 | {{ counts['total'] }}
27 | Playing 28 |
38 | 39 | 40 | 41 | 44 | 46 | {{ feed.title }} {% if feed.is_podcast %}{% else %}{% endif %} 47 | 49 | {% if feed.fetch_failed %}{% endif %} 50 | {{ counts[feed.id] }}
57 |
58 | 59 | {% if feed %} 60 |

{{ feed.title }}

61 | 62 | {% if feed_value_spec and feed_value_spec.is_supported %} 63 | 64 | 67 | 70 | 71 | 72 | 75 | 80 | 81 | 82 | 83 | 87 | 88 | 89 | 90 | 94 | 95 | 96 | 97 | 103 | 104 | {% for action in actions %} 105 | 106 | 107 | 110 | 111 | {% endfor %} 112 | {% endif %} 113 |
65 | suggested 66 | 68 | {{ feed_value_spec.sats_amount }} sats / minute 69 |
73 | streamed 74 | 76 | {{ played_value }} 77 | minutes 78 | 79 |
contributed 98 | 99 | {% for action_name, amount in paid_value_amounts %} 100 | {{ action_name }} {{ amount }} sats 101 | {% endfor %} 102 |
114 | {% endif %} 115 | 116 | {% if show_player %} 117 | 118 | 124 |
119 | 123 |
125 | {% endif %} 126 | 127 | 128 | {% for i in items %} 129 | 138 | 158 | {% if i.updated_at %} 159 | 160 | {% else %} 161 | 162 | {% endif %} 163 | 164 | 165 | {% endfor %} 166 |
139 | 140 | 141 | 142 | 145 | {% if not liked %} 146 | 147 | 148 | 149 | 150 | 151 | 152 | {% endif %} 153 | 154 | 155 | 156 | {{ i.title }} 157 | {{ i.updated_at.strftime('%-d %b') }}
167 |
170 | {% endblock %} -------------------------------------------------------------------------------- /usocial/static/mu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /usocial/static/utils.js: -------------------------------------------------------------------------------- 1 | // NB: PODCAST_HEARTBEAT_DELAY * PODCAST_VALUE_HEARTBEAT_COUNT should always be 60000 (1 minute) 2 | // see: https://github.com/Podcastindex-org/podcast-namespace/blob/main/value/value.md#payment-intervals 3 | PODCAST_HEARTBEAT_DELAY = 1000; 4 | PODCAST_VALUE_HEARTBEAT_COUNT = 60; 5 | 6 | function getCookie(name) { 7 | const value = `; ${document.cookie}`; 8 | const parts = value.split(`; ${name}=`); 9 | if (parts.length === 2) { 10 | return parts.pop().split(';').shift(); 11 | } 12 | } 13 | 14 | function replaceClass(eId, cOld, cNew) { 15 | var e = document.getElementById(eId); 16 | if (e.classList.contains(cOld)) { 17 | e.classList.remove(cOld); 18 | } 19 | e.classList.add(cNew); 20 | } 21 | 22 | function hasParentWithClass(element, classNames) { 23 | for (const className of classNames) { 24 | if (element.classList && element.classList.contains(className)) { 25 | return true; 26 | } 27 | } 28 | return element.parentNode && hasParentWithClass(element.parentNode, classNames); 29 | } 30 | 31 | function buildXhr(successCB, errorCB) { 32 | var xhr = new XMLHttpRequest(); 33 | xhr.onreadystatechange = function() { 34 | if (xhr.readyState == 4 && xhr.status == 200) 35 | { 36 | var resp = JSON.parse(xhr.responseText); 37 | if (resp.ok) { 38 | successCB(resp); 39 | } else { 40 | if (errorCB) { 41 | errorCB(resp); 42 | } 43 | } 44 | } 45 | } 46 | return xhr; 47 | } 48 | 49 | function doPost(url, data, successCB, errorCB) { 50 | var xhr = buildXhr(successCB, errorCB); 51 | xhr.open('POST', url); 52 | xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); 53 | var token = getCookie('csrf_access_token'); 54 | xhr.send(`${data}&csrf_token=${csrfToken}&jwt_csrf_token=${token}`); 55 | return false; 56 | } 57 | 58 | function likeItem(feedId, itemId, value) { 59 | doPost(`/feeds/${feedId}/items/${itemId}/like`, `value=${value}`, 60 | function(_) { 61 | replaceClass(`item-${itemId}`, `item-liked-${1-value}`, `item-liked-${value}`); 62 | } 63 | ); 64 | } 65 | 66 | function hideItem(feedId, itemId, value) { 67 | doPost(`/feeds/${feedId}/items/${itemId}/hide`, `value=${value}`, 68 | function(_) { 69 | replaceClass(`item-${itemId}`, `item-hidden-${1-value}`, `item-hidden-${value}`); 70 | } 71 | ); 72 | } 73 | 74 | function updatePodcastItemPosition(feedId, itemId, position) { 75 | document.getElementById('item-' + itemId).dataset.play_position = position.toString(); 76 | doPost(`/feeds/${feedId}/items/${itemId}/position`, `value=${position}`, function(_) { }); 77 | } 78 | 79 | function playedItemValue(feedId, itemId, value) { 80 | var el_played_minutes = document.getElementById('stream-value-played'); 81 | var playedMinutes = parseInt(el_played_minutes.innerText); 82 | el_played_minutes.innerText = (playedMinutes + value).toString(); 83 | doPost(`/feeds/${feedId}/items/${itemId}/played-value`, `value=${value}`, function(_) { }); 84 | } 85 | 86 | function sendBoostValue() { 87 | var source = document.getElementById('audioSource'); 88 | var feedId = null, itemId = null; 89 | for (const item of document.querySelectorAll(".item")) { 90 | if (source.src === item.dataset.enclosure_url) { 91 | feedId = item.dataset.feed_id; 92 | itemId = item.dataset.id; 93 | } 94 | } 95 | if (itemId) { 96 | var player = document.getElementById('podcastPlayer'); 97 | var ts = parseInt(player.currentTime); 98 | var amount = parseInt(document.getElementById('boost-value-amount').value); 99 | sendValue(feedId, itemId, 'boost', amount, ts, document.getElementById('send-boost-value')); 100 | } 101 | } 102 | 103 | function sendStreamValue(feedId) { 104 | var amount = parseInt(document.getElementById('stream-value-amount').value); 105 | sendValue(feedId, null, 'stream', amount, null, document.getElementById('send-stream-value')); 106 | } 107 | 108 | function sendValue(feedId, itemId, action, amount, ts, button) { 109 | button.style.display = 'none'; 110 | var player = document.getElementById('podcastPlayer'); 111 | var postUrl = `/feeds/${feedId}` + (itemId ? `/items/${itemId}` : "") + "/send-value"; 112 | var params = `action=${action}&amount=${amount}` + (ts ? `&ts=${ts}` : ""); 113 | doPost(postUrl, params, 114 | function(response) { 115 | var contributionAmount = document.getElementById(`contribution-amount-${action}`); 116 | contributionAmount.innerText = (parseInt(contributionAmount.innerText) + amount).toString(); 117 | 118 | if (action === 'stream') { 119 | document.getElementById('stream-value-paid').innerText = document.getElementById('stream-value-played').innerText; 120 | } 121 | 122 | var feedDetailsBody = document.getElementById('feed-details').getElementsByTagName('tbody')[0]; 123 | var row = feedDetailsBody.insertRow(); 124 | row.className = "actionRow"; 125 | var existingActionsVisible = false; 126 | for(const row of document.querySelectorAll('.actionRow')) { 127 | if (row.style.display === "table-row") { 128 | existingActionsVisible = true; 129 | } 130 | } 131 | if (!existingActionsVisible) { 132 | row.style.display = 'none'; 133 | } 134 | row.insertCell(); 135 | 136 | var extra = response.has_errors ? '' : ""; 137 | var d = new Date(); 138 | var formattedDate = d.toLocaleString('default', { day: 'numeric' }) + " " + d.toLocaleString('default', { month: 'short' }) + " " + d.toLocaleString('default', { year: 'numeric' }); 139 | var formattedTime = d.toLocaleString('default', { hour12: false, hour: 'numeric', minute: 'numeric' }); 140 | row.insertCell().innerHTML = `${formattedDate} ${formattedTime} ${extra} ${action} ${amount} sats`; 141 | button.style.display = 'inline'; 142 | }, 143 | function(_) { 144 | button.style.display = 'inline'; 145 | alert("Send failed!"); 146 | }); 147 | } 148 | 149 | function followFeed(feedId, value) { 150 | doPost(`/feeds/${feedId}/follow`, `value=${value}`, 151 | function(_) { 152 | replaceClass(`feed-${feedId}`, `feed-followed-${1-value}`, `feed-followed-${value}`); 153 | } 154 | ); 155 | } 156 | 157 | function followPodcast(podcastindex_id, url, homepage_url, title) { 158 | var followLink = document.querySelector(`#podcast-${podcastindex_id} .follow-link span`); 159 | var oldContent = followLink.innerHTML; 160 | var oldCB = followLink.onclick; 161 | followLink.textContent = "..."; 162 | followLink.onclick = function() { return false; }; 163 | doPost("/feeds/podcasts/follow", `url=${url}&homepage_url=${homepage_url}&title=${title}`, 164 | function(_) { 165 | replaceClass(`podcast-${podcastindex_id}`, `feed-followed-0`, `feed-followed-1`); 166 | followLink.onclick = oldCB; 167 | followLink.innerHTML = oldContent; 168 | }, 169 | function(_) { 170 | followLink.onclick = oldCB; 171 | followLink.innerHTML = oldContent; 172 | alert("Failed to follow podcast."); 173 | } 174 | ); 175 | } 176 | 177 | function unfollowPodcast(podcastindex_id, url) { 178 | var unfollowLink = document.querySelector(`#podcast-${podcastindex_id} .unfollow-link span`); 179 | var oldContent = unfollowLink.innerHTML; 180 | var oldCB = unfollowLink.onclick; 181 | unfollowLink.textContent = "..."; 182 | unfollowLink.onclick = function() { return false; }; 183 | doPost("/feeds/podcasts/unfollow", `url=${url}`, 184 | function(_) { 185 | replaceClass(`podcast-${podcastindex_id}`, `feed-followed-1`, `feed-followed-0`); 186 | unfollowLink.onclick = oldCB; 187 | unfollowLink.innerHTML = oldContent; 188 | } 189 | ); 190 | } 191 | 192 | function podcastHeartbeat(feedId, itemId) { 193 | var player = document.getElementById('podcastPlayer'); 194 | if (player.duration > 0 && !player.paused) { 195 | var source = document.getElementById('audioSource'); 196 | var item = document.getElementById('item-' + itemId); 197 | 198 | if (source.src === item.dataset.enclosure_url) { // currently playing item could have changed in the last second! 199 | updatePodcastItemPosition(feedId, itemId, player.currentTime); 200 | } 201 | 202 | if (item.dataset.has_value_spec) { 203 | if (!player.valueHeartbeatCount) { 204 | player.valueHeartbeatCount = 0; 205 | } 206 | player.valueHeartbeatCount += 1; 207 | if (player.valueHeartbeatCount == PODCAST_VALUE_HEARTBEAT_COUNT) { 208 | playedItemValue(feedId, itemId, 1); 209 | player.valueHeartbeatCount = 0; 210 | } 211 | } 212 | setTimeout(function() { podcastHeartbeat(feedId, itemId) }, PODCAST_HEARTBEAT_DELAY); 213 | } 214 | } 215 | 216 | function playPodcastItem(feedId, itemId) { 217 | for (const activeItem of document.querySelectorAll('.item-active')) { 218 | activeItem.classList.remove('item-active'); 219 | } 220 | 221 | var item = document.getElementById('item-' + itemId); 222 | item.classList.add('item-active'); 223 | 224 | var boost = document.getElementById('boost-payment'); 225 | if (boost) { 226 | boost.style.display = "table-row"; 227 | } 228 | 229 | var source = document.getElementById('audioSource'); 230 | source.src = item.dataset.enclosure_url; 231 | source.type = item.dataset.enclosure_type; 232 | 233 | var player = document.getElementById('podcastPlayer'); 234 | player.onplay = function() { 235 | setTimeout(function() { podcastHeartbeat(feedId, itemId) }, PODCAST_HEARTBEAT_DELAY); 236 | } 237 | player.onended = function() { 238 | var currItem = null; 239 | var nextItem = null; 240 | for (const i of document.querySelectorAll('.item')) { 241 | if (!i.dataset.enclosure_url) { 242 | continue; 243 | } 244 | if (parseInt(i.dataset.id) === itemId) { 245 | hideItem(feedId, itemId, 1); 246 | currItem = i; 247 | continue; 248 | } 249 | if (currItem) { 250 | nextItem = i; 251 | break; 252 | } 253 | } 254 | if (boost) { 255 | boost.style.display = "none"; 256 | } 257 | if (nextItem) { 258 | playPodcastItem(parseInt(nextItem.dataset.feed_id), parseInt(nextItem.dataset.id)); 259 | } else { 260 | currItem.classList.remove('item-active'); 261 | } 262 | } 263 | player.load(); 264 | player.currentTime = parseFloat(item.dataset.play_position); 265 | player.play(); 266 | } 267 | 268 | function podcastPlayerVolumeChanged() { 269 | var player = document.getElementById('podcastPlayer'); 270 | doPost(`/account/volume`, `value=${player.volume}`, function(_) { }); 271 | } 272 | 273 | function itemClick(e, feedId, itemId) { 274 | if (hasParentWithClass(e.target, ['like-link', 'unlike-link', 'hide-link', 'unhide-link', 'open-link'])) { 275 | return; 276 | } 277 | 278 | var item = document.getElementById('item-' + itemId); 279 | if (item.dataset.enclosure_url) { 280 | playPodcastItem(feedId, itemId); 281 | } 282 | } 283 | 284 | function feedClick(e, feedId, liked) { 285 | if (hasParentWithClass(e.target, ['follow-link', 'unfollow-link'])) { 286 | return; 287 | } 288 | 289 | var smallScreen = window.innerWidth < 768; 290 | var anchor = smallScreen ? "#feed-title" : ""; 291 | window.location = `/feeds/${feedId}/items` + (liked ? '/liked' : '') + anchor; 292 | } 293 | 294 | function showStreamPayment() { 295 | var streamRow = document.getElementById('stream-payment'); 296 | streamRow.style.display = streamRow.style.display === "none" ? "table-row" : "none"; 297 | var paymentAmount = parseInt(document.getElementById('value-spec-amount').innerText) * (parseInt(document.getElementById('stream-value-played').innerText) - parseInt(document.getElementById('stream-value-paid').innerText)); 298 | document.getElementById('stream-value-amount').value = paymentAmount.toString(); 299 | } 300 | 301 | function showActions() { 302 | for(const row of document.querySelectorAll('.actionRow')) { 303 | row.style.display = row.style.display === "none" ? "table-row" : "none"; 304 | } 305 | } 306 | 307 | function onBodyLoad() { 308 | var player = document.getElementById('podcastPlayer'); 309 | if (player) { 310 | player.volume = parseFloat(player.dataset.volume); 311 | } 312 | if (username && userTimezone === '') { 313 | var tz = Intl.DateTimeFormat().resolvedOptions().timeZone; 314 | doPost(`/account/timezone`, `value=${tz}`, function(_) { }); 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /usocial/controllers/feed.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import json 3 | 4 | from babel.dates import format_timedelta 5 | from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for 6 | from flask_jwt_extended import current_user 7 | import requests 8 | from sqlalchemy import func 9 | from urllib.parse import urlparse 10 | 11 | from feedparsley import extract_feed_links, parse_feed 12 | 13 | from usocial import forms, models as m, payments 14 | from usocial.main import app, db, get_podcastindex, jwt_required 15 | 16 | import config 17 | 18 | feed_blueprint = Blueprint('feed', __name__) 19 | 20 | def get_items_feeds(feed_id, item_q): 21 | def item_to_dict(i): 22 | return { 23 | 'user_item': i[0], 'url': i[1], 'title': i[2], 'feed_id': i[3], 'updated_at': i[4], 24 | 'enclosure_url': i[5], 'enclosure_type': i[6]} 25 | items = m.UserItem.query \ 26 | .join(m.Item) \ 27 | .add_columns(m.Item.url, m.Item.title, m.Item.feed_id, m.Item.updated_at, m.Item.enclosure_url, m.Item.enclosure_type) \ 28 | .filter(m.UserItem.user == current_user) \ 29 | .filter(item_q) \ 30 | .order_by(m.Item.updated_at.desc()) 31 | try: 32 | feed_id = int(feed_id) 33 | items = items.filter(m.Item.feed_id == feed_id) 34 | except ValueError: 35 | if feed_id == 'playing': 36 | items = items.filter(m.UserItem.play_position != 0) 37 | feeds = [] 38 | for feed in m.Feed.query \ 39 | .join(m.FeedGroup).join(m.Group) \ 40 | .filter(m.Group.user == current_user) \ 41 | .order_by(db.func.lower(m.Feed.title)) \ 42 | .all(): 43 | 44 | feeds.append({ 45 | 'id': feed.id, 46 | 'title': feed.title, 47 | 'is_podcast': feed.is_podcast, 48 | 'url': feed.url, 49 | 'fetched_at': current_user.localize(feed.fetched_at), 50 | 'fetch_failed': feed.fetch_failed, 51 | 'subscribed': 1, 52 | 'active': feed_id == feed.id, 53 | }) 54 | counts = {f_id: c for f_id, c in db.session.query(m.Feed.id, db.func.count(m.UserItem.item_id)) \ 55 | .select_from(m.Feed).join(m.Item).join(m.UserItem) \ 56 | .filter(m.UserItem.user == current_user) \ 57 | .filter(item_q) \ 58 | .group_by(m.Feed.id).all()} 59 | counts['total'] = sum(counts.values()) 60 | return map(item_to_dict, items), feeds, counts 61 | 62 | @feed_blueprint.route('/feeds/all/items', methods=['GET'], defaults={'feed_id': 'all', 'liked': False}) 63 | @feed_blueprint.route('/feeds/playing/items', methods=['GET'], defaults={'feed_id': 'playing', 'liked': False}) 64 | @feed_blueprint.route('/feeds//items', methods=['GET'], defaults={'liked': False}) 65 | @feed_blueprint.route('/feeds/all/items/liked', methods=['GET'], defaults={'feed_id': 'all', 'liked': True}) 66 | @feed_blueprint.route('/feeds/playing/items/liked', methods=['GET'], defaults={'feed_id': 'playing', 'liked': True}) 67 | @feed_blueprint.route('/feeds//items/liked', methods=['GET'], defaults={'liked': True}) 68 | @jwt_required 69 | def items(liked, feed_id): 70 | user_item_filter = m.UserItem.liked == True if liked else m.UserItem.read == False 71 | items, feeds, counts = get_items_feeds(feed_id, user_item_filter) 72 | 73 | feed = None 74 | played_value, paid_value, paid_value_amounts, actions = 0, 0, [], [] 75 | if feed_id == 'all': 76 | show_player = any(f['is_podcast'] for f in feeds) 77 | elif feed_id == 'playing': 78 | show_player = True 79 | else: 80 | feed = m.Feed.query.get_or_404(feed_id) 81 | show_player = feed.is_podcast 82 | q = db.session.query(m.UserItem) \ 83 | .filter(m.UserItem.user_id == current_user.id, m.UserItem.item_id == m.Item.id, m.Item.feed_id == feed_id) 84 | sum_q = q.statement.with_only_columns([ 85 | db.func.coalesce(db.func.sum(m.UserItem.stream_value_played), 0), 86 | db.func.coalesce(db.func.sum(m.UserItem.stream_value_paid), 0)]) 87 | played_value, paid_value = q.session.execute(sum_q).one() 88 | paid_value_amounts = m.Action.get_total_amounts(current_user, feed_id) 89 | actions = m.Action.query.filter_by(user_id=current_user.id, feed_id=feed_id).order_by(m.Action.date) 90 | 91 | return render_template('items.html', liked=liked, user=current_user, 92 | feeds=feeds, items=items, counts=counts, 93 | feed=feed, show_player=show_player, 94 | played_value=played_value, paid_value=paid_value, paid_value_amounts=paid_value_amounts, actions=actions) 95 | 96 | @feed_blueprint.route('/feeds//follow', methods=['POST']) 97 | @jwt_required 98 | def follow(feed_id): 99 | if not bool(int(request.form['value'])): # unfollow 100 | for fg in m.FeedGroup.query \ 101 | .join(m.Group) \ 102 | .filter(m.Group.user == current_user, m.FeedGroup.feed_id == feed_id): 103 | db.session.delete(fg) 104 | for ue in m.UserItem.query \ 105 | .join(m.Item) \ 106 | .filter(m.UserItem.user == current_user, m.UserItem.liked == False, m.Item.feed_id == feed_id): 107 | db.session.delete(ue) 108 | else: 109 | group = m.Group.query.filter(m.Group.user == current_user, m.Group.name == m.Group.DEFAULT_GROUP).one_or_none() 110 | if not group: 111 | group = m.Group(user=current_user, name=m.Group.DEFAULT_GROUP) 112 | db.session.add(group) 113 | db.session.commit() 114 | db.session.add(m.FeedGroup(group=group, feed_id=feed_id)) 115 | existing_item_ids = {ue.item_id for ue in m.UserItem.query.join(m.Item).filter(m.UserItem.user == current_user, m.Item.feed_id == feed_id)} 116 | for item in m.Feed.query.filter_by(id=feed_id).first().items: 117 | if item.id not in existing_item_ids: 118 | db.session.add(m.UserItem(user=current_user, item=item)) 119 | db.session.commit() 120 | return jsonify(ok=True) 121 | 122 | @feed_blueprint.route('/feeds/websites/add', methods=['GET', 'POST']) 123 | @jwt_required 124 | def add_website(): 125 | if request.method == 'GET': 126 | return render_template('add_website.html', user=current_user, 127 | form=forms.FollowWebsiteForm(), jwt_csrf_token=request.cookies.get('csrf_access_token')) 128 | 129 | url = request.form['url'] 130 | if not '://' in url: 131 | url = f"http://{url}" 132 | while url.endswith('/'): 133 | url = url[:-1] 134 | 135 | r = requests.get(url) 136 | alt_links = extract_feed_links(url, r.text) 137 | if not alt_links: # NOTE: here we just assumed that "no links" means this is a feed on itself 138 | feed_url = request.form['url'] 139 | parsed_feed = parse_feed(feed_url) 140 | if not parsed_feed: 141 | flash(f"Cannot parse feed at: {feed_url}") 142 | return redirect(url_for('feed.add_website')) 143 | feed = db.session.query(m.Feed).filter_by(url=feed_url).one_or_none() 144 | if not feed: 145 | feed = m.Feed(url=feed_url) 146 | db.session.add(feed) 147 | feed.update(parsed_feed) 148 | db.session.commit() 149 | new_items, updated_items = feed.update_items(parsed_feed) 150 | db.session.commit() 151 | group = m.Group.query.filter(m.Group.user == current_user, m.Group.name == m.Group.DEFAULT_GROUP).one_or_none() 152 | if not group: 153 | group = m.Group(user=current_user, name=m.Group.DEFAULT_GROUP) 154 | db.session.add(group) 155 | db.session.commit() 156 | db.session.add(m.FeedGroup(group=group, feed_id=feed.id)) 157 | db.session.commit() 158 | existing_item_ids = {ue.item_id for ue in m.UserItem.query.join(m.Item).filter(m.UserItem.user == current_user, m.Item.feed_id == feed.id)} 159 | for item in feed.items: 160 | if item.id not in existing_item_ids: 161 | db.session.add(m.UserItem(user=current_user, item=item)) 162 | db.session.commit() 163 | return redirect(url_for('feed.items', liked=False)) 164 | else: 165 | form = forms.FollowFeedForm() 166 | form.url.choices = alt_links 167 | return render_template('add_website.html', 168 | user=current_user, 169 | form=form, jwt_csrf_token=request.cookies.get('csrf_access_token')) 170 | 171 | @feed_blueprint.route('/feeds/podcasts/search', methods=['GET', 'POST']) 172 | @jwt_required 173 | def search_podcasts(): 174 | if request.method == 'GET': 175 | return render_template('search_podcasts.html', user=current_user, 176 | form=forms.SearchPodcastForm(), jwt_csrf_token=request.cookies.get('csrf_access_token')) 177 | 178 | q = m.Feed.query.join(m.FeedGroup).join(m.Group).filter(m.Group.user == current_user).all() 179 | subscribed_urls = {f.url for f in q} 180 | result = get_podcastindex().search(request.form['keywords']) 181 | feeds = [{'id': f['id'], 182 | 'url': f['url'], 183 | 'title': f['title'], 184 | 'domain': urlparse(f['link']).netloc, 185 | 'homepage_url': f['link'], 186 | 'description': f['description'], 187 | 'image': f['artwork'], 188 | 'categories': [c for c in (f['categories'] or {}).values()], 189 | 'subscribed': f['url'] in subscribed_urls} 190 | for f in result['feeds']] 191 | return render_template('search_podcasts.html', 192 | user=current_user, 193 | podcastindex_feeds=feeds, 194 | form=forms.SearchPodcastForm(), jwt_csrf_token=request.cookies.get('csrf_access_token')) 195 | 196 | @feed_blueprint.route('/feeds/podcasts/follow', methods=['POST']) 197 | @jwt_required 198 | def follow_podcast(): 199 | feed_url = request.form['url'] 200 | feed = db.session.query(m.Feed).filter_by(url=feed_url).one_or_none() 201 | if not feed: 202 | feed = m.Feed( 203 | url=request.form['url'], 204 | homepage_url=request.form['homepage_url'], 205 | title=request.form['title']) 206 | db.session.add(feed) 207 | parsed_feed = parse_feed(feed.url) 208 | if not parsed_feed: 209 | return jsonify(ok=False) 210 | feed_from_index = get_podcastindex().podcastByFeedUrl(feed_url) 211 | value_from_index = feed_from_index.get('feed', {}).get('value') if feed_from_index else None 212 | feed.update(parsed_feed) 213 | feed.update_value_spec(parsed_feed['value_spec'], parsed_feed['value_recipients'], value_from_index) 214 | db.session.commit() 215 | new_items, updated_items = feed.update_items(parsed_feed) 216 | db.session.commit() 217 | group = m.Group.query.filter(m.Group.user == current_user, m.Group.name == m.Group.DEFAULT_GROUP).one_or_none() 218 | if not group: 219 | group = m.Group(user=current_user, name=m.Group.DEFAULT_GROUP) 220 | db.session.add(group) 221 | db.session.commit() 222 | db.session.add(m.FeedGroup(group=group, feed_id=feed.id)) 223 | db.session.commit() 224 | existing_item_ids = {ue.item_id for ue in m.UserItem.query.join(m.Item).filter(m.UserItem.user == current_user, m.Item.feed_id == feed.id)} 225 | for item in feed.items: 226 | if item.id not in existing_item_ids: 227 | db.session.add(m.UserItem(user=current_user, item=item)) 228 | db.session.commit() 229 | return jsonify(ok=True) 230 | 231 | @feed_blueprint.route('/feeds/podcasts/unfollow', methods=['POST']) 232 | @jwt_required 233 | def unfollow_podcast(): 234 | feed = m.Feed.query.filter_by(url=request.form['url']).one_or_none() 235 | if feed: 236 | for fg in m.FeedGroup.query.join(m.Group).filter(m.Group.user == current_user, m.FeedGroup.feed == feed): 237 | db.session.delete(fg) 238 | db.session.commit() 239 | # TODO: delete items! 240 | return jsonify(ok=True) 241 | 242 | def update_item(item_id, do): 243 | user_item = m.UserItem.query.filter_by(user=current_user, item_id=item_id).first() 244 | do(user_item) 245 | db.session.add(user_item) 246 | db.session.commit() 247 | return jsonify(ok=True) 248 | 249 | @feed_blueprint.route('/feeds//items//like', methods=['POST']) 250 | @jwt_required 251 | def like_item(feed_id, item_id): 252 | def update(ui): 253 | ui.liked = bool(int(request.form['value'])) 254 | return update_item(item_id, update) 255 | 256 | @feed_blueprint.route('/feeds//items//hide', methods=['POST']) 257 | @jwt_required 258 | def hide_item(feed_id, item_id): 259 | def update(ui): 260 | ui.read = bool(int(request.form['value'])) 261 | return update_item(item_id, update) 262 | 263 | @feed_blueprint.route('/feeds//items//position', methods=['POST']) 264 | @jwt_required 265 | def update_item_position(feed_id, item_id): 266 | def update(ui): 267 | ui.play_position = int(float(request.form['value'])) 268 | return update_item(item_id, update) 269 | 270 | # NB: the "value" here refers to the podcast:value interval which is a minute 271 | # see: https://github.com/Podcastindex-org/podcast-namespace/blob/main/value/value.md#payment-intervals 272 | @feed_blueprint.route('/feeds//items//played-value', methods=['POST']) 273 | @jwt_required 274 | def increment_value_item(feed_id, item_id): 275 | def update(ui): 276 | ui.stream_value_played += int(request.form['value']) 277 | return update_item(item_id, update) 278 | 279 | @feed_blueprint.route('/feeds//send-value', methods=['POST']) 280 | @feed_blueprint.route('/feeds//items//send-value', methods=['POST']) 281 | @jwt_required 282 | def send_value(feed_id, item_id=None): 283 | feed = m.Feed.query.get_or_404(feed_id) 284 | user_items = None 285 | 286 | user_item = m.UserItem.query.get_or_404((current_user.id, item_id)) if item_id else None 287 | 288 | item = user_item.item if item_id else None 289 | ts = int(request.form['ts']) if 'ts' in request.form else None 290 | 291 | action = request.form['action'] 292 | total_amount = int(request.form['amount']) 293 | total_amount_msat = total_amount * 1000 294 | 295 | if action == m.Action.Actions.stream.value: 296 | user_items = list(m.UserItem.query \ 297 | .filter(m.UserItem.user == current_user) \ 298 | .filter(m.UserItem.item_id == m.Item.id).filter(m.Item.feed_id == feed_id) \ 299 | .filter(m.UserItem.stream_value_played > m.UserItem.stream_value_paid).all()) 300 | total_value_to_pay = sum(i.stream_value_played - i.stream_value_paid for i in user_items) 301 | 302 | errors = [] 303 | success_count = 0 304 | recipient_amount_sum_msat = 0 305 | for recipient_id, recipient_amount_msat in (item or feed).value_spec.split_amount(total_amount_msat).items(): 306 | recipient_amount_sum_msat += recipient_amount_msat 307 | recipient = m.ValueRecipient.query.filter_by(id=recipient_id).first() 308 | try: 309 | if action == m.Action.Actions.boost.value: 310 | tlv = payments.get_podcast_tlv(recipient_amount_msat, current_user, action, feed, item, ts) 311 | elif action == m.Action.Actions.stream.value: 312 | tlvs = [] 313 | for i in user_items: 314 | amount_ratio = (i.stream_value_played - i.stream_value_paid) / total_value_to_pay 315 | amount_msat = int(recipient_amount_msat * amount_ratio) 316 | tlv = payments.get_podcast_tlv(amount_msat, current_user, action, feed, i.item) 317 | tlvs.append(tlv) 318 | tlv = tlvs[0] if len(tlvs) == 1 else tlvs 319 | else: 320 | return "Invalid action.", 400 321 | 322 | payments.send_payment(recipient, amount_msat=recipient_amount_msat, podcast_tlv=tlv) 323 | 324 | success_count += 1 325 | except payments.PaymentFailed as e: 326 | app.logger.exception(e) 327 | error = m.Error( 328 | address=recipient.address, amount_msat=recipient_amount_msat, 329 | item_ids=str(item_id or ''), custom_records=json.dumps(e.custom_records), 330 | message=str(e)) 331 | errors.append(error) 332 | 333 | if recipient_amount_sum_msat != total_amount_msat: 334 | app.logger.warn("Sum after splitting amongst recipients (%s) does not match original amount (%s)." % (recipient_amount_sum_msat, total_amount_msat)) 335 | 336 | if success_count: 337 | if user_items: 338 | for user_item in user_items: 339 | user_item.stream_value_paid = user_item.stream_value_played 340 | db.session.add(user_item) 341 | a = m.Action( 342 | user=current_user, 343 | feed_id=feed_id, 344 | action=action, 345 | amount_msat=total_amount_msat, 346 | item=item, 347 | ts=ts, 348 | errors=errors) 349 | db.session.add(a) 350 | db.session.commit() 351 | return jsonify(ok=True, has_errors=bool(errors)) 352 | else: 353 | return jsonify(ok=False) 354 | -------------------------------------------------------------------------------- /usocial/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import enum 3 | import hashlib 4 | import os 5 | import os.path 6 | import pytz 7 | from urllib.parse import urljoin, urlparse 8 | 9 | from babel.dates import format_timedelta 10 | 11 | from usocial.main import app, db, bcrypt 12 | 13 | import config 14 | 15 | def strip_protocol(url): 16 | return url.replace('http://', '').replace('https://', '') 17 | 18 | class User(db.Model): 19 | __tablename__ = 'users' 20 | 21 | DEFAULT_USERNAME = 'me' 22 | 23 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 24 | username = db.Column(db.String(255), unique=True, nullable=False) 25 | password = db.Column(db.String(255)) 26 | fever_api_key = db.Column(db.String(255)) 27 | registered_on = db.Column(db.DateTime, nullable=False) 28 | public = db.Column(db.Boolean, nullable=False, default=False) 29 | 30 | timezone = db.Column(db.String(100)) 31 | audio_volume = db.Column(db.Float, nullable=False, default=1.0) 32 | 33 | groups = db.relationship('Group', backref='user') 34 | 35 | @classmethod 36 | def create_default_user(cls): 37 | app.logger.info("Creating the default user.") 38 | user = cls(cls.DEFAULT_USERNAME) 39 | if config.DEFAULT_USER_PASSWORD: 40 | app.logger.info("Setting password for the default user.") 41 | user.set_password(config.DEFAULT_USER_PASSWORD) 42 | return user 43 | 44 | def __init__(self, username): 45 | self.username = username 46 | self.fever_api_key = hashlib.md5(("%s:" % username).encode('utf-8')).hexdigest() 47 | self.registered_on = datetime.utcnow() 48 | 49 | def set_password(self, password): 50 | if password: 51 | self.password = bcrypt.generate_password_hash(password, config.BCRYPT_LOG_ROUNDS).decode() 52 | else: 53 | self.password = None 54 | self.fever_api_key = hashlib.md5(("%s:%s" % (self.username, password or "")).encode('utf-8')).hexdigest() 55 | 56 | def verify_password(self, password): 57 | if not self.password: 58 | return False 59 | return bcrypt.check_password_hash(self.password, password) 60 | 61 | def localize(self, d): 62 | if not d: 63 | return None 64 | else: 65 | if self.timezone: 66 | return d.replace(tzinfo=pytz.utc).astimezone(pytz.timezone(self.timezone)) 67 | else: 68 | return d 69 | 70 | class Group(db.Model): 71 | __tablename__ = 'groups' 72 | 73 | DEFAULT_GROUP = 'Default' 74 | 75 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 76 | user_id = db.Column(db.Integer, db.ForeignKey(User.id)) 77 | name = db.Column(db.String(1000)) 78 | public = db.Column(db.Boolean, nullable=False, default=False) 79 | 80 | class Feed(db.Model): 81 | __tablename__ = 'feeds' 82 | 83 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 84 | url = db.Column(db.String(1000), unique=True, nullable=False) 85 | homepage_url = db.Column(db.String(1000), nullable=False) 86 | title = db.Column(db.String(1000)) 87 | is_podcast = db.Column(db.Boolean, nullable=False, default=False) 88 | updated_at = db.Column(db.DateTime) 89 | fetched_at = db.Column(db.DateTime) 90 | fetch_failed = db.Column(db.Boolean, default=False) 91 | parser = db.Column(db.Integer, nullable=False) 92 | 93 | items = db.relationship('Item', back_populates='feed') 94 | value_specs = db.relationship('ValueSpec') 95 | 96 | @property 97 | def domain_name(self): 98 | return urlparse(self.homepage_url).netloc 99 | 100 | @property 101 | def value_spec(self): 102 | # get value_spec for the feed (item_id is None) 103 | value_specs = [s for s in self.value_specs if s.item_id is None] 104 | assert len(value_specs) in (0, 1) 105 | return value_specs[0] if value_specs else None 106 | 107 | def update(self, parsed_feed): 108 | feed_domain = urlparse(self.url).netloc 109 | urls = {strip_protocol(self.url)} 110 | for e in parsed_feed['items']: 111 | url = strip_protocol(e['url']) 112 | if url.startswith(feed_domain): 113 | urls.add(url) 114 | common_prefix = os.path.commonprefix(list(urls)).strip('#?') 115 | self.homepage_url = f'http://{common_prefix}' 116 | self.title = parsed_feed['title'] 117 | self.updated_at = parsed_feed['updated_at'] 118 | self.parser = parsed_feed['parser'] 119 | 120 | def update_value_spec(self, p_value_spec, p_value_recipients, value_from_index): 121 | value_spec = self.value_spec 122 | 123 | # NB: the value spec coming from podcastindex.org now always overrides the one from the feed 124 | # is this good? bad? TODO: maybe we should just check whether they differ? 125 | if value_from_index and value_from_index.get('model'): 126 | p_value_spec = {'protocol': value_from_index['model']['type'], 127 | 'method': value_from_index['model']['method'], 128 | 'suggested_amount': float(value_from_index['model'].get('suggested', 0))} 129 | if value_from_index and value_from_index.get('destinations'): 130 | p_value_recipients = [ 131 | {'name': d.get('name'), 132 | 'address_type': d['type'], 133 | 'address': d['address'], 134 | 'custom_key': d.get('customKey'), 135 | 'custom_value': d.get('customValue'), 136 | 'split': d['split']} 137 | for d in value_from_index['destinations']] 138 | 139 | if not p_value_spec: 140 | if value_spec: 141 | db.session.delete(value_spec) 142 | app.logger.info(f"ValueSpec deleted for feed_id={self.id}") 143 | else: # got a value spec from the feed 144 | if value_spec: 145 | if value_spec.protocol != p_value_spec['protocol'] or value_spec.method != p_value_spec['method'] or value_spec.suggested_amount != p_value_spec['suggested_amount']: 146 | value_spec.protocol = p_value_spec['protocol'] 147 | value_spec.method = p_value_spec['method'] 148 | value_spec.suggested_amount = p_value_spec['suggested_amount'] 149 | db.session.add(value_spec) 150 | app.logger.info(f"ValueSpec changed for feed_id={self.id}: protocol {value_spec.protocol} -> {p_value_spec['protocol']}, method {value_spec.method} -> {p_value_spec['method']}, suggested_amount {value_spec.suggested_amount} -> {p_value_spec['suggested_amount']}") 151 | 152 | recipients_by_address = {r.address: r for r in value_spec.recipients} 153 | p_recipients_by_address = {p_r['address']: p_r for p_r in p_value_recipients} 154 | for deleted_a in set(recipients_by_address.keys()) - set(p_recipients_by_address.keys()): 155 | db.session.delete(recipients_by_address[deleted_a]) 156 | del recipients_by_address[deleted_a] 157 | app.logger.info(f"ValueSpec changed for feed_id={self.id}: recipient {deleted_a} removed") 158 | for added_a in set(p_recipients_by_address.keys()) - set(recipients_by_address.keys()): 159 | p_recipient = p_recipients_by_address[added_a] 160 | recipient = ValueRecipient( 161 | value_spec_id=value_spec.id, 162 | name=p_recipient['name'], 163 | address_type=p_recipient['address_type'], address=p_recipient['address'], 164 | custom_key=p_recipient['custom_key'], custom_value=p_recipient['custom_value'], 165 | split=p_recipient['split']) 166 | db.session.add(recipient) 167 | recipients_by_address[added_a] = recipient 168 | app.logger.info(f"ValueSpec changed for feed_id={self.id}: recipient {added_a} added. name: {recipient.name} split: {recipient.split}") 169 | 170 | for r_a, r in recipients_by_address.items(): 171 | p_r = p_recipients_by_address[r_a] 172 | if r.name != p_r['name']: 173 | r.name = p_r['name'] 174 | db.session.add(r) 175 | app.logger.info(f"ValueSpec changed for feed_id={self.id}: recipient {r_a} name {r.name} -> {p_r['name']}") 176 | if r.address_type != p_r['address_type']: 177 | r.address_type = p_r['address_type'] 178 | db.session.add(r) 179 | app.logger.info(f"ValueSpec changed for feed_id={self.id}: recipient {r_a} address type {r.address_type} -> {p_r['address_type']}") 180 | if r.split != p_r['split']: 181 | r.split = p_r['split'] 182 | db.session.add(r) 183 | app.logger.info(f"ValueSpec changed for feed_id={self.id}: recipient {r_a} split {r.split} -> {p_r['split']}") 184 | else: # this is new, we didn't have ValueSpec yet, so just add 185 | value_spec = ValueSpec(feed_id=self.id, protocol=p_value_spec['protocol'], method=p_value_spec['method'], suggested_amount=p_value_spec['suggested_amount']) 186 | db.session.add(value_spec) 187 | for p_recipient in p_value_recipients: 188 | recipient = ValueRecipient( 189 | value_spec=value_spec, 190 | name=p_recipient['name'], 191 | address_type=p_recipient['address_type'], address=p_recipient['address'], 192 | custom_key=p_recipient['custom_key'], custom_value=p_recipient['custom_value'], 193 | split=p_recipient['split']) 194 | db.session.add(recipient) 195 | value_spec.recipients.append(recipient) 196 | self.value_specs.append(value_spec) 197 | app.logger.info(f"ValueSpec added to feed_id={self.id} with suggested_amount={value_spec.suggested_amount} and {len(p_value_recipients)} recipients") 198 | 199 | def update_items(self, parsed_feed): 200 | new_item_urls = set() 201 | new_items = [] 202 | updated_items = [] 203 | for e in parsed_feed['items']: 204 | item_url = e['url'] 205 | if item_url.startswith('/'): 206 | item_url = urljoin(self.homepage_url, item_url) 207 | item = db.session.query(Item).filter_by(feed_id=self.id, url=item_url).first() 208 | if not item: 209 | if item_url not in new_item_urls: 210 | item = Item(feed_id=self.id, url=item_url, title=e['title'], 211 | content_from_feed=e['content'], 212 | updated_at=e['updated_at']) 213 | db.session.add(item) 214 | if e['enclosure']: 215 | item.enclosure_url = e['enclosure']['href'] 216 | item.enclosure_type = e['enclosure']['type'] 217 | try: 218 | item.enclosure_length = int(e['enclosure'].get('length', 0)) 219 | except ValueError: 220 | item.enclosure_length = 0 221 | self.is_podcast = True 222 | new_items.append(item) 223 | new_item_urls.add(item_url) 224 | elif item.title != e['title'] or item.updated_at != e['updated_at']: 225 | item.title = e['title'] 226 | item.content_from_feed = e['content'] 227 | item.updated_at = e['updated_at'] 228 | updated_items.append(item) 229 | return new_items, updated_items 230 | 231 | class FeedGroup(db.Model): 232 | __tablename__ = 'feed_groups' 233 | 234 | feed_id = db.Column(db.Integer, db.ForeignKey(Feed.id), primary_key=True) 235 | feed = db.relationship(Feed) 236 | group_id = db.Column(db.Integer, db.ForeignKey(Group.id), primary_key=True) 237 | group = db.relationship(Group) 238 | 239 | class Item(db.Model): 240 | __tablename__ = 'items' 241 | __table_args__ = ( 242 | db.UniqueConstraint('feed_id', 'url', name='uq_items_feed_id'), 243 | ) 244 | 245 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 246 | feed_id = db.Column(db.Integer, db.ForeignKey(Feed.id)) 247 | feed = db.relationship(Feed, back_populates='items') 248 | url = db.Column(db.String(1000), nullable=False) 249 | title = db.Column(db.String(1000)) 250 | content_from_feed = db.deferred(db.Column(db.String(10000))) 251 | enclosure_url = db.Column(db.String(1000)) 252 | enclosure_type = db.Column(db.String(100)) 253 | enclosure_length = db.Column(db.Integer) 254 | updated_at = db.Column(db.DateTime) 255 | 256 | @property 257 | def domain_name(self): 258 | return urlparse(self.url).netloc 259 | 260 | @property 261 | def value_spec(self): 262 | # TODO: check item value specs first, which can override feed value specs 263 | return self.feed.value_spec 264 | 265 | class UserItem(db.Model): 266 | __tablename__ = 'user_items' 267 | 268 | user_id = db.Column(db.Integer, db.ForeignKey(User.id), primary_key=True) 269 | user = db.relationship(User) 270 | item_id = db.Column(db.Integer, db.ForeignKey(Item.id), primary_key=True) 271 | item = db.relationship(Item) 272 | liked = db.Column(db.Boolean, nullable=False, default=False) 273 | read = db.Column(db.Boolean, nullable=False, default=False) 274 | play_position = db.Column(db.Integer, nullable=False, default=0) 275 | stream_value_played = db.Column(db.Integer, nullable=False, default=0) 276 | stream_value_paid = db.Column(db.Integer, nullable=False, default=0) 277 | 278 | class ValueSpec(db.Model): 279 | __tablename__ = 'value_specs' 280 | 281 | SUPPORTED_PROTOCOLS = [ 282 | ('lightning', 'keysend'), 283 | ] 284 | 285 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 286 | protocol = db.Column(db.String(20), nullable=False) 287 | method = db.Column(db.String(20), nullable=False) 288 | suggested_amount = db.Column(db.Float) 289 | feed_id = db.Column(db.Integer, db.ForeignKey(Feed.id)) 290 | item_id = db.Column(db.Integer, db.ForeignKey(Item.id), nullable=True) 291 | 292 | recipients = db.relationship('ValueRecipient', cascade="all,delete", backref='value_spec') 293 | 294 | @property 295 | def is_supported(self): 296 | return (self.protocol, self.method) in ValueSpec.SUPPORTED_PROTOCOLS 297 | 298 | @property 299 | def sats_amount(self): 300 | sats = round(self.suggested_amount * 100000000, 3) 301 | return int(sats) if sats == int(sats) else sats 302 | 303 | def split_amount(self, amount): 304 | shares = {r.id: r.split for r in self.recipients} 305 | total_shares = sum(shares.values()) 306 | return {r_id: int(amount * (r_shares / total_shares)) for r_id, r_shares in shares.items()} 307 | 308 | class ValueRecipient(db.Model): 309 | __tablename__ = 'value_recipients' 310 | 311 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 312 | value_spec_id = db.Column(db.Integer, db.ForeignKey(ValueSpec.id)) 313 | name = db.Column(db.String(100)) 314 | address_type = db.Column(db.String(20), nullable=False) 315 | address = db.Column(db.String(100), nullable=False) 316 | custom_key = db.Column(db.String(100)) 317 | custom_value = db.Column(db.String(100)) 318 | split = db.Column(db.Integer, nullable=False) 319 | 320 | class Action(db.Model): 321 | __tablename__ = 'actions' 322 | 323 | class Actions(str, enum.Enum): 324 | stream = 'stream' 325 | boost = 'boost' 326 | 327 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 328 | date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 329 | user_id = db.Column(db.Integer, db.ForeignKey(User.id)) 330 | feed_id = db.Column(db.Integer, db.ForeignKey(Feed.id)) 331 | action = db.Column(db.Enum(Actions), nullable=False) 332 | amount_msat = db.Column(db.Integer, nullable=False) 333 | item_id = db.Column(db.Integer, db.ForeignKey(Item.id), nullable=True) 334 | ts = db.Column(db.Integer, nullable=True) 335 | message = db.Column(db.String(100), nullable=True) 336 | 337 | user = db.relationship(User) 338 | feed = db.relationship(Feed) 339 | item = db.relationship(Item) 340 | errors = db.relationship('Error', backref='action') 341 | 342 | __table_args__ = ( 343 | db.ForeignKeyConstraint( 344 | [user_id, item_id], 345 | [UserItem.user_id, UserItem.item_id], 346 | ), 347 | ) 348 | 349 | @classmethod 350 | def get_total_amounts(cls, user, feed_id=None): 351 | q = Action.query \ 352 | .with_entities(Action.action, db.func.sum(Action.amount_msat)) \ 353 | .group_by(Action.action) \ 354 | .filter_by(user_id=user.id) 355 | if feed_id: 356 | q = q.filter_by(feed_id=feed_id) 357 | total_amounts = [(a.value, amount_msat // 1000) for a, amount_msat in q.all()] 358 | missing_actions = {a.value for a in Action.Actions} - {a for a, _ in total_amounts} 359 | for a in missing_actions: 360 | total_amounts.append((a, 0)) 361 | total_amounts.sort() 362 | return total_amounts 363 | 364 | class Error(db.Model): 365 | __tablename__ = 'errors' 366 | 367 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 368 | action_id = db.Column(db.Integer, db.ForeignKey(Action.id)) 369 | address = db.Column(db.String(100), nullable=False) 370 | amount_msat = db.Column(db.Integer, nullable=False) 371 | item_ids = db.Column(db.String(1000), nullable=True) 372 | custom_records = db.Column(db.String(1000), nullable=False) 373 | message = db.Column(db.String(1000), nullable=False) 374 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------