├── 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 |
8 |
9 | Delete Bookmark
10 |
11 |
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 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/links/templates/add.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block content %}
4 |
5 | Add Bookmark
6 |
18 |
19 | {% endblock %}
20 |
--------------------------------------------------------------------------------
/links/templates/edit.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block content %}
4 |
5 | Edit Bookmark
6 |
18 |
19 | {% endblock %}
20 |
--------------------------------------------------------------------------------
/links/templates/settings.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 |
3 | {% block content %}
4 |
5 | Settings
6 |
7 |
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 |
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 |
15 | {% for message in messages %}
16 | - {{ message }}
17 | {% endfor %}
18 |
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 |
5 |
6 | {% if not links %}
7 |
No bookmarks yet!
8 | {% else %}
9 |
10 | {% for link in links %}
11 | {% include 'includes/link.html' with link=link %}
12 | {% endfor %}
13 | {% endif %}
14 |
15 | {% if next or prev %}
16 |
27 | {% endif %}
28 |
29 | {% if user %}
30 |
31 | Logged in as {{ user }}
32 |
33 | {% endif %}
34 |
35 |
36 |
39 |
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 |
4 |
5 |
6 | Show all | Add bookmark | Settings
7 | | Logout
8 |
9 |
10 |
11 | {% comment %}
12 |
18 | {% endcomment %}
19 | Bookmarklet: bm2
20 |
21 |
22 |
28 |
29 |
35 |
36 |
42 |
--------------------------------------------------------------------------------
/links/templates/includes/link.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |

4 |
5 |
6 |
30 |
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 |
--------------------------------------------------------------------------------