├── ansible ├── hosts ├── run.sh ├── roles │ ├── firewall │ │ ├── defaults │ │ │ └── main.yml │ │ ├── handlers │ │ │ └── main.yml │ │ ├── templates │ │ │ └── jail.local.j2 │ │ └── tasks │ │ │ └── main.yml │ ├── mysql │ │ └── tasks │ │ │ └── main.yml │ ├── nginx │ │ ├── handlers │ │ │ └── main.yml │ │ ├── files │ │ │ └── https.conf │ │ ├── vars │ │ │ └── main.yml │ │ └── tasks │ │ │ ├── main.yml │ │ │ └── ssl.yml │ ├── osmcards │ │ ├── templates │ │ │ ├── transifexrc │ │ │ └── config.py.tmpl │ │ ├── files │ │ │ ├── supervisord.conf │ │ │ ├── uwsgi.ini │ │ │ └── nginx.conf │ │ ├── handlers │ │ │ └── main.yml │ │ └── tasks │ │ │ ├── db.yml │ │ │ ├── main.yml │ │ │ ├── translations.yml │ │ │ └── nginx_uwsgi.yml │ ├── backup │ │ ├── tasks │ │ │ └── main.yml │ │ └── templates │ │ │ └── backup.j2 │ └── common │ │ └── tasks │ │ └── main.yml ├── update.sh ├── requirements.yml ├── ansible.cfg ├── install_roles.yml ├── playbook.yml └── group_vars │ └── all │ ├── vars.yml │ └── vault.yml ├── osmcards.wsgi ├── www ├── static │ ├── robots.txt │ ├── uz9osm.jpg │ ├── style.css │ └── jquery.autocomplete.min.js ├── mail.py ├── front.html ├── templates │ ├── index.html │ ├── send.html │ ├── register.html │ ├── admin.html │ ├── front.html │ ├── card.html │ ├── layout.html │ ├── settings.html │ └── profile.html ├── config_default.py ├── __init__.py ├── db.py └── crossing.py ├── babel.cfg ├── .gitignore ├── requirements.txt ├── README.md ├── .tx └── config ├── Makefile └── LICENSE /ansible/hosts: -------------------------------------------------------------------------------- 1 | [osmcards] 2 | osmcards.org 3 | -------------------------------------------------------------------------------- /osmcards.wsgi: -------------------------------------------------------------------------------- 1 | from www import create_app 2 | application = create_app() 3 | -------------------------------------------------------------------------------- /www/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Disallow: /login 3 | Disallow: /profile/ 4 | -------------------------------------------------------------------------------- /www/static/uz9osm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zverik/osmcards/HEAD/www/static/uz9osm.jpg -------------------------------------------------------------------------------- /ansible/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ansible-playbook -i hosts playbook.yml ${1+--start-at-task "$1"} 3 | -------------------------------------------------------------------------------- /ansible/roles/firewall/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | timezone: Europe/Moscow 3 | admin_email: nobody@example.com 4 | -------------------------------------------------------------------------------- /ansible/roles/firewall/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart ufw 3 | systemd: name=ufw state=restarted 4 | -------------------------------------------------------------------------------- /ansible/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname "$0")" 3 | ansible-playbook -v -i hosts playbook.yml --tags osmcards 4 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: www/**.py] 2 | [jinja2: www/templates/**.html] 3 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ 4 | -------------------------------------------------------------------------------- /www/mail.py: -------------------------------------------------------------------------------- 1 | from flask_mailman import Mail 2 | from flask_mailman import EmailMessage as Message 3 | 4 | mail = Mail() 5 | -------------------------------------------------------------------------------- /ansible/roles/mysql/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install mysql 3 | apt: 4 | name: mariadb-server 5 | state: present 6 | -------------------------------------------------------------------------------- /ansible/roles/nginx/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart nginx 3 | service: name=nginx state=restarted 4 | become: yes 5 | -------------------------------------------------------------------------------- /ansible/requirements.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - src: oefenweb.swapfile 3 | - src: geerlingguy.ntp 4 | - src: geerlingguy.certbot 5 | - src: geerlingguy.mysql 6 | -------------------------------------------------------------------------------- /www/front.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /ansible/ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | pipelining = True 3 | roles_path = roles.galaxy:roles 4 | vault_password_file = .vault_pass 5 | retry_files_enabled = False 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.py 2 | Pipfile.lock 3 | __pycache__/ 4 | ansible/.* 5 | ansible/roles.galaxy 6 | *.swp 7 | *.db 8 | *.pot 9 | www/translations/ 10 | venv/ 11 | logo/ 12 | -------------------------------------------------------------------------------- /ansible/roles/nginx/files/https.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | server_name _; 5 | return 301 https://$host$request_uri; 6 | } 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | peewee 3 | flask-compress 4 | ruamel-yaml 5 | authlib 6 | requests 7 | flask-wtf 8 | pymysql 9 | flask-babel 10 | flask-mailman 11 | flask-table 12 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/templates/transifexrc: -------------------------------------------------------------------------------- 1 | [https://www.transifex.com] 2 | api_hostname = https://api.transifex.com 3 | hostname = https://www.transifex.com 4 | username = api 5 | password = {{ transifex_token }} 6 | rest_hostname = https://rest.api.transifex.com 7 | token = {{ transifex_token }} 8 | 9 | -------------------------------------------------------------------------------- /ansible/roles/nginx/vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | acme_challenge_type: http-01 3 | acme_directory: https://acme-v02.api.letsencrypt.org/directory 4 | acme_version: 2 5 | acme_email: ilya@zverev.info 6 | 7 | letsencrypt_dir: /etc/letsencrypt 8 | letsencrypt_account_key: /etc/letsencrypt/account/account.key 9 | domain_name: osmcards.org 10 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/files/supervisord.conf: -------------------------------------------------------------------------------- 1 | [program:osmcards] 2 | user=www-data 3 | command=/usr/bin/uwsgi_python3 --ini /opt/src/uwsgi.ini 4 | stopsignal=QUIT 5 | stdout_logfile = /var/log/supervisor/osmcards-stdout.log 6 | stdout_logfile_backups = 5 7 | stderr_logfile = /var/log/supervisor/osmcards-error.log 8 | stderr_logfile_backups = 5 9 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/files/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | socket = /run/uwsgi/uwsgi.sock 3 | chmod-socket = 775 4 | chown-socket = www-data:www-data 5 | 6 | master = true 7 | gid = www-data 8 | uid = www-data 9 | processes = 2 10 | threads = 4 11 | 12 | chdir = /opt/src/osmcards 13 | virtualenv = /opt/src/osmcards/venv 14 | module = www:create_app() 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OSM Cards 2 | 3 | This is like a PostCrossing for OpenStreetMap members, but encouraging conversation. 4 | 5 | ## Translating 6 | 7 | Please translate the project [at Transifex](https://www.transifex.com/openstreetmap/osm-cards/dashboard/). 8 | 9 | ## Author and License 10 | 11 | Written by Ilya Zverev, published under MIT License. 12 | -------------------------------------------------------------------------------- /ansible/install_roles.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: localhost 3 | vars: 4 | galaxy_path: roles.galaxy 5 | tasks: 6 | - name: Remove old galaxy roles 7 | file: path={{ galaxy_path }} state=absent 8 | - name: Install Ansible Galaxy roles 9 | local_action: command ansible-galaxy install -r requirements.yml --roles-path {{ galaxy_path }} 10 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart nginx 3 | become: yes 4 | systemd: name=nginx state=restarted 5 | 6 | - name: restart supervisord 7 | become: yes 8 | systemd: name=supervisor state=restarted 9 | 10 | - name: restart uwsgi 11 | become: yes 12 | supervisorctl: 13 | name: osmcards 14 | state: restarted 15 | -------------------------------------------------------------------------------- /www/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 | }})
{{ _("You will be given a profile with an address. Please send them a postcard.") }}
5 | 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /www/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | font-size: 100%; 4 | line-height: 1.5; 5 | } 6 | 7 | .navbar-brand { 8 | font-weight: bold; 9 | font-family: "PT Sans Caption", sans-serif; 10 | } 11 | 12 | .container-md a { 13 | text-decoration: underline; 14 | } 15 | 16 | .container-md a.btn { 17 | text-decoration: none; 18 | } 19 | 20 | .pre-wrap { 21 | white-space: pre-wrap; 22 | } 23 | 24 | #login-panel { text-align: center; margin: 2em 0 2em; } 25 | -------------------------------------------------------------------------------- /ansible/roles/nginx/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install nginx and uwsgi 3 | apt: 4 | name: uwsgi,uwsgi-plugin-python3,nginx-full,git 5 | state: present 6 | 7 | - name: Redirect HTTPS 8 | copy: 9 | src: https.conf 10 | dest: /etc/nginx/sites-available/https.conf 11 | 12 | - name: Enable redirecting HTTPS 13 | file: 14 | state: link 15 | src: /etc/nginx/sites-available/https.conf 16 | path: /etc/nginx/sites-enabled/https.conf 17 | notify: restart nginx 18 | -------------------------------------------------------------------------------- /www/config_default.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = False 4 | 5 | BASE_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), '..') 6 | DATABASE = 'sqlite:///' + os.path.join(BASE_DIR, 'osmcards.db') 7 | BASE_URL = 'http://localhost:5000' 8 | ADMINS = [1] 9 | 10 | OPENSTREETMAP_CLIENT_ID = '' 11 | OPENSTREETMAP_CLIENT_SECRET = '' 12 | SECRET_KEY = 'sdkdfsdf213fhsfljhsadf' 13 | 14 | REPLY_TO = 'osmcrossing@localhost' 15 | MAIL_FROM = 'osmcrossing@localhost' 16 | MAIL_SERVER = '' 17 | MAIL_PORT = 465 18 | MAIL_USE_SSL = True 19 | MAIL_USERNAME = '' 20 | MAIL_PASSWORD = '' 21 | -------------------------------------------------------------------------------- /ansible/roles/backup/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install Borg Backup 3 | apt: name=borgbackup 4 | 5 | - name: Create a private key 6 | copy: 7 | content: "{{ borg_key }}" 8 | dest: /root/.ssh/borg 9 | mode: 0600 10 | 11 | - name: Add rsync.net to authorized keys 12 | known_hosts: 13 | host: ch-s012.rsync.net 14 | key: 'ch-s012.rsync.net,82.197.184.220 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO5lfML3qjBiDXi4yh3xPoXPHqIOeLNp66P3Unrl+8g3' 15 | 16 | - name: Install backup script 17 | template: 18 | src: backup.j2 19 | dest: /etc/cron.daily/backup 20 | mode: 0700 21 | -------------------------------------------------------------------------------- /ansible/roles/backup/templates/backup.j2: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -u -e 3 | export BORG_REPO={{ borg_repo }} 4 | export BORG_PASSPHRASE='{{ borg_pass }}' 5 | export BORG_REMOTE_PATH=borg1 6 | export BORG_RSH='ssh -i /root/.ssh/borg -oBatchMode=yes' 7 | 8 | DBDUMP=/var/tmp/osmcards.sql 9 | mysqldump --user=osmcards '--password={{ mysql_osmcards_password }}' osmcards > "$DBDUMP" 10 | 11 | borg create --compression zstd,5 ::'OSMCards_{now:%Y-%m-%d_%H%M}' \ 12 | $DBDUMP \ 13 | /etc/letsencrypt 14 | 15 | borg prune --prefix 'OSMCards_' --keep-daily=7 --keep-weekly=2 --keep-monthly=3 16 | 17 | rm $DBDUMP 18 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/templates/config.py.tmpl: -------------------------------------------------------------------------------- 1 | OPENSTREETMAP_CLIENT_ID = '{{ openstreetmap_client_id }}' 2 | OPENSTREETMAP_CLIENT_SECRET = '{{ openstreetmap_client_secret }}' 3 | SECRET_KEY = '{{ osmcards_secret_key }}' 4 | DATABASE = 'mysql://osmcards:{{ mysql_osmcards_password }}@localhost:5432/osmcards?unix_socket=/var/run/mysqld/mysqld.sock' 5 | BASE_URL = 'https://osmcards.org' 6 | 7 | REPLY_TO = 'ilya@zverev.info' 8 | MAIL_FROM = 'notify@osmcards.org' 9 | MAIL_SERVER = '{{ email_server }}' 10 | MAIL_PORT = 465 11 | MAIL_USE_SSL = True 12 | MAIL_USERNAME = '{{ email_user }}' 13 | MAIL_PASSWORD = '{{ email_password }}' 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | run: venv 2 | FLASK_APP=www FLASK_ENV=development venv/bin/flask run 3 | 4 | venv: 5 | python3 -m venv $@ 6 | venv/bin/pip install -r requirements.txt 7 | 8 | venv-up: venv 9 | venv/bin/pip install -r requirements.txt 10 | 11 | migrate: venv 12 | FLASK_APP=www FLASK_ENV=development venv/bin/flask migrate 13 | 14 | tr-extract: venv 15 | venv/bin/pybabel extract -F babel.cfg -k _l -k _p:1c,2 -o messages.pot . 16 | tx push -s 17 | 18 | tr-update: venv 19 | tx pull -a 20 | for lang in www/translations/*; do sed -i '/^#.*fuzzy/d' $$lang/LC_MESSAGES/messages.po; done 21 | venv/bin/pybabel compile -d www/translations 22 | -------------------------------------------------------------------------------- /ansible/roles/firewall/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install UFW 3 | apt: name=ufw state=present 4 | 5 | - name: Configure ufw defaults 6 | ufw: direction={{ item.direction }} policy={{ item.policy }} 7 | with_items: 8 | - { direction: 'incoming', policy: 'deny' } 9 | - { direction: 'outgoing', policy: 'allow' } 10 | notify: restart ufw 11 | 12 | - name: Open Nginx and SSH ports 13 | ufw: 14 | port: "{{ item }}" 15 | proto: tcp 16 | rule: allow 17 | with_items: 18 | - ssh 19 | - http 20 | - https 21 | notify: restart ufw 22 | 23 | - name: Enable ufw logging 24 | ufw: logging=on 25 | notify: restart ufw 26 | 27 | - name: Enable ufw 28 | ufw: state=enabled 29 | -------------------------------------------------------------------------------- /ansible/roles/nginx/tasks/ssl.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create directories for Let's Encrypt 3 | become: yes 4 | file: 5 | path: "{{ letsencrypt_dir }}/{{ item }}" 6 | state: directory 7 | owner: root 8 | group: root 9 | mode: u=rwx,g=x,o=x 10 | with_items: 11 | - account 12 | - certs 13 | - csrs 14 | - keys 15 | 16 | - name: Generate account key 17 | command: openssl genrsa 4096 -out {{ letsencrypt_account_key }} 18 | args: 19 | creates: "{{ letsencrypt_account_key }}" 20 | 21 | - name: Generate domain key 22 | command: openssl genrsa 4096 -out {{ letsencrypt_dir }}/keys/{{ domain_name }}.key 23 | args: 24 | creates: "{{ letsencrypt_dir }}/keys/{{ domain_name }}.key" 25 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/tasks/db.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create MySQL database 3 | become: yes 4 | mysql_db: 5 | login_unix_socket: /var/run/mysqld/mysqld.sock 6 | name: osmcards 7 | encoding: utf8 8 | state: present 9 | 10 | - name: Create MySQL user 11 | become: yes 12 | mysql_user: 13 | login_unix_socket: /var/run/mysqld/mysqld.sock 14 | host: localhost 15 | name: osmcards 16 | password: "{{ mysql_osmcards_password }}" 17 | priv: osmcards.*:ALL 18 | state: present 19 | 20 | - name: Migrate the database 21 | environment: 22 | FLASK_APP: /opt/src/osmcards/www 23 | command: /opt/src/osmcards/venv/bin/flask migrate 24 | register: db_migrate 25 | changed_when: db_migrate.stdout 26 | tags: osmcards 27 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/files/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2 default_server; 3 | ssl_certificate /etc/letsencrypt/live/osmcards.org/fullchain.pem; 4 | ssl_certificate_key /etc/letsencrypt/live/osmcards.org/privkey.pem; 5 | ssl_session_cache shared:SSL:20m; 6 | ssl_session_timeout 60m; 7 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 8 | add_header Strict-Transport-Security "max-age=31536000" always; 9 | 10 | server_name osmcards.org; 11 | 12 | location / { 13 | include uwsgi_params; 14 | uwsgi_pass unix:/run/uwsgi/uwsgi.sock; 15 | } 16 | 17 | location /static { 18 | alias /opt/src/osmcards/www/static; 19 | } 20 | 21 | location /favicon.ico { 22 | alias /opt/src/osmcards/www/static/favicon.ico; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ansible/playbook.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: all 3 | gather_facts: no 4 | become: yes 5 | remote_user: root 6 | roles: 7 | - common 8 | 9 | - hosts: all 10 | become: yes 11 | remote_user: root 12 | roles: 13 | - role: oefenweb.swapfile 14 | swapfile_size: 1GB 15 | - geerlingguy.ntp 16 | - geerlingguy.mysql 17 | - backup 18 | - { role: geerlingguy.certbot, tags: certbot } 19 | - nginx 20 | - firewall 21 | 22 | - hosts: all 23 | remote_user: zverik 24 | vars: 25 | ansible_ssh_private_key_file: "{{ lookup('env', 'HOME') }}/.ssh/id_rsa" 26 | roles: 27 | - { role: osmcards, tags: osmcards } 28 | 29 | - hosts: localhost 30 | gather_facts: no 31 | tasks: 32 | - name: Test connection to OSM Cards 33 | uri: 34 | url: https://osmcards.org/ 35 | status_code: 200 36 | tags: osmcards 37 | -------------------------------------------------------------------------------- /www/templates/register.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ _("Register a Postcard") }} —{% endblock %} 3 | {% block content %} 4 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /ansible/roles/osmcards/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Checkout osmcards 3 | git: 4 | repo: https://github.com/Zverik/osmcards.git 5 | dest: /opt/src/osmcards 6 | notify: restart uwsgi 7 | 8 | - name: Upload osmcards config 9 | template: 10 | src: config.py.tmpl 11 | dest: /opt/src/osmcards/config.py 12 | 13 | - name: Install virtualenv 14 | become: yes 15 | apt: 16 | name: 17 | - python3-virtualenv 18 | - python3-dev 19 | state: present 20 | 21 | - name: Create virtualenv 22 | pip: 23 | requirements: /opt/src/osmcards/requirements.txt 24 | virtualenv: /opt/src/osmcards/venv 25 | virtualenv_python: python3.8 26 | state: present 27 | 28 | - name: Init the database 29 | import_tasks: db.yml 30 | 31 | - name: Set up translations 32 | import_tasks: translations.yml 33 | 34 | - name: Set up nginx and uwsgi 35 | import_tasks: nginx_uwsgi.yml 36 | -------------------------------------------------------------------------------- /www/__init__.py: -------------------------------------------------------------------------------- 1 | from . import db 2 | from . import crossing 3 | from .mail import mail 4 | from flask import Flask, request 5 | from flask_compress import Compress 6 | from flask_wtf.csrf import CSRFProtect 7 | from flask_babel import Babel 8 | 9 | 10 | # TODO: make a function to find these 11 | SUPPORTED_LOCALES = ['en', 'ru'] 12 | 13 | 14 | def create_app(test_config=None): 15 | app = Flask(__name__) 16 | app.config.from_object('www.config_default') 17 | 18 | try: 19 | app.config.from_object('config') 20 | except FileNotFoundError: 21 | raise 22 | pass 23 | db.init_app(app) 24 | app.cli.add_command(db.migrate) 25 | crossing.oauth.init_app(app) 26 | CSRFProtect(app) 27 | babel = Babel(app) 28 | Compress(app) 29 | mail.init_app(app) 30 | 31 | def get_locale(): 32 | return request.accept_languages.best_match(SUPPORTED_LOCALES) 33 | 34 | if babel.locale_selector_func is None: 35 | babel.locale_selector_func = get_locale 36 | 37 | app.register_blueprint(crossing.cross) 38 | return app 39 | -------------------------------------------------------------------------------- /ansible/group_vars/all/vars.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ntp_timezone: Europe/Moscow 3 | admin_email: ilya@zverev.info 4 | 5 | borg_repo: "{{ v_borg_repo }}" 6 | borg_pass: "{{ v_borg_pass }}" 7 | borg_key: "{{ v_borg_key }}" 8 | 9 | transifex_token: "{{ v_transifex_token }}" 10 | 11 | mysql_osmcards_password: "{{ v_mysql_osmcards_password }}" 12 | osmcards_secret_key: "{{ v_osmcards_secret_key }}" 13 | openstreetmap_client_id: "{{ v_openstreetmap_client_id }}" 14 | openstreetmap_client_secret: "{{ v_openstreetmap_client_secret }}" 15 | 16 | certbot_admin_email: ilya@zverev.info 17 | certbot_create_if_missing: true 18 | certbot_create_method: standalone 19 | certbot_create_standalone_stop_services: [apache2] 20 | certbot_certs: 21 | - domains: 22 | - osmcards.org 23 | 24 | mysql_packages: 25 | - mariadb-server 26 | - mariadb-client 27 | mysql_python_package_debian: python3-mysqldb 28 | mysql_root_password: "{{ v_mariadb_root_password }}" 29 | # mysql_root_password_update: true 30 | 31 | email_server: "{{ v_email_server }}" 32 | email_user: "{{ v_email_user }}" 33 | email_password: "{{ v_email_password }}" 34 | -------------------------------------------------------------------------------- /www/templates/admin.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Statistics —{% endblock %} 3 | {% block content %} 4 | 5 | 10 | 11 | {% if panel == 'users' %} 12 | 13 | {{ users }} 14 | 15 | {% elif panel == 'interact' %} 16 | 17 |{{ _('Welcome to OSM Cards! Here we exchange postcards and emotions. To connect ' 4 | 'to another member of the OpenStreetMap community, just click this button') }}:
5 | 6 |{{ _('Register a Postcard') }}
7 | 8 | {% if requests %} 9 |{{ _("Postcard requests") }}: 10 | {% for req in requests %} 11 | {{ req.requested_by.name }}{{ ',' if not loop.last }} 12 | {% endfor %} 13 |
14 | {% endif %} 15 | 16 | {% if addr_requests %} 17 |{{ _("Address reveal requests") }}: 18 | {% for req in addr_requests %} 19 | {{ req.requested_by.name }}{{ ',' if not loop.last }} 20 | {% endfor %} 21 |
22 | {% endif %} 23 | 24 | {% if mailcodes %} 25 |{{ _("Postcards to send") }}: 26 | {% for code in mailcodes %} 27 | {{ code.lcode }}{{ ',' if not loop.last }} 28 | {% endfor %} 29 |
30 | {% endif %} 31 | 32 | {% if sent_cards %} 33 |{{ _("Travelling postcards") }}: 34 | {% for code in sent_cards %} 35 | {{ code.lcode }}{{ ',' if not loop.last }} 36 | {% endfor %} 37 |
38 | {% endif %} 39 | 40 | {% if delivered_cards %} 41 |{{ _("Recently delivered postcards") }}:
42 |{{ _("Your postcard has been received by %(user)s — thank you!", user=user_link) }}
17 | {% else %} 18 |{{ _("Your received this postcard from %(user)s.", user=user_link) }}
19 | {% endif %} 20 | 21 |{{ _("Sent on %(sent)s, received on %(received)s.", 22 | sent=my_format_date(code.sent_on or code.created_on), 23 | received=my_format_date(code.received_on)) }}
24 | 25 | {% set ndays %}{{ ngettext("%(num)s day", "%(num)s days", (code.received_on - (code.sent_on or code.created_on)).days) }}{% endset %} 26 |{{ _("%(from_country)s to %(to_country)s in %(days)s.", 27 | from_country=code.sent_by.country or _("Unknown country"), 28 | to_country=code.sent_to.country or _("Unknown country"), days=ndays) }}
29 | 30 | {% if code.comment %} 31 | {% if from_me %} 32 |{{ _("%(user)s left a reply", user=code.sent_to.name) }}:
33 | {% else %} 34 |{{ _("You left a reply") }}:
35 | {% endif %} 36 |{{ code.comment }}
37 | {% elif not from_me and not code.comment %} 38 | 48 | {% endif %} 49 | 50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /www/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |{{ _("This is where you can send a postcard to any - or a specific - OpenStreetMap contributor, or receive a postcard. Make OSM feel more personal, be more connected to other mappers by exchanging hand-written cards. Share your personal code (below) to establish personal connection with somebody.") }}
15 |{{ _("Please fill in your name and address to start using the service. By registering you consent to storing your personal data. It won't be shared with anybody except for other registered users of this website.") }}
16 | {% else %} 17 |{{ _("Please use English for all fields, so that people from other countries could read your profile.") }}
21 | 22 | {% macro render_field(field) %} 23 |{{ _("Your postcard has been received — thank you!") }}
24 | {% else %} 25 |{{ _("You have marked that you have sent the postcard to this address") }}:
28 | {% else %} 29 |{{ _('You should mail it to this address — do not forget to write the code "%(code)s" on it!', code=code.lcode) }}
30 | {% endif %} 31 | {% endif %} 32 | 33 | {% if asked_for_address %} 34 | 35 | {% endif %} 36 | 37 | {% if (code and not code.received_on and can_see_address) or me %} 38 | {% if me %} 39 |{{ _("This is your address. Edit it if it's obsolete or wrong.", url=url_for('c.user')) }}
40 | {% endif %} 41 |{{ user.address }}
42 |{{ _("Country") }}: {{ user.country }}.
43 | {% endif %} 44 | 45 | {% if user.languages %} 46 |{{ _("Languages they understand: %(lang)s", lang=user.languages) }}.
47 | {% endif %} 48 | 49 | {% if user.description %} 50 |{{ _("Few words about %(user)s", user=user.name) }}:
51 |{{ user.description }}
52 | {% endif %} 53 | 54 | {% if recent_card and not recent_card.comment %} 55 | 64 | {% endif %} 65 | 66 | {% if can_send %} 67 | {% if they_requested %} 68 |{{ _("They requested a postcard from you. Please click this button and send one") }}:
69 | {% endif %} 70 | 75 | {% endif %} 76 | 77 | {% if can_ask %} 78 | 83 | {% endif %} 84 | 85 | {% if req %} 86 |{{ _("You have requested a postcard from them.") }}
87 | {% elif can_request %} 88 | 93 | {% endif %} 94 | 95 | {% if code and not code.received_on %} 96 | 97 | {% if not code.sent_on %}{{ _("Mark as sent") }}{% else %}{{ _("Mark as not sent") }}{% endif %} 98 | 99 | {% endif %} 100 | 101 | {% endblock %} 102 | -------------------------------------------------------------------------------- /www/db.py: -------------------------------------------------------------------------------- 1 | import click 2 | from enum import IntEnum 3 | from datetime import datetime 4 | from flask.cli import with_appcontext, current_app 5 | from playhouse.db_url import connect 6 | from playhouse.migrate import ( 7 | migrate as peewee_migrate, 8 | SqliteMigrator, 9 | MySQLMigrator, 10 | PostgresqlMigrator 11 | ) 12 | from peewee import ( 13 | fn, 14 | DatabaseProxy, 15 | Model, 16 | CharField, 17 | TextField, 18 | IntegerField, 19 | ForeignKeyField, 20 | BooleanField, 21 | DateTimeField 22 | ) 23 | 24 | database = DatabaseProxy() 25 | 26 | 27 | CODE_LETTERS = 'ABCFJKMNPT' 28 | 29 | 30 | def set_up_logging(): 31 | import logging 32 | logger = logging.getLogger('peewee') 33 | logger.addHandler(logging.StreamHandler()) 34 | logger.setLevel(logging.DEBUG) 35 | 36 | 37 | def init_app(app): 38 | def open_db(): 39 | database.connect() 40 | 41 | def close_db(exception): 42 | if not database.is_closed(): 43 | database.close() 44 | 45 | # set_up_logging() 46 | new_db = connect(app.config['DATABASE']) 47 | database.initialize(new_db) 48 | app.before_request(open_db) 49 | app.teardown_request(close_db) 50 | 51 | 52 | def fn_Random(): 53 | if 'mysql' in current_app.config['DATABASE']: 54 | return fn.Rand() 55 | else: 56 | return fn.Random() 57 | 58 | 59 | class AddressPrivacy(IntEnum): 60 | OPEN = 2 # Any user can get this user's data 61 | CONFIRMED = 4 # Visible to only confirmed users 62 | PROFILE = 6 # Does not participate in random 63 | ASK = 8 # Ask for permission, not in random 64 | CLOSED = 10 # Does not appear anywhere 65 | 66 | 67 | class BaseModel(Model): 68 | class Meta: 69 | database = database 70 | 71 | 72 | class User(BaseModel): 73 | created_on = DateTimeField(default=datetime.now) 74 | active_on = DateTimeField(default=datetime.now) 75 | name = CharField(max_length=250) 76 | code = CharField(max_length=32, index=True) # Secret code to request a postcard 77 | osm_uid = IntegerField(index=True) 78 | osm_name = CharField() 79 | email = CharField(max_length=250, null=True) 80 | description = TextField(null=True) # "About", like on postcrossing. No links. 81 | languages = CharField(default='English') # Plain text 82 | site_lang = CharField(max_length=7, default='en') 83 | address = TextField(null=True) # Properly formatted, with newlines 84 | is_active = BooleanField(default=True) # False if not visible. Kind of account deletion 85 | privacy = IntegerField(default=AddressPrivacy.OPEN) 86 | does_requests = BooleanField(default=False) 87 | country = CharField(max_length=250, null=True) # TODO: free-form or string name? 88 | 89 | @property 90 | def is_registered(self): 91 | return self.name and self.address and self.is_active 92 | 93 | @property 94 | def is_admin(self): 95 | return self.id == 1 96 | 97 | 98 | class MailCode(BaseModel): 99 | created_on = DateTimeField(default=datetime.now) 100 | code = IntegerField(primary_key=True) 101 | sent_by = ForeignKeyField(User, index=True) 102 | sent_to = ForeignKeyField(User) 103 | sent_address = CharField() 104 | sent_on = DateTimeField(null=True) 105 | received_on = DateTimeField(null=True) 106 | comment = TextField(null=True) # Comment from receiver 107 | is_active = BooleanField(default=True) # Not received and not expired 108 | 109 | @property 110 | def lcode(self): 111 | """Letter code: A2345 for 12345.""" 112 | s = str(self.code) 113 | return CODE_LETTERS[int(s[0]) - 1] + s[1:] 114 | 115 | @staticmethod 116 | def restore_code(code): 117 | if not code: 118 | return None 119 | if isinstance(code, int): 120 | return code 121 | code = code.upper() 122 | cl_idx = CODE_LETTERS.find(code[0]) 123 | if cl_idx >= 0: 124 | code = str(cl_idx + 1) + code[1:] 125 | try: 126 | return int(code) 127 | except ValueError: 128 | return 0 129 | 130 | 131 | class MailRequest(BaseModel): 132 | created_on = DateTimeField(default=datetime.now) 133 | is_active = BooleanField(default=True) 134 | is_hidden = BooleanField(default=False) 135 | requested_by = ForeignKeyField(User, index=True) 136 | requested_from = ForeignKeyField(User, index=True) 137 | comment = TextField(null=True) 138 | 139 | 140 | class ProfileRequest(BaseModel): 141 | created_on = DateTimeField(default=datetime.now) 142 | requested_by = ForeignKeyField(User, index=True) 143 | requested_from = ForeignKeyField(User, index=True) 144 | granted = BooleanField(null=True) 145 | 146 | 147 | # MIGRATION ############################################# 148 | 149 | 150 | LAST_VERSION = 1 151 | 152 | 153 | class Version(BaseModel): 154 | version = IntegerField() 155 | 156 | 157 | @click.command('migrate') 158 | @with_appcontext 159 | def migrate(): 160 | database.connect() 161 | database.create_tables([Version], safe=True) 162 | try: 163 | v = Version.select().get() 164 | except Version.DoesNotExist: 165 | # Prints are here to mark a change for Ansible 166 | print('Creating tables') 167 | database.create_tables([User, MailCode, MailRequest, ProfileRequest]) 168 | v = Version(version=LAST_VERSION) 169 | v.save() 170 | 171 | if v.version >= LAST_VERSION: 172 | return 173 | 174 | print('Upgrading database version {} to version {}'.format(v.version, LAST_VERSION)) 175 | 176 | uri = current_app.config['DATABASE'] 177 | if 'mysql' in uri: 178 | migrator = MySQLMigrator(database) 179 | elif 'sqlite' in uri: 180 | migrator = SqliteMigrator(database) 181 | else: 182 | migrator = PostgresqlMigrator(database) 183 | 184 | if v.version == 0: 185 | database.create_tables([ProfileRequest]) 186 | v.version = 1 187 | v.save() 188 | 189 | # When making further migrations, refer to 190 | # https://github.com/mapsme/cf_audit/blob/master/www/db.py 191 | 192 | if v.version != LAST_VERSION: 193 | raise ValueError('LAST_VERSION in db.py should be {}'.format(v.version)) 194 | -------------------------------------------------------------------------------- /ansible/group_vars/all/vault.yml: -------------------------------------------------------------------------------- 1 | $ANSIBLE_VAULT;1.1;AES256 2 | 33623463363535653032636164653061316463323430656131633531306433333636383732376233 3 | 6138313865303161623965386661636330366633653133350a626538396164633331656632326136 4 | 35303931356438313831653739346438653864316437333433633463643266303364323032366234 5 | 6164656364643864390a623736396564323465343961346130643662626337623039613131323161 6 | 65343236323731396366383835326231316430373230626537383335396162633861393031633161 7 | 32373565656163333564643639303561373631313666363436643238666262376237626539376234 8 | 39633665373735323862653266393433353239656337396138363565383638613035313762363166 9 | 35663063336333376466353134363435656539653135383963323631613861303332623662326238 10 | 36653364323835343037663366316466393833313231303564313634306563383561376531303534 11 | 35383064653463393764666435356161356138343936666530343432303963353732346630636461 12 | 37353137373262353262323663376339386437363461653832363466336366396637613063383864 13 | 66636539306338303735663439333436656662623864663134376437656534393465303662626631 14 | 63643862613866343662393730316566343731383464303938306638616362343631623864623564 15 | 65396135333164316233303564353433616232366235326265616265383866613964326638646263 16 | 64633266323965396561356139393638393039356636393637323737643233663932353765313138 17 | 66313865366665633737656665343431303439313530353964366562343436653331396662616363 18 | 36623032373164633066353164346433643436333739646361616433323135656338363231636563 19 | 64613539383165646438663432316235666165396434343730633461346365646566643637383461 20 | 62663234646635383762623364613139336466343961643062316432373334323634653131383138 21 | 31643039636337366230613464653832313564323762333330356139326135373364613766626664 22 | 30656232666561323162326262356333366638633836393465373666303831656630356436663435 23 | 37613034373134363531393665343435666438373565366666626236373232356335643263303462 24 | 37613136343965376131386663383361376439343133373634373439396334373631373633333235 25 | 62643639343436386661343566383339633165383065343466633566656666306137326133333334 26 | 32316165356163346538633265646466643935366638333230613165373163646431396563623734 27 | 66326466356234376639646537313035376365333565326263353638633239646433306561613739 28 | 62386336643231633734613333613238363834313463323533363435356162666237303236643431 29 | 33363131623132383161343331626536393362326664343562363231613164343433646466306365 30 | 33383962353432333234643633633937333833383863366637666639366338663833363231623037 31 | 37383966386633613961613136366233323534643039636464363563396433356633313536366136 32 | 36376262366166323631643964343965383861653063366463643061653639363538363930323464 33 | 38333563353763353235326665633030353964323033393533613737376633393363343930643030 34 | 35626466643662656163663239303239353836336661343434646166386338376534613030653530 35 | 36653665343166346437613330303737383966323861313865333937663034643930333562356232 36 | 34316333636237623930396263323430663863323733386633396661663936336537346166353265 37 | 37323863393538366433353836636166633033613534616661613463633765653834396539303137 38 | 39623233666336663537366433663962633233396638633537623965623933396239326464383936 39 | 30323739306164626236363733363433326366613961356566633437313732326262306365373830 40 | 66383765663332663232343132353632376639643733333936396566343431663938386538666663 41 | 39633666333930343364373932363366333361626537323837353366663435653136346134336232 42 | 31356437633331333332393338313462363437313532646337396233643934376163663830643635 43 | 30393737333031613538316139376264376433633461383137343062363630306639643136346266 44 | 61656364343332303232313330333831373639383265356336376663336561613239626634386566 45 | 30393031623566656662626137633166633032613036366439323361653562633831373265363864 46 | 36313133626131343662633739643636366166383038643134373466326634306261383730303636 47 | 33653537626637396465633637643031633162303262316631373061616134636239376636383236 48 | 35343662353065333662636362346563313664633635396237666430666464343362363037623366 49 | 61616638333035636636663861666139653931353962343964666162633662343661363565663830 50 | 36393362626330393939363166633261393337366661633333316262643435623166386366626564 51 | 32373432373461366564663735643361636630646230663834646339356632623463323536633862 52 | 37303962343531323633363636633863313863386135303961613963336564303066613731323631 53 | 30613664653665386330393161356633376433656562343030323830636264323164353163363633 54 | 30663231653633336632316430356636666233303366303961336533313936636433343262613035 55 | 65363866393963393136343430663462323963616430346266356135396532363837616636633731 56 | 33633134393735306465623834336438633738343661363465616337333662626362393334633934 57 | 34613262303864303563383932333633363236346133613964306465326566376438323965623465 58 | 61363039306562333039303530656565346634646434646264653230373038336262333737343133 59 | 36613338626434633461616630386530656661646234396465353033633138646238313963613936 60 | 30313534303538373266363764656562646164326239666363643734646533316166336466373766 61 | 32346231616539633939316438613965353438323030653339636637356634313164353164393139 62 | 61643465323330643936326230336634646634653966663030346535613732666264363539653633 63 | 66376561613837373262353538626536613339396566666633313635393533626239353736336439 64 | 36636433386465306530386164313633396661343636313830346563366535616263373037666362 65 | 33346166313535343566643531396261663362616261386364643637353662316330323835616565 66 | 30663239313537316262343562656636353962363564636337356566393035643734633965303733 67 | 64333735643864643730626637643431646366633636373739393965306434383166666462633839 68 | 33643266343938626231323565626637383538333434323962623533616166353135313166306361 69 | 65343862353936393563666232653438306535633864653765653335633739613662666238333439 70 | 31363239643930346330333937663762366630346238653738653032626163356333306336643162 71 | 39396665326566343666303565313365646435323361386665323633646537346638363462353831 72 | 38336232623335333331323665653065613037383338613734626635316135346134393834306464 73 | 30353133396663393434323964396434636164616364316433353964326164626462303330663939 74 | 32636131386332646333326263363738323665396332326564656236366661666462366438666632 75 | 65333136396664363431326664376463353765336566393963316635623236323034616432663465 76 | 32636362653531383265633339343935643139613039353536373930616233333064316532616538 77 | 38656231626161633731303364623830313432376666616339333264363138663432346132316466 78 | 64393532353761326435353336353633303765353666643766383630656266346336346434626436 79 | 36353662393736653464363666346165613065653463306663373264373333643465653665373538 80 | 63306535623639666365663936346563326539336164313534396133323632626135396130643366 81 | 35356133343430396232303539623565653834343365393038363166663061323934616139636131 82 | 39653965393930633065656339383261366531383835393334643538336163613539666138373232 83 | 63393965646236323332643838373434323538666331363033346232366466376634356166323466 84 | 31613932666632356161363238633066386666373264613661366232613933626630656263343962 85 | 33656432303765656634316462616230333430306565386135623964653238616664333931353762 86 | 64636336383061306631306338393238626634613033306330316363363132353263376265353162 87 | 31626232383739393134633863663263633337343139356434326432323434626466613761373731 88 | 64373166386362373961393564633831306436306261653036626137633861353331613561346466 89 | 35343461633662663238313039306363653464636339306463346637306532336564313662383264 90 | 64303938356534303336643832323435323235666632646161366466656237393630393034623139 91 | 61646466316337636261356261303538326164346537366165333261326535366165393534613861 92 | 30663865306166346337616332643064353763376231643261656131653631623131336464656339 93 | 33366664316364313863653664386462396336643038396131613230653233666465343638346138 94 | 38323539636566343933393238623837653465396134383431316639383165653738353665623930 95 | 33656535346130643731663333613435616438353233643036396130386566636436636566623030 96 | 62636530393138616239376433373433356536343137653861303333663566323862386261383938 97 | 61343836623034353864343831616539396635313266343233303634616336663435666661376435 98 | 62383462663830653131303365316437343435653832616665653638633133323436663863646438 99 | 61323339643161396538633664613362333532346665646463333065613136646363623035653666 100 | 33383363646234653066613166626232656437383935663532323662356463376334633862366133 101 | 62373662333431373666613239666562363236653137643638386433623637383531623166393632 102 | 35636564306132373739316233663662346538613232666364363861313438333636646232356137 103 | 30313138666236353266313764373064646262376362376666373530373732363931653762336337 104 | 37633763663333653066336635373961623738313735613530363532646534373530623162303530 105 | 34336135613130326137373337633163313062383665393938386164323535343037323938393730 106 | 34633462343833343362333763666266623330323333376563666433383562316634316134643761 107 | 37383964623266633064666337313233383066383035346363646566653730653761616639353537 108 | 38366435653231333065353038646231323062323762613534373639386236383166653439613937 109 | 38363763613138633235613261316437373438383562636531666264653162323635616563333565 110 | 39326562653734313938353233333365386436343864353237373234386639346239656632666436 111 | 65323232303232316532323634663364356533383136303932326165303330333732306431343738 112 | 32336430646664616362366437336539383633316433393831306431633665353832396136613839 113 | 33326330336162376661303164633834396634343437633737306266653332633437386664323063 114 | 35383765613462313532366365363439333737393032373261323733373963353264306638343334 115 | 39653337663334663833333134396534353436623737363530663434666237383431643839376233 116 | 61383061353137356466643665303830663137343437623263346436656363663739306331306364 117 | 65383431636534353438363538346261656430336263613433343862326162386637376135346433 118 | 64636461336239323864643764313338333731326436393864353061386130653132363638646337 119 | 64356537313861386264363538326336363137333337353433623166323466303738616335633661 120 | 62393064613835633664643464336136326663663738643739323935376534333331663032393366 121 | 36646233326339303935656635346366656131623464383031613262363463663138313034313430 122 | 36383234633936326532656234313336353561653662353632363331663239356363303461636433 123 | 64663238343838613930363938633563326162346330613665366631313162623635633064393531 124 | 333135393833396465643731343164393837 125 | -------------------------------------------------------------------------------- /www/static/jquery.autocomplete.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Ajax Autocomplete for jQuery, version 1.4.11 3 | * (c) 2017 Tomas Kirda 4 | * 5 | * Ajax Autocomplete for jQuery is freely distributable under the terms of an MIT-style license. 6 | * For details, see the web site: https://github.com/devbridge/jQuery-Autocomplete 7 | */ 8 | !function(a){"use strict";"function"==typeof define&&define.amd?define(["jquery"],a):a("object"==typeof exports&&"function"==typeof require?require("jquery"):jQuery)}(function(a){"use strict";function b(c,d){var e=this;e.element=c,e.el=a(c),e.suggestions=[],e.badQueries=[],e.selectedIndex=-1,e.currentValue=e.element.value,e.timeoutId=null,e.cachedResponse={},e.onChangeTimeout=null,e.onChange=null,e.isLocal=!1,e.suggestionsContainer=null,e.noSuggestionsContainer=null,e.options=a.extend(!0,{},b.defaults,d),e.classes={selected:"autocomplete-selected",suggestion:"autocomplete-suggestion"},e.hint=null,e.hintValue="",e.selection=null,e.initialize(),e.setOptions(d)}function c(a,b,c){return a.value.toLowerCase().indexOf(c)!==-1}function d(b){return"string"==typeof b?a.parseJSON(b):b}function e(a,b){if(!b)return a.value;var c="("+g.escapeRegExChars(b)+")";return a.value.replace(new RegExp(c,"gi"),"$1").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/<(\/?strong)>/g,"<$1>")}function f(a,b){return'')
457 | @login_requred
458 | def grant(code):
459 | req = ProfileRequest.get_or_none(
460 | ProfileRequest.id == code,
461 | ProfileRequest.requested_from == g.user
462 | )
463 | if not req:
464 | flash(_('There is no request with this code'))
465 | return redirect(url_for('c.front'))
466 |
467 | req.granted = True
468 | req.save()
469 |
470 | user = req.requested_by
471 | with force_locale(user.site_lang):
472 | send_email(user, _p('mail', '%(user)s wants your postcard', user=g.user.name),
473 | '{}\n\n{}'.format(
474 | _p('mail', '%(user)s has accepted your request to send them a postcard. '
475 | 'Please click on the button in their profile and send one!',
476 | user=g.user.name),
477 | url_for('c.profile', pcode=g.user.code, _external=True))
478 | )
479 | flash(_('Thank you for granting permission to send you a postcard!'), 'info')
480 | if user.privacy < AddressPrivacy.CLOSED:
481 | return redirect(url_for('c.profile', pcode=user.code))
482 | return redirect(url_for('c.front'))
483 |
484 |
485 | @cross.route('/profile')
486 | @cross.route('/profile/')
487 | @cross.route('/send/')
488 | @login_requred
489 | def profile(pcode=None, scode=None):
490 | # 6 requests for one page! Refactor this somehow maybe.
491 | mailcode = None
492 | if pcode:
493 | puser = User.get_or_none(User.code == pcode)
494 | if not puser or not puser.is_registered:
495 | flash(_('There is no user with this private code.'))
496 | return redirect(url_for('c.front'))
497 | elif scode:
498 | mailcode = MailCode.get_or_none(MailCode.code == scode)
499 | if not mailcode or mailcode.sent_by != g.user:
500 | flash(_('No such mailcode.'))
501 | return redirect(url_for('c.front'))
502 | if mailcode.received_on:
503 | return redirect(url_for('c.card', code=mailcode.code))
504 | puser = mailcode.sent_to
505 | else:
506 | puser = g.user
507 | if not puser:
508 | # Should not happen
509 | return _('Sorry, no user with this code.')
510 |
511 | prequest = recent_postcard = they_requested = asked_for_address = None
512 | can_send = can_request = can_ask = recently_registered = can_see_address = False
513 | is_me = g.user == puser
514 | if not is_me:
515 | if puser.privacy == AddressPrivacy.ASK:
516 | my_request = ProfileRequest.get_or_none(
517 | ProfileRequest.requested_by == g.user,
518 | ProfileRequest.requested_from == puser
519 | )
520 | if not my_request:
521 | can_ask = True
522 | else:
523 | can_see_address = my_request.granted
524 | else:
525 | can_see_address = puser.privacy <= AddressPrivacy.PROFILE
526 |
527 | if g.user.privacy == AddressPrivacy.ASK:
528 | asked_for_address = ProfileRequest.get_or_none(
529 | ProfileRequest.requested_from == g.user,
530 | ProfileRequest.requested_by == puser,
531 | ProfileRequest.granted.is_null(True)
532 | )
533 |
534 | prequest = MailRequest.get_or_none(
535 | MailRequest.requested_by == g.user,
536 | MailRequest.requested_from == puser,
537 | MailRequest.is_active == True
538 | )
539 | they_requested = MailRequest.get_or_none(
540 | MailRequest.requested_by == puser,
541 | MailRequest.requested_from == g.user,
542 | MailRequest.is_active == True
543 | )
544 | they_sending = MailCode.get_or_none(
545 | MailCode.sent_by == puser,
546 | MailCode.sent_to == g.user,
547 | MailCode.is_active == True
548 | ) is not None
549 | if not mailcode:
550 | mailcode = MailCode.get_or_none(
551 | MailCode.sent_by == g.user,
552 | MailCode.sent_to == puser,
553 | MailCode.is_active == True
554 | )
555 | try:
556 | recent_postcard = MailCode.select().where(
557 | MailCode.sent_by == puser,
558 | MailCode.sent_to == g.user,
559 | MailCode.received_on.is_null(False)
560 | ).order_by(MailCode.received_on.desc()).get()
561 | except MailCode.DoesNotExist:
562 | pass
563 |
564 | recently_registered = (
565 | recent_postcard and
566 | recent_postcard.received_on >= datetime.now() - timedelta(days=1)
567 | )
568 | can_send = not mailcode and (they_requested or can_see_address)
569 | can_request = (not scode and not they_requested and puser.does_requests and
570 | not recent_postcard and not they_sending)
571 |
572 | return render_template(
573 | 'profile.html',
574 | user=puser,
575 | me=is_me,
576 | code=mailcode,
577 | req=prequest,
578 | from_mailcode=scode is not None,
579 | can_send=can_send,
580 | can_ask=can_ask,
581 | can_request=can_request,
582 | can_see_address=can_see_address,
583 | they_requested=they_requested,
584 | asked_for_address=asked_for_address,
585 | recent_card=None if not recently_registered else recent_postcard
586 | )
587 |
588 |
589 | @cross.route('/togglesent/')
590 | @login_requred
591 | def togglesent(code):
592 | mailcode = MailCode.get_or_none(MailCode.code == code)
593 | if not mailcode or mailcode.sent_by != g.user:
594 | flash(_('No such mailcode.'))
595 | return redirect(url_for('c.front'))
596 | if mailcode.sent_on:
597 | mailcode.sent_on = None
598 | else:
599 | mailcode.sent_on = datetime.now()
600 | mailcode.save()
601 | return redirect(url_for('c.profile', scode=code))
602 |
603 |
604 | @cross.route('/card/')
605 | @login_requred
606 | def card(code):
607 | mailcode = MailCode.get_or_none(
608 | (MailCode.code == code) &
609 | ((MailCode.sent_by == g.user) | (MailCode.sent_to == g.user))
610 | )
611 | if not mailcode:
612 | flash(_('Cannot find a postcard with this code. Please check it again.'))
613 | return redirect(url_for('c.front'))
614 | if not mailcode.received_on:
615 | if mailcode.sent_by == g.user:
616 | # If the card was not received, show the user profile
617 | return redirect(url_for('c.profile', scode=mailcode.code))
618 | else:
619 | # User should not know the code before they've received the card.
620 | # Let's nudge them towards the registering page.
621 | return redirect(url_for('c.register'))
622 | other_user = mailcode.sent_by if mailcode.sent_to == g.user else mailcode.sent_to
623 | return render_template(
624 | 'card.html', code=mailcode, from_me=mailcode.sent_by == g.user,
625 | other_user=other_user,
626 | can_see_profile=other_user.privacy < AddressPrivacy.CLOSED)
627 |
628 |
629 | class RegisterForm(FlaskForm):
630 | code = StringField(
631 | _l('Code on the postcard'),
632 | validators=[validators.DataRequired(), validators.Length(min=5, max=5)]
633 | )
634 | comment = TextAreaField(
635 | _l('Please send the user a comment about their postcard')
636 | )
637 |
638 |
639 | @cross.route('/register', methods=['GET', 'POST'])
640 | @login_requred
641 | def register():
642 | form = RegisterForm()
643 | if not form.validate_on_submit():
644 | return render_template('register.html', form=form)
645 |
646 | code = form.code.data
647 | mailcode = MailCode.get_or_none(
648 | MailCode.code == MailCode.restore_code(code),
649 | MailCode.sent_to == g.user,
650 | )
651 | if not mailcode:
652 | flash(_('Cannot find a postcard with this code. Please check it again.'))
653 | return render_template('register.html', form=form)
654 |
655 | if not mailcode.is_active:
656 | flash(_('This postcard has already been registered. Thank you!'), 'info')
657 | return redirect(url_for('c.card', code=mailcode.code))
658 |
659 | comment = form.comment.data.strip()
660 | if comment:
661 | mailcode.comment = comment
662 | mailcode.received_on = datetime.now()
663 | mailcode.is_active = False
664 | mailcode.save()
665 |
666 | with force_locale(mailcode.sent_by.site_lang or 'en'):
667 | send_email(
668 | mailcode.sent_by,
669 | _p('mail', 'Your postcard %(code)s has been received', code=mailcode.lcode),
670 | '{}{}\n\n{}'.format(
671 | _p('mail', 'Your postcard to %(user)s has arrived and has been registered '
672 | 'just now!', user=g.user.name),
673 | '' if not comment else ' {}:\n\n{}'.format(
674 | _p('mail', 'They have left a reply to it'), comment
675 | ),
676 | url_for('c.card', code=mailcode.code, _external=True))
677 | )
678 |
679 | flash(_('Thank you for registering the postcard!'), 'info')
680 | return redirect(url_for('c.card', code=mailcode.code))
681 |
682 |
683 | @cross.route('/comment/', methods=['POST'])
684 | @login_requred
685 | def comment(code):
686 | mailcode = MailCode.get_or_none(
687 | MailCode.code == code,
688 | MailCode.received_on.is_null(False),
689 | MailCode.sent_to == g.user
690 | )
691 | if not mailcode:
692 | flash(_('No such mailcode.'))
693 | return redirect(url_for('c.front'))
694 | comment = request.form.get('comment', '').strip()
695 | if comment:
696 | if mailcode.comment:
697 | flash(_('Cannot change already stored comment, sorry.'))
698 | else:
699 | mailcode.comment = comment
700 | mailcode.save()
701 | with force_locale(mailcode.sent_by.site_lang or 'en'):
702 | send_email(
703 | mailcode.sent_by,
704 | _p('mail', 'Comment on your postcard %(code)s', code=mailcode.lcode),
705 | '{}:\n\n{}\n\n{}'.format(
706 | _p('mail', '%(user)s has just left a reply to your postcard',
707 | user=g.user.name),
708 | comment, url_for('c.card', code=mailcode.code, _external=True))
709 | )
710 | flash(_('Comment sent, thank you for connecting!'), 'info')
711 | return redirect(url_for('c.card', code=mailcode.code))
712 |
713 |
714 | class LCodeCol(Col):
715 | def td_format(self, content):
716 | s = str(content)
717 | CODE_LETTERS = 'ABCFJKMNPT'
718 | return CODE_LETTERS[int(s[0]) - 1] + s[1:]
719 |
720 |
721 | class UsersTable(Table):
722 | classes = ['table', 'table-sm']
723 | id = Col('id')
724 | created_on = DateCol('Created', date_format='d.MM.yyyy')
725 | name = LinkCol('Name', 'c.profile', attr='name', url_kwargs={'pcode': 'code'})
726 | osm_name = Col('OSM Name')
727 | country = Col('Country')
728 | privacy = OptCol('Privacy', {
729 | 2: 'Open', 4: 'Confirmed', 6: 'Profile',
730 | 8: 'Ask', 10: 'Closed'
731 | })
732 | does_requests = BoolNaCol('Requests?')
733 | is_active = BoolNaCol('Active?')
734 |
735 |
736 | class CodesTable(Table):
737 | classes = ['table', 'table-sm']
738 | code = LCodeCol('Code')
739 | sent_by = Col('From')
740 | sent_to = Col('To')
741 | created_on = DateCol('Created', date_format='d.MM.yyyy')
742 | sent_on = DateCol('Sent', date_format='d.MM.yyyy')
743 |
744 |
745 | class ReceivedTable(Table):
746 | classes = ['table', 'table-sm']
747 | code = LCodeCol('Code')
748 | sent_by = Col('From')
749 | sent_to = Col('To')
750 | created_on = DateCol('Created', date_format='d.MM.yyyy')
751 | sent_on = DateCol('Sent', date_format='d.MM.yyyy')
752 | received_on = DateCol('Received', date_format='d.MM.yyyy')
753 |
754 |
755 | class RequestsTable(Table):
756 | classes = ['table', 'table-sm']
757 | created_on = DateCol('Created', date_format='d.MM.yyyy')
758 | req_by = Col('By')
759 | req_from = Col('From')
760 | is_hidden = BoolNaCol('Hidden?')
761 | is_active = BoolNaCol('Active?')
762 |
763 |
764 | @cross.route('/admin')
765 | @login_requred
766 | def admin():
767 | if not g.user.is_admin:
768 | return redirect(url_for('c.front'))
769 | panel = request.args.get('panel', 'log')
770 |
771 | User2 = User.alias()
772 | codes = CodesTable(
773 | MailCode.select(
774 | MailCode.code, User.name.alias('sent_by'),
775 | User2.name.alias('sent_to'),
776 | MailCode.created_on, MailCode.sent_on
777 | )
778 | .join(User, on=MailCode.sent_by == User.id)
779 | .join_from(MailCode, User2, on=MailCode.sent_to)
780 | .where(MailCode.is_active == True)
781 | .order_by(MailCode.created_on.desc()).limit(30)
782 | .dicts()
783 | )
784 | received = ReceivedTable(
785 | MailCode.select(
786 | MailCode.code, User.name.alias('sent_by'),
787 | User2.name.alias('sent_to'), MailCode.created_on,
788 | MailCode.sent_on, MailCode.received_on,
789 | MailCode.is_active
790 | )
791 | .join(User, on=MailCode.sent_by == User.id)
792 | .join_from(MailCode, User2, on=MailCode.sent_to)
793 | .where(MailCode.received_on.is_null(False))
794 | .order_by(MailCode.received_on.desc()).limit(30)
795 | .dicts()
796 | )
797 | requests = RequestsTable(
798 | MailRequest.select(
799 | MailRequest.created_on, MailRequest.is_active,
800 | MailRequest.is_hidden, User.name.alias('req_by'),
801 | User2.name.alias('req_from')
802 | )
803 | .join(User, on=MailRequest.requested_by)
804 | .join_from(MailRequest, User2, on=MailRequest.requested_from)
805 | .order_by(MailRequest.created_on.desc()).limit(30)
806 | .dicts()
807 | )
808 | users = UsersTable(
809 | User.select(
810 | User.id, User.created_on, User.name, User.osm_name,
811 | User.country, User.privacy, User.does_requests, User.is_active,
812 | User.code
813 | )
814 | .where(User.address.is_null(False))
815 | .order_by(User.created_on.desc()).limit(50)
816 | )
817 |
818 | lines = []
819 | try:
820 | line = 0
821 | need_lines = 100
822 | with open('/var/log/supervisor/osmcards-error.log', 'r') as f:
823 | for line_str in f:
824 | if line < need_lines:
825 | lines.append(line_str)
826 | else:
827 | lines[line % need_lines] = line_str
828 | line += 1
829 | lines = lines[line % need_lines:] + lines[:line % need_lines]
830 | except OSError:
831 | pass
832 |
833 | return render_template(
834 | 'admin.html', panel=panel, codes=codes, received=received,
835 | requests=requests, users=users, log=lines)
836 |
837 |
838 | @cross.route('/set-lang', methods=['POST'])
839 | @login_requred
840 | def set_lang():
841 | user = g.user
842 | if not user:
843 | return redirect(url_for('c.front'))
844 | new_lang = request.form['lang']
845 | # Proper list of languages
846 | if new_lang != user.lang and new_lang in ['en', 'ru']:
847 | user.lang = new_lang
848 | user.save()
849 | return redirect(request.form.get('redirect', url_for('c.user')))
850 |
--------------------------------------------------------------------------------