├── bm2 ├── __init__.py ├── asgi.py ├── wsgi.py ├── urls.py ├── middleware.py └── settings.py ├── links ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── example_command.py ├── migrations │ ├── __init__.py │ ├── 0006_alter_linkscreenshot_options.py │ ├── 0004_usersettings_hn_username.py │ ├── 0005_linkscreenshot.py │ ├── 0003_usersettings_feedbin_password_and_more.py │ ├── 0002_usersettings.py │ └── 0001_initial.py ├── admin.py ├── static │ ├── favicon.ico │ └── css │ │ ├── style.css │ │ └── mobi.min.css ├── importers │ ├── __init__.py │ ├── hackernews.py │ ├── github.py │ └── feedbin.py ├── apps.py ├── templates │ ├── delete.html │ ├── add.html │ ├── edit.html │ ├── settings.html │ ├── screenshot.html │ ├── base.html │ ├── links.html │ └── includes │ │ ├── sidebar.html │ │ └── link.html ├── forms.py ├── ssrf.py ├── models.py ├── views.py └── tests.py ├── up ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── up.py ├── ansible │ └── roles │ │ ├── nginx │ │ ├── defaults │ │ │ └── main.yml │ │ ├── handlers │ │ │ └── main.yml │ │ ├── tasks │ │ │ └── main.yml │ │ └── templates │ │ │ ├── nginx_django.conf.j2 │ │ │ └── nginx_django_ssl.conf.j2 │ │ ├── opensmtpd │ │ └── tasks │ │ │ └── main.yml │ │ ├── django │ │ ├── defaults │ │ │ └── main.yml │ │ ├── handlers │ │ │ └── main.yml │ │ ├── templates │ │ │ ├── env.sh.j2 │ │ │ ├── app.systemd.service.j2 │ │ │ └── app.sh.j2 │ │ └── tasks │ │ │ └── main.yml │ │ ├── ufw │ │ └── tasks │ │ │ └── main.yml │ │ ├── postgres │ │ └── tasks │ │ │ └── main.yml │ │ └── base │ │ └── tasks │ │ └── main.yml ├── LICENSE ├── .gitignore └── README.md ├── authuser ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0004_user_totp_secret.py │ ├── 0003_alter_apikey_key.py │ ├── 0002_apikey.py │ └── 0001_initial.py ├── templates │ └── registration │ │ ├── logged_out.html │ │ └── login.html ├── LICENSE ├── forms.py ├── README.md ├── admin.py ├── .gitignore ├── views.py └── models.py ├── pyproject.toml ├── Pipfile ├── requirements.txt ├── manage.py ├── .pre-commit-config.yaml ├── .github └── workflows │ └── pipeline.yml ├── .gitignore ├── README.md └── Pipfile.lock /bm2/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /links/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /up/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /authuser/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /links/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /links/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /up/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /authuser/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /links/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /up/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /links/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | -------------------------------------------------------------------------------- /up/ansible/roles/nginx/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | nginx_timeout: 120 4 | -------------------------------------------------------------------------------- /links/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/bm2/main/links/static/favicon.ico -------------------------------------------------------------------------------- /up/ansible/roles/opensmtpd/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Install OpenSMTPd 4 | apt: name=opensmtpd state=latest 5 | -------------------------------------------------------------------------------- /up/ansible/roles/django/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | gunicorn_port: 9000 4 | gunicorn_workers: 4 5 | python_version: python3.10 6 | -------------------------------------------------------------------------------- /links/importers/__init__.py: -------------------------------------------------------------------------------- 1 | class MissingCredentialException(Exception): 2 | pass 3 | 4 | 5 | class ExpiredCredentialException(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /up/ansible/roles/django/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Restart app 4 | service: 5 | name: "{{ service_name }}" 6 | state: restarted 7 | enabled: yes 8 | -------------------------------------------------------------------------------- /links/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class LinksConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "links" 7 | -------------------------------------------------------------------------------- /up/ansible/roles/django/templates/env.sh.j2: -------------------------------------------------------------------------------- 1 | {% for variable_name, value in env.items() %} 2 | export {{ variable_name }}="{{ value }}" 3 | {% endfor %} 4 | 5 | . /srv/www/{{ app_path }}/venv/bin/activate 6 | -------------------------------------------------------------------------------- /up/ansible/roles/nginx/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Reload nginx 4 | service: name=nginx state=reloaded 5 | 6 | 7 | - name: Restart nginx 8 | service: name=nginx state=restarted enabled=yes 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 120 3 | 4 | [tool.black] 5 | line-length = 120 6 | 7 | [tool.isort] 8 | profile = "black" 9 | 10 | [tool.bandit] 11 | exclude_dirs = ["up"] 12 | 13 | [tool.coverage.report] 14 | omit = ["bm2/settings.py", "up/**", "manage.py"] 15 | -------------------------------------------------------------------------------- /up/ansible/roles/ufw/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Ensure latest UFW 4 | apt: name=ufw state=latest 5 | 6 | - ufw: policy=allow direction=outgoing 7 | - ufw: policy=deny direction=incoming 8 | 9 | - ufw: rule=allow port=22 10 | - ufw: rule=allow port=80 proto=tcp 11 | - ufw: rule=allow port=443 proto=tcp 12 | 13 | - ufw: state=enabled 14 | -------------------------------------------------------------------------------- /authuser/templates/registration/logged_out.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Logged out 8 | 9 | 10 | Logged out 11 | 12 | 13 | -------------------------------------------------------------------------------- /up/ansible/roles/django/templates/app.systemd.service.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Runner for {{ app_name }} 3 | After=network.target 4 | 5 | [Service] 6 | User={{ app_name }} 7 | Group={{ app_name }} 8 | WorkingDirectory=/srv/www/{{ app_path }}/ 9 | ExecStart=/srv/www/{{ app_path }}/{{ app_name }}.sh 10 | ExecReload=/bin/kill -s HUP $MAINPID 11 | ExecStop=/bin/kill -s TERM $MAINPID 12 | PrivateTmp=true 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /bm2/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for bm2 project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bm2.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /bm2/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for bm2 project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bm2.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /links/migrations/0006_alter_linkscreenshot_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.6 on 2023-10-21 21:15 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("links", "0005_linkscreenshot"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="linkscreenshot", 14 | options={"ordering": ["-added"]}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | thttp = "*" 8 | sentry-sdk = "*" 9 | django = "<5.3" 10 | certifi = "==2025.11.12" 11 | urllib3 = "==2.5.0" 12 | django-taggit = "==6.1.0" 13 | dj-database-url = "==3.0.1" 14 | pyyaml = "==6.0.3" 15 | 16 | [dev-packages] 17 | isort = "*" 18 | coverage = "*" 19 | django-debug-toolbar = "*" 20 | ipdb = "*" 21 | black = "*" 22 | bandit = "*" 23 | 24 | [requires] 25 | python_version = "3.11" 26 | -------------------------------------------------------------------------------- /authuser/migrations/0004_user_totp_secret.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2023-07-03 10:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("authuser", "0003_alter_apikey_key"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="user", 14 | name="totp_secret", 15 | field=models.CharField(blank=True, default="", max_length=200), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /links/management/commands/example_command.py: -------------------------------------------------------------------------------- 1 | # Example management command 2 | # https://docs.djangoproject.com/en/4.0/howto/custom-management-commands/ 3 | 4 | from django.core.management.base import BaseCommand, CommandError 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "An example management command created by djbs" 9 | 10 | def handle(self, *args, **options): 11 | self.stdout.write("Configure your management commands here...") 12 | raise CommandError("Management command not implemented") 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # These requirements were autogenerated by pipenv 3 | # To regenerate from the project's Pipfile, run: 4 | # 5 | # pipenv lock --requirements 6 | # 7 | 8 | -i https://pypi.org/simple 9 | asgiref==3.7.2; python_version >= '3.7' 10 | certifi==2023.11.17 11 | dj-database-url==2.1.0 12 | django-taggit==5.0.1 13 | django==4.2.8 14 | pyyaml==6.0.1 15 | sentry-sdk==1.39.1 16 | sqlparse==0.4.4; python_version >= '3.5' 17 | thttp==1.3.0 18 | typing-extensions==4.9.0; python_version >= '3.8' 19 | urllib3==2.1.0 20 | -------------------------------------------------------------------------------- /authuser/migrations/0003_alter_apikey_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2023-07-02 02:53 2 | 3 | from django.db import migrations, models 4 | 5 | import authuser.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("authuser", "0002_apikey"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="apikey", 16 | name="key", 17 | field=models.CharField(default=authuser.models.generate_api_key, max_length=200, unique=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /links/templates/delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |
6 | ← Back to Dashboard 7 |
8 | 9 |

Delete Bookmark

10 | 11 |
12 | 13 | {% include 'includes/link.html' with link=link %} 14 | {% csrf_token %} 15 |

16 | 17 |

18 |
19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /links/migrations/0004_usersettings_hn_username.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2023-06-30 23:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("links", "0003_usersettings_feedbin_password_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersettings", 14 | name="hn_username", 15 | field=models.CharField(blank=True, help_text="Your Hacker News username", max_length=40), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /authuser/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Login 8 | 9 | 10 | 11 |

Login

12 |
13 | {{ form.as_p }} 14 | {% csrf_token %} 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /links/templates/add.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Add Bookmark

6 |
7 | {{ form.render }} 8 | {% csrf_token %} 9 |
10 |
11 | ← Back to Dashboard 12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /links/templates/edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Edit Bookmark

6 |
7 | {{ form.render }} 8 | {% csrf_token %} 9 |
10 |
11 | ← Back to Dashboard 12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /links/templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Settings

6 | 7 |
8 | {{ form.render }} 9 | {% csrf_token %} 10 | 11 |
12 |
13 | ← Back to Dashboard 14 |
15 |
16 | 17 |
18 |
19 |
20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /up/ansible/roles/django/templates/app.sh.j2: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | LOGFILE=/srv/www/{{ app_name }}/logs/{{ app_path }}.log 5 | LOGDIR=$(dirname $LOGFILE) 6 | NUM_WORKERS={{ gunicorn_workers }} 7 | 8 | # user/group to run as 9 | USER={{ app_name }} 10 | GROUP={{ app_name }} 11 | 12 | {% for variable_name, value in env.items() %} 13 | export {{ variable_name }}="{{ value }}" 14 | {% endfor %} 15 | 16 | cd /srv/www/{{ app_path }}/code 17 | source /srv/www/{{ app_path }}/venv/bin/activate 18 | 19 | test -d $LOGDIR || mkdir -p $LOGDIR 20 | 21 | exec gunicorn {{ app_name }}.wsgi:application -w $NUM_WORKERS \ 22 | --timeout=300 --user=$USER --group=$GROUP --log-level=debug \ 23 | -b [::]:{{ gunicorn_port }} --log-file=$LOGFILE 2>> $LOGFILE 24 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bm2.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /authuser/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Brenton Cleeland (https://brntn.me) 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /authuser/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import User 4 | 5 | 6 | class SignUpForm(forms.Form): 7 | email = forms.EmailField() 8 | password = forms.CharField(widget=forms.PasswordInput()) 9 | password_again = forms.CharField(widget=forms.PasswordInput()) 10 | 11 | def clean_email(self): 12 | value = self.cleaned_data["email"].strip() 13 | if User.objects.filter(email=value): 14 | raise forms.ValidationError("That email is already in user") 15 | return value 16 | 17 | def clean(self): 18 | cleaned_data = super().clean() 19 | if cleaned_data["password"] != cleaned_data["password_again"]: 20 | raise forms.ValidationError("The passwords you entered did not match") 21 | -------------------------------------------------------------------------------- /links/templates/screenshot.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 | {% if link %} 6 |

7 | Screenshot of {{ screenshot.link.url }} taken on {{ screenshot.added|date:"c" }}
8 | {% spaceless %}Other screenshots: 9 | {% for screenshot in link.linkscreenshot_set.all %} 10 | #{{ forloop.counter0 }} ({{ screenshot.added|date:"Y-m-d" }}){% if not forloop.last %}, {% endif %} 11 | {% endfor %} 12 | {% endspaceless %} 13 |

14 | {% endif %} 15 |

16 | Screenshot of {{ screenshot.link.url }} 17 |

18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /links/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | bm2 8 | 9 | 10 | 11 | 12 | {% if messages %} 13 |
14 | 19 |
20 | {% endif %} 21 | 22 | {% block content %}{% endblock %} 23 | 24 | 25 | -------------------------------------------------------------------------------- /links/migrations/0005_linkscreenshot.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.3 on 2023-08-01 02:51 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("links", "0004_usersettings_hn_username"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="LinkScreenshot", 17 | fields=[ 18 | ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), 19 | ("url", models.URLField(max_length=2000)), 20 | ("added", models.DateTimeField(auto_now_add=True)), 21 | ("link", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="links.link")), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /links/migrations/0003_usersettings_feedbin_password_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2023-06-29 06:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("links", "0002_usersettings"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersettings", 14 | name="feedbin_password", 15 | field=models.CharField(blank=True, help_text="Your feedbin password", max_length=300), 16 | ), 17 | migrations.AddField( 18 | model_name="usersettings", 19 | name="feedbin_username", 20 | field=models.CharField( 21 | blank=True, help_text="Your feedbin username, probably an email address", max_length=300 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-added-large-files 10 | 11 | - repo: https://github.com/psf/black 12 | rev: 23.3.0 13 | hooks: 14 | - id: black 15 | args: ["--line-length=120"] 16 | 17 | - repo: https://github.com/PyCQA/isort 18 | rev: 5.12.0 19 | hooks: 20 | - id: isort 21 | args: ["--profile=black"] 22 | 23 | - repo: https://github.com/astral-sh/ruff-pre-commit 24 | rev: v0.0.275 25 | hooks: 26 | - id: ruff 27 | args: ["--line-length=120"] 28 | 29 | - repo: https://github.com/PyCQA/bandit 30 | rev: 1.9.1 31 | hooks: 32 | - id: bandit 33 | args: ["--exclude=up"] 34 | -------------------------------------------------------------------------------- /links/importers/hackernews.py: -------------------------------------------------------------------------------- 1 | import thttp 2 | 3 | from links.importers import MissingCredentialException 4 | from links.models import Link, UserSettings 5 | 6 | 7 | def import_favourites(user, request=None): 8 | settings = UserSettings.objects.get(user=user) 9 | 10 | if not settings.hn_username: 11 | raise MissingCredentialException() 12 | 13 | url = f"https://osnhvzckcf.execute-api.ap-southeast-2.amazonaws.com/api/users/{settings.hn_username}" 14 | response = thttp.request(url) 15 | 16 | count_added = 0 17 | 18 | if response.json: 19 | for favourite in response.json.get("links", []): 20 | link, created = Link.objects.get_or_create(url=favourite["url"], user=user) 21 | 22 | if created: 23 | count_added += 1 24 | link.title = favourite["title"] 25 | 26 | link.tags.add("hn-fav") 27 | link.save() 28 | 29 | return count_added 30 | -------------------------------------------------------------------------------- /links/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import Link, UserSettings 4 | 5 | 6 | class LinkForm(forms.ModelForm): 7 | class Meta: 8 | model = Link 9 | fields = ["url", "title", "note", "tags"] 10 | 11 | 12 | class UserSettingsForm(forms.ModelForm): 13 | github_pat = forms.CharField( 14 | label="Github Personal Access Token", 15 | strip=False, 16 | widget=forms.PasswordInput(render_value=True), 17 | required=False, 18 | ) 19 | 20 | feedbin_password = forms.CharField( 21 | label="Feedbin password", 22 | strip=False, 23 | widget=forms.PasswordInput(render_value=True), 24 | required=False, 25 | ) 26 | 27 | hn_username = forms.CharField(label="HN username", strip=True, required=False) 28 | 29 | class Meta: 30 | model = UserSettings 31 | fields = ["github_pat", "feedbin_username", "feedbin_password", "hn_username"] 32 | -------------------------------------------------------------------------------- /authuser/README.md: -------------------------------------------------------------------------------- 1 | # django-authuser 2 | 3 | 4 | A reusable custom user model for Django projects. 5 | 6 | Includes: 7 | 8 | - The user model (email address to sign in, full-name only) 9 | - Sign up form and view 10 | - Logout view with redirect to the homepage 11 | - Admin registration 12 | 13 | 14 | ### Usage 15 | 16 | Add the `authuser` app as a git submodule: 17 | 18 | ``` 19 | git submodule add git@github.com:sesh/django-authuser.git authuser 20 | ``` 21 | 22 | Add the app to your `INSTALLED_APPS`, and configure your `AUTH_USER_MODEL` setting: 23 | 24 | ``` 25 | INSTALLED_APPS = [ 26 | ... 27 | "authuser", 28 | ] 29 | 30 | AUTH_USER_MODEL = "authuser.User" 31 | ``` 32 | 33 | Add the following to your `settings.py` to allow signups: 34 | 35 | ``` 36 | AUTH_USER_ALLOW_SIGNUP = True 37 | ``` 38 | 39 | Update your `urls.py` in include the signup and logout urls: 40 | 41 | ``` 42 | urlpatterns = [ 43 | ..., 44 | path('accounts/', include('authuser.urls')), 45 | ] 46 | ``` 47 | -------------------------------------------------------------------------------- /authuser/migrations/0002_apikey.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2023-07-02 02:51 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | import authuser.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | ("authuser", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="ApiKey", 18 | fields=[ 19 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 20 | ("key", models.CharField(default=authuser.models.generate_api_key, max_length=200)), 21 | ("expires", models.DateTimeField(default=authuser.models.expiry_time)), 22 | ("created", models.DateTimeField(auto_now_add=True)), 23 | ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /up/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Brenton Cleeland 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /links/templates/links.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 | 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /links/migrations/0002_usersettings.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2023-06-29 05:49 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("links", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="UserSettings", 17 | fields=[ 18 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 19 | ( 20 | "github_pat", 21 | models.CharField( 22 | blank=True, help_text="A Github personal access token with access to your stars", max_length=200 23 | ), 24 | ), 25 | ( 26 | "user", 27 | models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), 28 | ), 29 | ], 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /links/ssrf.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import re 3 | import socket 4 | from urllib.parse import urlparse 5 | 6 | 7 | def domain_is_valid(domain): 8 | if re.match( 9 | "^(?!\-)(?:(?:[a-zA-Z\d\_][a-zA-Z\d\-]{0,61})?[a-zA-Z\d]\.){1,126}(?!\d+)[a-zA-Z\d]{1,63}$", 10 | domain, 11 | ): 12 | return True 13 | return False 14 | 15 | 16 | def domain_is_safe(domain): 17 | if not domain_is_valid(domain): 18 | return False 19 | 20 | parts = urlparse("https://" + domain) 21 | 22 | if parts.path or parts.params or parts.query or parts.fragment: 23 | return False 24 | 25 | try: 26 | result = socket.getaddrinfo(parts.netloc, 8000) 27 | except socket.gaierror: 28 | # didn't resolve 29 | return True 30 | 31 | for r in result: 32 | ip = r[4][0] 33 | if ip: 34 | if ipaddress.ip_address(ip).is_private: 35 | return False 36 | 37 | return True 38 | 39 | 40 | def uri_is_safe(uri): 41 | if uri.startswith("https://") or uri.startswith("http://"): 42 | parts = urlparse(uri) 43 | return domain_is_safe(parts.netloc) 44 | return False 45 | -------------------------------------------------------------------------------- /links/importers/github.py: -------------------------------------------------------------------------------- 1 | import thttp 2 | 3 | from links.importers import ExpiredCredentialException, MissingCredentialException 4 | from links.models import Link, UserSettings 5 | 6 | 7 | def import_stars(user, request=None): 8 | settings = UserSettings.objects.get(user=user) 9 | 10 | if not settings.github_pat: 11 | raise MissingCredentialException() 12 | 13 | url = "https://api.github.com/user/starred" 14 | response = thttp.request( 15 | url, headers={"Authorization": f"token {settings.github_pat}", "Accept": "application/vnd.github.v3.star+json"} 16 | ) 17 | 18 | if response.status != 200: 19 | raise ExpiredCredentialException() 20 | 21 | count_added = 0 22 | for star_json in response.json: 23 | link, created = Link.objects.get_or_create(url=star_json["repo"]["html_url"], user=user) 24 | 25 | if created: 26 | count_added += 1 27 | link.title = star_json["repo"]["full_name"] or star_json["repo"]["name"] 28 | link.note = star_json["repo"]["description"] or "" 29 | 30 | link.tags.add("github-starred", *star_json["repo"].get("topics", [])) 31 | link.added = star_json["starred_at"] 32 | link.save() 33 | 34 | return count_added 35 | -------------------------------------------------------------------------------- /up/ansible/roles/postgres/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - apt: update_cache=yes 4 | 5 | 6 | - name: Install postgresql 7 | apt: 8 | pkg: 9 | - postgresql 10 | - postgresql-client 11 | - python3-psycopg2 12 | 13 | 14 | - name: Ensure postgres is running 15 | service: 16 | name: postgresql 17 | state: started 18 | 19 | 20 | - name: Create our database 21 | postgresql_db: 22 | name: "{{ app_name }}" 23 | encoding: "Unicode" 24 | template: "template0" 25 | become: yes 26 | become_user: postgres 27 | 28 | 29 | - name: Check if there is a previous DB password saved 30 | shell: "cat /srv/www/{{ app_name }}/.dbpass" 31 | ignore_errors: yes 32 | register: dot_dbpass 33 | 34 | 35 | - name: Replace the random DB password with the one from the .dbpass file 36 | set_fact: 37 | db_password: "{{ dot_dbpass.stdout }}" 38 | when: dot_dbpass.stdout != "" 39 | 40 | 41 | - name: Create the database user for this app 42 | postgresql_user: 43 | name: "{{ app_name }}" 44 | db: "{{ app_name }}" 45 | password: "{{ db_password }}" 46 | become: yes 47 | become_user: postgres 48 | 49 | 50 | - name: Save the db password for next time 51 | copy: 52 | content: "{{ db_password }}" 53 | dest: "/srv/www/{{ app_name }}/.dbpass" 54 | -------------------------------------------------------------------------------- /authuser/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin 3 | 4 | from .models import User 5 | 6 | 7 | class CustomUserAdmin(UserAdmin): 8 | add_fieldsets = ( 9 | (None, {"fields": ("name", "email", "password1", "password2")}), 10 | ( 11 | "Permissions", 12 | { 13 | "fields": ( 14 | "is_active", 15 | "is_staff", 16 | "is_superuser", 17 | "groups", 18 | "user_permissions", 19 | ) 20 | }, 21 | ), 22 | ) 23 | fieldsets = ( 24 | (None, {"fields": ("name", "email", "password")}), 25 | ( 26 | "Permissions", 27 | { 28 | "fields": ( 29 | "is_active", 30 | "is_staff", 31 | "is_superuser", 32 | "groups", 33 | "user_permissions", 34 | ) 35 | }, 36 | ), 37 | ("Important dates", {"fields": ("last_login", "date_joined")}), 38 | ) 39 | list_display = ("email", "name", "is_staff") 40 | search_fields = ("name", "email") 41 | ordering = ["email"] 42 | 43 | 44 | admin.site.register(User, CustomUserAdmin) 45 | -------------------------------------------------------------------------------- /authuser/.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/058d4918973c034c1c53215f28845c0342a0f6b1/python.gitignore 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask instance folder 59 | instance/ 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # Spyder project settings 83 | .spyderproject 84 | -------------------------------------------------------------------------------- /links/templates/includes/sidebar.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |

6 | Show all | Add bookmark | Settings 7 | | Logout 8 |

9 | 10 |

11 | {% comment %} 12 | 18 | {% endcomment %} 19 | Bookmarklet: bm2 20 |

21 | 22 |
23 | {% csrf_token %} 24 |

25 | 26 |

27 |
28 | 29 |
30 | {% csrf_token %} 31 |

32 | 33 |

34 |
35 | 36 |
37 | {% csrf_token %} 38 |

39 | 40 |

41 |
42 | -------------------------------------------------------------------------------- /links/templates/includes/link.html: -------------------------------------------------------------------------------- 1 | 31 | -------------------------------------------------------------------------------- /up/ansible/roles/base/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Exit if target is not Ubuntu 22.04 4 | meta: end_play 5 | when: ansible_distribution_release not in ["jammy"] 6 | 7 | 8 | - name: Add Deadsnakes Nightly APT repository 9 | apt_repository: 10 | repo: ppa:deadsnakes/ppa 11 | 12 | 13 | - apt: update_cache=yes 14 | - apt: upgrade=dist 15 | 16 | 17 | - name: Install base packages 18 | apt: 19 | name: 20 | - build-essential 21 | - "{{ python_version }}" 22 | - "{{ python_version }}-dev" 23 | - "{{ python_version }}-distutils" 24 | - "{{ python_version }}-venv" 25 | - virtualenvwrapper 26 | - libpq-dev 27 | - libjpeg-dev 28 | - zlib1g-dev 29 | state: latest 30 | 31 | 32 | - name: Add app user group 33 | group: 34 | name: "{{ app_name }}" 35 | system: yes 36 | state: present 37 | 38 | 39 | - name: Add app user 40 | user: 41 | name: "{{ app_name }}" 42 | groups: 43 | - "{{ app_name }}" 44 | state: present 45 | append: yes 46 | shell: /bin/bash 47 | 48 | 49 | - name: Install acme.sh 50 | shell: curl https://get.acme.sh | sh -s email={{ certbot_email }} 51 | 52 | 53 | - name: Make directories for application 54 | file: path={{ item }} state=directory owner={{ app_name }} group=staff 55 | with_items: 56 | - /srv/www/{{ app_name }} 57 | - /srv/www/{{ app_name }}/logs 58 | - /srv/www/{{ app_name }}/static 59 | - /srv/www/{{ app_name }}/media 60 | - /srv/www/{{ app_path }} 61 | - /srv/www/{{ app_path }}/code 62 | - /srv/www/{{ app_path }}/logs 63 | -------------------------------------------------------------------------------- /authuser/views.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hmac 3 | import struct 4 | import time 5 | 6 | from django import forms 7 | from django.contrib.auth.forms import AuthenticationForm 8 | from django.contrib.auth.views import LoginView 9 | 10 | # mintotp 11 | # https://github.com/susam/mintotp 12 | 13 | 14 | def hotp(key, counter, digits=6, digest="sha1"): 15 | key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8)) 16 | counter = struct.pack(">Q", counter) 17 | mac = hmac.new(key, counter, digest).digest() 18 | offset = mac[-1] & 0x0F 19 | binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF 20 | return str(binary)[-digits:].zfill(digits) 21 | 22 | 23 | def totp(key, time_step=30, digits=6, digest="sha1"): 24 | return hotp(key, int(time.time() / time_step), digits, digest) 25 | 26 | 27 | class LoginWithTotpForm(AuthenticationForm): 28 | one_time_password = forms.CharField( 29 | max_length=6, required=False, widget=forms.TextInput(attrs={"autocomplete": "one-time-code"}) 30 | ) 31 | 32 | def confirm_login_allowed(self, user): 33 | if user.totp_secret: 34 | try: 35 | code = self.cleaned_data["one_time_password"] 36 | secret = base64.b32encode(user.totp_secret.encode()).decode() 37 | if code != totp(secret): 38 | raise Exception("Code does not match") 39 | except (IndexError, Exception): 40 | raise forms.ValidationError("Please enter the correct one time code.") 41 | 42 | 43 | class LoginWithTotpView(LoginView): 44 | authentication_form = LoginWithTotpForm 45 | -------------------------------------------------------------------------------- /links/importers/feedbin.py: -------------------------------------------------------------------------------- 1 | import thttp 2 | 3 | from links.importers import ExpiredCredentialException, MissingCredentialException 4 | from links.models import Link, UserSettings 5 | 6 | 7 | def short_text(text): 8 | parts = text.split(".") 9 | 10 | result = "" 11 | 12 | for p in parts: 13 | result += p + "." 14 | if len(result) > 80: 15 | return result 16 | 17 | return result 18 | 19 | 20 | def import_stars(user, request=None): 21 | settings = UserSettings.objects.get(user=user) 22 | 23 | if not (settings.feedbin_username and settings.feedbin_password): 24 | raise MissingCredentialException() 25 | 26 | response = thttp.request( 27 | "https://api.feedbin.com/v2/starred_entries.json", 28 | basic_auth=(settings.feedbin_username, settings.feedbin_password), 29 | ) 30 | 31 | if response.status != 200: 32 | raise ExpiredCredentialException() 33 | 34 | if len(response.json) > 0: 35 | entries = thttp.request( 36 | "https://api.feedbin.com/v2/entries.json", 37 | basic_auth=(settings.feedbin_username, settings.feedbin_password), 38 | params={"ids": ",".join([str(x) for x in response.json[-100:]])}, 39 | ) 40 | 41 | count_added = 0 42 | for feedbin_link in entries.json: 43 | link, created = Link.objects.get_or_create( 44 | url=feedbin_link["url"] or "https://example.org", user=request.user 45 | ) 46 | 47 | if created: 48 | count_added += 1 49 | link.title = feedbin_link["title"] or short_text(feedbin_link.get("summary", "")) or "No title" 50 | link.added = feedbin_link["created_at"] 51 | link.tags.add("feedbin-starred") 52 | link.save() 53 | 54 | return count_added 55 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Test and check pipeline 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | django-test: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - uses: actions/setup-python@v4 18 | with: 19 | python-version: '3.11' 20 | 21 | - name: Install Dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -r requirements.txt 25 | 26 | - name: Install coverage.py 27 | run: | 28 | pip install coverage[toml] 29 | 30 | - name: Run Tests 31 | run: | 32 | coverage run manage.py test 33 | env: 34 | DJANGO_SECRET_KEY: ${{ secrets.DJANGO_SECRET_KEY_TEST }} 35 | 36 | - name: Fail if coverage below threshold 37 | run: | 38 | coverage report -m --fail-under=80 39 | 40 | black: 41 | runs-on: ubuntu-latest 42 | 43 | steps: 44 | - uses: actions/checkout@v3 45 | 46 | - name: Set up Python 47 | uses: actions/setup-python@v4 48 | with: 49 | python-version: "3.11" 50 | 51 | - name: Install black 52 | run: | 53 | python -m pip install black 54 | 55 | - name: Run black 56 | run: | 57 | black --check . 58 | 59 | isort: 60 | runs-on: ubuntu-latest 61 | 62 | steps: 63 | - uses: actions/checkout@v3 64 | 65 | - name: Set up Python 66 | uses: actions/setup-python@v4 67 | with: 68 | python-version: "3.11" 69 | 70 | - name: Install isort 71 | run: | 72 | python -m pip install isort 73 | 74 | - name: Run isort 75 | run: | 76 | isort --check . 77 | 78 | bandit: 79 | runs-on: ubuntu-latest 80 | 81 | steps: 82 | - uses: actions/checkout@v3 83 | 84 | - name: Set up Python 85 | uses: actions/setup-python@v4 86 | with: 87 | python-version: 3 88 | 89 | - name: Install bandit 90 | run: | 91 | python -m pip install bandit[toml] 92 | 93 | - name: Run bandit scan 94 | run: | 95 | bandit -c pyproject.toml -r . 96 | -------------------------------------------------------------------------------- /bm2/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for bm2 project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.conf import settings 19 | from django.http import HttpResponse 20 | from django.urls import include, path 21 | 22 | from authuser.views import LoginWithTotpView 23 | from links.views import ( 24 | add, 25 | api_link, 26 | dashboard, 27 | delete, 28 | edit, 29 | import_feedbin, 30 | import_github, 31 | import_hackernews, 32 | screenshot, 33 | user_settings, 34 | ) 35 | 36 | 37 | def robots(request): 38 | return HttpResponse("User-Agent: *", headers={"Content-Type": "text/plain; charset=UTF-8"}) 39 | 40 | 41 | def security(request): 42 | return HttpResponse( 43 | "Contact: security@brntn.me\nExpires: 2025-01-01T00:00:00.000Z", 44 | headers={"Content-Type": "text/plain; charset=UTF-8"}, 45 | ) 46 | 47 | 48 | urlpatterns = [ 49 | path("", dashboard, name="dashboard"), 50 | path("add/", add, name="add"), 51 | path("delete//", delete, name="delete-link"), 52 | path("edit//", edit, name="edit-link"), 53 | path("settings/", user_settings, name="user-settings"), 54 | path("screenshot//", screenshot, name="screenshot"), 55 | # "api" 56 | path("api//", api_link, name="api-link"), 57 | # importers 58 | path("import/github/", import_github, name="github-import"), 59 | path("import/feedbin/", import_feedbin, name="feedbin-import"), 60 | path("import/hackernews/", import_hackernews, name="hackernews-import"), 61 | # .well-known 62 | path("robots.txt", robots), 63 | path(".well-known/security.txt", security), 64 | # Django accounts 65 | path("accounts/login/", LoginWithTotpView.as_view(), name="login"), 66 | path("accounts/", include("django.contrib.auth.urls")), 67 | ] 68 | 69 | if settings.DEBUG and settings.ENABLE_DEBUG_TOOLBAR: 70 | urlpatterns += [ 71 | path("__debug__/", include("debug_toolbar.urls")), 72 | ] 73 | -------------------------------------------------------------------------------- /links/static/css/style.css: -------------------------------------------------------------------------------- 1 | header, section { 2 | width: 90em; 3 | max-width: 96%; 4 | margin: 2em auto; 5 | } 6 | 7 | .links { 8 | display: flex; 9 | } 10 | 11 | .links div { 12 | width: 100%; 13 | } 14 | 15 | .links .sidebar { 16 | flex: 0 0 320px; 17 | margin-left: 2em; 18 | } 19 | 20 | @media (max-width:900px) { 21 | .links { 22 | flex-direction:column; 23 | } 24 | 25 | .links .sidebar { 26 | margin: 2em 0 0 0; 27 | } 28 | } 29 | 30 | .link { 31 | display: flex; 32 | margin-bottom: 2em; 33 | } 34 | 35 | .link .title { 36 | display: block; 37 | margin-bottom: 0.2em; 38 | } 39 | 40 | .link p { 41 | margin: 0; 42 | margin-bottom: 0.5em; 43 | } 44 | 45 | .link img, .link .img-wrapper { 46 | margin: 0; 47 | padding: 0; 48 | 49 | flex: 0 0 24px; 50 | 51 | width: 24px; 52 | height: 24px; 53 | 54 | margin-right: 0.5rem; 55 | 56 | overflow: hidden; 57 | } 58 | 59 | .link ul, .all-tags { 60 | margin: 0; 61 | padding: 0; 62 | list-style: none; 63 | } 64 | 65 | .link ul li, .all-tags li { 66 | padding: 0 0.5rem 0 0; 67 | display: inline-block; 68 | } 69 | 70 | .delete p:first-of-type { 71 | margin-bottom: 1rem; 72 | } 73 | 74 | .sidebar .form input { 75 | margin-top: 0; 76 | } 77 | 78 | ul.all-tags { 79 | margin-top: 1em; 80 | } 81 | 82 | /* Tiny Grid */ 83 | 84 | .grid { 85 | display: flex; 86 | justify-content: space-between; 87 | } 88 | 89 | .grid.vertical-align { 90 | align-items: center; 91 | } 92 | 93 | .btn-link { 94 | margin: .9375rem 0 0; 95 | padding: .3125rem 0; 96 | line-height: 1.25; 97 | display: inline-block; 98 | } 99 | 100 | /* 101 | Django Messages formatting 102 | */ 103 | 104 | .messages { 105 | list-style: none; 106 | margin: 1em 0 2em; 107 | padding: 0; 108 | width: 100%; 109 | } 110 | 111 | .messages li { 112 | background-color: #ffec99; 113 | padding: 12px; 114 | width: 100%; 115 | line-height: 2.0em; 116 | } 117 | 118 | .messages li.info { 119 | background-color: #d0ebff; 120 | } 121 | 122 | .messages li.warning { 123 | background-color: #ffec99; 124 | } 125 | 126 | .messages li.error { 127 | background-color: #ffe3e3; 128 | } 129 | 130 | /* Django Forms */ 131 | 132 | .form [type="email"], .form [type="number"], .form [type="password"], .form [type="search"], .form [type="tel"], .form [type="text"], .form [type="url"], .form select, .form textarea { 133 | display: inline; 134 | margin-top: 0; 135 | } 136 | 137 | .helptext { 138 | display: block; 139 | } 140 | -------------------------------------------------------------------------------- /up/ansible/roles/nginx/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Apt update 4 | apt: update_cache=yes 5 | 6 | 7 | - name: Install nginx 8 | apt: name=nginx state=latest 9 | notify: 10 | - Restart nginx 11 | 12 | 13 | - name: Ensure the challenges directory exists 14 | file: path=/var/www/challenges/ state=directory 15 | 16 | 17 | - name: Ensure the acme.sh/nginx certs directory exists 18 | file: path=/etc/acme.sh/live/{{ domain }} state=directory 19 | 20 | 21 | # Check if there is already a certificate installed for {{ domain }} 22 | - name: Find the latest SSL certificate for this domain 23 | shell: "ls /etc/acme.sh/live/{{ domain }} | tail -n 1" 24 | register: cert_check 25 | 26 | # If no cert: 27 | # Add nginx config without SSL 28 | - name: Add nginx config (No SSL) 29 | template: src=nginx_django.conf.j2 dest=/etc/nginx/sites-available/{{ app_name }}.conf 30 | when: cert_check.stdout == "" 31 | 32 | - name: Link nginx config (No SSL) 33 | file: src=/etc/nginx/sites-available/{{ app_name }}.conf dest=/etc/nginx/sites-enabled/{{ app_name }}.conf state=link 34 | when: cert_check.stdout == "" 35 | 36 | - name: Reload nginx 37 | service: name=nginx state=reloaded 38 | when: cert_check.stdout == "" 39 | 40 | # Use acme.sh to request a certificate 41 | - name: Use acme.sh to request a certificate 42 | shell: /root/.acme.sh/acme.sh --issue {{ certbot_domains }} --server letsencrypt -w /var/www/challenges/ 43 | when: cert_check.stdout == "" 44 | 45 | # Use acme.sh to "install" the certificate 46 | - name: Install the certificates with acme.sh 47 | shell: /root/.acme.sh/acme.sh --install-cert {{ certbot_domains }} \ 48 | --key-file /etc/acme.sh/live/{{ domain }}/key.pem \ 49 | --fullchain-file /etc/acme.sh/live/{{ domain }}/cert.pem \ 50 | --reloadcmd "service nginx force-reload" 51 | when: cert_check.stdout == "" 52 | 53 | 54 | # Check if there is already a certificate installed for {{ domain }} 55 | - name: Find the latest SSL certificate for this domain 56 | shell: "ls /etc/acme.sh/live/ | grep -i ^{{ domain }}$ | tail -n 1" 57 | register: cert_check 58 | 59 | # If cert: 60 | # Just setup the SSL config 61 | 62 | - name: Add nginx config (with SSL) 63 | template: src=nginx_django_ssl.conf.j2 dest=/etc/nginx/sites-available/{{ app_name }}.conf 64 | when: cert_check.stdout != "" 65 | notify: 66 | - Reload nginx 67 | - Restart nginx 68 | 69 | 70 | - name: Link nginx config (with SSL) 71 | file: src=/etc/nginx/sites-available/{{ app_name }}.conf dest=/etc/nginx/sites-enabled/{{ app_name }}.conf state=link 72 | when: cert_check.stdout != "" 73 | notify: 74 | - Reload nginx 75 | - Restart nginx 76 | -------------------------------------------------------------------------------- /authuser/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.4 on 2022-05-19 03:42 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | import authuser.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [ 13 | ("auth", "0012_alter_user_first_name_max_length"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="User", 19 | fields=[ 20 | ( 21 | "id", 22 | models.BigAutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("password", models.CharField(max_length=128, verbose_name="password")), 30 | ( 31 | "email", 32 | models.EmailField(blank=True, default="", max_length=254, unique=True), 33 | ), 34 | ("name", models.CharField(blank=True, default="", max_length=200)), 35 | ("is_active", models.BooleanField(default=True)), 36 | ("is_staff", models.BooleanField(default=False)), 37 | ("is_superuser", models.BooleanField(default=False)), 38 | ("last_login", models.DateTimeField(blank=True, null=True)), 39 | ( 40 | "date_joined", 41 | models.DateTimeField(default=django.utils.timezone.now), 42 | ), 43 | ( 44 | "groups", 45 | models.ManyToManyField( 46 | blank=True, 47 | help_text="The groups this user belongs to. " 48 | "A user will get all permissions granted to each of their groups.", 49 | related_name="user_set", 50 | related_query_name="user", 51 | to="auth.group", 52 | verbose_name="groups", 53 | ), 54 | ), 55 | ( 56 | "user_permissions", 57 | models.ManyToManyField( 58 | blank=True, 59 | help_text="Specific permissions for this user.", 60 | related_name="user_set", 61 | related_query_name="user", 62 | to="auth.permission", 63 | verbose_name="user permissions", 64 | ), 65 | ), 66 | ], 67 | options={ 68 | "verbose_name": "User", 69 | "verbose_name_plural": "Users", 70 | }, 71 | managers=[ 72 | ("objects", authuser.models.CustomUserManager()), 73 | ], 74 | ), 75 | ] 76 | -------------------------------------------------------------------------------- /up/ansible/roles/nginx/templates/nginx_django.conf.j2: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name {{ domain_names }}; 4 | 5 | client_max_body_size 50M; 6 | 7 | # no security problem here, since / is alway passed to upstream 8 | root /srv/www/{{ app_name }}/code/{{ app_name }}; 9 | 10 | # always serve this directory for settings up let's encrypt 11 | location /.well-known/acme-challenge/ { 12 | root /var/www/challenges/; 13 | try_files $uri =404; 14 | } 15 | 16 | # favicon 17 | location /favicon.ico { 18 | log_not_found off; 19 | root /srv/www/{{ app_name }}/static/; 20 | expires 24h; 21 | gzip on; 22 | gzip_types image/x-icon; 23 | } 24 | 25 | # serve directly - analogous for static/staticfiles 26 | location /static/ { 27 | root /srv/www/{{ app_name }}/; 28 | gzip on; 29 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject text/html application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 30 | expires 24h; 31 | } 32 | 33 | location /media/ { 34 | root /srv/www/{{ app_name }}/; 35 | gzip on; 36 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject text/html application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 37 | expires 24h; 38 | } 39 | 40 | location / { 41 | proxy_pass_header Server; 42 | proxy_set_header Host $host; 43 | proxy_redirect off; 44 | proxy_set_header X-Real-IP $remote_addr; 45 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 46 | proxy_set_header X-Scheme $scheme; 47 | proxy_connect_timeout {{ nginx_timeout }}; 48 | proxy_read_timeout {{ nginx_timeout }}; 49 | proxy_pass http://localhost:{{ gunicorn_port }}/; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /links/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2023-06-29 04:44 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | import taggit.managers 7 | from django.conf import settings 8 | from django.db import migrations, models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | initial = True 13 | 14 | dependencies = [ 15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 16 | ("contenttypes", "0002_remove_content_type_name"), 17 | ("taggit", "0005_auto_20220424_2025"), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name="UUIDTaggedItem", 23 | fields=[ 24 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 25 | ("object_id", models.UUIDField(db_index=True, verbose_name="object ID")), 26 | ( 27 | "content_type", 28 | models.ForeignKey( 29 | on_delete=django.db.models.deletion.CASCADE, 30 | related_name="%(app_label)s_%(class)s_tagged_items", 31 | to="contenttypes.contenttype", 32 | verbose_name="content type", 33 | ), 34 | ), 35 | ( 36 | "tag", 37 | models.ForeignKey( 38 | on_delete=django.db.models.deletion.CASCADE, 39 | related_name="%(app_label)s_%(class)s_items", 40 | to="taggit.tag", 41 | ), 42 | ), 43 | ], 44 | options={ 45 | "verbose_name": "Tag", 46 | "verbose_name_plural": "Tags", 47 | }, 48 | ), 49 | migrations.CreateModel( 50 | name="Link", 51 | fields=[ 52 | ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), 53 | ("url", models.URLField(max_length=2000)), 54 | ("title", models.CharField(blank=True, default="", max_length=1000)), 55 | ("note", models.TextField(blank=True, default="")), 56 | ("added", models.DateTimeField(auto_now_add=True)), 57 | ("updated", models.DateTimeField(auto_now=True)), 58 | ( 59 | "tags", 60 | taggit.managers.TaggableManager( 61 | blank=True, 62 | help_text="A comma-separated list of tags.", 63 | through="links.UUIDTaggedItem", 64 | to="taggit.Tag", 65 | verbose_name="Tags", 66 | ), 67 | ), 68 | ( 69 | "user", 70 | models.ForeignKey( 71 | null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL 72 | ), 73 | ), 74 | ], 75 | options={ 76 | "ordering": ["-added"], 77 | }, 78 | ), 79 | ] 80 | -------------------------------------------------------------------------------- /links/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from urllib.parse import urlsplit 3 | 4 | from django.conf import settings 5 | from django.db import models 6 | from django.urls import reverse 7 | from taggit.managers import TaggableManager 8 | from taggit.models import GenericUUIDTaggedItemBase, TaggedItemBase 9 | 10 | 11 | class UUIDTaggedItem(GenericUUIDTaggedItemBase, TaggedItemBase): 12 | # Required to support foreign key from Tag -> Link with UUID as the primary key 13 | # https://github.com/jazzband/django-taggit/issues/679 14 | 15 | class Meta: 16 | verbose_name = "Tag" 17 | verbose_name_plural = "Tags" 18 | 19 | 20 | class Link(models.Model): 21 | id = models.UUIDField(default=uuid.uuid4, primary_key=True) 22 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) 23 | 24 | url = models.URLField(max_length=2000) 25 | title = models.CharField(max_length=1000, default="", blank=True) 26 | note = models.TextField(default="", blank=True) 27 | tags = TaggableManager(blank=True, through=UUIDTaggedItem) 28 | 29 | added = models.DateTimeField(auto_now_add=True) 30 | updated = models.DateTimeField(auto_now=True) 31 | 32 | class Meta: 33 | ordering = ["-added"] 34 | 35 | def __str__(self): 36 | return self.title 37 | 38 | def get_absolute_url(self): 39 | return reverse("edit-link", kwargs={"pk": self.pk}) 40 | 41 | def icon(self): 42 | return f"https://icons.duckduckgo.com/ip3/{self.domain()}.ico" 43 | 44 | def domain(self): 45 | parts = urlsplit(self.url) 46 | return parts.netloc 47 | 48 | def as_json(self): 49 | return { 50 | "id": str(self.id), 51 | "url": self.url, 52 | "note": self.note, 53 | "tags": [t.name for t in self.tags.all()], 54 | "added": self.added.isoformat(), 55 | "updated": self.updated.isoformat(), 56 | "screenshots": [s.as_json() for s in self.linkscreenshot_set.all()], 57 | } 58 | 59 | 60 | class LinkScreenshot(models.Model): 61 | id = models.UUIDField(default=uuid.uuid4, primary_key=True) 62 | link = models.ForeignKey("Link", on_delete=models.CASCADE) 63 | url = models.URLField(max_length=2000) 64 | added = models.DateTimeField(auto_now_add=True) 65 | 66 | class Meta: 67 | ordering = ["-added"] 68 | 69 | def get_absolute_url(self): 70 | return reverse("screenshot", kwargs={"pk": self.pk}) 71 | 72 | def as_json(self): 73 | return { 74 | "id": str(self.id), 75 | "url": self.url, 76 | "added": self.added.isoformat(), 77 | } 78 | 79 | 80 | class UserSettings(models.Model): 81 | user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 82 | github_pat = models.CharField( 83 | max_length=200, blank=True, help_text="A Github personal access token with access to your stars" 84 | ) 85 | 86 | feedbin_username = models.CharField( 87 | max_length=300, blank=True, help_text="Your feedbin username, probably an email address" 88 | ) 89 | 90 | feedbin_password = models.CharField(max_length=300, blank=True, help_text="Your feedbin password") 91 | 92 | hn_username = models.CharField(max_length=40, blank=True, help_text="Your Hacker News username") 93 | -------------------------------------------------------------------------------- /authuser/models.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import secrets 3 | from datetime import timedelta 4 | 5 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, UserManager 6 | from django.db import models 7 | from django.utils import timezone 8 | 9 | 10 | class CustomUserManager(UserManager): 11 | def _create_user(self, email, password, **extra_fields): 12 | """ 13 | Create and save a User with the provided email and password. 14 | """ 15 | if not email: 16 | raise ValueError("The given email address must be set") 17 | 18 | email = self.normalize_email(email) 19 | user = self.model(email=email, **extra_fields) 20 | user.set_password(password) 21 | user.save(using=self._db) 22 | return user 23 | 24 | def create_user(self, email=None, password=None, **extra_fields): 25 | extra_fields.setdefault("is_staff", False) 26 | extra_fields.setdefault("is_superuser", False) 27 | return self._create_user(email, password, **extra_fields) 28 | 29 | def create_superuser(self, email, password, **extra_fields): 30 | extra_fields.setdefault("is_staff", True) 31 | extra_fields.setdefault("is_superuser", True) 32 | 33 | if extra_fields.get("is_staff") is not True: 34 | raise ValueError("Superuser must have is_staff=True.") 35 | if extra_fields.get("is_superuser") is not True: 36 | raise ValueError("Superuser must have is_superuser=True.") 37 | 38 | return self._create_user(email, password, **extra_fields) 39 | 40 | 41 | class User(AbstractBaseUser, PermissionsMixin): 42 | """ 43 | User model that uses email addresses instead of usernames, and 44 | name instead of first / last name fields. 45 | 46 | All other fields from the Django auth.User model are kept to 47 | ensure maximum compatibility with the built in management 48 | commands. 49 | """ 50 | 51 | email = models.EmailField(blank=True, default="", unique=True) 52 | name = models.CharField(max_length=200, blank=True, default="") 53 | 54 | totp_secret = models.CharField(max_length=200, blank=True, default="") 55 | 56 | is_active = models.BooleanField(default=True) 57 | is_staff = models.BooleanField(default=False) 58 | is_superuser = models.BooleanField(default=False) 59 | 60 | last_login = models.DateTimeField(blank=True, null=True) 61 | date_joined = models.DateTimeField(default=timezone.now) 62 | 63 | objects = CustomUserManager() 64 | 65 | USERNAME_FIELD = "email" 66 | EMAIL_FIELD = "email" 67 | REQUIRED_FIELDS = [] 68 | 69 | class Meta: 70 | verbose_name = "User" 71 | verbose_name_plural = "Users" 72 | 73 | def get_full_name(self): 74 | return self.name 75 | 76 | def get_short_name(self): 77 | return self.name or self.email.split("@")[0] 78 | 79 | def totp_url(self): 80 | if not self.totp_secret: 81 | self.totp_secret = secrets.token_urlsafe() 82 | self.save() 83 | 84 | secret = base64.b32encode(self.totp_secret.encode()).decode() 85 | return f"otpauth://totp/{self.email}?secret={secret.rstrip('=')}&issuer=bm2&algorithm=SHA1&digits=6&period=30" 86 | 87 | 88 | def generate_api_key(): 89 | return "bm2_" + secrets.token_urlsafe() 90 | 91 | 92 | def expiry_time(): 93 | return timezone.now() + timedelta(days=30) 94 | 95 | 96 | class ApiKey(models.Model): 97 | user = models.ForeignKey("User", on_delete=models.CASCADE) 98 | key = models.CharField(max_length=200, default=generate_api_key, unique=True) 99 | expires = models.DateTimeField(default=expiry_time) 100 | created = models.DateTimeField(auto_now_add=True) 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /up/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /up/ansible/roles/nginx/templates/nginx_django_ssl.conf.j2: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2; 3 | listen [::]:443 ssl http2; 4 | server_name {{ domain_names }}; 5 | 6 | ssl_certificate /etc/acme.sh/live/{{ cert_check.stdout }}/cert.pem; 7 | ssl_certificate_key /etc/acme.sh/live/{{ cert_check.stdout }}/key.pem; 8 | 9 | ssl_session_timeout 1d; 10 | ssl_session_cache shared:MozSSL:10m; # about 40000 sessions 11 | ssl_session_tickets off; 12 | 13 | # modern settings from the Mozilla SSL config generator 14 | # https://mozilla.github.io/server-side-tls/ssl-config-generator/ 15 | # intermediate configuration 16 | ssl_protocols TLSv1.2 TLSv1.3; 17 | ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; 18 | ssl_prefer_server_ciphers off; 19 | 20 | ssl_stapling on; 21 | ssl_stapling_verify on; 22 | 23 | # enable hsts 24 | add_header Strict-Transport-Security "max-age=31536000; includeSubdomains; preload"; 25 | 26 | client_max_body_size 50M; 27 | 28 | # no security problem here, since / is alway passed to upstream 29 | root /srv/www/{{ app_name }}/code/{{ app_name }}; 30 | 31 | # favicon 32 | location /favicon.ico { 33 | log_not_found off; 34 | root /srv/www/{{ app_name }}/static/; 35 | expires 24h; 36 | gzip on; 37 | gzip_types image/x-icon; 38 | } 39 | 40 | # serve directly - analogous for static/staticfiles 41 | location /static/ { 42 | root /srv/www/{{ app_name }}/; 43 | gzip on; 44 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 45 | expires 24h; 46 | } 47 | 48 | location /media/ { 49 | root /srv/www/{{ app_name }}/; 50 | gzip on; 51 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 52 | expires 24h; 53 | } 54 | 55 | location / { 56 | proxy_pass_header Server; 57 | proxy_set_header Host $host; 58 | proxy_redirect off; 59 | proxy_set_header X-Real-IP $remote_addr; 60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 61 | proxy_set_header X-Scheme $scheme; 62 | proxy_connect_timeout {{ nginx_timeout }}; 63 | proxy_read_timeout {{ nginx_timeout }}; 64 | proxy_pass http://localhost:{{ gunicorn_port }}/; 65 | } 66 | } 67 | 68 | server { 69 | listen 80; 70 | listen [::]:80; 71 | server_name {{ domain_names }}; 72 | 73 | location /.well-known/acme-challenge/ { 74 | root /var/www/challenges/; 75 | try_files $uri =404; 76 | } 77 | 78 | location / { 79 | return 301 https://$server_name$request_uri; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `bm2` is a public iteration of my personal bookmarks site. 2 | 3 | --- 4 | 5 | ## About 6 | 7 | This project exists primarily for two reasons: 8 | 9 | - I use it, deployed to bm2.brntn.me, to bookmark sites and manage those bookmarks 10 | - As a playground for me to experiment with techniques, tools and practices 11 | 12 | There's are many examples of the former. I was a long-time Pinboard user, and a del.icio.us user before that. 13 | For the later, this codebase hits a bunch of things I like: 14 | 15 | - Uses the steps in my "[Six things I do every time I start a Django project][six-things]" post (automated with my poorly-documented [`djbs`][djbs] script) 16 | - Runs the "[Open source Python CI pipeline][ci]" in Github Actions (and runs most of the checks locally with [pre-commit][precommit]) 17 | - Takes a testing approach that relies heavily on [integration tests][integration-tests] ran with the Django test runner 18 | - The majority of new code is added with [Test Driven Development][tdd] and a [trunk-based][tbd] workflow 19 | - Uses my [django-middleware][middleware] and [django-authuser][authuser] projects 20 | - Deploys with [Django Up][up] onto a VPS 21 | - Takes a HTML-first approach with no Javascript 22 | - Gets an A+ on [Security Headers][headers] and the [SSL Labs Report][ssl] (June 2023) 23 | - Commits are GPG signed and (mostly) use [Conventional Commits][conventional-commits] 24 | - Logins require TOTP as a second factor (an extension to my authuser model I'm experimenting with) 25 | 26 | 27 | [six-things]: https://brntn.me/blog/six-things-i-do-every-time-i-start-a-django-project/ 28 | [ci]: https://brntn.me/blog/open-source-python-ci/ 29 | [integration-tests]: https://brntn.me/blog/types-of-testing-you-should-care-about-integration-testing/ 30 | [middleware]: https://github.com/sesh/django-middleware 31 | [authuser]: https://github.com/sesh/django-authuser 32 | [up]: https://github.com/sesh/django-up 33 | [tdd]: https://www.martinfowler.com/bliki/TestDrivenDevelopment.html 34 | [tbd]: https://martinfowler.com/articles/branching-patterns.html 35 | [headers]: https://securityheaders.com/?q=bm2.brntn.me&followRedirects=on 36 | [ssl]: https://www.ssllabs.com/ssltest/analyze.html?d=bm2.brntn.me&latest 37 | [djbs]: https://github.com/sesh/djbs 38 | [conventional-commits]: https://www.conventionalcommits.org/en/v1.0.0/ 39 | [precommit]: https://pre-commit.com/ 40 | 41 | 42 | ## Usage 43 | 44 | I generally use `pipenv` for Python/Django projects because it's familiar. 45 | You can adopt the usage instructions below to a different tool if that's more your jam. 46 | 47 | Getting this running locally is pretty straight forward. 48 | 49 | Install the dependencies: 50 | 51 | ``` 52 | pipenv install 53 | ``` 54 | 55 | Generate a secure Django secret key and add it to `.env`: 56 | 57 | ``` 58 | echo "DJANGO_SECRET_KEY=" > .env 59 | ``` 60 | 61 | Run the initial migrations to setup the database: 62 | 63 | ``` 64 | pipenv run python manage.py migrate 65 | ``` 66 | 67 | There's currently no way to create an account through the web interface, so use the CLI to create a user: 68 | 69 | ``` 70 | pipenv run python manage.py createsuperuser 71 | ``` 72 | 73 | Running the development server: 74 | 75 | ``` 76 | pipenv run python manage.py runserver 77 | ``` 78 | 79 | ### Running the tests 80 | 81 | ``` 82 | pipenv run python manage.py test 83 | ``` 84 | 85 | ### Deploying to a VPS 86 | 87 | Notes: 88 | 89 | - Ansible must be installed on your local machine 90 | - Target should be running Ubuntu 22.04 91 | - The domain that you are deploying to must be in `ALLOWED_HOSTS` 92 | 93 | ``` 94 | pipenv run python manage.py up --email= 95 | ``` 96 | 97 | ### Checks 98 | 99 | A [pre-commit](https://pre-commit.com) configuration is available that runs the same checks as the Github Actions pipeline. 100 | 101 | ``` 102 | pre-commit install 103 | ``` 104 | 105 | There checks can be manually run with: 106 | 107 | ``` 108 | pre-commit run --all-files 109 | ``` 110 | 111 | --- 112 | 113 | Generated with [sesh/djbs](https://github.com/sesh/djbs). 114 | -------------------------------------------------------------------------------- /bm2/middleware.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is free and unencumbered software released into the public domain. 3 | 4 | https://github.com/sesh/django-middleware 5 | """ 6 | 7 | import logging 8 | 9 | from django.conf import settings 10 | from django.contrib.auth import login, logout 11 | from django.utils import timezone 12 | 13 | from authuser.models import ApiKey 14 | 15 | logger = logging.getLogger("django") 16 | 17 | 18 | def login_with_api_key(get_response): 19 | def middleware(request): 20 | authorization_header = request.META.get("HTTP_AUTHORIZATION") 21 | api_key = authorization_header.replace("Bearer", "").strip() if authorization_header else None 22 | 23 | if api_key: 24 | try: 25 | api_key_obj = ApiKey.objects.get(key=api_key, expires__gt=timezone.now()) 26 | except ApiKey.DoesNotExist: 27 | api_key_obj = None 28 | 29 | if api_key_obj: 30 | login(request, api_key_obj.user) 31 | response = get_response(request) 32 | logout(request) 33 | return response 34 | 35 | return get_response(request) 36 | 37 | return middleware 38 | 39 | 40 | def set_remote_addr(get_response): 41 | def middleware(request): 42 | request.META["REMOTE_ADDR"] = request.META.get("HTTP_X_REAL_IP", request.META["REMOTE_ADDR"]) 43 | response = get_response(request) 44 | return response 45 | 46 | return middleware 47 | 48 | 49 | def permissions_policy(get_response): 50 | def middleware(request): 51 | response = get_response(request) 52 | response.headers["Permissions-Policy"] = "interest-cohort=(),microphone=(),camera=(),autoplay=()" 53 | return response 54 | 55 | return middleware 56 | 57 | 58 | def referrer_policy(get_response): 59 | def middleware(request): 60 | response = get_response(request) 61 | response.headers["Referrer-Policy"] = "same-origin" # using no-referrer breaks CSRF 62 | return response 63 | 64 | return middleware 65 | 66 | 67 | def csp(get_response): 68 | def middleware(request): 69 | response = get_response(request) 70 | response.headers["Content-Security-Policy"] = ( 71 | "default-src 'none'; script-src 'self'; style-src 'self'; " 72 | "img-src 'self' https://icons.duckduckgo.com https://media.brntn.me; " 73 | "child-src 'self'; form-action 'self'" 74 | ) 75 | 76 | if settings.DEBUG and settings.ENABLE_DEBUG_TOOLBAR: 77 | response.headers["Content-Security-Policy"] += "; connect-src 'self'" 78 | return response 79 | 80 | return middleware 81 | 82 | 83 | def xss_protect(get_response): 84 | def middleware(request): 85 | response = get_response(request) 86 | response.headers["X-XSS-Protection"] = "1; mode=block" 87 | return response 88 | 89 | return middleware 90 | 91 | 92 | def expect_ct(get_response): 93 | def middleware(request): 94 | response = get_response(request) 95 | response.headers["Expect-CT"] = "enforce, max-age=30m" 96 | return response 97 | 98 | return middleware 99 | 100 | 101 | def cache(get_response): 102 | def middleware(request): 103 | response = get_response(request) 104 | if request.method in ["GET", "HEAD"] and "Cache-Control" not in response.headers: 105 | response.headers["Cache-Control"] = "max-age=10" 106 | return response 107 | 108 | return middleware 109 | 110 | 111 | def corp_coop_coep(get_response): 112 | def middleware(request): 113 | response = get_response(request) 114 | response.headers["Cross-Origin-Resource-Policy"] = "same-origin" 115 | response.headers["Cross-Origin-Opener-Policy"] = "same-origin" 116 | # response.headers["Cross-Origin-Embedder-Policy"] = "require-corp" 117 | return response 118 | 119 | return middleware 120 | 121 | 122 | def dns_prefetch(get_response): 123 | def middleware(request): 124 | response = get_response(request) 125 | response.headers["X-DNS-Prefetch-Control"] = "off" 126 | return response 127 | 128 | return middleware 129 | -------------------------------------------------------------------------------- /up/ansible/roles/django/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Check if there is a previous DB password saved 4 | shell: "cat /srv/www/{{ app_name }}/.dbpass" 5 | ignore_errors: yes 6 | register: dot_dbpass 7 | 8 | 9 | - name: Replace the random DB password with the one from the .dbpass file 10 | set_fact: 11 | db_password: "{{ dot_dbpass.stdout }}" 12 | when: dot_dbpass.stdout != "" 13 | 14 | 15 | - name: Merge django_environment and our DATABASE_URL for environment 16 | set_fact: 17 | env: "{{ django_environment|combine({'DATABASE_URL': 'postgres://{{ app_name }}:{{ db_password }}@localhost:5432/{{ app_name }}'}) }}" 18 | 19 | 20 | - name: Copy application files to server 21 | copy: src={{ app_tar }} dest=/tmp/{{ app_path }}.tar 22 | 23 | 24 | - name: Create temporary directory 25 | file: path=/tmp/{{ app_path }}/code state=directory 26 | 27 | 28 | - name: Extract code 29 | unarchive: src=/tmp/{{ app_path }}.tar dest=/tmp/{{ app_path }}/code copy=no owner={{ app_name }} group={{ app_name }} 30 | 31 | 32 | - name: Set Django's static root 33 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="STATIC_ROOT = '/srv/www/{{ app_name }}/static/'" regexp="^STATIC_ROOT" 34 | 35 | 36 | - name: Set Django's media root 37 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="MEDIA_ROOT = '/srv/www/{{ app_name }}/media/'" regexp="^MEDIA_ROOT" 38 | 39 | 40 | - name: Set Django DEBUG=False 41 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="DEBUG = False" regexp="^DEBUG =" 42 | when: django_debug == "no" 43 | 44 | 45 | - name: Set Django DEBUG=True 46 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="DEBUG = True" regexp="^DEBUG =" 47 | when: django_debug == "yes" 48 | 49 | 50 | - name: Add app.sh file 51 | template: src=app.sh.j2 dest=/srv/www/{{ app_path }}/{{ app_name }}.sh owner={{ app_name }} group={{ app_name }} mode=ug+x 52 | 53 | 54 | - name: Add env.sh file 55 | template: src=env.sh.j2 dest=/srv/www/{{ app_path }}/env.sh owner={{ app_name }} group={{ app_name }} mode=ug+x 56 | 57 | 58 | - name: Ensure latest pip 59 | pip: virtualenv=/srv/www/{{ app_path }}/venv name=pip state=latest virtualenv_python={{ python_version }} 60 | 61 | 62 | - name: Ensure latest gunicorn 63 | pip: virtualenv=/srv/www/{{ app_path }}/venv name=gunicorn state=latest virtualenv_python={{ python_version }} 64 | 65 | 66 | - name: Ensure latest psycopg2 67 | pip: virtualenv=/srv/www/{{ app_path }}/venv name=psycopg2-binary state=latest virtualenv_python={{ python_version }} 68 | 69 | 70 | - name: Recreate code directory 71 | file: path=/srv/www/{{ app_path }}/code state=directory 72 | 73 | 74 | - name: Copy code to /srv/ 75 | copy: src=/tmp/{{ app_path }}/code dest=/srv/www/{{ app_path }} remote_src="yes" owner={{ app_name }} group={{ app_name }} 76 | 77 | 78 | - name: Install requirements from requirements.txt 79 | pip: virtualenv=/srv/www/{{ app_path }}/venv requirements=/srv/www/{{ app_path }}/code/requirements.txt virtualenv_python={{ python_version }} 80 | 81 | 82 | - name: Django collect static 83 | django_manage: command=collectstatic app_path=/srv/www/{{ app_path }}/code/ virtualenv=/srv/www/{{ app_path }}/venv 84 | environment: 85 | - "{{ env }}" 86 | ignore_errors: yes # this will fail if `staticfiles` is not in installed apps. That's okay. 87 | become: yes 88 | become_user: "{{ app_name }}" 89 | 90 | 91 | - name: Django create cache table 92 | django_manage: command=createcachetable app_path=/srv/www/{{ app_path }}/code/ virtualenv=/srv/www/{{ app_path }}/venv 93 | environment: 94 | - "{{ env }}" 95 | ignore_errors: yes # this will fail if `CACHES` doesn't use DB caching 96 | become: yes 97 | become_user: "{{ app_name }}" 98 | 99 | 100 | # stop the service before we run migrate 101 | - name: Stop app 102 | service: name={{ service_name }} state=stopped 103 | ignore_errors: yes # service could be running 104 | 105 | 106 | # TODO: check if there are any migrations to run, don't stop service if there isn't 107 | - name: Django migrate 108 | django_manage: command=migrate app_path=/srv/www/{{ app_path }}/code/ virtualenv=/srv/www/{{ app_path }}/venv 109 | environment: 110 | - "{{ env }}" 111 | become: yes 112 | become_user: "{{ app_name }}" 113 | 114 | 115 | # Update the systemd config with our new service 116 | - name: Systemd config 117 | template: src=app.systemd.service.j2 dest=/etc/systemd/system/{{ service_name }}.service 118 | 119 | 120 | - name: Reload service 121 | service: name={{ service_name }} state=reloaded daemon_reload=yes 122 | 123 | 124 | - name: Start app 125 | service: name={{ service_name }} state=started enabled=true 126 | 127 | 128 | - name: Clean up old deployments 129 | shell: find /srv/www/ -type d -name "{{ app_name }}-*" ! -name "{{ app_path }}" -prune -exec rm -r "{}" \; 130 | ignore_errors: yes 131 | -------------------------------------------------------------------------------- /up/README.md: -------------------------------------------------------------------------------- 1 | # django-up 2 | 3 | `django-up` is a tool to quickly deploy your Django application to a Ubuntu 22.04 server with almost zero configuration. 4 | 5 | ```shell 6 | python manage.py up django-up.com --email= 7 | ``` 8 | 9 | Running `django-up` will deploy a production ready, SSL-enabled, Django application to a VPS using: 10 | 11 | - Nginx 12 | - Gunicorn 13 | - PostgreSQL 14 | - SSL with acme.sh (using Let's Encrypt) 15 | - UFW 16 | - OpenSMTPd 17 | 18 | 19 | ## Supporting this project 20 | 21 | The easiest way to support the development of this project is to use [my Linode referal code][linode] if you need a hosting provider. 22 | By using this link you will receive a $100, 60-day credit once a valid payment method is added. 23 | If you spend $25 I will receive $25 credit in my account. 24 | 25 | `django-up` costs around $7/month to host on Linode, referrals cover that cost, plus help to support my other projects hosted there. I've used various hosting providers over the years but Linode is the one that I like the most. 26 | 27 | _This is the only place where referral codes are used. All other links in the documentation will take you to the services without my reference._ 28 | 29 | 30 | ## Quick Start (with Pipenv) 31 | 32 | Create a new VPS with your preferred provider and update your domain's DNS records to point at it. 33 | Check that you can SSH to the new server as `root` before continuing. 34 | 35 | Ensure that `ansible` is installed on the system your are deploying from. 36 | 37 | Create a directory for your new project and `cd` into it: 38 | 39 | ```shell 40 | mkdir testproj 41 | cd testproj 42 | ``` 43 | 44 | Install Django, PyYAML and dj_database_url: 45 | 46 | ```shell 47 | pipenv install Django pyyaml dj_database_url 48 | ``` 49 | 50 | Start a new Django project: 51 | 52 | ```shell 53 | pipenv run django-admin startproject testproj . 54 | ``` 55 | 56 | Run `git init` to initialise the new project as a git repository: 57 | 58 | ```shell 59 | git init 60 | ``` 61 | 62 | Add `django-up` as a git submodule: 63 | 64 | ```shell 65 | git submodule add git@github.com:sesh/django-up.git up 66 | ``` 67 | 68 | Add `up` to your `INSTALLED_APPS` to enable the management command: 69 | 70 | ```python 71 | INSTALLED_APPS = [ 72 | # ... 73 | 'up', 74 | ] 75 | ``` 76 | 77 | Add your target domain to the `ALLOWED_HOSTS` in your `settings.py`. 78 | 79 | ```python 80 | ALLOWED_HOSTS = [ 81 | 'djup-test.brntn.me', 82 | 'localhost' 83 | ] 84 | ``` 85 | 86 | Set the `SECURE_PROXY_SSL_HEADER` setting in your `settings.py` to ensure the connection is considered secure. 87 | 88 | ```python 89 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_SCHEME', 'https') 90 | ``` 91 | 92 | Set up your database to use `dj_database_url`: 93 | 94 | ```python 95 | import dj_database_url 96 | DATABASES = { 97 | 'default': dj_database_url.config(default=f'sqlite:///{BASE_DIR / "db.sqlite3"}') 98 | } 99 | ``` 100 | 101 | Generate a new secret key (either manually, or with a [trusted tool](https://utils.brntn.me/django-secret/)), and configure your application to pull it out of the environment. 102 | 103 | In `.env`: 104 | 105 | ``` 106 | DJANGO_SECRET_KEY= 107 | ``` 108 | 109 | And in your `settings.py` replace the existing `SECRET_KEY` line with this: 110 | 111 | ``` 112 | SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] 113 | ``` 114 | 115 | Create a requirements file from your environment if one doesn't exist: 116 | 117 | ```shell 118 | pipenv lock -r > requirements.txt 119 | ``` 120 | 121 | Deploy with the `up` management command: 122 | 123 | ```shell 124 | pipenv run python manage.py up yourdomain.example --email= 125 | ``` 126 | 127 | 128 | ## Extra Configuration 129 | 130 | ### Setting environment variables 131 | 132 | Add environment variables to a `.env` file alongside your `manage.py`. These will be exported into the environment before running your server (and management commands during deployment). 133 | 134 | For example, to configure Django to load the `SECRET_KEY` from your environment, and add a secure secret key to your `.env` file: 135 | 136 | `settings.py`: 137 | 138 | ```python 139 | SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] 140 | ``` 141 | 142 | `.env`: 143 | 144 | ``` 145 | DJANGO_SECRET_KEY="dt(t9)7+&cm$nrq=p(pg--i)#+93dffwt!r05k-isd^8y1y0" 146 | ``` 147 | 148 | 149 | ### Specifying a Python version 150 | 151 | By default, `django-up` uses Python 3.10. 152 | If your application targets a different version you can use the `UP_PYTHON_VERSION` environment variable. 153 | Valid choices are: 154 | 155 | - `python3.8` 156 | - `python3.9` 157 | - `python3.10` (default) 158 | - `python3.11` 159 | 160 | ```python 161 | UP_PYTHON_VERSION = "python3.11" 162 | ``` 163 | 164 | These are the Python version available in the deadsnakes PPA. 165 | Versions older than Python 3.8 require older versions of OpenSSL so are not included in the PPA for Ubuntu 22.04. 166 | 167 | 168 | ### Deploying multiple applications to the same server 169 | 170 | Your application will bind to an internal port on your server. 171 | To deploy multiple applications to the same server you will need to manually specify this port. 172 | 173 | In your `settings.py`, set `UP_GUNICORN_PORT` is set to a unique port for the server that you are deploying to: 174 | 175 | ```python 176 | UP_GUNICORN_PORT = 8556 177 | ``` 178 | 179 | 180 | ### Using manifest file storage 181 | 182 | To minimise downtime, during the deployment `collectstatic` is executed while your previous deployment is still running. 183 | In order make sure that the correct version of static files are used _during the deployment_ you can use the `ManifestStaticFilesStorage` storage backend that Django provides. 184 | 185 | ```python 186 | STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" 187 | ``` 188 | 189 | For most projects using this backend will be a best practice, regardless of whether you are deploying with `django-up`. 190 | 191 | 192 | ### Supporting multiple domains 193 | 194 | As long as all domains that you plan on supporting are pointing to your server, you can include them in your `ALLOWED_HOSTS`. 195 | Certificates will be requested for each domain. 196 | 197 | For example, so support both the apex and `www` subdomain for a project, your could configure your application with: 198 | 199 | ```python 200 | ALLOWED_HOSTS = [ 201 | 'django-up.com', 202 | 'www.django-up.com' 203 | ] 204 | ``` 205 | 206 | 207 | ### Adding `django-up` directly to your project 208 | 209 | If you are likely to customise the Ansible files then it's probably easier to just add the `django-up` files to your own git repository, rather than using a submodule. 210 | 211 | You can use a shell one liner to download the repository from Github and extract it into an "up" directory in your project: 212 | 213 | ```shell 214 | mkdir -p up && curl -L https://github.com/sesh/django-up/tarball/main | tar -xz --strip-components=1 -C up 215 | ``` 216 | 217 | 218 | [django]: https://www.djangoproject.com 219 | [linode]: https://www.linode.com/lp/refer/?r=46340a230dfd33a24e40407c7ea938e31b295dec 220 | -------------------------------------------------------------------------------- /bm2/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for bm2 project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | 16 | import dj_database_url 17 | 18 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 19 | BASE_DIR = Path(__file__).resolve().parent.parent 20 | 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") 27 | if not SECRET_KEY: 28 | raise Exception("DJANGO_SECRET_KEY environment variable must be set") 29 | 30 | # SECURITY WARNING: don't run with debug turned on in production! 31 | DEBUG = True 32 | 33 | ALLOWED_HOSTS = ["bm2.brntn.me", "localhost"] 34 | 35 | 36 | # Application definition 37 | 38 | INSTALLED_APPS = [ 39 | "django.contrib.auth", 40 | "django.contrib.contenttypes", 41 | "django.contrib.sessions", 42 | "django.contrib.messages", 43 | "django.contrib.staticfiles", 44 | "authuser", 45 | "django.contrib.admin", 46 | "up", 47 | "links", 48 | "taggit", 49 | ] 50 | 51 | MIDDLEWARE = [ 52 | "django.middleware.security.SecurityMiddleware", 53 | "django.contrib.sessions.middleware.SessionMiddleware", 54 | "django.middleware.common.CommonMiddleware", 55 | "django.middleware.csrf.CsrfViewMiddleware", 56 | "django.contrib.auth.middleware.AuthenticationMiddleware", 57 | "django.contrib.messages.middleware.MessageMiddleware", 58 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 59 | "bm2.middleware.set_remote_addr", 60 | "bm2.middleware.csp", 61 | "bm2.middleware.permissions_policy", 62 | "bm2.middleware.xss_protect", 63 | "bm2.middleware.expect_ct", 64 | "bm2.middleware.cache", 65 | "bm2.middleware.corp_coop_coep", 66 | "bm2.middleware.dns_prefetch", 67 | "bm2.middleware.referrer_policy", 68 | "bm2.middleware.login_with_api_key", 69 | ] 70 | 71 | ROOT_URLCONF = "bm2.urls" 72 | 73 | TEMPLATES = [ 74 | { 75 | "BACKEND": "django.template.backends.django.DjangoTemplates", 76 | "DIRS": [], 77 | "APP_DIRS": True, 78 | "OPTIONS": { 79 | "context_processors": [ 80 | "django.template.context_processors.debug", 81 | "django.template.context_processors.request", 82 | "django.contrib.auth.context_processors.auth", 83 | "django.contrib.messages.context_processors.messages", 84 | ], 85 | }, 86 | }, 87 | ] 88 | 89 | WSGI_APPLICATION = "bm2.wsgi.application" 90 | 91 | 92 | # Database 93 | # https://docs.djangoproject.com/en/4.2/ref/settings/ 94 | 95 | DATABASES = {"default": dj_database_url.config(default=f'sqlite:///{BASE_DIR / "db.sqlite3"}')} 96 | 97 | 98 | # Password validation 99 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 100 | 101 | AUTH_PASSWORD_VALIDATORS = [ 102 | { 103 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 104 | }, 105 | { 106 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 107 | }, 108 | { 109 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 110 | }, 111 | { 112 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 113 | }, 114 | ] 115 | 116 | 117 | # Internationalization 118 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 119 | 120 | LANGUAGE_CODE = "en-us" 121 | TIME_ZONE = "UTC" 122 | USE_I18N = True 123 | USE_TZ = True 124 | 125 | 126 | # Static files (CSS, JavaScript, Images) 127 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 128 | 129 | STATIC_URL = "static/" 130 | if not DEBUG: 131 | STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" 132 | 133 | 134 | # Default primary key field type 135 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 136 | 137 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 138 | 139 | 140 | # Logging Configuration 141 | 142 | if DEBUG is False: 143 | LOGGING = { 144 | "version": 1, 145 | "disable_existing_loggers": False, 146 | "handlers": { 147 | "file": { 148 | "level": "DEBUG", 149 | "class": "logging.FileHandler", 150 | "filename": "/srv/www/bm2/logs/debug.log", 151 | }, 152 | }, 153 | "loggers": { 154 | "django": { 155 | "handlers": ["file"], 156 | "level": "DEBUG", 157 | "propagate": True, 158 | }, 159 | }, 160 | } 161 | 162 | 163 | # Cookies 164 | # https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/#https 165 | 166 | if not DEBUG: 167 | CSRF_COOKIE_SECURE = True 168 | SESSION_COOKIE_SECURE = True 169 | 170 | 171 | # Django Up 172 | # https://github.com/sesh/django-up 173 | 174 | UP_GUNICORN_PORT = 15276 175 | UP_PYTHON_VERSION = "python3.11" 176 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_SCHEME", "https") 177 | 178 | 179 | # Custom user model 180 | 181 | AUTH_USER_MODEL = "authuser.User" 182 | AUTH_USER_ALLOW_SIGNUP = True 183 | LOGIN_REDIRECT_URL = "/" 184 | 185 | # Sentry 186 | # https://sentry.io 187 | # Enabled by setting SENTRY_DSN, install sentry_sdk if you plan on using Sentry 188 | 189 | SENTRY_DSN = os.environ.get("SENTRY_DSN", "") 190 | 191 | if SENTRY_DSN and not DEBUG: 192 | import sentry_sdk 193 | from sentry_sdk.integrations.django import DjangoIntegration 194 | 195 | sentry_sdk.init( 196 | dsn=SENTRY_DSN, 197 | integrations=[ 198 | DjangoIntegration(), 199 | ], 200 | # Set traces_sample_rate to 1.0 to capture 100% 201 | # of transactions for performance monitoring. 202 | # We recommend adjusting this value in production. 203 | traces_sample_rate=0.05, 204 | # If you wish to associate users to errors (assuming you are using 205 | # django.contrib.auth) you may enable sending PII data. 206 | send_default_pii=False, 207 | ) 208 | 209 | 210 | # django-taggit 211 | # https://django-taggit.readthedocs.io/en/latest/ 212 | 213 | TAGGIT_CASE_INSENSITIVE = True 214 | 215 | 216 | # django-debug-toolbar 217 | 218 | if DEBUG: 219 | ENABLE_DEBUG_TOOLBAR = False 220 | 221 | try: 222 | import debug_toolbar # noqa 223 | 224 | ENABLE_DEBUG_TOOLBAR = True 225 | INSTALLED_APPS += [ 226 | "debug_toolbar", 227 | ] 228 | MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") 229 | INTERNAL_IPS = [ 230 | "127.0.0.1", 231 | ] 232 | except ImportError: 233 | print("django-debug-toolbar is disabled because it is not installed") 234 | -------------------------------------------------------------------------------- /up/management/commands/up.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | import shutil 5 | import string 6 | import subprocess 7 | import sys 8 | import tempfile 9 | 10 | import yaml 11 | from django.conf import settings 12 | from django.core.management.base import BaseCommand 13 | from django.core.validators import ValidationError, validate_email 14 | from django.utils.crypto import get_random_string 15 | 16 | """ 17 | Deploying Django applications as quickly as you create them 18 | 19 | Usage: 20 | ./manage.py up [--email=] [--debug] [--verbose] 21 | """ 22 | 23 | 24 | class Command(BaseCommand): 25 | help = "Deploy your Django site to a remote server" 26 | 27 | def add_arguments(self, parser): 28 | parser.add_argument("hostnames", nargs="+", type=str) 29 | parser.add_argument("--email", nargs=1, type=str, dest="email") 30 | parser.add_argument("--domain", nargs=1, type=str, dest="domain") 31 | parser.add_argument("--debug", action="store_true", default=False, dest="debug") 32 | parser.add_argument("--verbose", action="store_true", default=False, dest="verbose") 33 | 34 | def handle(self, *args, **options): 35 | ansible_dir = os.path.join(os.path.dirname(__file__), "..", "..", "ansible") 36 | hostnames = options["hostnames"] 37 | email = options["email"] 38 | 39 | try: 40 | if email: 41 | email = email[0] 42 | else: 43 | email = os.environ.get("UP_EMAIL", None) 44 | validate_email(email) 45 | except (ValidationError, IndexError, TypeError): 46 | sys.exit( 47 | "The --email argument or UP_EMAIL environment variable must be set for the SSL certificate request" 48 | ) 49 | 50 | try: 51 | open("requirements.txt", "r") 52 | except FileNotFoundError: 53 | sys.exit( 54 | "requirements.txt not found in the root directory, use `pip freeze` or `pipenv lock -r` to generate." 55 | ) 56 | 57 | app_name = settings.WSGI_APPLICATION.split(".")[0] 58 | 59 | up_dir = tempfile.TemporaryDirectory().name + "/django_up" 60 | app_tar = tempfile.NamedTemporaryFile(suffix=".tar") 61 | 62 | # copy our ansible files into our up_dir 63 | shutil.copytree(ansible_dir, up_dir) 64 | 65 | # Build up the django_environment variable from the contents of the .env 66 | # file on the local machine. These environment variables are injected into 67 | # the running environment using the app.sh file that's created. 68 | django_environment = {} 69 | try: 70 | with open(os.path.join(settings.BASE_DIR, ".env")) as env_file: 71 | for line in env_file.readlines(): 72 | if line and "=" in line and line.strip()[0] not in ["#", ";"]: 73 | var, val = line.split("=", 1) 74 | if " " not in var: 75 | django_environment[var] = val.strip() 76 | else: 77 | print("Ignoring environment variable with space: ", line) 78 | print("Loaded environment from .env: ", django_environment.keys()) 79 | except FileNotFoundError: 80 | pass 81 | 82 | # create a tarball of our application code, excluding some common directories 83 | # and files that are unlikely to be wanted on the remote machine 84 | subprocess.call( 85 | [ 86 | "tar", 87 | "--exclude", 88 | "*.pyc", 89 | "--exclude", 90 | ".git", 91 | "--exclude", 92 | "*.sqlite3", 93 | "--exclude", 94 | "__pycache__", 95 | "--exclude", 96 | "*.log", 97 | "--exclude", 98 | "{}.tar".format(app_name), 99 | "--dereference", 100 | "-cf", 101 | app_tar.name, 102 | ".", 103 | ] 104 | ) 105 | 106 | # use allowed_hosts to set up our domain names 107 | domains = [] 108 | 109 | if options["domain"]: 110 | domains = options["domain"] 111 | else: 112 | for host in settings.ALLOWED_HOSTS: 113 | if host.startswith("."): 114 | domains.append("*" + host) 115 | elif "." in host and not host.startswith("127."): 116 | domains.append(host) 117 | 118 | for h in hostnames: 119 | if h not in domains: 120 | sys.exit("{} isn't in allowed domains or DJANGO_ALLOWED_HOSTS".format(h)) 121 | 122 | yam = [ 123 | { 124 | "hosts": app_name, 125 | "remote_user": "root", 126 | "gather_facts": "yes", 127 | "vars": { 128 | # app_name is used for our user, database and to refer to our main application folder 129 | "app_name": app_name, 130 | # app_path is the directory for this specific deployment 131 | "app_path": app_name + "-" + str(get_random_string(6, string.ascii_letters + string.digits)), 132 | # service_name is our systemd service (you cannot have _ or other special characters) 133 | "service_name": app_name.replace("_", ""), 134 | "domain_names": " ".join(domains), 135 | "certbot_domains": "-d " + " -d ".join(domains), 136 | "gunicorn_port": getattr(settings, "UP_GUNICORN_PORT", "9000"), 137 | "app_tar": app_tar.name, 138 | "python_version": getattr(settings, "UP_PYTHON_VERSION", "python3.8"), 139 | # create a random database password to use for the database user, this is 140 | # saved on the remote machine and will be overridden by the ansible run 141 | # if it exists 142 | "db_password": str(get_random_string(12, string.ascii_letters + string.digits)), 143 | "django_debug": "yes" if options["debug"] else "no", 144 | "django_environment": django_environment, 145 | "certbot_email": email, 146 | "domain": domains[0], 147 | }, 148 | "roles": ["base", "ufw", "opensmtpd", "postgres", "nginx", "django"], 149 | } 150 | ] 151 | 152 | app_yml = open(os.path.join(up_dir, "{}.yml".format(app_name)), "w") 153 | yaml.dump(yam, app_yml) 154 | 155 | # create the hosts file for ansible 156 | with open(os.path.join(up_dir, "hosts"), "w") as hosts_file: 157 | hosts_file.write("[{}]\n".format(app_name)) 158 | hosts_file.write("\n".join(hostnames)) 159 | 160 | # add any extra ansible arguments that we need 161 | ansible_args = [] 162 | if options["verbose"]: 163 | ansible_args.append("-vvvv") 164 | 165 | # build the ansible command 166 | command = ["ansible-playbook", "-i", os.path.join(up_dir, "hosts")] 167 | command.extend(ansible_args) 168 | command.extend([os.path.join(up_dir, "{}.yml".format(app_name))]) 169 | 170 | # execute ansible 171 | return_code = subprocess.call(command) 172 | sys.exit(return_code) 173 | -------------------------------------------------------------------------------- /links/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit 3 | 4 | from django.contrib import messages 5 | from django.contrib.auth.decorators import login_required 6 | from django.core.paginator import Paginator 7 | from django.db.models import Q 8 | from django.http import JsonResponse 9 | from django.shortcuts import get_object_or_404, redirect, render 10 | from django.views.decorators.csrf import csrf_exempt 11 | 12 | from links.forms import LinkForm, UserSettingsForm 13 | from links.importers import ( 14 | ExpiredCredentialException, 15 | MissingCredentialException, 16 | feedbin, 17 | github, 18 | hackernews, 19 | ) 20 | from links.models import Link, LinkScreenshot, UserSettings 21 | from links.ssrf import uri_is_safe 22 | 23 | 24 | def build_absolute_uri_with_added_params(request, *, params={}): 25 | url = request.build_absolute_uri() 26 | parts = urlsplit(url) 27 | query = parse_qs(parts.query) 28 | 29 | for k, v in params.items(): 30 | query[k] = v 31 | 32 | url = urlunsplit([parts.scheme, parts.netloc, parts.path, urlencode(query, doseq=True), parts.fragment]) 33 | return url 34 | 35 | 36 | @login_required 37 | def dashboard(request): 38 | links = Link.objects.filter(user=request.user) 39 | 40 | # filtering 41 | if "domain" in request.GET: 42 | domain = request.GET["domain"] 43 | links = links.filter(Q(url__startswith=f"https://{domain}") | Q(url__startswith=f"http://{domain}")) 44 | 45 | if "date" in request.GET: 46 | d = request.GET["date"] 47 | links = links.filter(added__date=d) 48 | 49 | if "tag" in request.GET: 50 | tag = request.GET["tag"] 51 | links = links.filter(tags__slug__iexact=tag) 52 | 53 | if "q" in request.GET: 54 | query = request.GET["q"] 55 | links = links.filter( 56 | Q(url__icontains=query) | Q(title__icontains=query) | Q(tags__slug__iexact=query) 57 | ).distinct() 58 | 59 | if "limit" in request.GET: 60 | limit = int(request.GET["limit"]) 61 | limit = min([limit, 100]) 62 | else: 63 | limit = 100 64 | 65 | if "random" in request.GET: 66 | links = links.order_by("?") 67 | 68 | # pagination 69 | paginator = Paginator(links, limit) 70 | 71 | try: 72 | page = int(request.GET.get("page", 1)) 73 | except ValueError: 74 | page = 1 75 | 76 | current_page = paginator.page(page) 77 | next_url, prev_url = None, None 78 | 79 | if current_page.has_next(): 80 | next_url = build_absolute_uri_with_added_params(request, params={"page": page + 1}) 81 | 82 | if current_page.has_previous(): 83 | prev_url = build_absolute_uri_with_added_params(request, params={"page": page - 1}) 84 | 85 | links = current_page.object_list.prefetch_related("tags").prefetch_related("linkscreenshot_set") 86 | 87 | if "json" in request.GET: 88 | data = { 89 | "data": [link.as_json() for link in links], 90 | "next": next_url, 91 | "prev": prev_url, 92 | } 93 | return JsonResponse(data) 94 | 95 | return render( 96 | request, 97 | "links.html", 98 | { 99 | "links": links, 100 | "next": next_url, 101 | "prev": prev_url, 102 | }, 103 | ) 104 | 105 | 106 | @login_required 107 | def add(request): 108 | url = request.GET.get("url") 109 | 110 | if url: 111 | existing_links = Link.objects.filter(url=url, user=request.user) 112 | if existing_links: 113 | return redirect(existing_links[0]) 114 | 115 | if request.method == "POST": 116 | form = LinkForm(request.POST) 117 | 118 | if form.is_valid(): 119 | link = form.save() 120 | link.user = request.user 121 | link.save() 122 | 123 | messages.info(request, "Bookmark added") 124 | return redirect("/") 125 | else: 126 | if url: 127 | form = LinkForm(request.GET) 128 | else: 129 | form = LinkForm() 130 | 131 | return render(request, "add.html", {"form": form}) 132 | 133 | 134 | @login_required 135 | def edit(request, pk): 136 | link = get_object_or_404(Link, pk=pk, user=request.user) 137 | 138 | if request.method == "POST": 139 | form = LinkForm(request.POST, instance=link) 140 | if form.is_valid(): 141 | form.save() 142 | messages.info(request, "Bookmark saved successfully") 143 | return redirect("/") 144 | else: 145 | form = LinkForm(instance=link) 146 | 147 | return render(request, "edit.html", {"form": form}) 148 | 149 | 150 | def screenshot(request, pk): 151 | # login not required for the screenshots 152 | # but don't display the other screenshots for this URL unless the user is authenticated 153 | screenshot = get_object_or_404(LinkScreenshot, pk=pk) 154 | if request.user == screenshot.link.user: 155 | link = screenshot.link 156 | else: 157 | link = None 158 | 159 | return render(request, "screenshot.html", {"screenshot": screenshot, "link": link}) 160 | 161 | 162 | @login_required 163 | def delete(request, pk): 164 | link = get_object_or_404(Link, pk=pk, user=request.user) 165 | 166 | if request.method == "POST": 167 | link.delete() 168 | return redirect("/") 169 | 170 | return render(request, "delete.html", {"link": link}) 171 | 172 | 173 | @login_required 174 | def user_settings(request): 175 | user_settings_obj, created = UserSettings.objects.get_or_create(user=request.user) 176 | if created: 177 | user_settings_obj.save() 178 | 179 | if request.method == "POST": 180 | form = UserSettingsForm(request.POST, instance=user_settings_obj) 181 | if form.is_valid(): 182 | form.save() 183 | messages.info(request, "Settings saved successfully") 184 | return redirect("/settings/") 185 | else: 186 | form = UserSettingsForm(instance=user_settings_obj) 187 | 188 | return render(request, "settings.html", {"form": form}) 189 | 190 | 191 | @login_required 192 | def import_github(request): 193 | if request.method == "POST": 194 | try: 195 | count = github.import_stars(request.user, request) 196 | except (UserSettings.DoesNotExist, MissingCredentialException): 197 | messages.warning(request, "Please add your Github token in settings") 198 | return redirect("/") 199 | except ExpiredCredentialException: 200 | messages.warning(request, "Github token is expired (or Github is having an issue!)") 201 | return redirect("/") 202 | 203 | if count >= 0: 204 | messages.info(request, f"Imported {count} stars from Github") 205 | 206 | return redirect("/") 207 | 208 | 209 | @login_required 210 | def import_feedbin(request): 211 | if request.method == "POST": 212 | try: 213 | count = feedbin.import_stars(request.user, request) 214 | except (UserSettings.DoesNotExist, MissingCredentialException): 215 | messages.warning(request, "Please add your Feedbin credentials in settings") 216 | return redirect("/") 217 | except ExpiredCredentialException: 218 | messages.warning(request, "Feedbin token is expired (or Feedbin is having an issue!)") 219 | return redirect("/") 220 | 221 | if count >= 0: 222 | messages.info(request, f"Imported {count} starred entries from Feedbin") 223 | 224 | return redirect("/") 225 | 226 | 227 | @login_required 228 | def import_hackernews(request): 229 | if request.method == "POST": 230 | try: 231 | count = hackernews.import_favourites(request.user, request) 232 | except (UserSettings.DoesNotExist, MissingCredentialException): 233 | messages.warning(request, "Please add your Hacker News username in settings") 234 | return redirect("/") 235 | 236 | if count >= 0: 237 | messages.info(request, f"Imported {count} favourites from Hacker News") 238 | 239 | return redirect("/") 240 | 241 | 242 | @login_required 243 | @csrf_exempt 244 | def api_link(request, pk): 245 | link = get_object_or_404(Link, pk=pk, user=request.user) 246 | 247 | if request.method == "POST": 248 | try: 249 | request_body = json.loads(request.body) 250 | except Exception: 251 | return JsonResponse( 252 | {"errors": [{"code": "bad_request", "message": "Failed to parse JSON body"}]}, 253 | status=400, 254 | ) 255 | 256 | if "screenshot_url" in request_body: 257 | url = request_body["screenshot_url"] 258 | if uri_is_safe(url): 259 | screenshot, created = LinkScreenshot.objects.get_or_create(link=link, url=url) 260 | 261 | if created: 262 | return JsonResponse({"data": link.as_json(), "messages": ["screeshot added"]}, status=201) 263 | else: 264 | return JsonResponse({"data": link.as_json(), "messages": ["screeshot already exists"]}) 265 | 266 | return JsonResponse( 267 | {"errors": [{"code": "bad_request", "message": "Invalid body or unsupported action"}]}, status=400 268 | ) 269 | 270 | else: 271 | return JsonResponse({"data": link.as_json()}) 272 | -------------------------------------------------------------------------------- /links/static/css/mobi.min.css: -------------------------------------------------------------------------------- 1 | /*! mobi.css v3.1.1 http://getmobicss.com */html{-moz-text-size-adjust:100%;-webkit-text-size-adjust:100%;box-sizing:border-box;text-size-adjust:100%}*,:after,:before{box-sizing:inherit}body{background-color:#fff;color:#333;font-size:1rem;line-height:1.5;margin:0}body,button,input,select,textarea{font-family:-apple-system,BlinkMacSystemFont,Hiragino Sans GB,Roboto,Segoe UI,Microsoft Yahei,微软雅黑,Oxygen-Sans,Ubuntu,Cantarell,Helvetica,Arial,STHeiti,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol}h1,h2,h3,h4,h5,h6{margin:1.875rem 0 0}address,blockquote,dl,figure,hr,ol,p,pre,table,ul{margin:.9375rem 0 0}h1,h2,h3,h4,h5,h6{font-weight:600}h1{font-size:2rem}h2{font-size:1.625rem}h3{font-size:1.375rem}h4{font-size:1.25rem}h5{font-size:1.125rem}h6{font-size:1rem}a{-webkit-text-decoration-skip:objects;color:#267fd9;text-decoration:none}a:active,a:hover{text-decoration:underline}b,dt,strong{font-weight:600}code,kbd,samp{background-color:#f2f2f2;font-size:85%;padding:.2em .3em}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}pre{-webkit-overflow-scrolling:touch;background-color:#f2f2f2;line-height:1.2;overflow:auto;padding:.9375rem}pre,pre code{font-size:.8125rem}pre code{background-color:transparent;padding:0}blockquote{border-left:5px solid #ddd;color:#777;padding-left:.9375rem}ol,ul{padding-left:1.875rem}dd,dt,ol ol,ol ul,ul ol,ul ul{margin:0}hr{border:0;border-top:1px solid #ddd}small,sub,sup{font-size:85%}sub,sup{line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.2em}sup{top:-.4em}address,time{font-style:normal}mark{background-color:#ff0;color:#333;padding:0 .2em}rt{font-size:60%}abbr[title]{-webkit-text-decoration:underline dotted;border-bottom:0;text-decoration:underline;text-decoration:underline dotted}audio:not([controls]){display:none;height:0}img{border-style:none;vertical-align:middle}audio,img,video{max-width:100%}figcaption{color:#777;font-size:85%}[role=button]{cursor:pointer}[role=button],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation;touch-action:manipulation}::-webkit-file-upload-button,[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button;appearance:button}[type=search]{-moz-appearance:none;-webkit-appearance:none;appearance:none}::-webkit-file-upload-button{font:inherit}[hidden]{display:none}fieldset{border:1px solid #ddd;margin:.9375rem 0 0;padding:0 .9375rem .9375rem}legend{padding:0 .2em}optgroup{color:#777;font-style:normal;font-weight:400}option{color:#333}progress{max-width:100%}.container,.container-fluid,.container-wider{-ms-flex-positive:1;-webkit-box-flex:1;-webkit-flex-grow:1;flex-grow:1;overflow:hidden;padding:0 .9375rem .9375rem}.container{max-width:50rem}.container-wider{max-width:75rem}.flex-bottom,.flex-center,.flex-left,.flex-middle,.flex-right,.flex-top,.flex-vertical{-ms-flex-flow:row nowrap;-webkit-box-direction:normal;-webkit-box-orient:horizontal;-webkit-flex-flow:row nowrap;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;flex-flow:row nowrap}.flex-bottom,.flex-center,.flex-left,.flex-middle,.flex-right,.flex-top,.flex-vertical.flex-bottom,.flex-vertical.flex-center,.flex-vertical.flex-left,.flex-vertical.flex-middle,.flex-vertical.flex-right,.flex-vertical.flex-top{-ms-flex-align:stretch;-ms-flex-pack:start;-webkit-align-items:stretch;-webkit-box-align:stretch;-webkit-box-pack:start;-webkit-justify-content:flex-start;align-items:stretch;justify-content:flex-start}.flex-center,.flex-vertical.flex-middle{-ms-flex-pack:center;-webkit-box-pack:center;-webkit-justify-content:center;justify-content:center}.flex-right,.flex-vertical.flex-bottom{-ms-flex-pack:end;-webkit-box-pack:end;-webkit-justify-content:flex-end;justify-content:flex-end}.flex-top,.flex-vertical.flex-left{-ms-flex-align:start;-webkit-align-items:flex-start;-webkit-box-align:start;align-items:flex-start}.flex-middle,.flex-vertical.flex-center{-ms-flex-align:center;-webkit-align-items:center;-webkit-box-align:center;align-items:center}.flex-bottom,.flex-vertical.flex-right{-ms-flex-align:end;-webkit-align-items:flex-end;-webkit-box-align:end;align-items:flex-end}.units-gap{margin-left:-.46875rem;margin-right:-.46875rem}.units-gap>.unit,.units-gap>.unit-0,.units-gap>.unit-1,.units-gap>.unit-1-2,.units-gap>.unit-1-3,.units-gap>.unit-1-4,.units-gap>.unit-1-on-mobile,.units-gap>.unit-2-3,.units-gap>.unit-3-4{padding-left:.46875rem;padding-right:.46875rem}.units-gap-big{margin-left:-.9375rem;margin-right:-.9375rem}.units-gap-big>.unit,.units-gap-big>.unit-0,.units-gap-big>.unit-1,.units-gap-big>.unit-1-2,.units-gap-big>.unit-1-3,.units-gap-big>.unit-1-4,.units-gap-big>.unit-1-on-mobile,.units-gap-big>.unit-2-3,.units-gap-big>.unit-3-4{padding-left:.9375rem;padding-right:.9375rem}.unit{-ms-flex-positive:1;-ms-flex-preferred-size:0;-webkit-box-flex:1;-webkit-flex-basis:0;-webkit-flex-grow:1;flex-basis:0;flex-grow:1;max-width:100%}.unit-1,.unit-1-2,.unit-1-3,.unit-1-4,.unit-1-on-mobile,.unit-2-3,.unit-3-4{-ms-flex-negative:0;-webkit-flex-shrink:0;flex-shrink:0}.unit-1{-ms-flex-preferred-size:100%;-webkit-flex-basis:100%;flex-basis:100%;max-width:100%}.unit-1-2{-ms-flex-preferred-size:50%;-webkit-flex-basis:50%;flex-basis:50%;max-width:50%}.unit-1-3{-ms-flex-preferred-size:33.33%;-webkit-flex-basis:33.33%;flex-basis:33.33%;max-width:33.33%}.unit-2-3{-ms-flex-preferred-size:66.67%;-webkit-flex-basis:66.67%;flex-basis:66.67%;max-width:66.67%}.unit-1-4{-ms-flex-preferred-size:25%;-webkit-flex-basis:25%;flex-basis:25%;max-width:25%}.unit-3-4{-ms-flex-preferred-size:75%;-webkit-flex-basis:75%;flex-basis:75%;max-width:75%}.flex-vertical{-ms-flex-direction:column;-webkit-box-direction:normal;-webkit-box-orient:vertical;-webkit-flex-direction:column;flex-direction:column}.flex-vertical>.unit,.flex-vertical>.unit-0,.flex-vertical>.unit-1,.flex-vertical>.unit-1-2,.flex-vertical>.unit-1-3,.flex-vertical>.unit-1-4,.flex-vertical>.unit-1-on-mobile,.flex-vertical>.unit-2-3,.flex-vertical>.unit-3-4{max-width:none}.flex-vertical>.unit-1{max-height:100%}.flex-vertical>.unit-1-2{max-height:50%}.flex-vertical>.unit-1-3{max-height:33.33%}.flex-vertical>.unit-2-3{max-height:66.67%}.flex-vertical>.unit-1-4{max-height:25%}.flex-vertical>.unit-3-4{max-height:75%}.flex-wrap{-ms-flex-wrap:wrap;-webkit-flex-wrap:wrap;flex-wrap:wrap}@media (max-width:767px){.unit-1-on-mobile{-ms-flex-preferred-size:100%;-webkit-flex-basis:100%;flex-basis:100%;max-width:100%}.flex-vertical>.unit-1-on-mobile{max-height:100%}}.top-gap-big{margin-top:1.875rem!important}.top-gap{margin-top:.9375rem!important}.top-gap-0{margin-top:0!important}@media (max-width:767px){.hide-on-mobile{display:none!important}}@media (min-width:768px){.show-on-mobile{display:none!important}}.table{background-color:#fff;border:0;border-collapse:collapse;border-spacing:0;width:100%}.table caption{caption-side:bottom;color:#777;font-size:85%}.table caption,.table td,.table th{padding:.3125rem;text-align:left}.table td,.table th{border:0;border-bottom:1px solid #ddd}.table th{background-color:#f2f2f2;font-weight:600}.btn{-moz-appearance:none;-webkit-appearance:none;appearance:none;background-color:#fff;border:1px solid #ddd;border-radius:.1875rem;color:#333;cursor:pointer;display:inline-block;font-family:-apple-system,BlinkMacSystemFont,Hiragino Sans GB,Roboto,Segoe UI,Microsoft Yahei,微软雅黑,Oxygen-Sans,Ubuntu,Cantarell,Helvetica,Arial,STHeiti,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol;font-size:1rem;line-height:1.25;margin:.9375rem 0 0;padding:.3125rem .625rem;text-align:center}.btn:active,.btn:hover{background-color:#f2f2f2;text-decoration:none}.btn[disabled]{cursor:default;opacity:.5;pointer-events:none}.btn-primary{background-color:#267fd9;border-color:#267fd9;color:#fff}.btn-primary:active,.btn-primary:hover{background-color:#2273c3}.btn-primary[disabled]{cursor:default;opacity:.5;pointer-events:none}.btn-danger{background-color:#db5757;border-color:#db5757;color:#fff}.btn-danger:active,.btn-danger:hover{background-color:#d74242}.btn-danger[disabled]{cursor:default;opacity:.5;pointer-events:none}.btn-block{display:block;width:100%}.form{margin:0}.form label{border:1px solid transparent;cursor:pointer;display:block;line-height:1.25;margin-top:.9375rem;padding-bottom:.3125rem;padding-top:.3125rem}.form [type=email],.form [type=number],.form [type=password],.form [type=search],.form [type=tel],.form [type=text],.form [type=url],.form select,.form textarea{-moz-appearance:none;-webkit-appearance:none;appearance:none;background-color:#fff;border:1px solid #ddd;border-radius:.1875rem;color:#333;display:block;font-family:-apple-system,BlinkMacSystemFont,Hiragino Sans GB,Roboto,Segoe UI,Microsoft Yahei,微软雅黑,Oxygen-Sans,Ubuntu,Cantarell,Helvetica,Arial,STHeiti,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol;font-size:1rem;line-height:1.25;margin:.9375rem 0 0;padding:.3125rem;width:100%}.form [type=email]:focus,.form [type=number]:focus,.form [type=password]:focus,.form [type=search]:focus,.form [type=tel]:focus,.form [type=text]:focus,.form [type=url]:focus,.form select:focus,.form textarea:focus{border-color:#267fd9;outline:0}@media (max-width:767px){.form [type=date],.form [type=datetime-local],.form [type=month],.form [type=time],.form [type=week]{-moz-appearance:none;-webkit-appearance:none;appearance:none;background-color:#fff;border:1px solid #ddd;border-radius:.1875rem;color:#333;display:block;font-family:-apple-system,BlinkMacSystemFont,Hiragino Sans GB,Roboto,Segoe UI,Microsoft Yahei,微软雅黑,Oxygen-Sans,Ubuntu,Cantarell,Helvetica,Arial,STHeiti,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol;font-size:1rem;line-height:1.25;margin:.9375rem 0 0;padding:.3125rem;width:100%}}@media (max-width:767px){.form [type=date]:focus,.form [type=datetime-local]:focus,.form [type=month]:focus,.form [type=time]:focus,.form [type=week]:focus{border-color:#267fd9;outline:0}}.form [type=checkbox],.form [type=radio]{cursor:pointer;margin:0 .3125rem 0 0}.form select{cursor:pointer}.form [type=file],.form [type=range]{border-bottom:1px solid transparent;border-top:1px solid transparent;line-height:1.25;padding-bottom:.3125rem;padding-top:.3125rem;width:100%}.form [type=color],.form [type=file],.form [type=image],.form [type=range]{cursor:pointer;display:block;margin:.9375rem 0 0}.form [disabled]{cursor:default;opacity:.5;pointer-events:none}.form [readonly]{background-color:#f2f2f2}.scroll-view{-webkit-overflow-scrolling:touch;overflow:auto}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-muted{color:#777}.text-primary{color:#267fd9}.text-danger{color:#db5757}a.text-danger,a.text-muted,a.text-primary{text-decoration:underline}.text-small{font-size:85%} 2 | -------------------------------------------------------------------------------- /links/tests.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from datetime import timedelta 3 | from unittest import mock 4 | 5 | from django.test import TestCase 6 | from django.utils import timezone 7 | from thttp import Response 8 | 9 | from authuser.models import User 10 | from links.models import Link, UserSettings 11 | 12 | 13 | class LinkModelTestCase(TestCase): 14 | def test_str_returns_title(self): 15 | link = Link.objects.create(url="https://example.org", title="Example Site") 16 | self.assertEqual("Example Site", str(link)) 17 | 18 | def test_icon_uses_ddg_service(self): 19 | link = Link.objects.create(url="https://example.org") 20 | self.assertEqual("https://icons.duckduckgo.com/ip3/example.org.ico", link.icon()) 21 | 22 | 23 | class DashboardTestCase(TestCase): 24 | def setUp(self): 25 | self.user = User.objects.create(email="tester@example.org") 26 | 27 | def test_dashboard_limits_links(self): 28 | self.client.force_login(self.user) 29 | 30 | for _ in range(1000): 31 | Link.objects.create(user=self.user, url=f"https://example.org/{secrets.token_hex()}") 32 | 33 | response = self.client.get("/") 34 | self.assertEqual(100, len(response.context["links"])) 35 | 36 | def test_dashboard_only_shows_users_bookmarks(self): 37 | self.client.force_login(self.user) 38 | second_user = User.objects.create(email="test2@example.org") 39 | 40 | for _ in range(10): 41 | Link.objects.create(user=self.user, url=f"https://example.org/{secrets.token_hex()}") 42 | Link.objects.create(user=second_user, url=f"https://example.org/{secrets.token_hex()}") 43 | 44 | response = self.client.get("/") 45 | self.assertEqual(10, len(response.context["links"])) 46 | 47 | def test_dashboard_filtering_by_domain(self): 48 | self.client.force_login(self.user) 49 | 50 | for _ in range(10): 51 | Link.objects.create(user=self.user, url=f"https://example.org/{secrets.token_hex()}") 52 | Link.objects.create(user=self.user, url=f"https://example.com/{secrets.token_hex()}") 53 | 54 | response = self.client.get("/?domain=example.com") 55 | self.assertEqual(10, len(response.context["links"])) 56 | 57 | def test_dashboard_filtering_by_domain_with_mixed_scheme(self): 58 | self.client.force_login(self.user) 59 | 60 | for _ in range(10): 61 | Link.objects.create(user=self.user, url=f"https://example.org/{secrets.token_hex()}") 62 | Link.objects.create(user=self.user, url=f"http://example.com/{secrets.token_hex()}") 63 | Link.objects.create(user=self.user, url=f"https://example.com/{secrets.token_hex()}") 64 | 65 | response = self.client.get("/?domain=example.com") 66 | self.assertEqual(20, len(response.context["links"])) 67 | 68 | def test_dashboard_filtering_by_date(self): 69 | self.client.force_login(self.user) 70 | 71 | for i in range(10): 72 | d = timezone.now() - timedelta(days=i) 73 | for _ in range(5): 74 | link = Link.objects.create(user=self.user, url=f"https://example.org/{secrets.token_hex()}") 75 | link.added = d 76 | link.save() 77 | 78 | yesterday = timezone.now() - timedelta(days=1) 79 | date_str = yesterday.strftime("%Y-%m-%d") 80 | 81 | response = self.client.get(f"/?date={date_str}") 82 | self.assertEqual(5, len(response.context["links"])) 83 | 84 | def test_dashboard_filtering_by_tag(self): 85 | self.client.force_login(self.user) 86 | 87 | for i in range(10): 88 | link = Link.objects.create(user=self.user, url=f"https://example.org/{secrets.token_hex()}") 89 | link.tags.add("a") 90 | 91 | link = Link.objects.create(user=self.user, url=f"https://example.org/{secrets.token_hex()}") 92 | link.tags.add("b") 93 | 94 | response = self.client.get("/?tag=a") 95 | self.assertEqual(10, len(response.context["links"])) 96 | 97 | def test_pagination_returns_first_page_if_invalid_page(self): 98 | self.client.force_login(self.user) 99 | 100 | for x in range(1000): 101 | Link.objects.create(user=self.user, url=f"https://example.org/{x}") 102 | 103 | response = self.client.get("/?page=asodas") 104 | self.assertEqual("https://example.org/999", response.context["links"][0].url) 105 | 106 | def test_pagination_next_and_previous_urls(self): 107 | self.client.force_login(self.user) 108 | 109 | for x in range(1000): 110 | Link.objects.create(user=self.user, url=f"https://example.org/{x}") 111 | 112 | response = self.client.get("/?page=4") 113 | self.assertEqual("http://testserver/?page=3", response.context["prev"]) 114 | self.assertEqual("http://testserver/?page=5", response.context["next"]) 115 | 116 | response = self.client.get("/") 117 | self.assertEqual(None, response.context["prev"]) 118 | self.assertEqual("http://testserver/?page=2", response.context["next"]) 119 | 120 | response = self.client.get("/?page=10") 121 | self.assertEqual("http://testserver/?page=9", response.context["prev"]) 122 | self.assertEqual(None, response.context["next"]) 123 | 124 | response = self.client.get("/?page=3&domain=example.org") 125 | self.assertEqual("http://testserver/?page=2&domain=example.org", response.context["prev"]) 126 | 127 | def test_limit_restricts_number_of_links(self): 128 | self.client.force_login(self.user) 129 | 130 | for x in range(1000): 131 | Link.objects.create(user=self.user, url=f"https://example.org/{x}") 132 | 133 | response = self.client.get("/?limit=10") 134 | self.assertEqual(10, len(response.context["links"])) 135 | 136 | def test_random_param_shuffles_links(self): 137 | self.client.force_login(self.user) 138 | 139 | for x in range(1000): 140 | link = Link.objects.create(user=self.user, url=f"https://example.org/{x}") 141 | 142 | response = self.client.get("/") 143 | self.assertEqual(link.pk, response.context["links"][0].pk) 144 | 145 | # there's a 1/1000 chance this will fail let's do it 10 times 146 | for _ in range(10): 147 | response = self.client.get("/?random=1") 148 | 149 | # if the first link isn't the last one created that means 150 | # the shuffle has worked: exit the test 151 | if link.pk != response.context["links"][0].pk: 152 | return 153 | 154 | self.assertTrue(False) 155 | 156 | def test_search(self): 157 | self.client.force_login(self.user) 158 | 159 | Link.objects.create(user=self.user, url="https://example.org/", title="example") 160 | Link.objects.create(user=self.user, url="https://waxy.org/wordle", title="Fun little game") 161 | Link.objects.create(user=self.user, url="https://games.nytimes.com", title="Crosswords & Wordles") 162 | link = Link.objects.create(user=self.user, url="https://puzzleanswers.com", title="") 163 | link.tags.add("wordle") 164 | link.save() 165 | 166 | response = self.client.get("/?q=wordle") 167 | self.assertEqual(3, len(response.context["links"])) 168 | 169 | 170 | class AddLinkTestCase(TestCase): 171 | def setUp(self): 172 | self.user = User.objects.create(email="tester@example.org") 173 | self.client.force_login(self.user) 174 | 175 | def test_add_form_when_logged_in(self): 176 | response = self.client.get("/add/") 177 | self.assertEqual(200, response.status_code) 178 | 179 | def test_prefills_url_if_provided_as_url_param(self): 180 | response = self.client.get("/add/?url=https://google.com") 181 | self.assertTrue('value="https://google.com"' in response.content.decode()) 182 | 183 | def test_add_link_to_url_that_already_exists_redirects(self): 184 | link = Link.objects.create(user=self.user, url="https://example.org") 185 | response = self.client.get("/add/?url=https://example.org") 186 | self.assertEqual(link.get_absolute_url(), response.url) 187 | 188 | def test_add_link_by_submitting_form(self): 189 | response = self.client.post("/add/", {"url": "https://example.org/added"}) 190 | self.assertEqual("/", response.url) 191 | 192 | links = Link.objects.filter(url__exact="https://example.org/added") 193 | self.assertEqual(1, len(links)) 194 | 195 | def test_add_link_fails_if_url_missing(self): 196 | response = self.client.post("/add/", {"notes": "Just a note, eh?"}) 197 | self.assertTrue(response.context["form"].has_error("url")) 198 | 199 | 200 | class EditLinkTestCase(TestCase): 201 | def setUp(self): 202 | self.user = User.objects.create(email="tester@example.org") 203 | self.client.force_login(self.user) 204 | 205 | def test_can_update_link(self): 206 | link = Link.objects.create(user=self.user, title="Test Title", url="https://example.com") 207 | response = self.client.post( 208 | f"/edit/{link.pk}/", {"title": "This is the updated title", "url": "https://example.com"} 209 | ) 210 | 211 | # returns redirect 212 | self.assertEqual(302, response.status_code) 213 | 214 | link = Link.objects.get(pk=link.pk) 215 | self.assertEqual("This is the updated title", link.title) 216 | 217 | def test_edit_form_is_prefilled_with_instance(self): 218 | link = Link.objects.create(user=self.user, title="Test Title", url="https://example.com") 219 | response = self.client.get(f"/edit/{link.pk}/") 220 | 221 | self.assertInHTML( 222 | '', 223 | response.content.decode(), 224 | ) 225 | 226 | 227 | class SettingsTestCase(TestCase): 228 | def setUp(self): 229 | self.user = User.objects.create(email="tester@example.org") 230 | self.client.force_login(self.user) 231 | 232 | def test_loading_settings_creates_settings_object_if_missing(self): 233 | self.assertEqual(0, UserSettings.objects.filter(user=self.user).count()) 234 | self.client.get("/settings/") 235 | self.assertEqual(1, UserSettings.objects.filter(user=self.user).count()) 236 | 237 | def test_updating_settings_saves_new_value(self): 238 | user_settings = UserSettings.objects.create(user=self.user, github_pat="BBB") 239 | self.client.post("/settings/", {"github_pat": "AAA"}) 240 | 241 | user_settings = UserSettings.objects.get(user=self.user) 242 | self.assertEqual("AAA", user_settings.github_pat) 243 | 244 | 245 | class ImporterTestCase(TestCase): 246 | def setUp(self): 247 | self.user = User.objects.create(email="tester@example.org") 248 | self.client.force_login(self.user) 249 | 250 | def test_imports_fail_with_missing_settings(self): 251 | for url in ["/import/github/", "/import/feedbin/", "/import/hackernews/"]: 252 | response = self.client.post(url, follow=True) 253 | messages = list(response.context["messages"]) 254 | self.assertTrue("in settings" in messages[0].message) 255 | 256 | def test_imports_fail_with_missing_credential(self): 257 | UserSettings.objects.create(user=self.user) 258 | 259 | for url in ["/import/github/", "/import/feedbin/", "/import/hackernews/"]: 260 | response = self.client.post(url, follow=True) 261 | messages = list(response.context["messages"]) 262 | self.assertTrue("in settings" in messages[0].message) 263 | 264 | def test_import_github_stars(self): 265 | UserSettings.objects.create(user=self.user, github_pat="AAA") 266 | 267 | mocked_response = Response( 268 | None, 269 | None, 270 | [ 271 | { 272 | "repo": { 273 | "html_url": "https://github.com/sesh/thttp", 274 | "full_name": "sesh/thttp", 275 | "description": "A tiny http library with a mocked response!", 276 | "topics": ["test", "http", "mocking"], 277 | }, 278 | "starred_at": "2023-06-29T23:39:35Z", 279 | } 280 | ], 281 | 200, 282 | None, 283 | {}, 284 | None, 285 | ) 286 | 287 | with mock.patch("links.importers.github.thttp.request", return_value=mocked_response): 288 | self.client.post("/import/github/") 289 | self.assertEqual(1, Link.objects.filter(user=self.user).count()) 290 | self.assertEqual("https://github.com/sesh/thttp", Link.objects.filter(user=self.user)[0].url) 291 | 292 | def test_import_feedbin_entries(self): 293 | UserSettings.objects.create(user=self.user, feedbin_username="aaa", feedbin_password="aaa") # nosec 294 | 295 | first_mocked_response = Response(None, None, ["123456"], 200, None, {}, None) 296 | second_mocked_response = Response( 297 | None, 298 | None, 299 | [{"url": "https://example.org", "title": "Official example", "created_at": "2023-06-29T23:39:35Z"}], 300 | 200, 301 | None, 302 | {}, 303 | None, 304 | ) 305 | 306 | with mock.patch( 307 | "links.importers.feedbin.thttp.request", side_effect=[first_mocked_response, second_mocked_response] 308 | ): 309 | self.client.post("/import/feedbin/") 310 | self.assertEqual(1, Link.objects.filter(user=self.user).count()) 311 | self.assertEqual("https://example.org", Link.objects.filter(user=self.user)[0].url) 312 | 313 | def test_import_feedbin_uses_summary_if_no_title(self): 314 | UserSettings.objects.create(user=self.user, feedbin_username="aaa", feedbin_password="aaa") # nosec 315 | 316 | first_mocked_response = Response(None, None, ["123456"], 200, None, {}, None) 317 | second_mocked_response = Response( 318 | None, 319 | None, 320 | [ 321 | { 322 | "url": "https://example.net", 323 | "title": "", 324 | "summary": "This is a summary of the post", 325 | "created_at": "2023-06-29T23:39:35Z", 326 | }, 327 | { 328 | "url": "https://example.org", 329 | "title": "", 330 | "summary": "Magni pariatur omnis ducimus atque tenetur. " 331 | "Unde culpa inventore ipsam et. Unde ipsam sed assumenda officiis. " 332 | "Asperiores qui aut consequuntur ullam sunt vero ea enim.", 333 | "created_at": "2023-06-29T23:39:35Z", 334 | }, 335 | ], 336 | 200, 337 | None, 338 | {}, 339 | None, 340 | ) 341 | 342 | with mock.patch( 343 | "links.importers.feedbin.thttp.request", side_effect=[first_mocked_response, second_mocked_response] 344 | ): 345 | self.client.post("/import/feedbin/") 346 | self.assertEqual(2, Link.objects.filter(user=self.user).count()) 347 | self.assertEqual("This is a summary of the post.", Link.objects.filter(user=self.user)[0].title) 348 | self.assertEqual( 349 | "Magni pariatur omnis ducimus atque tenetur. " 350 | "Unde culpa inventore ipsam et. Unde ipsam sed assumenda officiis.", 351 | Link.objects.filter(user=self.user)[1].title, 352 | ) 353 | 354 | def test_import_hackernews_favoutires(self): 355 | UserSettings.objects.create(user=self.user, hn_username="brntn") 356 | 357 | mocked_response = Response( 358 | None, None, {"links": [{"url": "https://example.org", "title": "ICAAN Example Site"}]}, 200, None, {}, None 359 | ) 360 | 361 | with mock.patch("links.importers.hackernews.thttp.request", side_effect=[mocked_response]): 362 | self.client.post("/import/hackernews/") 363 | self.assertEqual(1, Link.objects.filter(user=self.user).count()) 364 | self.assertEqual("ICAAN Example Site", Link.objects.filter(user=self.user)[0].title) 365 | 366 | 367 | class WellKnownTestCase(TestCase): 368 | def test_robots(self): 369 | response = self.client.get("/robots.txt") 370 | self.assertEqual("text/plain; charset=UTF-8", response.headers["content-type"]) 371 | 372 | def test_security(self): 373 | response = self.client.get("/.well-known/security.txt") 374 | self.assertTrue("security@brntn.me" in response.content.decode()) 375 | 376 | 377 | class DeleteLinkTestCase(TestCase): 378 | def setUp(self): 379 | self.user = User.objects.create(email="tester@example.org") 380 | self.client.force_login(self.user) 381 | 382 | def test_deletes_link(self): 383 | link = Link.objects.create(url="https://example.org", user=self.user) 384 | self.client.post(f"/delete/{link.pk}/") 385 | self.assertEqual(0, Link.objects.count()) 386 | 387 | def test_delete_link_fails_if_wrong_user(self): 388 | link = Link.objects.create(url="https://example.org") # no user 389 | response = self.client.post(f"/delete/{link.pk}/") 390 | 391 | self.assertEqual(404, response.status_code) 392 | self.assertEqual(1, Link.objects.count()) 393 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "41160929fb54316ec8a1113684be4b170566018bf2028fa1aee85659ae66a4c4" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.11" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "asgiref": { 20 | "hashes": [ 21 | "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", 22 | "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e" 23 | ], 24 | "markers": "python_version >= '3.9'", 25 | "version": "==3.10.0" 26 | }, 27 | "certifi": { 28 | "hashes": [ 29 | "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", 30 | "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316" 31 | ], 32 | "index": "pypi", 33 | "markers": "python_version >= '3.7'", 34 | "version": "==2025.11.12" 35 | }, 36 | "dj-database-url": { 37 | "hashes": [ 38 | "sha256:43950018e1eeea486bf11136384aec0fe55b29fe6fd8a44553231b85661d9383", 39 | "sha256:8994961efb888fc6bf8c41550870c91f6f7691ca751888ebaa71442b7f84eff8" 40 | ], 41 | "index": "pypi", 42 | "version": "==3.0.1" 43 | }, 44 | "django": { 45 | "hashes": [ 46 | "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f", 47 | "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f" 48 | ], 49 | "index": "pypi", 50 | "markers": "python_version >= '3.10'", 51 | "version": "==5.2.8" 52 | }, 53 | "django-taggit": { 54 | "hashes": [ 55 | "sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0", 56 | "sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3" 57 | ], 58 | "index": "pypi", 59 | "markers": "python_version >= '3.8'", 60 | "version": "==6.1.0" 61 | }, 62 | "pyyaml": { 63 | "hashes": [ 64 | "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", 65 | "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", 66 | "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", 67 | "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", 68 | "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", 69 | "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", 70 | "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", 71 | "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", 72 | "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", 73 | "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", 74 | "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", 75 | "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", 76 | "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", 77 | "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", 78 | "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", 79 | "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", 80 | "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", 81 | "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", 82 | "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", 83 | "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", 84 | "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", 85 | "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", 86 | "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", 87 | "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", 88 | "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", 89 | "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", 90 | "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", 91 | "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", 92 | "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", 93 | "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", 94 | "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", 95 | "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", 96 | "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", 97 | "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", 98 | "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", 99 | "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", 100 | "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", 101 | "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", 102 | "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", 103 | "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", 104 | "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", 105 | "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", 106 | "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", 107 | "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", 108 | "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", 109 | "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", 110 | "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", 111 | "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", 112 | "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", 113 | "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", 114 | "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", 115 | "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", 116 | "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", 117 | "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", 118 | "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", 119 | "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", 120 | "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", 121 | "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", 122 | "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", 123 | "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", 124 | "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", 125 | "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", 126 | "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", 127 | "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", 128 | "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", 129 | "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", 130 | "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", 131 | "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", 132 | "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", 133 | "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", 134 | "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", 135 | "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", 136 | "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" 137 | ], 138 | "index": "pypi", 139 | "markers": "python_version >= '3.8'", 140 | "version": "==6.0.3" 141 | }, 142 | "sentry-sdk": { 143 | "hashes": [ 144 | "sha256:86c8ab05dc3e8666aece77a5c747b45b25aa1d5f35f06cde250608f495d50f23", 145 | "sha256:e9bbfe69d5f6742f48bad22452beffb525bbc5b797d817c7f1b1f7d210cdd271" 146 | ], 147 | "index": "pypi", 148 | "markers": "python_version >= '3.6'", 149 | "version": "==2.45.0" 150 | }, 151 | "sqlparse": { 152 | "hashes": [ 153 | "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", 154 | "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca" 155 | ], 156 | "markers": "python_version >= '3.8'", 157 | "version": "==0.5.3" 158 | }, 159 | "thttp": { 160 | "hashes": [ 161 | "sha256:36f18932385e840ffb18821598cd5e7d112a3f2a79f068f86cf9ddfc044e71ff", 162 | "sha256:fceff289adc386121e275a35b3860759d2346b73e073fc64c3a3677c7f3d18a2" 163 | ], 164 | "index": "pypi", 165 | "version": "==1.3.0" 166 | }, 167 | "urllib3": { 168 | "hashes": [ 169 | "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", 170 | "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" 171 | ], 172 | "index": "pypi", 173 | "markers": "python_version >= '3.9'", 174 | "version": "==2.5.0" 175 | } 176 | }, 177 | "develop": { 178 | "asgiref": { 179 | "hashes": [ 180 | "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", 181 | "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" 182 | ], 183 | "markers": "python_version >= '3.7'", 184 | "version": "==3.7.2" 185 | }, 186 | "asttokens": { 187 | "hashes": [ 188 | "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24", 189 | "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0" 190 | ], 191 | "version": "==2.4.1" 192 | }, 193 | "bandit": { 194 | "hashes": [ 195 | "sha256:0a1f34c04f067ee28985b7854edaa659c9299bd71e1b7e18236e46cccc79720b", 196 | "sha256:6dbafd1a51e276e065404f06980d624bad142344daeac3b085121fcfd117b7cf" 197 | ], 198 | "index": "pypi", 199 | "markers": "python_version >= '3.10'", 200 | "version": "==1.9.1" 201 | }, 202 | "black": { 203 | "hashes": [ 204 | "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b", 205 | "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37", 206 | "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0", 207 | "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", 208 | "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc", 209 | "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd", 210 | "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", 211 | "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", 212 | "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", 213 | "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e", 214 | "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", 215 | "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a", 216 | "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc", 217 | "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993", 218 | "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2", 219 | "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", 220 | "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06", 221 | "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", 222 | "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", 223 | "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170", 224 | "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc", 225 | "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", 226 | "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", 227 | "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e", 228 | "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c", 229 | "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03" 230 | ], 231 | "index": "pypi", 232 | "markers": "python_version >= '3.9'", 233 | "version": "==25.11.0" 234 | }, 235 | "click": { 236 | "hashes": [ 237 | "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", 238 | "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" 239 | ], 240 | "markers": "python_version >= '3.10'", 241 | "version": "==8.3.1" 242 | }, 243 | "coverage": { 244 | "hashes": [ 245 | "sha256:020d56d2da5bc22a0e00a5b0d54597ee91ad72446fa4cf1b97c35022f6b6dbf0", 246 | "sha256:11ab62d0ce5d9324915726f611f511a761efcca970bd49d876cf831b4de65be5", 247 | "sha256:183c16173a70caf92e2dfcfe7c7a576de6fa9edc4119b8e13f91db7ca33a7923", 248 | "sha256:27ee94f088397d1feea3cb524e4313ff0410ead7d968029ecc4bc5a7e1d34fbf", 249 | "sha256:3024ec1b3a221bd10b5d87337d0373c2bcaf7afd86d42081afe39b3e1820323b", 250 | "sha256:309ed6a559bc942b7cc721f2976326efbfe81fc2b8f601c722bff927328507dc", 251 | "sha256:33e63c578f4acce1b6cd292a66bc30164495010f1091d4b7529d014845cd9bee", 252 | "sha256:36797b3625d1da885b369bdaaa3b0d9fb8865caed3c2b8230afaa6005434aa2f", 253 | "sha256:36d75ef2acab74dc948d0b537ef021306796da551e8ac8b467810911000af66a", 254 | "sha256:38d0b307c4d99a7aca4e00cad4311b7c51b7ac38fb7dea2abe0d182dd4008e05", 255 | "sha256:3d892a19ae24b9801771a5a989fb3e850bd1ad2e2b6e83e949c65e8f37bc67a1", 256 | "sha256:3f477fb8a56e0c603587b8278d9dbd32e54bcc2922d62405f65574bd76eba78a", 257 | "sha256:47ee56c2cd445ea35a8cc3ad5c8134cb9bece3a5cb50bb8265514208d0a65928", 258 | "sha256:4a4184dcbe4f98d86470273e758f1d24191ca095412e4335ff27b417291f5964", 259 | "sha256:5214362abf26e254d749fc0c18af4c57b532a4bfde1a057565616dd3b8d7cc94", 260 | "sha256:607b6c6b35aa49defaebf4526729bd5238bc36fe3ef1a417d9839e1d96ee1e4c", 261 | "sha256:610afaf929dc0e09a5eef6981edb6a57a46b7eceff151947b836d869d6d567c1", 262 | "sha256:6879fe41c60080aa4bb59703a526c54e0412b77e649a0d06a61782ecf0853ee1", 263 | "sha256:74397a1263275bea9d736572d4cf338efaade2de9ff759f9c26bcdceb383bb49", 264 | "sha256:758ebaf74578b73f727acc4e8ab4b16ab6f22a5ffd7dd254e5946aba42a4ce76", 265 | "sha256:782693b817218169bfeb9b9ba7f4a9f242764e180ac9589b45112571f32a0ba6", 266 | "sha256:7c4277ddaad9293454da19121c59f2d850f16bcb27f71f89a5c4836906eb35ef", 267 | "sha256:85072e99474d894e5df582faec04abe137b28972d5e466999bc64fc37f564a03", 268 | "sha256:8a9c5bc5db3eb4cd55ecb8397d8e9b70247904f8eca718cc53c12dcc98e59fc8", 269 | "sha256:8ce03e25e18dd9bf44723e83bc202114817f3367789052dc9e5b5c79f40cf59d", 270 | "sha256:93698ac0995516ccdca55342599a1463ed2e2d8942316da31686d4d614597ef9", 271 | "sha256:997aa14b3e014339d8101b9886063c5d06238848905d9ad6c6eabe533440a9a7", 272 | "sha256:9ac17b94ab4ca66cf803f2b22d47e392f0977f9da838bf71d1f0db6c32893cb9", 273 | "sha256:a02ac7c51819702b384fea5ee033a7c202f732a2a2f1fe6c41e3d4019828c8d3", 274 | "sha256:a1c3e9d2bbd6f3f79cfecd6f20854f4dc0c6e0ec317df2b265266d0dc06535f1", 275 | "sha256:a877810ef918d0d345b783fc569608804f3ed2507bf32f14f652e4eaf5d8f8d0", 276 | "sha256:a8e258dcc335055ab59fe79f1dec217d9fb0cdace103d6b5c6df6b75915e7959", 277 | "sha256:aefbb29dc56317a4fcb2f3857d5bce9b881038ed7e5aa5d3bcab25bd23f57328", 278 | "sha256:aff2bd3d585969cc4486bfc69655e862028b689404563e6b549e6a8244f226df", 279 | "sha256:b1e0f25ae99cf247abfb3f0fac7ae25739e4cd96bf1afa3537827c576b4847e5", 280 | "sha256:b710869a15b8caf02e31d16487a931dbe78335462a122c8603bb9bd401ff6fb2", 281 | "sha256:bfed0ec4b419fbc807dec417c401499ea869436910e1ca524cfb4f81cf3f60e7", 282 | "sha256:c15fdfb141fcf6a900e68bfa35689e1256a670db32b96e7a931cab4a0e1600e5", 283 | "sha256:c6a23ae9348a7a92e7f750f9b7e828448e428e99c24616dec93a0720342f241d", 284 | "sha256:c75738ce13d257efbb6633a049fb2ed8e87e2e6c2e906c52d1093a4d08d67c6b", 285 | "sha256:d1d0ce6c6947a3a4aa5479bebceff2c807b9f3b529b637e2b33dea4468d75fc7", 286 | "sha256:d5b14abde6f8d969e6b9dd8c7a013d9a2b52af1235fe7bebef25ad5c8f47fa18", 287 | "sha256:d6ed790728fb71e6b8247bd28e77e99d0c276dff952389b5388169b8ca7b1c28", 288 | "sha256:e0d84099ea7cba9ff467f9c6f747e3fc3906e2aadac1ce7b41add72e8d0a3712", 289 | "sha256:e4353923f38d752ecfbd3f1f20bf7a3546993ae5ecd7c07fd2f25d40b4e54571", 290 | "sha256:e91029d7f151d8bf5ab7d8bfe2c3dbefd239759d642b211a677bc0709c9fdb96", 291 | "sha256:ea473c37872f0159294f7073f3fa72f68b03a129799f3533b2bb44d5e9fa4f82", 292 | "sha256:f154bd866318185ef5865ace5be3ac047b6d1cc0aeecf53bf83fe846f4384d5d", 293 | "sha256:f97ff5a9fc2ca47f3383482858dd2cb8ddbf7514427eecf5aa5f7992d0571429", 294 | "sha256:f99b7d3f7a7adfa3d11e3a48d1a91bb65739555dd6a0d3fa68aa5852d962e5b1", 295 | "sha256:fb220b3596358a86361139edce40d97da7458412d412e1e10c8e1970ee8c09ab", 296 | "sha256:fd2f8a641f8f193968afdc8fd1697e602e199931012b574194052d132a79be13" 297 | ], 298 | "index": "pypi", 299 | "version": "==7.3.4" 300 | }, 301 | "decorator": { 302 | "hashes": [ 303 | "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", 304 | "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186" 305 | ], 306 | "markers": "python_version >= '3.11'", 307 | "version": "==5.1.1" 308 | }, 309 | "django": { 310 | "hashes": [ 311 | "sha256:6cb5dcea9e3d12c47834d32156b8841f533a4493c688e2718cafd51aa430ba6d", 312 | "sha256:d69d5e36cc5d9f4eb4872be36c622878afcdce94062716cf3e25bcedcb168b62" 313 | ], 314 | "index": "pypi", 315 | "version": "==4.2.8" 316 | }, 317 | "django-debug-toolbar": { 318 | "hashes": [ 319 | "sha256:af99128c06e8e794479e65ab62cc6c7d1e74e1c19beb44dcbf9bad7a9c017327", 320 | "sha256:bc7fdaafafcdedefcc67a4a5ad9dac96efd6e41db15bc74d402a54a2ba4854dc" 321 | ], 322 | "index": "pypi", 323 | "version": "==4.2.0" 324 | }, 325 | "executing": { 326 | "hashes": [ 327 | "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147", 328 | "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc" 329 | ], 330 | "markers": "python_version >= '3.5'", 331 | "version": "==2.0.1" 332 | }, 333 | "ipdb": { 334 | "hashes": [ 335 | "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4", 336 | "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726" 337 | ], 338 | "index": "pypi", 339 | "version": "==0.13.13" 340 | }, 341 | "ipython": { 342 | "hashes": [ 343 | "sha256:2f55d59370f59d0d2b2212109fe0e6035cfea436b1c0e6150ad2244746272ec5", 344 | "sha256:ac4da4ecf0042fb4e0ce57c60430c2db3c719fa8bdf92f8631d6bd8a5785d1f0" 345 | ], 346 | "markers": "python_version >= '3.11'", 347 | "version": "==8.19.0" 348 | }, 349 | "isort": { 350 | "hashes": [ 351 | "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", 352 | "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" 353 | ], 354 | "index": "pypi", 355 | "version": "==5.13.2" 356 | }, 357 | "jedi": { 358 | "hashes": [ 359 | "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd", 360 | "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0" 361 | ], 362 | "markers": "python_version >= '3.6'", 363 | "version": "==0.19.1" 364 | }, 365 | "markdown-it-py": { 366 | "hashes": [ 367 | "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", 368 | "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" 369 | ], 370 | "markers": "python_version >= '3.10'", 371 | "version": "==4.0.0" 372 | }, 373 | "matplotlib-inline": { 374 | "hashes": [ 375 | "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311", 376 | "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304" 377 | ], 378 | "markers": "python_version >= '3.5'", 379 | "version": "==0.1.6" 380 | }, 381 | "mdurl": { 382 | "hashes": [ 383 | "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", 384 | "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" 385 | ], 386 | "markers": "python_version >= '3.7'", 387 | "version": "==0.1.2" 388 | }, 389 | "mypy-extensions": { 390 | "hashes": [ 391 | "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", 392 | "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" 393 | ], 394 | "markers": "python_version >= '3.8'", 395 | "version": "==1.1.0" 396 | }, 397 | "packaging": { 398 | "hashes": [ 399 | "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", 400 | "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" 401 | ], 402 | "markers": "python_version >= '3.8'", 403 | "version": "==25.0" 404 | }, 405 | "parso": { 406 | "hashes": [ 407 | "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0", 408 | "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75" 409 | ], 410 | "markers": "python_version >= '3.6'", 411 | "version": "==0.8.3" 412 | }, 413 | "pathspec": { 414 | "hashes": [ 415 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", 416 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 417 | ], 418 | "markers": "python_version >= '3.8'", 419 | "version": "==0.12.1" 420 | }, 421 | "pexpect": { 422 | "hashes": [ 423 | "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", 424 | "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" 425 | ], 426 | "markers": "sys_platform != 'win32'", 427 | "version": "==4.9.0" 428 | }, 429 | "platformdirs": { 430 | "hashes": [ 431 | "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", 432 | "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3" 433 | ], 434 | "markers": "python_version >= '3.10'", 435 | "version": "==4.5.0" 436 | }, 437 | "prompt-toolkit": { 438 | "hashes": [ 439 | "sha256:3527b7af26106cbc65a040bcc84839a3566ec1b051bb0bfe953631e704b0ff7d", 440 | "sha256:a11a29cb3bf0a28a387fe5122cdb649816a957cd9261dcedf8c9f1fef33eacf6" 441 | ], 442 | "markers": "python_version >= '3.7'", 443 | "version": "==3.0.43" 444 | }, 445 | "ptyprocess": { 446 | "hashes": [ 447 | "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", 448 | "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" 449 | ], 450 | "version": "==0.7.0" 451 | }, 452 | "pure-eval": { 453 | "hashes": [ 454 | "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350", 455 | "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3" 456 | ], 457 | "version": "==0.2.2" 458 | }, 459 | "pygments": { 460 | "hashes": [ 461 | "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", 462 | "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" 463 | ], 464 | "markers": "python_version >= '3.8'", 465 | "version": "==2.19.2" 466 | }, 467 | "pytokens": { 468 | "hashes": [ 469 | "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", 470 | "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3" 471 | ], 472 | "markers": "python_version >= '3.8'", 473 | "version": "==0.3.0" 474 | }, 475 | "pyyaml": { 476 | "hashes": [ 477 | "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", 478 | "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", 479 | "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", 480 | "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", 481 | "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", 482 | "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", 483 | "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", 484 | "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", 485 | "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", 486 | "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", 487 | "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", 488 | "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", 489 | "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", 490 | "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", 491 | "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", 492 | "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", 493 | "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", 494 | "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", 495 | "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", 496 | "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", 497 | "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", 498 | "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", 499 | "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", 500 | "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", 501 | "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", 502 | "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", 503 | "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", 504 | "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", 505 | "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", 506 | "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", 507 | "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", 508 | "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", 509 | "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", 510 | "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", 511 | "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", 512 | "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", 513 | "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", 514 | "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", 515 | "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", 516 | "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", 517 | "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", 518 | "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", 519 | "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", 520 | "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", 521 | "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", 522 | "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", 523 | "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", 524 | "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", 525 | "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", 526 | "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", 527 | "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", 528 | "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", 529 | "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", 530 | "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", 531 | "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", 532 | "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", 533 | "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", 534 | "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", 535 | "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", 536 | "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", 537 | "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", 538 | "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", 539 | "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", 540 | "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", 541 | "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", 542 | "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", 543 | "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", 544 | "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", 545 | "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", 546 | "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", 547 | "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", 548 | "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", 549 | "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" 550 | ], 551 | "markers": "python_version >= '3.8'", 552 | "version": "==6.0.3" 553 | }, 554 | "rich": { 555 | "hashes": [ 556 | "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", 557 | "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd" 558 | ], 559 | "markers": "python_full_version >= '3.8.0'", 560 | "version": "==14.2.0" 561 | }, 562 | "sqlparse": { 563 | "hashes": [ 564 | "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", 565 | "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" 566 | ], 567 | "markers": "python_version >= '3.5'", 568 | "version": "==0.4.4" 569 | }, 570 | "stack-data": { 571 | "hashes": [ 572 | "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", 573 | "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695" 574 | ], 575 | "version": "==0.6.3" 576 | }, 577 | "stevedore": { 578 | "hashes": [ 579 | "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", 580 | "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73" 581 | ], 582 | "markers": "python_version >= '3.9'", 583 | "version": "==5.5.0" 584 | }, 585 | "traitlets": { 586 | "hashes": [ 587 | "sha256:f14949d23829023013c47df20b4a76ccd1a85effb786dc060f34de7948361b33", 588 | "sha256:fcdaa8ac49c04dfa0ed3ee3384ef6dfdb5d6f3741502be247279407679296772" 589 | ], 590 | "markers": "python_version >= '3.8'", 591 | "version": "==5.14.0" 592 | }, 593 | "wcwidth": { 594 | "hashes": [ 595 | "sha256:f01c104efdf57971bcb756f054dd58ddec5204dd15fa31d6503ea57947d97c02", 596 | "sha256:f26ec43d96c8cbfed76a5075dac87680124fa84e0855195a6184da9c187f133c" 597 | ], 598 | "version": "==0.2.12" 599 | } 600 | } 601 | } 602 | --------------------------------------------------------------------------------