diff --git a/.gitignore b/.gitignore
index 2a52338..1a25983 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,4 @@ dump_all*.sql
dist/
.env.local
tmp/
+logs/
diff --git a/FILE_UPLOAD_SECURITY.md b/FILE_UPLOAD_SECURITY.md
new file mode 100644
index 0000000..0e3b248
--- /dev/null
+++ b/FILE_UPLOAD_SECURITY.md
@@ -0,0 +1,229 @@
+# File Upload Security Documentation
+
+## Overview
+
+The ivatar application now includes comprehensive file upload security features to protect against malicious file uploads, data leaks, and other security threats.
+
+## Security Features
+
+### 1. File Type Validation
+
+**Magic Bytes Verification**
+
+- Validates file signatures (magic bytes) to ensure uploaded files are actually images
+- Supports JPEG, PNG, GIF, WebP, BMP, and TIFF formats
+- Prevents file extension spoofing attacks
+
+**MIME Type Validation**
+
+- Uses python-magic library to detect actual MIME types
+- Cross-references with allowed MIME types list
+- Prevents MIME type confusion attacks
+
+### 2. Content Security Scanning
+
+**Malicious Content Detection**
+
+- Scans for embedded scripts (`'
+ self.large_data = b"x" * (10 * 1024 * 1024) # 10MB
+
+ def tearDown(self):
+ """Clean up after tests"""
+ pass
+
+ def test_valid_jpeg_validation(self):
+ """Test validation of valid JPEG file"""
+ validator = FileValidator(self.valid_jpeg_data, "test.jpg")
+
+ # Mock PIL validation to avoid issues with test data
+ with patch.object(validator, "validate_pil_image") as mock_pil:
+ mock_pil.return_value = {
+ "valid": True,
+ "image_info": {
+ "format": "JPEG",
+ "mode": "RGB",
+ "size": (100, 100),
+ "width": 100,
+ "height": 100,
+ "has_transparency": False,
+ },
+ "errors": [],
+ "warnings": [],
+ }
+
+ results = validator.comprehensive_validation()
+
+ self.assertTrue(results["valid"])
+ self.assertEqual(results["file_info"]["detected_type"], "image/jpeg")
+ self.assertGreaterEqual(results["security_score"], 80)
+
+ def test_magic_bytes_validation(self):
+ """Test magic bytes validation"""
+ validator = FileValidator(self.valid_jpeg_data, "test.jpg")
+ results = validator.validate_magic_bytes()
+
+ self.assertTrue(results["valid"])
+ self.assertEqual(results["detected_type"], "image/jpeg")
+
+ def test_malicious_content_detection(self):
+ """Test detection of malicious content"""
+ validator = FileValidator(self.malicious_data, "malicious.gif")
+ results = validator.scan_for_malicious_content()
+
+ self.assertTrue(results["suspicious"])
+ self.assertGreater(len(results["threats"]), 0)
+
+ def test_file_size_validation(self):
+ """Test file size validation"""
+ validator = FileValidator(self.large_data, "large.jpg")
+ results = validator.validate_basic()
+
+ self.assertFalse(results["valid"])
+ self.assertIn("File too large", results["errors"][0])
+
+ def test_invalid_extension_validation(self):
+ """Test invalid file extension validation"""
+ validator = FileValidator(self.valid_jpeg_data, "test.exe")
+ results = validator.validate_basic()
+
+ self.assertFalse(results["valid"])
+ self.assertIn("File extension not allowed", results["errors"][0])
+
+ def test_exif_sanitization(self):
+ """Test EXIF data sanitization"""
+ validator = FileValidator(self.valid_jpeg_data, "test.jpg")
+ sanitized_data = validator.sanitize_exif_data()
+
+ # Should return data (may be same or sanitized)
+ self.assertIsInstance(sanitized_data, bytes)
+ self.assertGreater(len(sanitized_data), 0)
+
+ def test_comprehensive_validation_function(self):
+ """Test the main validation function"""
+ # Mock PIL validation to avoid issues with test data
+ with patch("ivatar.file_security.FileValidator.validate_pil_image") as mock_pil:
+ mock_pil.return_value = {
+ "valid": True,
+ "image_info": {"format": "JPEG", "size": (100, 100)},
+ "errors": [],
+ "warnings": [],
+ }
+
+ is_valid, results, sanitized_data = validate_uploaded_file(
+ self.valid_jpeg_data, "test.jpg"
+ )
+
+ self.assertTrue(is_valid)
+ self.assertIsInstance(results, dict)
+ self.assertIsInstance(sanitized_data, bytes)
+
+ def test_security_report_generation(self):
+ """Test security report generation"""
+ # Mock PIL validation to avoid issues with test data
+ with patch("ivatar.file_security.FileValidator.validate_pil_image") as mock_pil:
+ mock_pil.return_value = {
+ "valid": True,
+ "image_info": {"format": "JPEG", "size": (100, 100)},
+ "errors": [],
+ "warnings": [],
+ }
+
+ report = get_file_security_report(self.valid_jpeg_data, "test.jpg")
+
+ self.assertIn("valid", report)
+ self.assertIn("security_score", report)
+ self.assertIn("file_info", report)
+
+ @patch("ivatar.file_security.magic.from_buffer")
+ def test_mime_type_validation(self, mock_magic):
+ """Test MIME type validation with mocked magic"""
+ mock_magic.return_value = "image/jpeg"
+
+ validator = FileValidator(self.valid_jpeg_data, "test.jpg")
+ results = validator.validate_mime_type()
+
+ self.assertTrue(results["valid"])
+ self.assertEqual(results["detected_mime"], "image/jpeg")
+
+ def test_polyglot_attack_detection(self):
+ """Test detection of polyglot attacks"""
+ polyglot_data = b'GIF89a'
+ validator = FileValidator(polyglot_data, "polyglot.gif")
+ results = validator.scan_for_malicious_content()
+
+ self.assertTrue(results["suspicious"])
+ # Check for either polyglot attack or suspicious script pattern
+ threats_text = " ".join(results["threats"]).lower()
+ self.assertTrue(
+ "polyglot attack" in threats_text or "suspicious pattern" in threats_text,
+ f"Expected polyglot attack or suspicious pattern, got: {results['threats']}",
+ )
+
+
+class UploadPhotoFormSecurityTestCase(TestCase):
+ """Test cases for UploadPhotoForm security enhancements"""
+
+ def setUp(self):
+ """Set up test data"""
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="testpass123"
+ )
+
+ def test_form_validation_with_valid_file(self):
+ """Test form validation with valid file"""
+ valid_jpeg_data = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c $.' \",#\x1c\x1c(7),01444\x1f'9=82<.342\xff\xc0\x00\x11\x08\x00\x01\x00\x01\x01\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\xff\xc4\x00\xb5\x10\x00\x02\x01\x03\x03\x02\x04\x03\x05\x05\x04\x04\x00\x00\x01}\x01\x02\x03\x00\x04\x11\x05\x12!1A\x06\x13Qa\x07\"q\x142\x81\x91\xa1\x08#B\xb1\xc1\x15R\xd1\xf0$3br\x82\t\n\x16\x17\x18\x19\x1a%&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz\x83\x84\x85\x86\x87\x88\x89\x8a\x92\x93\x94\x95\x96\x97\x98\x99\x9a\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00\xf9\xff\xd9"
+
+ uploaded_file = SimpleUploadedFile(
+ "test.jpg", valid_jpeg_data, content_type="image/jpeg"
+ )
+
+ form_data = {"photo": uploaded_file, "not_porn": True, "can_distribute": True}
+
+ form = UploadPhotoForm(data=form_data, files={"photo": uploaded_file})
+
+ # Mock the validation to avoid PIL issues in tests
+ with patch(
+ "ivatar.ivataraccount.forms.validate_uploaded_file"
+ ) as mock_validate:
+ mock_validate.return_value = (
+ True,
+ {"security_score": 95, "errors": [], "warnings": []},
+ valid_jpeg_data,
+ )
+
+ self.assertTrue(form.is_valid())
+
+ def test_form_validation_with_malicious_file(self):
+ """Test form validation with malicious file"""
+ malicious_data = b'GIF89a'
+
+ uploaded_file = SimpleUploadedFile(
+ "malicious.gif", malicious_data, content_type="image/gif"
+ )
+
+ form_data = {"photo": uploaded_file, "not_porn": True, "can_distribute": True}
+
+ form = UploadPhotoForm(data=form_data, files={"photo": uploaded_file})
+
+ # Mock the validation to return malicious file detection
+ with patch(
+ "ivatar.ivataraccount.forms.validate_uploaded_file"
+ ) as mock_validate:
+ mock_validate.return_value = (
+ False,
+ {
+ "security_score": 20,
+ "errors": ["Malicious content detected"],
+ "warnings": [],
+ },
+ malicious_data,
+ )
+
+ self.assertFalse(form.is_valid())
+ # Check for any error message indicating validation failure
+ error_text = str(form.errors["photo"]).lower()
+ self.assertTrue(
+ "malicious" in error_text or "validation failed" in error_text,
+ f"Expected malicious or validation failed message, got: {form.errors['photo']}",
+ )
+
+
+class UploadPhotoViewSecurityTestCase(TestCase):
+ """Test cases for UploadPhotoView security enhancements"""
+
+ def setUp(self):
+ """Set up test data"""
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="testpass123"
+ )
+
+ def tearDown(self):
+ """Clean up after tests"""
+ pass
+
+
+@override_settings(
+ ENABLE_FILE_SECURITY_VALIDATION=True,
+ ENABLE_EXIF_SANITIZATION=True,
+ ENABLE_MALICIOUS_CONTENT_SCAN=True,
+ ENABLE_RATE_LIMITING=True,
+)
+class FileSecurityIntegrationTestCase(TestCase):
+ """Integration tests for file upload security"""
+
+ def setUp(self):
+ """Set up test data"""
+ self.user = User.objects.create_user(
+ username="testuser", email="test@example.com", password="testpass123"
+ )
+
+ def test_end_to_end_security_validation(self):
+ """Test end-to-end security validation"""
+ # This would test the complete flow from upload to storage
+ # with all security checks enabled
+ pass
+
+ def test_security_logging(self):
+ """Test that security events are properly logged"""
+ # This would test that security events are logged
+ # when malicious files are uploaded
+ pass
diff --git a/ivatar/test_utils.py b/ivatar/test_utils.py
index 5ecf362..30b017b 100644
--- a/ivatar/test_utils.py
+++ b/ivatar/test_utils.py
@@ -47,71 +47,67 @@ class Tester(TestCase):
self.assertEqual(openid_variations(openid3)[3], openid3)
def test_is_trusted_url(self):
- test_gravatar_true = is_trusted_url("https://gravatar.com/avatar/63a75a80e6b1f4adfdb04c1ca02e596c", [
- {
- "schemes": [
- "http",
- "https"
- ],
- "host_equals": "gravatar.com",
- "path_prefix": "/avatar/"
- }
- ])
+ test_gravatar_true = is_trusted_url(
+ "https://gravatar.com/avatar/63a75a80e6b1f4adfdb04c1ca02e596c",
+ [
+ {
+ "schemes": ["http", "https"],
+ "host_equals": "gravatar.com",
+ "path_prefix": "/avatar/",
+ }
+ ],
+ )
self.assertTrue(test_gravatar_true)
- test_gravatar_false = is_trusted_url("https://gravatar.com.example.org/avatar/63a75a80e6b1f4adfdb04c1ca02e596c", [
- {
- "schemes": [
- "http",
- "https"
- ],
- "host_suffix": ".gravatar.com",
- "path_prefix": "/avatar/"
- }
- ])
+ test_gravatar_false = is_trusted_url(
+ "https://gravatar.com.example.org/avatar/63a75a80e6b1f4adfdb04c1ca02e596c",
+ [
+ {
+ "schemes": ["http", "https"],
+ "host_suffix": ".gravatar.com",
+ "path_prefix": "/avatar/",
+ }
+ ],
+ )
self.assertFalse(test_gravatar_false)
- test_open_redirect = is_trusted_url("https://github.com/SethFalco/?boop=https://secure.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50", [
- {
- "schemes": [
- "http",
- "https"
- ],
- "host_suffix": ".gravatar.com",
- "path_prefix": "/avatar/"
- }
- ])
+ test_open_redirect = is_trusted_url(
+ "https://github.com/SethFalco/?boop=https://secure.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50",
+ [
+ {
+ "schemes": ["http", "https"],
+ "host_suffix": ".gravatar.com",
+ "path_prefix": "/avatar/",
+ }
+ ],
+ )
self.assertFalse(test_open_redirect)
- test_multiple_filters = is_trusted_url("https://ui-avatars.com/api/blah", [
- {
- "schemes": [
- "https"
- ],
- "host_equals": "ui-avatars.com",
- "path_prefix": "/api/"
- },
- {
- "schemes": [
- "http",
- "https"
- ],
- "host_suffix": ".gravatar.com",
- "path_prefix": "/avatar/"
- }
- ])
+ test_multiple_filters = is_trusted_url(
+ "https://ui-avatars.com/api/blah",
+ [
+ {
+ "schemes": ["https"],
+ "host_equals": "ui-avatars.com",
+ "path_prefix": "/api/",
+ },
+ {
+ "schemes": ["http", "https"],
+ "host_suffix": ".gravatar.com",
+ "path_prefix": "/avatar/",
+ },
+ ],
+ )
self.assertTrue(test_multiple_filters)
- test_url_prefix_true = is_trusted_url("https://ui-avatars.com/api/blah", [
- {
- "url_prefix": "https://ui-avatars.com/api/"
- }
- ])
+ test_url_prefix_true = is_trusted_url(
+ "https://ui-avatars.com/api/blah",
+ [{"url_prefix": "https://ui-avatars.com/api/"}],
+ )
self.assertTrue(test_url_prefix_true)
- test_url_prefix_false = is_trusted_url("https://ui-avatars.com/api/blah", [
- {
- "url_prefix": "https://gravatar.com/avatar/"
- }
- ])
+ test_url_prefix_false = is_trusted_url(
+ "https://ui-avatars.com/api/blah",
+ [{"url_prefix": "https://gravatar.com/avatar/"}],
+ )
self.assertFalse(test_url_prefix_false)
diff --git a/ivatar/utils.py b/ivatar/utils.py
index 3df96bf..8252234 100644
--- a/ivatar/utils.py
+++ b/ivatar/utils.py
@@ -6,6 +6,7 @@ Simple module providing reusable random_string function
import contextlib
import random
import string
+import logging
from io import BytesIO
from PIL import Image, ImageDraw, ImageSequence
from urllib.parse import urlparse
@@ -13,6 +14,9 @@ import requests
from ivatar.settings import DEBUG, URL_TIMEOUT
from urllib.request import urlopen as urlopen_orig
+# Initialize logger
+logger = logging.getLogger("ivatar")
+
BLUESKY_IDENTIFIER = None
BLUESKY_APP_PASSWORD = None
with contextlib.suppress(Exception):
@@ -88,7 +92,7 @@ class Bluesky:
)
profile_response.raise_for_status()
except Exception as exc:
- print(f"Bluesky profile fetch failed with HTTP error: {exc}")
+ logger.warning(f"Bluesky profile fetch failed with HTTP error: {exc}")
return None
return profile_response.json()
diff --git a/ivatar/views.py b/ivatar/views.py
index a0d43d9..89ac32a 100644
--- a/ivatar/views.py
+++ b/ivatar/views.py
@@ -7,6 +7,7 @@ import contextlib
from io import BytesIO
from os import path
import hashlib
+import logging
from ivatar.utils import urlopen, Bluesky
from urllib.error import HTTPError, URLError
from ssl import SSLError
@@ -38,6 +39,10 @@ from .ivataraccount.models import Photo
from .ivataraccount.models import pil_format, file_format
from .utils import is_trusted_url, mm_ng, resize_animated_gif
+# Initialize loggers
+logger = logging.getLogger("ivatar")
+security_logger = logging.getLogger("ivatar.security")
+
def get_size(request, size=DEFAULT_AVATAR_SIZE):
"""
@@ -137,14 +142,14 @@ class AvatarImageView(TemplateView):
if default is not None:
if TRUSTED_DEFAULT_URLS is None:
- print("Query parameter `default` is disabled.")
+ logger.warning("Query parameter `default` is disabled.")
default = None
elif default.find("://") > 0:
# Check if it's trusted, if not, reset to None
trusted_url = is_trusted_url(default, TRUSTED_DEFAULT_URLS)
if not trusted_url:
- print(
+ security_logger.warning(
f"Default URL is not in trusted URLs: '{default}'; Kicking it!"
)
default = None
@@ -373,7 +378,7 @@ class GravatarProxyView(View):
if exc.code == 404:
cache.set(gravatar_test_url, "default", 60)
else:
- print(f"Gravatar test url fetch failed: {exc}")
+ logger.warning(f"Gravatar test url fetch failed: {exc}")
return redir_default(default)
gravatar_url = (
@@ -384,23 +389,25 @@ class GravatarProxyView(View):
try:
if cache.get(gravatar_url) == "err":
- print(f"Cached Gravatar fetch failed with URL error: {gravatar_url}")
+ logger.warning(
+ f"Cached Gravatar fetch failed with URL error: {gravatar_url}"
+ )
return redir_default(default)
gravatarimagedata = urlopen(gravatar_url)
except HTTPError as exc:
if exc.code not in [404, 503]:
- print(
+ logger.warning(
f"Gravatar fetch failed with an unexpected {exc.code} HTTP error: {gravatar_url}"
)
cache.set(gravatar_url, "err", 30)
return redir_default(default)
except URLError as exc:
- print(f"Gravatar fetch failed with URL error: {exc.reason}")
+ logger.warning(f"Gravatar fetch failed with URL error: {exc.reason}")
cache.set(gravatar_url, "err", 30)
return redir_default(default)
except SSLError as exc:
- print(f"Gravatar fetch failed with SSL error: {exc.reason}")
+ logger.warning(f"Gravatar fetch failed with SSL error: {exc.reason}")
cache.set(gravatar_url, "err", 30)
return redir_default(default)
try:
@@ -416,7 +423,7 @@ class GravatarProxyView(View):
return response
except ValueError as exc:
- print(f"Value error: {exc}")
+ logger.error(f"Value error: {exc}")
return redir_default(default)
# We shouldn't reach this point... But make sure we do something
@@ -446,7 +453,7 @@ class BlueskyProxyView(View):
return HttpResponseRedirect(url)
size = get_size(request)
- print(size)
+ logger.debug(f"Bluesky avatar size requested: {size}")
blueskyimagedata = None
default = None
@@ -461,7 +468,7 @@ class BlueskyProxyView(View):
Q(digest=kwargs["digest"]) | Q(digest_sha256=kwargs["digest"])
).first()
except Exception as exc:
- print(exc)
+ logger.warning(f"Exception: {exc}")
# If no identity is found in the email table, try the openid table
if not identity:
@@ -473,7 +480,7 @@ class BlueskyProxyView(View):
| Q(alt_digest3=kwargs["digest"])
).first()
except Exception as exc:
- print(exc)
+ logger.warning(f"Exception: {exc}")
# If still no identity is found, redirect to the default
if not identity:
@@ -494,7 +501,9 @@ class BlueskyProxyView(View):
try:
if cache.get(bluesky_url) == "err":
- print(f"Cached Bluesky fetch failed with URL error: {bluesky_url}")
+ logger.warning(
+ f"Cached Bluesky fetch failed with URL error: {bluesky_url}"
+ )
return redir_default(default)
blueskyimagedata = urlopen(bluesky_url)
@@ -506,11 +515,11 @@ class BlueskyProxyView(View):
cache.set(bluesky_url, "err", 30)
return redir_default(default)
except URLError as exc:
- print(f"Bluesky fetch failed with URL error: {exc.reason}")
+ logger.warning(f"Bluesky fetch failed with URL error: {exc.reason}")
cache.set(bluesky_url, "err", 30)
return redir_default(default)
except SSLError as exc:
- print(f"Bluesky fetch failed with SSL error: {exc.reason}")
+ logger.warning(f"Bluesky fetch failed with SSL error: {exc.reason}")
cache.set(bluesky_url, "err", 30)
return redir_default(default)
try:
@@ -536,7 +545,7 @@ class BlueskyProxyView(View):
response["Vary"] = ""
return response
except ValueError as exc:
- print(f"Value error: {exc}")
+ logger.error(f"Value error: {exc}")
return redir_default(default)
# We shouldn't reach this point... But make sure we do something
diff --git a/ivatar/wsgi.py b/ivatar/wsgi.py
index 18866fb..883517b 100644
--- a/ivatar/wsgi.py
+++ b/ivatar/wsgi.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
"""
WSGI config for ivatar project.
diff --git a/manage.py b/manage.py
index fcd61ac..21b3133 100755
--- a/manage.py
+++ b/manage.py
@@ -1,4 +1,5 @@
#!/usr/bin/env python
+# -*- coding: utf-8 -*-
import os
import sys
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..044fe4d
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,25 @@
+[tool:pytest]
+# Pytest configuration for ivatar project
+
+# Test discovery
+testpaths = ivatar
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+
+# Markers for test categorization
+markers =
+ bluesky: marks tests as requiring Bluesky API credentials (deselect with '-m "not bluesky"')
+ slow: marks tests as slow (deselect with '-m "not slow"')
+ integration: marks tests as integration tests
+ unit: marks tests as unit tests
+
+# Default options
+addopts =
+ --strict-markers
+ --strict-config
+ --verbose
+ --tb=short
+
+# Minimum version
+minversion = 6.0
diff --git a/requirements.txt b/requirements.txt
index 0f8c139..fb25018 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
+argon2-cffi>=21.3.0
autopep8
bcrypt
defusedxml
@@ -31,8 +32,10 @@ pyLibravatar
pylint
pymemcache
PyMySQL
+pytest
python-coveralls
python-language-server
+python-magic>=0.4.27
pytz
rope
setuptools
diff --git a/run_tests_local.sh b/run_tests_local.sh
new file mode 100755
index 0000000..1acaffa
--- /dev/null
+++ b/run_tests_local.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+# Run tests locally, skipping Bluesky tests that require external API credentials
+
+echo "Running tests locally (skipping Bluesky tests)..."
+echo "================================================"
+
+# Run Django tests excluding the Bluesky test file
+python3 manage.py test \
+ ivatar.ivataraccount.test_auth \
+ ivatar.ivataraccount.test_views \
+ ivatar.test_auxiliary \
+ ivatar.test_file_security \
+ ivatar.test_static_pages \
+ ivatar.test_utils \
+ ivatar.test_views \
+ ivatar.test_views_stats \
+ ivatar.tools.test_views \
+ ivatar.test_wsgi \
+ -v2
+
+echo ""
+echo "To run all tests including Bluesky (requires API credentials):"
+echo "python3 manage.py test -v2"
+echo ""
+echo "To run only Bluesky tests:"
+echo "python3 manage.py test ivatar.ivataraccount.test_views_bluesky -v2"
diff --git a/templates/description.html b/templates/description.html
index a1ecc9f..62595a4 100644
--- a/templates/description.html
+++ b/templates/description.html
@@ -24,7 +24,7 @@ All you have to do is sign up on libravatar.or
Once you've done that, a bunch of websites (where you've entered your email address, usually as part of the registration process) will start displaying your avatar next to your name.
-
+
Freedom and federation
How is Libravatar different from Gravatar though? The main difference is that while Libravatar.org is an online avatar hosting service just like Gravatar, the software that powers the former is also available for download under a free software license.
@@ -64,7 +64,7 @@ If you're interested in the details of how third-party websites display Libravat
<img src="https://seccdn.libravatar.org/avatar/63a75a80e6b1f4adfdb04c1ca02e596c">
-
+
It's pretty simple, but for most web applications it's even easier because they're just using one of the convenient libraries provided by the community.
diff --git a/test_indexes.py b/test_indexes.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/test_indexes.py
@@ -0,0 +1 @@
+