48 Commits

Author SHA1 Message Date
Oliver Falk
44a6b9a099 Merge branch 'devel' into cipher_test 2022-02-18 14:19:02 +01:00
Oliver Falk
2163c60f5b Merge branch 'master' into cipher_test 2022-02-18 14:14:20 +01:00
Oliver Falk
0c68843849 Add test for the stats 2022-02-18 14:09:38 +01:00
Oliver Falk
92f495ebe0 Add stats about unconfirmed mail/openid + avatar count 2022-02-18 14:09:38 +01:00
Oliver Falk
39ff27984c Remove redundant all() before count() 2022-02-18 14:09:38 +01:00
Oliver Falk
f0a2d5aae4 Add a few more trusted URLs that we gathered from the logs 2022-02-18 14:09:10 +01:00
Oliver Falk
cc761467f5 Update gravatar check to be easier and less error prone 2022-02-18 14:09:10 +01:00
Oliver Falk
44e85365bc First preparations for Django >= 4.x 2022-02-18 14:09:09 +01:00
Oliver Falk
8c9b14ec0e Fix building of master, do not use Django >4 yet 2022-02-18 14:09:09 +01:00
Oliver Falk
88e6e5a80f Add Daniel Aleksandersen to the security page 2022-02-18 14:09:09 +01:00
Oliver Falk
aa3a7a2497 A few more sites known to use default param 2022-02-18 14:09:09 +01:00
Oliver Falk
9a5bdf9364 Enhance and fix tests to accomodate the changes related to CWE-601 2022-02-18 14:09:09 +01:00
Oliver Falk
01f0f29859 String search returns > 0 if found... 2022-02-18 14:09:09 +01:00
Oliver Falk
a80d704a59 Enhance the list. It's possible some non-ssl sites still use gravatar without https and some sites use secure.gravatar.com 2022-02-18 14:09:09 +01:00
Oliver Falk
63a4737717 Increase version 2022-02-18 14:09:09 +01:00
Oliver Falk
5ec186e039 Fix CWE-601 - Open URL redirection
- Only a few URLs are allowed now and this _will_ break some implementations
- Print information in the log about which URL was kicked
2022-02-18 14:09:08 +01:00
Oliver Falk
358b3cec8c Redesigned profile page 2022-02-18 14:09:08 +01:00
Oliver Falk
bc3ce39a19 Do not use 404 in case no default is set - we need to redir to the default Gravatar 2022-02-18 14:09:08 +01:00
Oliver Falk
70ffb10d51 Additional logging of gravatar fetches and ensure we don't send d=None, if default hasn't been set; Reformat with black 2022-02-18 14:08:47 +01:00
Oliver Falk
3797018139 We're aware there are some complext functions, it's a complex topic. 2022-02-18 14:08:47 +01:00
Oliver Falk
c6601b60c0 Update ignored files (for coverage report) 2022-02-18 14:08:47 +01:00
Oliver Falk
26ae405899 v1.5 - massive code update 2022-02-18 14:08:47 +01:00
Oliver Falk
a40f262ceb More testing of the export 2022-02-18 14:08:47 +01:00
Oliver Falk
817984a43b Use SCHEMAROOT from config and reformat with black 2022-02-18 14:08:46 +01:00
Oliver Falk
84a265eca5 Central place for the schema root 2022-02-18 14:08:46 +01:00
Oliver Falk
f53ad22524 Wire up the export functionality in the menu 2022-02-18 14:08:46 +01:00
Oliver Falk
abd9ccff7c Add export functionality and reformat with black 2022-02-18 14:08:46 +01:00
Oliver Falk
1b70908f58 Test export page - without any functionality and reformat with black 2022-02-18 14:08:46 +01:00
Oliver Falk
0fe50c6317 Need to ignore E402 - we check this with pylint 2022-02-18 14:08:46 +01:00
Oliver Falk
af98487c1d Some safety measures to avoid breaking old/new export and reformat with black 2022-02-18 14:08:46 +01:00
Oliver Falk
ccd72d5422 Fix trailing whitespace and reformat with black 2022-02-18 14:08:46 +01:00
Oliver Falk
86bb77eb9d Ingore W503 2022-02-18 14:08:45 +01:00
Oliver Falk
0f84845ca9 Make sure we list the email instead of the dict and 2022-02-18 14:08:45 +01:00
Oliver Falk
d9c33f7ee3 Ignore this module, as it's hardly used and very difficult to test 2022-02-18 14:08:45 +01:00
Oliver Falk
c04e5ad266 Clean up with black 2022-02-18 14:08:45 +01:00
Oliver Falk
4cdb61724f Clean up with black 2022-02-18 14:08:45 +01:00
Oliver Falk
507e623962 Clean up with black 2022-02-18 14:08:45 +01:00
Oliver Falk
85ccf0f7be Clean up with black 2022-02-18 14:08:45 +01:00
Oliver Falk
d2e28fe809 Add flake and pre commit config 2022-02-18 14:08:45 +01:00
Oliver Falk
6c6afd5152 Clean up with black 2022-02-18 14:08:44 +01:00
Oliver Falk
4db099156b Reuse username as email if it looks like a valid email address
* Automatically add it as UnconfirmedEmail and trigger confirmation mail
* Clean up views with black
2022-02-18 14:08:44 +01:00
Oliver Falk
870a2a2365 Add some padding at the end, so the logo is fully visible 2022-02-18 14:08:44 +01:00
Oliver Falk
3f01e0c3e1 Add Gandi logo - since sponsoring 2022-02-18 14:08:44 +01:00
Oliver Falk
03bf496117 Merge branch 'master' into cipher_test 2021-09-10 12:46:49 +02:00
Oliver Falk
a573985248 Merge branch 'devel' into cipher_test 2020-02-25 16:41:09 +01:00
Oliver Falk
c3214a9a2b Merge branch 'master' into cipher_test 2020-02-25 13:48:20 +01:00
Oliver Falk
29c8f17e06 Merge branch 'devel' into cipher_test 2020-02-25 13:45:01 +01:00
Oliver Falk
c864c2f115 Provide examples how to encode mail address with PHP, decrypt in python, using standard AES for use as local proxy for libravatar image requests 2019-10-01 14:28:16 +02:00
104 changed files with 1450 additions and 37098 deletions

View File

@@ -1 +0,0 @@
https://github.com/heroku/heroku-buildpack-python

11
.env
View File

@@ -1,11 +0,0 @@
if [ ! -d .virtualenv ]; then
if [ ! "$(which virtualenv)" == "" ]; then
if [ -f .env ]; then
virtualenv -p python3 .virtualenv
fi
fi
fi
if [ -f .virtualenv/bin/activate ]; then
source .virtualenv/bin/activate
AUTOENV_ENABLE_LEAVE=True
fi

View File

@@ -1 +0,0 @@
deactivate

View File

@@ -1,5 +1,5 @@
[flake8] [flake8]
ignore = E501, W503, E402, C901, E231, E702 ignore = E501, W503, E402, C901
max-line-length = 79 max-line-length = 79
max-complexity = 18 max-complexity = 18
select = B,C,E,F,W,T4,B9 select = B,C,E,F,W,T4,B9

10
.gitignore vendored
View File

@@ -1,6 +1,6 @@
__pycache__ __pycache__
/db.sqlite3 /db.sqlite3
/static/* /static/
**.*.swp **.*.swp
.coverage .coverage
htmlcov/ htmlcov/
@@ -14,11 +14,3 @@ node_modules/
config_local.py config_local.py
locale/*/LC_MESSAGES/django.mo locale/*/LC_MESSAGES/django.mo
.DS_Store .DS_Store
.idea/
contacts.csv
falko_gravatar.jpg
*.egg-info
dump_all*.sql
dist/
.env.local
tmp/

View File

@@ -1,130 +1,55 @@
image: image:
name: quay.io/rhn_support_ofalk/fedora36-python3 name: quay.io/rhn_support_ofalk/fedora34-python3
entrypoint: entrypoint: [ '/bin/sh', '-c' ]
- "/bin/sh"
- "-c"
# Cache pip deps to speed up builds before_script:
cache: - virtualenv -p python3 /tmp/.virtualenv
paths: - source /tmp/.virtualenv/bin/activate
- .pipcache - pip install Pillow
variables: - pip install -r requirements.txt
PIP_CACHE_DIR: .pipcache - pip install python-coveralls
- pip install coverage
- pip install pycco
- pip install django_coverage_plugin
test_and_coverage: test_and_coverage:
stage: build stage: test
coverage: "/^TOTAL.*\\s+(\\d+\\%)$/"
services:
- postgres:latest
variables:
POSTGRES_DB: django_db
POSTGRES_USER: django_user
POSTGRES_PASSWORD: django_password
POSTGRES_HOST: postgres
DATABASE_URL: "postgres://django_user:django_password@postgres/django_db"
PYTHONUNBUFFERED: 1
before_script:
- virtualenv -p python3 /tmp/.virtualenv
- source /tmp/.virtualenv/bin/activate
- pip install -U pip
- pip install Pillow
- pip install -r requirements.txt
- pip install python-coveralls
- pip install coverage
- pip install pycco
- pip install django_coverage_plugin
script: script:
- source /tmp/.virtualenv/bin/activate - echo 'from ivatar.settings import TEMPLATES' > config_local.py
- echo 'from ivatar.settings import TEMPLATES' > config_local.py - echo 'TEMPLATES[0]["OPTIONS"]["debug"] = True' >> config_local.py
- echo 'TEMPLATES[0]["OPTIONS"]["debug"] = True' >> config_local.py - echo "DEBUG = True" >> config_local.py
- echo "DEBUG = True" >> config_local.py - python manage.py collectstatic --noinput
- echo "from config import CACHES" >> config_local.py - coverage run --source . manage.py test -v3
- echo "CACHES['default'] = CACHES['filesystem']" >> config_local.py - coverage report --fail-under=70
- python manage.py sqldsn - coverage html
- python manage.py collectstatic --noinput
- coverage run --source . manage.py test -v3 --noinput
- coverage report --fail-under=70
- coverage html
artifacts: artifacts:
paths: paths:
- htmlcov/ - htmlcov/
pycco: pycco:
stage: test stage: test
before_script:
- virtualenv -p python3 /tmp/.virtualenv
- source /tmp/.virtualenv/bin/activate
- pip install -U pip
- pip install Pillow
- pip install -r requirements.txt
- pip install python-coveralls
- pip install coverage
- pip install pycco
- pip install django_coverage_plugin
script: script:
- "/bin/true" - /bin/true
- find ivatar/ -type f -name "*.py"|grep -v __pycache__|grep -v __init__.py|grep - find ivatar/ -type f -name "*.py"|grep -v __pycache__|grep -v __init__.py|grep -v /migrations/ | xargs pycco -p -d pycco -i -s
-v /migrations/ | xargs pycco -p -d pycco -i -s
artifacts: artifacts:
paths: paths:
- pycco/ - pycco/
expire_in: 14 days expire_in: 14 days
pages: pages:
before_script:
- /bin/true
- /bin/true
stage: deploy stage: deploy
dependencies: dependencies:
- test_and_coverage - test_and_coverage
- pycco - pycco
script: script:
- mv htmlcov/ public/ - mv htmlcov/ public/
- mv pycco/ public/ - mv pycco/ public/
artifacts: artifacts:
paths: paths:
- public - public
expire_in: 14 days expire_in: 14 days
only:
- master
#build-image:
# image: docker
# only:
# - master
# - devel
# services:
# - docker:dind
# before_script:
# - docker info
# - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
# script:
# - ls -lah
# - |
# if [[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]]; then
# tag=""
# echo "Running on default branch '$CI_DEFAULT_BRANCH': tag = 'latest'"
# else
# tag=":$CI_COMMIT_REF_SLUG"
# echo "Running on branch '$CI_COMMIT_BRANCH': tag = $tag"
# fi
# - docker build --pull -t "$CI_REGISTRY_IMAGE${tag}" .
# - docker push "$CI_REGISTRY_IMAGE${tag}"
semgrep:
stage: test
allow_failure: true
image: registry.gitlab.com/gitlab-org/security-products/analyzers/semgrep:latest
only: only:
- master - master
- devel
variables:
CI_PROJECT_DIR: "/tmp/app"
SECURE_LOG_LEVEL: "debug"
script:
- rm -rf .virtualenv
- /analyzer run
artifacts:
paths:
- gl-sast-report.json
- semgrep.sarif
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml

View File

@@ -1,5 +0,0 @@
# Dscribe your issue
# What have you tried to far?
# Links / Pointer / Resources

View File

@@ -4,16 +4,16 @@ repos:
hooks: hooks:
- id: check-useless-excludes - id: check-useless-excludes
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.0.0-alpha.4 rev: v2.4.0
hooks: hooks:
- id: prettier - id: prettier
files: \.(css|js|md|markdown|json) files: \.(css|js|md|markdown|json)
- repo: https://github.com/python/black - repo: https://github.com/python/black
rev: 22.12.0 rev: 21.9b0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0 rev: v4.0.1
hooks: hooks:
- id: check-added-large-files - id: check-added-large-files
- id: check-ast - id: check-ast
@@ -37,8 +37,8 @@ repos:
- id: requirements-txt-fixer - id: requirements-txt-fixer
- id: sort-simple-yaml - id: sort-simple-yaml
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/PyCQA/flake8 - repo: https://gitlab.com/pycqa/flake8
rev: 6.0.0 rev: 3.9.2
hooks: hooks:
- id: flake8 - id: flake8
- repo: local - repo: local
@@ -57,17 +57,16 @@ repos:
types: types:
- shell - shell
- repo: https://github.com/asottile/blacken-docs - repo: https://github.com/asottile/blacken-docs
rev: v1.12.1 rev: v1.11.0
hooks: hooks:
- id: blacken-docs - id: blacken-docs
# YASpeller does not seem to work anymore - repo: https://github.com/hcodes/yaspeller.git
# - repo: https://github.com/hcodes/yaspeller.git rev: v7.0.0
# rev: v8.0.1 hooks:
# hooks: - id: yaspeller
# - id: yaspeller
# types:
# types: - markdown
# - markdown
- repo: https://github.com/kadrach/pre-commit-gitlabci-lint - repo: https://github.com/kadrach/pre-commit-gitlabci-lint
rev: 22d0495c9894e8b27cc37c2ed5d9a6b46385a44c rev: 22d0495c9894e8b27cc37c2ed5d9a6b46385a44c
hooks: hooks:

View File

@@ -1,22 +0,0 @@
FROM git.linux-kernel.at:5050/oliver/fedora40-python3:latest
LABEL maintainer Oliver Falk <oliver@linux-kernel.at>
EXPOSE 8081
ADD . /opt/ivatar-devel
WORKDIR /opt/ivatar-devel
RUN pip3 install pip --upgrade \
&& virtualenv .virtualenv \
&& source .virtualenv/bin/activate \
&& pip3 install Pillow \
&& pip3 install -r requirements.txt \
&& pip3 install python-coveralls coverage pycco django_coverage_plugin
RUN echo "DEBUG = True" >> /opt/ivatar-devel/config_local.py
RUN echo "EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'" >> /opt/ivatar-devel/config_local.py
RUN source .virtualenv/bin/activate \
&& python3 manage.py migrate \
&& python3 manage.py collectstatic --noinput \
&& echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('admin', 'admin@local.tld', 'admin')" | python manage.py shell
ENTRYPOINT source .virtualenv/bin/activate && python3 ./manage.py runserver 0:8081

View File

@@ -1,6 +1,6 @@
# Installation # Installation
## Prerequisites ## Prequisits
Python 3.x + virtualenv Python 3.x + virtualenv
@@ -10,28 +10,21 @@ Python 3.x + virtualenv
yum install python34-virtualenv.noarch yum install python34-virtualenv.noarch
``` ```
### Debian 11
```
sudo apt-get update
sudo apt-get install git python3-virtualenv libmariadb-dev libldap2-dev libsasl2-dev
```
## Checkout ## Checkout
```bash ~~~~bash
git clone https://git.linux-kernel.at/oliver/ivatar.git git clone https://git.linux-kernel.at/oliver/ivatar.git
cd ivatar cd ivatar
``` ~~~~
## Virtual environment ## Virtual environment
```bash ~~~~bash
virtualenv -p python3 .virtualenv virtualenv -p python3 .virtualenv
source .virtualenv/bin/activate source .virtualenv/bin/activate
pip install pillow
pip install -r requirements.txt pip install -r requirements.txt
``` pip install pillow
~~~~
## (SQL) Migrations ## (SQL) Migrations
@@ -58,27 +51,10 @@ pip install -r requirements.txt
``` ```
## Running the testsuite ## Running the testsuite
``` ```
./manage.py test -v3 # Or any other verbosity level you like ./manage.py test -v3 # Or any other verbosity level you like
``` ```
## OpenID Connect authentication with Fedora
To enable OpenID Connect (OIDC) authentication with Fedora, you must have obtained a `client_id` and `client_secret` pair from the Fedora Infrastructure.
Then you must set these values in `config_local.py`:
```
SOCIAL_AUTH_FEDORA_KEY = "the-client-id"
SOCIAL_AUTH_FEDORA_SECRET = "the-client-secret"
```
You can override the location of the OIDC provider with the `SOCIAL_AUTH_FEDORA_OIDC_ENDPOINT` setting. For example, to authenticate with Fedora's staging environment, set this in `config_local.py`:
```
SOCIAL_AUTH_FEDORA_OIDC_ENDPOINT = "https://id.stg.fedoraproject.org"
```
# Production deployment Webserver (non-cloudy) # Production deployment Webserver (non-cloudy)
To deploy this Django application with WSGI on Apache, NGINX or any other web server, please refer to the the webserver documentation; There are also plenty of howtos on the net (I'll not LMGTFY...) To deploy this Django application with WSGI on Apache, NGINX or any other web server, please refer to the the webserver documentation; There are also plenty of howtos on the net (I'll not LMGTFY...)
@@ -99,4 +75,4 @@ There is a file called ebcreate.txt as well as a directory called .ebextensions,
## Database ## Database
It should work with SQLite (do _not_ use in production!), MySQL/MariaDB, as well as PostgreSQL. It should work with SQLite (do *not* use in production!), MySQL/MariaDB, as well as PostgreSQL.

View File

@@ -1,10 +0,0 @@
include *.py
include *.md
include COPYING
include LICENSE
recursive-include templates *
recursive-include ivatar *
exclude .virtualenv
exclude libravatar.egg-info
global-exclude *.py[co]
global-exclude __pycache__

View File

@@ -1,16 +1,20 @@
# ivatar / libravatar ivatar / libravatar
===================
# Pipeline and coverage status Pipeline and coverage status
============================
[![pipeline status](https://git.linux-kernel.at/oliver/ivatar/badges/master/pipeline.svg)](https://git.linux-kernel.at/oliver/ivatar/commits/master) [![pipeline status](https://git.linux-kernel.at/oliver/ivatar/badges/master/pipeline.svg)](https://git.linux-kernel.at/oliver/ivatar/commits/master)
[![coverage report](https://git.linux-kernel.at/oliver/ivatar/badges/master/coverage.svg)](http://git.linux-kernel.at/oliver/ivatar/commits/master) [![coverage report](https://git.linux-kernel.at/oliver/ivatar/badges/master/coverage.svg)](http://git.linux-kernel.at/oliver/ivatar/commits/master)
# Reports / code documentation Reports / code documentation
============================
- [Coverage HTML report](http://oliver.git.linux-kernel.at/ivatar) - [Coverage HTML report](http://oliver.git.linux-kernel.at/ivatar)
- [Code documentation (autogenerated, pycco)](http://oliver.git.linux-kernel.at/ivatar/pycco/) - [Code documentation (autogenerated, pycco)](http://oliver.git.linux-kernel.at/ivatar/pycco/)
# Authors and contributors Authors and contributors
========================
Lead developer/Owner: Oliver Falk (aka ofalk or falko) - https://git.linux-kernel.at/oliver Lead developer/Owner: Oliver Falk (aka ofalk or falko) - https://git.linux-kernel.at/oliver

View File

@@ -1,2 +0,0 @@
https://django-debug-toolbar.readthedocs.io/en/latest/installation.html
https://stackoverflow.com/questions/6548947/how-can-django-debug-toolbar-be-set-to-work-for-just-some-users/6549317#6549317

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import django
import timeit
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "ivatar.settings"
) # pylint: disable=wrong-import-position
django.setup() # pylint: disable=wrong-import-position
from ivatar.ivataraccount.models import ConfirmedEmail, APIKey
from simplecrypt import decrypt
from binascii import unhexlify
digest = None
digest_sha256 = None
def get_digest_sha256():
digest_sha256 = ConfirmedEmail.objects.first().encrypted_digest_sha256(
secret_key=APIKey.objects.first()
)
return digest_sha256
def get_digest():
digest = ConfirmedEmail.objects.first().encrypted_digest(
secret_key=APIKey.objects.first()
)
return digest
def decrypt_digest():
return decrypt(APIKey.objects.first().secret_key, unhexlify(digest))
def decrypt_digest_256():
return decrypt(APIKey.objects.first().secret_key, unhexlify(digest_sha256))
digest = get_digest()
digest_sha256 = get_digest_sha256()
print("Encrypt digest: %s" % timeit.timeit(get_digest, number=1))
print("Encrypt digest_sha256: %s" % timeit.timeit(get_digest_sha256, number=1))
print("Decrypt digest: %s" % timeit.timeit(decrypt_digest, number=1))
print("Decrypt digest_sha256: %s" % timeit.timeit(decrypt_digest_256, number=1))

View File

@@ -1,7 +0,0 @@
DATABASES['default'] = {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'libravatar',
'USER': 'libravatar',
'PASSWORD': 'libravatar',
'HOST': 'localhost',
}

131
config.py
View File

@@ -31,7 +31,7 @@ INSTALLED_APPS.extend(
MIDDLEWARE.extend( MIDDLEWARE.extend(
[ [
"ivatar.middleware.CustomLocaleMiddleware", "django.middleware.locale.LocaleMiddleware",
] ]
) )
MIDDLEWARE.insert( MIDDLEWARE.insert(
@@ -44,7 +44,6 @@ AUTHENTICATION_BACKENDS = (
# See INSTALL for more information. # See INSTALL for more information.
# 'django_auth_ldap.backend.LDAPBackend', # 'django_auth_ldap.backend.LDAPBackend',
"django_openid_auth.auth.OpenIDBackend", "django_openid_auth.auth.OpenIDBackend",
"ivatar.ivataraccount.auth.FedoraOpenIdConnect",
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
) )
@@ -59,13 +58,9 @@ TEMPLATES[0]["OPTIONS"]["context_processors"].append(
OPENID_CREATE_USERS = True OPENID_CREATE_USERS = True
OPENID_UPDATE_DETAILS_FROM_SREG = True OPENID_UPDATE_DETAILS_FROM_SREG = True
SOCIAL_AUTH_JSONFIELD_ENABLED = True
# Fedora authentication (OIDC). You need to set these two values to use it.
SOCIAL_AUTH_FEDORA_KEY = None # Also known as client_id
SOCIAL_AUTH_FEDORA_SECRET = None # Also known as client_secret
SITE_NAME = os.environ.get("SITE_NAME", "libravatar") SITE_NAME = os.environ.get("SITE_NAME", "libravatar")
IVATAR_VERSION = "1.8.0" IVATAR_VERSION = "1.6"
SCHEMAROOT = "https://www.libravatar.org/schemas/export/0.2" SCHEMAROOT = "https://www.libravatar.org/schemas/export/0.2"
@@ -158,20 +153,7 @@ if "POSTGRESQL_DATABASE" in os.environ:
"HOST": "postgresql", "HOST": "postgresql",
} }
# CI/CD config has different naming SESSION_SERIALIZER = "django.contrib.sessions.serializers.PickleSerializer"
if "POSTGRES_DB" in os.environ:
DATABASES["default"] = { # pragma: no cover
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": os.environ["POSTGRES_HOST"],
"TEST": {
"NAME": os.environ["POSTGRES_DB"],
},
}
SESSION_SERIALIZER = "django.contrib.sessions.serializers.JSONSerializer"
USE_X_FORWARDED_HOST = True USE_X_FORWARDED_HOST = True
ALLOWED_EXTERNAL_OPENID_REDIRECT_DOMAINS = [ ALLOWED_EXTERNAL_OPENID_REDIRECT_DOMAINS = [
@@ -209,17 +191,15 @@ MESSAGE_TAGS = {
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache", "BACKEND": "django.core.cache.backends.memcached.MemcachedCache",
"LOCATION": [ "LOCATION": [
"127.0.0.1:11211", "127.0.0.1:11211",
], ],
# "OPTIONS": {"MAX_ENTRIES": 1000000},
}, },
"filesystem": { "filesystem": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache", "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
"LOCATION": "/var/tmp/ivatar_cache", "LOCATION": "/var/tmp/ivatar_cache",
"TIMEOUT": 900, # 15 minutes "TIMEOUT": 900, # 15 minutes
"OPTIONS": {"MAX_ENTRIES": 1000000},
}, },
} }
@@ -231,98 +211,21 @@ CACHE_RESPONSE = True
# Trusted URLs for default redirection # Trusted URLs for default redirection
TRUSTED_DEFAULT_URLS = [ TRUSTED_DEFAULT_URLS = [
{"schemes": ["https"], "host_equals": "ui-avatars.com", "path_prefix": "/api/"}, "https://ui-avatars.com/api/",
{ "http://gravatar.com/avatar/",
"schemes": ["http", "https"], "https://gravatar.com/avatar/",
"host_equals": "gravatar.com", "http://www.gravatar.org/avatar/",
"path_prefix": "/avatar/", "https://www.gravatar.org/avatar/",
}, "https://secure.gravatar.com/avatar/",
{ "http://0.gravatar.com/avatar/",
"schemes": ["http", "https"], "https://0.gravatar.com/avatar/",
"host_suffix": ".gravatar.com", "https://avatars.dicebear.com/api/",
"path_prefix": "/avatar/", "https://badges.fedoraproject.org/static/img/",
}, "http://www.planet-libre.org/themes/planetlibre/images/",
{ "https://www.azuracast.com/img/",
"schemes": ["http", "https"], "https://reps.mozilla.org/static/base/img/remo/",
"host_equals": "www.gravatar.org",
"path_prefix": "/avatar/",
},
{
"schemes": ["https"],
"host_equals": "avatars.dicebear.com",
"path_prefix": "/api/",
},
{
"schemes": ["https"],
"host_equals": "api.dicebear.com",
"path_prefix": "/",
},
{
"schemes": ["https"],
"host_equals": "badges.fedoraproject.org",
"path_prefix": "/static/img/",
},
{
"schemes": ["http"],
"host_equals": "www.planet-libre.org",
"path_prefix": "/themes/planetlibre/images/",
},
{"schemes": ["https"], "host_equals": "www.azuracast.com", "path_prefix": "/img/"},
{
"schemes": ["https"],
"host_equals": "reps.mozilla.org",
"path_prefix": "/static/base/img/remo/",
},
] ]
URL_TIMEOUT = 10
def map_legacy_config(trusted_url):
"""
For backward compability with the legacy configuration
for trusting URLs. Adapts them to fit the new config.
"""
if isinstance(trusted_url, str):
return {"url_prefix": trusted_url}
return trusted_url
# Backward compability for legacy behavior
TRUSTED_DEFAULT_URLS = list(map(map_legacy_config, TRUSTED_DEFAULT_URLS))
# Bluesky settings
BLUESKY_IDENTIFIER = os.environ.get("BLUESKY_IDENTIFIER", None)
BLUESKY_APP_PASSWORD = os.environ.get("BLUESKY_APP_PASSWORD", None)
# Celery Configuration
# Try Redis first, fallback to memory broker for development
try:
import redis
redis.Redis(host="localhost", port=6379, db=0).ping()
CELERY_BROKER_URL = "redis://localhost:6379/0"
except Exception: # pylint: disable=broad-except
# Fallback to memory broker for development
CELERY_BROKER_URL = "memory://"
print("Warning: Redis not available, using memory broker for development")
CELERY_RESULT_BACKEND = "django-db"
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = "UTC"
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 300 # 5 minutes
CELERY_TASK_SOFT_TIME_LIMIT = 240 # 4 minutes
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TASK_ACKS_LATE = True
CELERY_RESULT_EXPIRES = 3600 # 1 hour
CELERY_WORKER_CONCURRENCY = (
1 # Max 1 parallel avatar generation task for local development
)
# This MUST BE THE LAST! # This MUST BE THE LAST!
if os.path.isfile(os.path.join(BASE_DIR, "config_local.py")): if os.path.isfile(os.path.join(BASE_DIR, "config_local.py")):
from config_local import * # noqa # flake8: noqa # NOQA # pragma: no cover from config_local import * # noqa # flake8: noqa # NOQA # pragma: no cover

View File

@@ -1,6 +1,6 @@
.stats span.mis { .stats span.mis {
background: #faa; background: #faa;
} }
.text p.mis { .text p.mis {
background: #faa; background: #faa;
} }

0
create_nobody_from_svg_with_inkscape.sh Executable file → Normal file
View File

View File

@@ -0,0 +1,8 @@
The code in here should be able to help to build up some encrypting proxy.
If your app uses a lot of libravatar and therefore has to do a lot of DNS
lookups, change your app in such a way, that it encodes the mail address,
sends it over to the proxy, which will decrypt it, do the DNS lookup and
return the image binary.
No guarantee for this code. It's untested and just provided as example.

View File

@@ -0,0 +1,90 @@
<?php
/**
* Valid encryption methods AES-256-CFB
* Code kindly borrowed from:
* https://github.com/arajapandi/php-python-encrypt-decrypt
*
* $cypher = new MyCypher($iv);
* $php_encrypted = $cypher->encrypt('test');
* $php_decrypted = $cypher->decrypt($php_encrypted);
*/
class MyCypher {
private $key = 'asdfa923aksadsYahoasdw998sdsads';
private $iv = null;
private $method = "AES-256-CFB";
private $blocksize = 32;
private $padwith = '`';
/*
* construct for cypher class - get, set key and iv
*/
function __construct($iv, $key = null) {
if (is_string($key)) {
$this->key = $key;
}
$this->iv = $iv;
}
/*
* get hased key - if key is not set on init, then default key wil be used
*/
private function getKEY() {
if (empty($this->key)) {
die('Key not set!');
}
return substr(hash('sha256', $this->key), 0, 32);
}
/*
* get hashed IV value - if no IV values then it throw error
*/
private function getIV() {
if (empty($this->iv)) {
die('IV not set!');
}
return substr(hash('sha256', $this->iv), 0, 16);
}
/*
* Encrypt given string using AES encryption standard
*/
public function encrypt($secret) {
try {
$padded_secret = $secret . str_repeat($this->padwith, ($this->blocksize - strlen($secret) % $this->blocksize));
$encrypted_string = openssl_encrypt($padded_secret, $this->method, $this->getKEY(), OPENSSL_RAW_DATA, $this->getIV());
$encrypted_secret = base64_encode($encrypted_string);
return $encrypted_secret;
} catch (Exception $e) {
die('Error : ' . $e->getMessage());
}
}
/*
* Decrypt given string using AES standard
*/
public function decrypt($secret) {
try {
$decoded_secret = base64_decode($secret);
$decrypted_secret = openssl_decrypt($decoded_secret, $this->method, $this->getKEY(), OPENSSL_RAW_DATA, $this->getIV());
return rtrim($decrypted_secret, $this->padwith);
} catch (Exception $e) {
die('Error : ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python2
#encoding: UTF-8
# Code kindly borrowed from:
# https://github.com/arajapandi/php-python-encrypt-decrypt
# Python Class for AES encryption
"""
Example Usage
enc_str = cipher.encrypt('secret')
enc_str = cipher.decrypt(enc_str)
print(enc_str); #secret
"""
from Crypto.Cipher import AES
import base64
import hashlib
import sys
class MyCypher:
# Default Key for encryption
rawkey = 'asdfa923aksadsYahoasdw998sdsads'
method = AES.MODE_CFB
blocksize = 32 # 16, 32..etc
padwith = '`'.encode('utf-8') # padding value for string
#lambda function for padding
pad = lambda self, s: s + (self.blocksize - len(s) % self.blocksize) * self.padwith
"""
construct for cypher class - get, set key and iv
"""
def __init__(self, iv, key=''):
if(not key):
key = self.rawkey
self.key = key.encode('utf-8')
self.iv = iv.encode('utf-8')
"""
get hased key - if key is not set on init, then default key wil be used
"""
def getKEY(self):
if(not self.key):
sys.exit()
return hashlib.sha256(self.key).hexdigest()[:32]
"""
get hashed IV value - if no IV values then it throw error
"""
def getIV(self):
if(not self.iv):
sys.exit()
self.iv = self.iv
return hashlib.sha256(self.iv).hexdigest()[:16]
"""
Encrypt given string using AES encryption standard
"""
def encrypt(self, raw):
cipher = AES.new(self.getKEY(), self.method, self.getIV(), segment_size=128)
return base64.b64encode(cipher.encrypt(self.pad(raw)))
"""
Decrypt given string using AES standard
"""
def decrypt(self, encrypted):
encrypted = base64.b64decode(encrypted)
cipher = AES.new(self.getKEY(), self.method, self.getIV(), segment_size=128)
return cipher.decrypt(encrypted).rstrip(self.padwith)

32
encrypted_proxy/proxy.py Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
import urllib.request
import sys
import os
from lib.MyCypher import MyCypher
import libravatar
# Both need to be the same as in your client code that encrypts the
# mail address
iv = 'asdf'
key = 'Hallo123'
#sys.stderr.buffer.write(b'%s' % bytes(os.environ.get("QUERY_STRING", "No Query String in url"), 'utf-8'))
cypher = MyCypher(iv = iv, key = key)
mail = cypher.decrypt(os.environ.get('QUERY_STRING').encode('utf-8')).decode('utf-8')
link = libravatar.libravatar_url(mail)
sys.stderr.buffer.write(b'%s' % bytes(link, 'utf-8'))
data = None
with urllib.request.urlopen(link) as f:
data = f.read()
for header in f.headers._headers:
if header[0] == 'Content-Type':
sys.stdout.buffer.write(b"%s: %s\n\n" % (bytes(header[0], 'utf-8'), bytes(header[1], 'utf-8')))
sys.stdout.flush()
break
sys.stdout.buffer.write(data)

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env python
from MyCypher import MyCypher
encstr = bytes('drEN/LqPBu1wJYHpN5eCjZXqVgvDEP3rZnXJt85Ma0k=', 'utf-8')
cypher = MyCypher(iv = str('asdf'))
print(cypher.decrypt(encstr))

View File

@@ -0,0 +1,8 @@
<?php
include 'lib/MyCypher.php';
$iv = 'asdf';
$key = 'Hallo123';
$cypher = new MyCypher($iv=$iv, $key=$key);
$php_encrypted = $cypher->encrypt('oliver@linux-kernel.at');
print($php_encrypted);

View File

@@ -3,10 +3,4 @@
Module init Module init
""" """
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ("celery_app",)
app_label = __name__ # pylint: disable=invalid-name app_label = __name__ # pylint: disable=invalid-name

View File

@@ -1,461 +0,0 @@
# -*- coding: utf-8 -*-
"""
AI service module for text-to-image avatar generation
Supports Stable Diffusion (local and API) for professional avatar generation
"""
import logging
import requests
import base64
from io import BytesIO
from PIL import Image
from django.conf import settings
logger = logging.getLogger(__name__)
class AIServiceError(Exception):
"""Custom exception for AI service errors"""
pass
class StableDiffusionService:
"""
Service for generating images using Stable Diffusion (local or API)
"""
# Model-specific token limits
TOKEN_LIMITS = {
"stable_diffusion": 77, # CLIP tokenizer limit
"stable_diffusion_v2": 77,
"stable_diffusion_xl": 77,
}
def __init__(self):
self.api_url = getattr(settings, "STABLE_DIFFUSION_API_URL", None)
self.api_key = getattr(settings, "STABLE_DIFFUSION_API_KEY", None)
self.timeout = getattr(settings, "STABLE_DIFFUSION_TIMEOUT", 60)
self._pipe = None # Cache for local model
self._tokenizer = None # Cache for tokenizer
def generate_image(
self, prompt, size=(512, 512), quality="medium", allow_nsfw=False
):
"""
Generate an image from text prompt using Stable Diffusion
Args:
prompt (str): Text description of the desired image
size (tuple): Image dimensions (width, height)
quality (str): Generation quality ('low', 'medium', 'high')
allow_nsfw (bool): Whether to allow potentially NSFW content
Returns:
PIL.Image: Generated image
Raises:
AIServiceError: If generation fails or prompt is too long
"""
# Validate prompt length first
validation = self.validate_prompt(prompt)
if not validation["valid"]:
raise AIServiceError(validation["warning"])
try:
if self.api_url and self.api_key:
return self._generate_via_api(prompt, size, quality, allow_nsfw)
else:
return self._generate_locally(prompt, size, quality, allow_nsfw)
except Exception as e:
logger.error(f"Failed to generate image: {e}")
raise AIServiceError(f"Image generation failed: {str(e)}")
def validate_prompt(self, prompt, model="stable_diffusion"):
"""
Validate prompt length against model token limits
Args:
prompt (str): Text prompt to validate
model (str): Model name to check limits for
Returns:
dict: Validation result with 'valid', 'token_count', 'limit', 'warning'
"""
try:
token_count = self._count_tokens(prompt)
limit = self.TOKEN_LIMITS.get(model, 77)
is_valid = token_count <= limit
warning = None
if not is_valid:
warning = f"Prompt too long: {token_count} tokens (limit: {limit}). Please shorten your prompt."
return {
"valid": is_valid,
"token_count": token_count,
"limit": limit,
"warning": warning,
}
except Exception as e:
logger.warning(f"Token counting failed: {e}")
return {
"valid": True, # Allow generation if counting fails
"token_count": 0,
"limit": 77,
"warning": None,
}
def _count_tokens(self, prompt):
"""
Count tokens in a prompt using CLIP tokenizer
"""
try:
if self._tokenizer is None:
from transformers import CLIPTokenizer
self._tokenizer = CLIPTokenizer.from_pretrained(
"openai/clip-vit-base-patch32"
)
tokens = self._tokenizer(prompt, return_tensors="pt", truncation=False)[
"input_ids"
]
return tokens.shape[1]
except ImportError:
# Fallback: more accurate estimation
# CLIP tokenizer typically produces ~1.3 tokens per word for English
words = len(prompt.split())
return int(words * 1.3)
except Exception as e:
logger.warning(f"Token counting error: {e}")
# Fallback: more accurate estimation
words = len(prompt.split())
return int(words * 1.3)
def _is_black_image(self, image):
"""
Check if an image is completely black (common NSFW response from APIs)
Args:
image (PIL.Image): Image to check
Returns:
bool: True if image is completely black
"""
# Convert to RGB if necessary
if image.mode != "RGB":
image = image.convert("RGB")
# Get image data
pixels = list(image.getdata())
# Check if all pixels are black (0, 0, 0)
black_pixels = sum(1 for r, g, b in pixels if r == 0 and g == 0 and b == 0)
total_pixels = len(pixels)
# Consider it a black image if more than 95% of pixels are black
return (black_pixels / total_pixels) > 0.95
def _generate_via_api(self, prompt, size, quality, allow_nsfw=False):
"""
Generate image via Stable Diffusion API (Replicate, Hugging Face, etc.)
"""
# Enhanced prompt for avatar generation
enhanced_prompt = f"""professional avatar portrait, {prompt}, high quality, detailed, clean background, centered composition, profile picture style, photorealistic"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"prompt": enhanced_prompt,
"width": size[0],
"height": size[1],
"num_inference_steps": 25
if quality == "high"
else (20 if quality == "medium" else 15),
"guidance_scale": 7.5, # Balanced for quality and speed
"negative_prompt": "blurry, low quality, distorted, ugly, deformed, bad anatomy",
}
# Add NSFW safety setting if supported by the API
if allow_nsfw:
payload["safety_tolerance"] = 2 # Some APIs support this
payload["nsfw"] = True # Some APIs support this
response = requests.post(
self.api_url, json=payload, headers=headers, timeout=self.timeout
)
if response.status_code != 200:
error_msg = f"Stable Diffusion API request failed: {response.status_code}"
try:
error_detail = response.json()
error_msg += f" - {error_detail}"
# Check for NSFW content detection
if isinstance(error_detail, dict):
error_text = str(error_detail).lower()
if (
"nsfw" in error_text
or "inappropriate" in error_text
or "black image" in error_text
):
if allow_nsfw:
# If user allowed NSFW but still got blocked, provide a different message
raise AIServiceError(
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
)
else:
raise AIServiceError(
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
)
elif isinstance(error_detail, str):
if (
"nsfw" in error_detail.lower()
or "inappropriate" in error_detail.lower()
or "black image" in error_detail.lower()
):
if allow_nsfw:
raise AIServiceError(
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
)
else:
raise AIServiceError(
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
)
except AIServiceError:
# Re-raise our custom NSFW error
raise
except Exception: # pylint: disable=broad-except
error_msg += f" - {response.text}"
# Also check response text for NSFW warnings
if (
"nsfw" in response.text.lower()
or "inappropriate" in response.text.lower()
or "black image" in response.text.lower()
):
if allow_nsfw:
raise AIServiceError(
"Content warning: The AI service still detected inappropriate content even with relaxed settings. Please try a different prompt or contact support if you believe this is an error."
)
else:
raise AIServiceError(
"Content warning: The AI service detected potentially inappropriate content in your prompt. Please modify your description to be more appropriate for all ages and try again."
)
raise AIServiceError(error_msg)
result = response.json()
if "image" in result:
# Decode base64 image
image_data = base64.b64decode(result["image"])
image = Image.open(BytesIO(image_data))
# Check if the image is completely black (common NSFW response)
if not allow_nsfw and self._is_black_image(image):
raise AIServiceError(
"Content warning: The AI service detected potentially inappropriate content in your prompt and returned a black image. Please modify your description to be more appropriate for all ages and try again."
)
return image
else:
raise AIServiceError("No image data in API response")
def _generate_locally(self, prompt, size, quality, allow_nsfw=False):
"""
Generate image using local Stable Diffusion installation
This requires diffusers library and a local model
"""
try:
from diffusers import StableDiffusionPipeline
import torch
# Enhanced prompt for avatar generation
enhanced_prompt = f"""professional avatar portrait, {prompt}, high quality, detailed, clean background, centered composition, profile picture style, photorealistic"""
# Use cached model if available, otherwise load it
if self._pipe is None:
logger.info("Loading Stable Diffusion model (first time or cache miss)")
self._pipe = StableDiffusionPipeline.from_pretrained(
"runwayml/stable-diffusion-v1-5",
torch_dtype=torch.float16
if torch.cuda.is_available()
else torch.float32,
)
if torch.cuda.is_available():
self._pipe = self._pipe.to("cuda")
else:
logger.info("Using cached Stable Diffusion model")
pipe = self._pipe
# Disable safety checker if NSFW override is enabled
if allow_nsfw:
pipe.safety_checker = None
pipe.requires_safety_checker = False
# Generate image with optimized settings for speed
image = pipe(
enhanced_prompt,
height=size[1],
width=size[0],
num_inference_steps=25
if quality == "high"
else (20 if quality == "medium" else 15),
guidance_scale=7.5, # Balanced for quality and speed
negative_prompt="blurry, low quality, distorted, ugly, deformed, bad anatomy",
).images[0]
return image
except ImportError:
logger.warning(
"diffusers library not installed, falling back to placeholder"
)
return self._generate_placeholder(prompt, size)
except Exception as e:
logger.error(f"Local Stable Diffusion generation failed: {e}")
return self._generate_placeholder(prompt, size)
def _generate_placeholder(self, prompt, size):
"""
Generate a placeholder image when Stable Diffusion is not available
"""
logger.info("Generating placeholder image")
# Create a more sophisticated placeholder
img = Image.new("RGBA", size, color=(240, 248, 255, 255))
from PIL import ImageDraw, ImageFont
draw = ImageDraw.Draw(img)
try:
font = ImageFont.load_default()
except Exception: # pylint: disable=broad-except
font = None
# Add title
title = "AI Avatar (Stable Diffusion)"
draw.text((10, 10), title, fill="darkblue", font=font)
# Add prompt
prompt_text = f"Prompt: {prompt[:50]}..."
draw.text((10, 40), prompt_text, fill="black", font=font)
# Add note
note = "Install Stable Diffusion for real generation"
draw.text((10, 70), note, fill="darkgreen", font=font)
# Create a more sophisticated avatar placeholder
center_x, center_y = size[0] // 2, size[1] // 2 + 20
radius = min(size) // 4
# Face circle
draw.ellipse(
[
center_x - radius,
center_y - radius,
center_x + radius,
center_y + radius,
],
outline="purple",
width=3,
fill=(255, 240, 245),
)
# Eyes
eye_radius = radius // 4
draw.ellipse(
[
center_x - radius // 2 - eye_radius,
center_y - radius // 2 - eye_radius,
center_x - radius // 2 + eye_radius,
center_y - radius // 2 + eye_radius,
],
fill="blue",
)
draw.ellipse(
[
center_x + radius // 2 - eye_radius,
center_y - radius // 2 - eye_radius,
center_x + radius // 2 + eye_radius,
center_y - radius // 2 + eye_radius,
],
fill="blue",
)
# Smile
smile_y = center_y + radius // 3
draw.arc(
[
center_x - radius // 2,
smile_y - radius // 4,
center_x + radius // 2,
smile_y + radius // 4,
],
0,
180,
fill="red",
width=3,
)
return img
def validate_avatar_prompt(prompt, model="stable_diffusion"):
"""
Convenience function to validate avatar prompts
Args:
prompt (str): Text description of the avatar
model (str): AI model to use
Returns:
dict: Validation result with 'valid', 'token_count', 'limit', 'warning'
"""
if model == "stable_diffusion":
service = StableDiffusionService()
return service.validate_prompt(prompt, model)
else:
# For other models, assume they're valid
return {"valid": True, "token_count": 0, "limit": 0, "warning": None}
def generate_avatar_image(
prompt,
model="stable_diffusion",
size=(512, 512),
quality="medium",
allow_nsfw=False,
):
"""
Convenience function to generate avatar images
Args:
prompt (str): Text description of the avatar
model (str): AI model to use (currently only 'stable_diffusion')
size (tuple): Image dimensions
quality (str): Generation quality ('low', 'medium', 'high')
allow_nsfw (bool): Whether to allow potentially NSFW content
Returns:
PIL.Image: Generated avatar image
"""
if model == "stable_diffusion":
service = StableDiffusionService()
return service.generate_image(prompt, size, quality, allow_nsfw)
else:
raise AIServiceError(
f"Unsupported model: {model}. Only 'stable_diffusion' is currently supported."
)

View File

@@ -1,56 +0,0 @@
# -*- coding: utf-8 -*-
"""
Celery configuration for ivatar
"""
import os
from celery import Celery
from django.conf import settings
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ivatar.settings")
app = Celery("ivatar")
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
app.config_from_object("django.conf:settings", namespace="CELERY")
# Load task modules from all registered Django apps.
app.autodiscover_tasks()
# Celery configuration
app.conf.update(
# Task routing - use default queue for simplicity
task_default_queue="default",
task_routes={
"ivatar.tasks.generate_avatar_task": {"queue": "default"},
"ivatar.tasks.update_queue_positions": {"queue": "default"},
"ivatar.tasks.cleanup_old_tasks": {"queue": "default"},
},
# Worker configuration
worker_prefetch_multiplier=1,
task_acks_late=True,
# Result backend
result_backend="django-db",
result_expires=3600, # 1 hour
# Task time limits
task_time_limit=300, # 5 minutes
task_soft_time_limit=240, # 4 minutes
# Task serialization
task_serializer="json",
accept_content=["json"],
result_serializer="json",
# Timezone
timezone="UTC",
enable_utc=True,
)
# Set worker concurrency from Django settings
if hasattr(settings, "CELERY_WORKER_CONCURRENCY"):
app.conf.worker_concurrency = settings.CELERY_WORKER_CONCURRENCY
@app.task(bind=True)
def debug_task(self):
print(f"Request: {self.request!r}")

View File

@@ -3,7 +3,7 @@
Default: useful variables for the base page templates. Default: useful variables for the base page templates.
""" """
from ipware import get_client_ip # type: ignore from ipware import get_client_ip
from ivatar.settings import IVATAR_VERSION, SITE_NAME, MAX_PHOTO_SIZE from ivatar.settings import IVATAR_VERSION, SITE_NAME, MAX_PHOTO_SIZE
from ivatar.settings import BASE_URL, SECURE_BASE_URL from ivatar.settings import BASE_URL, SECURE_BASE_URL
from ivatar.settings import MAX_NUM_UNCONFIRMED_EMAILS from ivatar.settings import MAX_NUM_UNCONFIRMED_EMAILS
@@ -28,7 +28,6 @@ def basepage(request):
context["BASE_URL"] = BASE_URL context["BASE_URL"] = BASE_URL
context["SECURE_BASE_URL"] = SECURE_BASE_URL context["SECURE_BASE_URL"] = SECURE_BASE_URL
context["max_emails"] = False context["max_emails"] = False
if request.user: if request.user:
if not request.user.is_anonymous: if not request.user.is_anonymous:
unconfirmed = request.user.unconfirmedemail_set.count() unconfirmed = request.user.unconfirmedemail_set.count()

View File

@@ -1,56 +0,0 @@
# -*- coding: utf-8 -*-
from social_core.backends.open_id_connect import OpenIdConnectAuth
from ivatar.ivataraccount.models import ConfirmedEmail, Photo
from ivatar.settings import logger, TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS
class FedoraOpenIdConnect(OpenIdConnectAuth):
name = "fedora"
USERNAME_KEY = "nickname"
OIDC_ENDPOINT = "https://id.fedoraproject.org"
DEFAULT_SCOPE = ["openid", "profile", "email"]
TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_post"
# Pipeline methods
def add_confirmed_email(backend, user, response, *args, **kwargs):
"""Add a ConfirmedEmail if we trust the auth backend to validate email."""
if not kwargs.get("is_new", False):
return None # Only act on account creation
if backend.name not in TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS:
return None
if ConfirmedEmail.objects.filter(email=user.email).count() > 0:
# email already exists
return None
(confirmed_id, external_photos) = ConfirmedEmail.objects.create_confirmed_email(
user, user.email, True
)
confirmed_email = ConfirmedEmail.objects.get(id=confirmed_id)
logger.debug(
"Email %s added upon creation of user %s", confirmed_email.email, user.pk
)
photo = Photo.objects.create(user=user, ip_address=confirmed_email.ip_address)
import_result = photo.import_image("Gravatar", confirmed_email.email)
if import_result:
logger.debug("Gravatar image imported for %s", confirmed_email.email)
def associate_by_confirmed_email(backend, details, user=None, *args, **kwargs):
"""
Associate current auth with a user that has their email address as ConfirmedEmail in the DB.
"""
if user:
return None
email = details.get("email")
if not email:
return None
try:
confirmed_email = ConfirmedEmail.objects.get(email=email)
except ConfirmedEmail.DoesNotExist:
return None
user = confirmed_email.user
logger.debug("Found a matching ConfirmedEmail for %s upon login", user.username)
return {"user": user, "is_new": False}

View File

@@ -118,7 +118,9 @@ class UploadPhotoForm(forms.Form):
photo.ip_address = get_client_ip(request)[0] photo.ip_address = get_client_ip(request)[0]
photo.data = data.read() photo.data = data.read()
photo.save() photo.save()
return photo if photo.pk else None if not photo.pk:
return None
return photo
class AddOpenIDForm(forms.Form): class AddOpenIDForm(forms.Form):
@@ -139,16 +141,13 @@ class AddOpenIDForm(forms.Form):
""" """
# Lowercase hostname port of the URL # Lowercase hostname port of the URL
url = urlsplit(self.cleaned_data["openid"]) url = urlsplit(self.cleaned_data["openid"])
return urlunsplit( data = urlunsplit(
( (url.scheme.lower(), url.netloc.lower(), url.path, url.query, url.fragment)
url.scheme.lower(),
url.netloc.lower(),
url.path,
url.query,
url.fragment,
)
) )
# TODO: Domain restriction as in libravatar?
return data
def save(self, user): def save(self, user):
""" """
Save the model, ensuring some safety Save the model, ensuring some safety
@@ -217,89 +216,6 @@ class UploadLibravatarExportForm(forms.Form):
) )
class GenerateAvatarForm(forms.Form):
"""
Form for generating avatars using AI text-to-image
"""
MODEL_CHOICES = [
("stable_diffusion", "Stable Diffusion"),
# Future models can be added here
]
prompt = forms.CharField(
label=_("Avatar Description"),
max_length=500,
widget=forms.Textarea(
attrs={
"rows": 3,
"placeholder": _(
'Describe the avatar you want to create, e.g., "A friendly robot with blue eyes"'
),
"id": "id_prompt",
"data-token-limit": "77",
"data-model": "stable_diffusion",
}
),
help_text=_(
"Describe the avatar you want to generate. Be specific about appearance, style, and mood.<br><small class='text-muted'>Stable Diffusion has a 77-token limit. Keep your description concise for best results.</small>"
),
)
model = forms.ChoiceField(
label=_("AI Model"),
choices=MODEL_CHOICES,
initial="stable_diffusion",
help_text=_("Select the AI model to use for generation."),
)
quality = forms.ChoiceField(
label=_("Generation Quality"),
choices=[
("low", _("Low (faster, lower quality)")),
("medium", _("Medium (balanced)")),
("high", _("High (slower, better quality)")),
],
initial="medium",
help_text=_("Higher quality takes longer but produces better results."),
)
not_porn = forms.BooleanField(
label=_("Suitable for all ages (no offensive content)"),
required=True,
error_messages={
"required": _(
'We only host "G-rated" images and so this field must be checked.'
)
},
)
can_distribute = forms.BooleanField(
label=_("Can be freely copied"),
required=True,
error_messages={
"required": _(
"This field must be checked since we need to be able to distribute photos to third parties."
)
},
)
def clean_prompt(self):
"""Validate prompt length against token limits"""
prompt = self.cleaned_data.get("prompt", "")
model = self.cleaned_data.get("model", "stable_diffusion")
if prompt:
from ivatar.ai_service import validate_avatar_prompt
validation = validate_avatar_prompt(prompt, model)
if not validation["valid"]:
raise forms.ValidationError(validation["warning"])
return prompt
class DeleteAccountForm(forms.Form): class DeleteAccountForm(forms.Form):
password = forms.CharField( password = forms.CharField(
label=_("Password"), required=False, widget=forms.PasswordInput() label=_("Password"), required=False, widget=forms.PasswordInput()

View File

@@ -3,12 +3,13 @@
Helper method to fetch Gravatar image Helper method to fetch Gravatar image
""" """
from ssl import SSLError from ssl import SSLError
from urllib.request import HTTPError, URLError from urllib.request import urlopen, HTTPError, URLError
from ivatar.utils import urlopen
import hashlib import hashlib
from ..settings import AVATAR_MAX_SIZE from ..settings import AVATAR_MAX_SIZE
URL_TIMEOUT = 5 # in seconds
def get_photo(email): def get_photo(email):
""" """
@@ -22,23 +23,29 @@ def get_photo(email):
+ "?s=%i&d=404" % AVATAR_MAX_SIZE + "?s=%i&d=404" % AVATAR_MAX_SIZE
) )
image_url = ( image_url = (
f"https://secure.gravatar.com/avatar/{hash_object.hexdigest()}?s=512&d=404" "https://secure.gravatar.com/avatar/" + hash_object.hexdigest() + "?s=512&d=404"
) )
# Will redirect to the public profile URL if it exists # Will redirect to the public profile URL if it exists
service_url = f"http://www.gravatar.com/{hash_object.hexdigest()}" service_url = "http://www.gravatar.com/" + hash_object.hexdigest()
try: try:
urlopen(image_url) urlopen(image_url, timeout=URL_TIMEOUT)
except HTTPError as exc: except HTTPError as exc:
if exc.code not in [404, 503]: if exc.code != 404 and exc.code != 503:
print(f"Gravatar fetch failed with an unexpected {exc.code} HTTP error") print( # pragma: no cover
"Gravatar fetch failed with an unexpected %s HTTP error" % exc.code
)
return False return False
except URLError as exc: # pragma: no cover except URLError as exc: # pragma: no cover
print(f"Gravatar fetch failed with URL error: {exc.reason}") print(
"Gravatar fetch failed with URL error: %s" % exc.reason
) # pragma: no cover
return False # pragma: no cover return False # pragma: no cover
except SSLError as exc: # pragma: no cover except SSLError as exc: # pragma: no cover
print(f"Gravatar fetch failed with SSL error: {exc.reason}") print(
"Gravatar fetch failed with SSL error: %s" % exc.reason
) # pragma: no cover
return False # pragma: no cover return False # pragma: no cover
return { return {

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.0 on 2024-05-31 15:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0017_auto_20210528_1314"),
]
operations = [
migrations.AlterField(
model_name="photo",
name="format",
field=models.CharField(max_length=4),
),
]

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.1.5 on 2025-01-27 10:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0018_alter_photo_format"),
]
operations = [
migrations.AddField(
model_name="confirmedemail",
name="bluesky_handle",
field=models.CharField(blank=True, max_length=256, null=True),
),
]

View File

@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.1.5 on 2025-01-27 13:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0019_confirmedemail_bluesky_handle"),
]
operations = [
migrations.AddField(
model_name="confirmedopenid",
name="bluesky_handle",
field=models.CharField(blank=True, max_length=256, null=True),
),
]

View File

@@ -1,50 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.2.1 on 2025-09-17 10:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0020_confirmedopenid_bluesky_handle"),
]
operations = [
migrations.AddField(
model_name="photo",
name="ai_generated",
field=models.BooleanField(
default=False, help_text="Whether this photo was generated by AI"
),
),
migrations.AddField(
model_name="photo",
name="ai_model",
field=models.CharField(
blank=True,
help_text="The AI model used for generation",
max_length=50,
null=True,
),
),
migrations.AddField(
model_name="photo",
name="ai_prompt",
field=models.TextField(
blank=True,
help_text="The prompt used to generate this image",
null=True,
),
),
migrations.AddField(
model_name="photo",
name="ai_quality",
field=models.CharField(
blank=True,
help_text="The quality setting used",
max_length=20,
null=True,
),
),
]

View File

@@ -1,80 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.2.1 on 2025-09-17 10:25
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0021_add_ai_generation_fields"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="GenerationTask",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"ip_address",
models.GenericIPAddressField(null=True, unpack_ipv4=True),
),
("add_date", models.DateTimeField(default=django.utils.timezone.now)),
("prompt", models.TextField()),
("model", models.CharField(max_length=50)),
("quality", models.CharField(max_length=20)),
("allow_nsfw", models.BooleanField(default=False)),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("processing", "Processing"),
("completed", "Completed"),
("failed", "Failed"),
("cancelled", "Cancelled"),
],
default="pending",
max_length=20,
),
),
("progress", models.IntegerField(default=0)),
("queue_position", models.IntegerField(default=0)),
("task_id", models.CharField(blank=True, max_length=255, null=True)),
("error_message", models.TextField(blank=True, null=True)),
(
"generated_photo",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="ivataraccount.photo",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Generation Task",
"verbose_name_plural": "Generation Tasks",
"ordering": ["-add_date"],
},
),
]

View File

@@ -1,22 +0,0 @@
# -*- coding: utf-8 -*-
# Generated by Django 5.2.1 on 2025-09-17 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ivataraccount", "0022_add_generation_task"),
]
operations = [
migrations.AddField(
model_name="photo",
name="ai_invalid",
field=models.BooleanField(
default=False,
help_text="Whether this AI-generated image is invalid (black, etc.)",
),
),
]

View File

@@ -9,8 +9,8 @@ import time
from io import BytesIO from io import BytesIO
from os import urandom from os import urandom
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from ivatar.utils import urlopen, Bluesky from urllib.request import urlopen
from urllib.parse import urlsplit, urlunsplit, quote from urllib.parse import urlsplit, urlunsplit
from PIL import Image from PIL import Image
from django.contrib.auth.models import User from django.contrib.auth.models import User
@@ -20,7 +20,6 @@ from django.utils import timezone
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse_lazy, reverse from django.urls import reverse_lazy, reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.mail import send_mail from django.core.mail import send_mail
from django.template.loader import render_to_string from django.template.loader import render_to_string
@@ -42,14 +41,12 @@ def file_format(image_type):
""" """
Helper method returning a short image type Helper method returning a short image type
""" """
if image_type in ("JPEG", "MPO"): if image_type == "JPEG":
return "jpg" return "jpg"
elif image_type == "PNG": elif image_type == "PNG":
return "png" return "png"
elif image_type == "GIF": elif image_type == "GIF":
return "gif" return "gif"
elif image_type == "WEBP":
return "webp"
return None return None
@@ -57,14 +54,12 @@ def pil_format(image_type):
""" """
Helper method returning the 'encoder name' for PIL Helper method returning the 'encoder name' for PIL
""" """
if image_type in ("jpg", "jpeg", "mpo"): if image_type == "jpg" or image_type == "jpeg":
return "JPEG" return "JPEG"
elif image_type == "png": elif image_type == "png":
return "PNG" return "PNG"
elif image_type == "gif": elif image_type == "gif":
return "GIF" return "GIF"
elif image_type == "webp":
return "WEBP"
logger.info("Unsupported file format: %s", image_type) logger.info("Unsupported file format: %s", image_type)
return None return None
@@ -118,42 +113,6 @@ class BaseAccountModel(models.Model):
abstract = True abstract = True
class GenerationTask(BaseAccountModel):
"""
Model to track avatar generation tasks in the queue
"""
STATUS_CHOICES = [
("pending", _("Pending")),
("processing", _("Processing")),
("completed", _("Completed")),
("failed", _("Failed")),
("cancelled", _("Cancelled")),
]
user = models.ForeignKey(User, on_delete=models.CASCADE)
prompt = models.TextField()
model = models.CharField(max_length=50)
quality = models.CharField(max_length=20)
allow_nsfw = models.BooleanField(default=False)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
progress = models.IntegerField(default=0) # 0-100
queue_position = models.IntegerField(default=0)
task_id = models.CharField(max_length=255, blank=True, null=True) # Celery task ID
error_message = models.TextField(blank=True, null=True)
generated_photo = models.ForeignKey(
"Photo", on_delete=models.SET_NULL, blank=True, null=True
)
class Meta:
ordering = ["-add_date"]
verbose_name = _("Generation Task")
verbose_name_plural = _("Generation Tasks")
def __str__(self):
return f"Task {self.pk}: {self.prompt[:50]}... ({self.status})"
class Photo(BaseAccountModel): class Photo(BaseAccountModel):
""" """
Model holding the photos and information about them Model holding the photos and information about them
@@ -161,30 +120,9 @@ class Photo(BaseAccountModel):
ip_address = models.GenericIPAddressField(unpack_ipv4=True) ip_address = models.GenericIPAddressField(unpack_ipv4=True)
data = models.BinaryField() data = models.BinaryField()
format = models.CharField(max_length=4) format = models.CharField(max_length=3)
access_count = models.BigIntegerField(default=0, editable=False) access_count = models.BigIntegerField(default=0, editable=False)
# AI Generation metadata
ai_generated = models.BooleanField(
default=False, help_text=_("Whether this photo was generated by AI")
)
ai_prompt = models.TextField(
blank=True, null=True, help_text=_("The prompt used to generate this image")
)
ai_model = models.CharField(
max_length=50,
blank=True,
null=True,
help_text=_("The AI model used for generation"),
)
ai_quality = models.CharField(
max_length=20, blank=True, null=True, help_text=_("The quality setting used")
)
ai_invalid = models.BooleanField(
default=False,
help_text=_("Whether this AI-generated image is invalid (black, etc.)"),
)
class Meta: # pylint: disable=too-few-public-methods class Meta: # pylint: disable=too-few-public-methods
""" """
Class attributes Class attributes
@@ -193,49 +131,6 @@ class Photo(BaseAccountModel):
verbose_name = _("photo") verbose_name = _("photo")
verbose_name_plural = _("photos") verbose_name_plural = _("photos")
def is_valid_avatar(self):
"""
Check if this photo is a valid avatar (not black/invalid)
"""
if not self.ai_generated:
return True # Non-AI photos are assumed valid
# If we've already marked it as invalid, return False
if self.ai_invalid:
return False
try:
from PIL import Image
import io
# Load the image data
image_data = io.BytesIO(self.data)
image = Image.open(image_data)
# Convert to RGB if needed
if image.mode != "RGB":
image = image.convert("RGB")
# Check if image is predominantly black (common NSFW response)
pixels = list(image.getdata())
black_pixels = sum(1 for r, g, b in pixels if r == 0 and g == 0 and b == 0)
total_pixels = len(pixels)
# If more than 95% black pixels, consider it invalid
black_ratio = black_pixels / total_pixels
is_valid = black_ratio < 0.95
# Cache the result
if not is_valid:
self.ai_invalid = True
self.save(update_fields=["ai_invalid"])
return is_valid
except Exception:
# If we can't analyze the image, assume it's valid
return True
def import_image(self, service_name, email_address): def import_image(self, service_name, email_address):
""" """
Allow to import image from other (eg. Gravatar) service Allow to import image from other (eg. Gravatar) service
@@ -243,7 +138,8 @@ class Photo(BaseAccountModel):
image_url = False image_url = False
if service_name == "Gravatar": if service_name == "Gravatar":
if gravatar := get_gravatar_photo(email_address): gravatar = get_gravatar_photo(email_address)
if gravatar:
image_url = gravatar["image_url"] image_url = gravatar["image_url"]
if service_name == "Libravatar": if service_name == "Libravatar":
@@ -253,11 +149,15 @@ class Photo(BaseAccountModel):
return False # pragma: no cover return False # pragma: no cover
try: try:
image = urlopen(image_url) image = urlopen(image_url)
# No idea how to test this
# pragma: no cover
except HTTPError as exc: except HTTPError as exc:
print(f"{service_name} import failed with an HTTP error: {exc.code}") print("%s import failed with an HTTP error: %s" % (service_name, exc.code))
return False return False
# No idea how to test this
# pragma: no cover
except URLError as exc: except URLError as exc:
print(f"{service_name} import failed: {exc.reason}") print("%s import failed: %s" % (service_name, exc.reason))
return False return False
data = image.read() data = image.read()
@@ -269,7 +169,7 @@ class Photo(BaseAccountModel):
self.format = file_format(img.format) self.format = file_format(img.format)
if not self.format: if not self.format:
print(f"Unable to determine format: {img}") print("Unable to determine format: %s" % img) # pragma: no cover
return False # pragma: no cover return False # pragma: no cover
self.data = data self.data = data
super().save() super().save()
@@ -284,9 +184,10 @@ class Photo(BaseAccountModel):
# Use PIL to read the file format # Use PIL to read the file format
try: try:
img = Image.open(BytesIO(self.data)) img = Image.open(BytesIO(self.data))
# Testing? Ideas anyone?
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
# For debugging only # For debugging only
print(f"Exception caught in Photo.save(): {exc}") print("Exception caught in Photo.save(): %s" % exc)
return False return False
self.format = file_format(img.format) self.format = file_format(img.format)
if not self.format: if not self.format:
@@ -361,7 +262,7 @@ class Photo(BaseAccountModel):
cropped_w, cropped_h = cropped.size cropped_w, cropped_h = cropped.size
max_w = AVATAR_MAX_SIZE max_w = AVATAR_MAX_SIZE
if cropped_w > max_w or cropped_h > max_w: if cropped_w > max_w or cropped_h > max_w:
cropped = cropped.resize((max_w, max_w), Image.LANCZOS) cropped = cropped.resize((max_w, max_w), Image.ANTIALIAS)
data = BytesIO() data = BytesIO()
cropped.save(data, pil_format(self.format), quality=JPEG_QUALITY) cropped.save(data, pil_format(self.format), quality=JPEG_QUALITY)
@@ -396,7 +297,8 @@ class ConfirmedEmailManager(models.Manager):
external_photos = [] external_photos = []
if is_logged_in: if is_logged_in:
if gravatar := get_gravatar_photo(confirmed.email): gravatar = get_gravatar_photo(confirmed.email)
if gravatar:
external_photos.append(gravatar) external_photos.append(gravatar)
return (confirmed.pk, external_photos) return (confirmed.pk, external_photos)
@@ -416,8 +318,6 @@ class ConfirmedEmail(BaseAccountModel):
null=True, null=True,
on_delete=models.deletion.SET_NULL, on_delete=models.deletion.SET_NULL,
) )
# Alternative assignment - use Bluesky handle
bluesky_handle = models.CharField(max_length=256, null=True, blank=True)
digest = models.CharField(max_length=32) digest = models.CharField(max_length=32)
digest_sha256 = models.CharField(max_length=64) digest_sha256 = models.CharField(max_length=64)
objects = ConfirmedEmailManager() objects = ConfirmedEmailManager()
@@ -438,19 +338,6 @@ class ConfirmedEmail(BaseAccountModel):
self.photo = photo self.photo = photo
self.save() self.save()
def set_bluesky_handle(self, handle):
"""
Helper method to set Bluesky handle
"""
bs = Bluesky()
handle = bs.normalize_handle(handle)
avatar = bs.get_profile(handle)
if not avatar:
raise ValueError("Invalid Bluesky handle")
self.bluesky_handle = handle
self.save()
def save( def save(
self, force_insert=False, force_update=False, using=None, update_fields=None self, force_insert=False, force_update=False, using=None, update_fields=None
): ):
@@ -463,20 +350,6 @@ class ConfirmedEmail(BaseAccountModel):
self.digest_sha256 = hashlib.sha256( self.digest_sha256 = hashlib.sha256(
self.email.strip().lower().encode("utf-8") self.email.strip().lower().encode("utf-8")
).hexdigest() ).hexdigest()
# We need to manually expire the page caches
# TODO: Verify this works as expected
# First check if we already have an ID
if self.pk:
cache_url = reverse_lazy(
"assign_photo_email", kwargs={"email_id": int(self.pk)}
)
cache_key = f"views.decorators.cache.cache_page.{quote(str(cache_url))}"
if cache.has_key(cache_key):
cache.delete(cache_key)
logger.debug("Successfully cleaned up cached page: %s" % cache_key)
return super().save(force_insert, force_update, using, update_fields) return super().save(force_insert, force_update, using, update_fields)
def __str__(self): def __str__(self):
@@ -537,7 +410,7 @@ class UnconfirmedEmail(BaseAccountModel):
try: try:
send_mail(email_subject, email_body, DEFAULT_FROM_EMAIL, [self.email]) send_mail(email_subject, email_body, DEFAULT_FROM_EMAIL, [self.email])
except Exception as e: except Exception as e:
self.last_status = f"{e}" self.last_status = "%s" % e
self.save() self.save()
return True return True
@@ -586,8 +459,6 @@ class ConfirmedOpenId(BaseAccountModel):
alt_digest2 = models.CharField(max_length=64, null=True, blank=True, default=None) alt_digest2 = models.CharField(max_length=64, null=True, blank=True, default=None)
# https://<id> - https w/o trailing slash # https://<id> - https w/o trailing slash
alt_digest3 = models.CharField(max_length=64, null=True, blank=True, default=None) alt_digest3 = models.CharField(max_length=64, null=True, blank=True, default=None)
# Alternative assignment - use Bluesky handle
bluesky_handle = models.CharField(max_length=256, null=True, blank=True)
access_count = models.BigIntegerField(default=0, editable=False) access_count = models.BigIntegerField(default=0, editable=False)
@@ -606,25 +477,13 @@ class ConfirmedOpenId(BaseAccountModel):
self.photo = photo self.photo = photo
self.save() self.save()
def set_bluesky_handle(self, handle):
"""
Helper method to set Bluesky handle
"""
bs = Bluesky()
handle = bs.normalize_handle(handle)
avatar = bs.get_profile(handle)
if not avatar:
raise ValueError("Invalid Bluesky handle")
self.bluesky_handle = handle
self.save()
def save( def save(
self, force_insert=False, force_update=False, using=None, update_fields=None self, force_insert=False, force_update=False, using=None, update_fields=None
): ):
url = urlsplit(self.openid) url = urlsplit(self.openid)
if url.username: # pragma: no cover if url.username: # pragma: no cover
password = url.password or "" password = url.password or ""
netloc = f"{url.username}:{password}@{url.hostname}" netloc = url.username + ":" + password + "@" + url.hostname
else: else:
netloc = url.hostname netloc = url.hostname
lowercase_url = urlunsplit( lowercase_url = urlunsplit(
@@ -744,7 +603,9 @@ class DjangoOpenIDStore(OpenIDStore):
self.removeAssociation(server_url, assoc.handle) self.removeAssociation(server_url, assoc.handle)
else: else:
associations.append((association.issued, association)) associations.append((association.issued, association))
return associations[-1][1] if associations else None if not associations:
return None
return associations[-1][1]
@staticmethod @staticmethod
def removeAssociation(server_url, handle): # pragma: no cover def removeAssociation(server_url, handle): # pragma: no cover
@@ -797,6 +658,6 @@ class DjangoOpenIDStore(OpenIDStore):
""" """
Helper method to cleanup associations Helper method to cleanup associations
""" """
OpenIDAssociation.objects.extra( OpenIDAssociation.objects.extra( # pylint: disable=no-member
where=[f"issued + lifetimeint < ({time.time()})"] where=["issued + lifetimeint < (%s)" % time.time()]
).delete() ).delete()

View File

@@ -32,6 +32,8 @@ def read_gzdata(gzdata=None):
""" """
Read gzipped data file Read gzipped data file
""" """
emails = [] # pylint: disable=invalid-name
openids = [] # pylint: disable=invalid-name
photos = [] # pylint: disable=invalid-name photos = [] # pylint: disable=invalid-name
username = None # pylint: disable=invalid-name username = None # pylint: disable=invalid-name
password = None # pylint: disable=invalid-name password = None # pylint: disable=invalid-name
@@ -43,8 +45,8 @@ def read_gzdata(gzdata=None):
content = fh.read() content = fh.read()
fh.close() fh.close()
root = xml.etree.ElementTree.fromstring(content) root = xml.etree.ElementTree.fromstring(content)
if root.tag != "{%s}user" % SCHEMAROOT: if not root.tag == "{%s}user" % SCHEMAROOT:
print(f"Unknown export format: {root.tag}") print("Unknown export format: %s" % root.tag)
exit(-1) exit(-1)
# Username # Username
@@ -54,21 +56,23 @@ def read_gzdata(gzdata=None):
if item[0] == "password": if item[0] == "password":
password = item[1] password = item[1]
emails = [ # Emails
{"email": email.text, "photo_id": email.attrib["photo_id"]} for email in root.findall("{%s}emails" % SCHEMAROOT)[0]:
for email in root.findall("{%s}emails" % SCHEMAROOT)[0] if email.tag == "{%s}email" % SCHEMAROOT:
if email.tag == "{%s}email" % SCHEMAROOT emails.append({"email": email.text, "photo_id": email.attrib["photo_id"]})
]
openids = [ # OpenIDs
{"openid": openid.text, "photo_id": openid.attrib["photo_id"]} for openid in root.findall("{%s}openids" % SCHEMAROOT)[0]:
for openid in root.findall("{%s}openids" % SCHEMAROOT)[0] if openid.tag == "{%s}openid" % SCHEMAROOT:
if openid.tag == "{%s}openid" % SCHEMAROOT openids.append(
] {"openid": openid.text, "photo_id": openid.attrib["photo_id"]}
)
# Photos # Photos
for photo in root.findall("{%s}photos" % SCHEMAROOT)[0]: for photo in root.findall("{%s}photos" % SCHEMAROOT)[0]:
if photo.tag == "{%s}photo" % SCHEMAROOT: if photo.tag == "{%s}photo" % SCHEMAROOT:
try: try:
# Safety measures to make sure we do not try to parse # Safty measures to make sure we do not try to parse
# a binary encoded string # a binary encoded string
photo.text = photo.text.strip("'") photo.text = photo.text.strip("'")
photo.text = photo.text.strip("\\n") photo.text = photo.text.strip("\\n")
@@ -76,14 +80,26 @@ def read_gzdata(gzdata=None):
data = base64.decodebytes(bytes(photo.text, "utf-8")) data = base64.decodebytes(bytes(photo.text, "utf-8"))
except binascii.Error as exc: except binascii.Error as exc:
print( print(
f'Cannot decode photo; Encoding: {photo.attrib["encoding"]}, Format: {photo.attrib["format"]}, Id: {photo.attrib["id"]}: {exc}' "Cannot decode photo; Encoding: %s, Format: %s, Id: %s: %s"
% (
photo.attrib["encoding"],
photo.attrib["format"],
photo.attrib["id"],
exc,
)
) )
continue continue
try: try:
Image.open(BytesIO(data)) Image.open(BytesIO(data))
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
print( print(
f'Cannot decode photo; Encoding: {photo.attrib["encoding"]}, Format: {photo.attrib["format"]}, Id: {photo.attrib["id"]}: {exc}' "Cannot decode photo; Encoding: %s, Format: %s, Id: %s: %s"
% (
photo.attrib["encoding"],
photo.attrib["format"],
photo.attrib["id"],
exc,
)
) )
continue continue
else: else:

View File

@@ -12,24 +12,23 @@
{% endif %} {% endif %}
<div class="row"> <div class="row">
{% for photo in photos %} {% for photo in photos %}
<div class="panel panel-tortin" style="width:182px;float:left;margin-left:20px"> <div class="panel panel-tortin" style="width:182px;float:left;margin-left:20px">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="panel-title"> <h3 class="panel-title">
<input type="checkbox" name="photo_{{photo.service_name}}" id="photo_{{photo.service_name}}" checked="checked"> <input type="checkbox" name="photo_{{photo.service_name}}" id="photo_{{photo.service_name}}" checked="checked">
<label for="photo_{{photo.service_name}}" style="width:100%"> <label for="photo_{{photo.service_name}}" style="width:100%">
{{ photo.service_name }} {{ photo.service_name }}
{% if photo.service_url %} {% if photo.service_url %}
<a href="{{ photo.service_url }}" style="float:right;color:#FFFFFF"><i class="fa fa-external-link"></i></a> <a href="{{ photo.service_url }}" style="float:right;color:#FFFFFF"><i class="fa fa-external-link"></i></a>
{% endif %} {% endif %}
</label> </label>
</h3> </h3></div>
</div> <div class="panel-body">
<div class="panel-body"> <center>
<center> <img src="{{ photo.thumbnail_url }}" style="max-width: 80px; max-height: 80px;" alt="{{ photo.service_name }} image">
<img src="{{ photo.thumbnail_url }}" style="max-width: 80px; max-height: 80px;" alt="{{ photo.service_name }} image"> </center>
</center> </div>
</div> </div>
</div>
{% endfor %} {% endfor %}
</div> </div>
<p> <p>

View File

@@ -17,17 +17,15 @@
{% if form.email.errors %} {% if form.email.errors %}
<div class="alert alert-danger" role="alert">{{ form.email.errors }}</div> <div class="alert alert-danger" role="alert">{{ form.email.errors }}</div>
{% endif %} {% endif %}
<div class="form-container"> <div style="max-width:640px">
<form action="{% url 'add_email' %}" name="addemail" method="post" id="form-addemail"> <form action="{% url 'add_email' %}" name="addemail" method="post" id="form-addemail">
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
<label for="id_email" class="form-label">{% trans 'Email' %}</label> <label for="id_email">{% trans 'Email' %}:</label>
<input type="email" name="email" autofocus required class="form-control" id="id_email" placeholder="{% trans 'Enter your email address' %}"> <input type="text" name="email" autofocus required class="form-control" id="id_email">
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">{% trans 'Add' %}</button>
</div> </div>
<button type="submit" class="button">{% trans 'Add' %}</button>
</form> </form>
</div> </div>

View File

@@ -4,81 +4,65 @@
{% block title %}{% blocktrans with email.email as email_address %}Choose a photo for {{ email_address }}{% endblocktrans %}{% endblock title %} {% block title %}{% blocktrans with email.email as email_address %}Choose a photo for {{ email_address }}{% endblocktrans %}{% endblock title %}
{% block content %} {% block content %}
<style>
.nobutton {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
</style>
<h1>{% blocktrans with email.email as email_address %}Choose a photo for {{ email_address }}{% endblocktrans %}</h1> <h1>{% blocktrans with email.email as email_address %}Choose a photo for {{ email_address }}{% endblocktrans %}</h1>
{% if user.photo_set.count %} {% if not user.photo_set.count %}
<p>{% trans 'Here are the pictures you have uploaded, click on the one you wish to associate with this email address:' %}</p>
<div class="photo-grid"> {% url 'upload_photo' as upload_url %}
{% for photo in user.photo_set.all %} <h4>{% blocktrans %}You need to <a href="{{ upload_url }}">upload some photos</a> first!{% endblocktrans %}</h4>
<form action="{% url 'assign_photo_email' view.kwargs.email_id %}" method="post" class="photo-card">{% csrf_token %}
<input type="hidden" name="photo_id" value="{{ photo.id }}"> <p><a href="{% url 'profile' %}" class="button">{% trans 'Back to your profile' %}</a></p>
<button type="submit" name="photo{{ photo.id }}" class="nobutton">
<div class="panel panel-tortin"> {% else %}
<div class="panel-heading">
<h3 class="panel-title">{% if email.photo.id == photo.id %}<i class="fa fa-check"></i>{% endif %} {% trans 'Image' %} {{ forloop.counter }}</h3> <p>{% trans 'Here are the pictures you have uploaded, click on the one you wish to associate with this email address:' %}</p>
</div> <div class="row">
<div class="panel-body" style="height:130px"> {% for photo in user.photo_set.all %}
<center> <form action="{% url 'assign_photo_email' view.kwargs.email_id %}" method="post" style="float:left;margin-left:20px">{% csrf_token %}
<img style="max-height:100px;max-width:100px" src="{% url 'raw_image' photo.id %}"> <input type="hidden" name="photo_id" value="{{ photo.id }}">
</center> <button type="submit" name="photo{{ photo.id }}" class="nobutton">
</div> <div class="panel panel-tortin" style="width:132px;margin:0">
</div> <div class="panel-heading">
</button> <h3 class="panel-title">{% ifequal email.photo.id photo.id %}<i class="fa fa-check"></i>{% endifequal %} {% trans 'Image' %} {{ forloop.counter }}</h3>
</form> </div>
{% endfor %} <div class="panel-body" style="height:130px">
</div> <center>
<img style="max-height:100px;max-width:100px" src="{% url 'raw_image' photo.id %}">
</center>
</div>
</div>
</button>
</form>
{% endfor %}
<form action="{% url 'assign_photo_email' view.kwargs.email_id %}" method="post" style="float:left;margin-left:20px">{% csrf_token %}
<button type="submit" name="photoNone" class="nobutton">
<div class="panel panel-tortin" style="width:132px;margin:0">
<div class="panel-heading">
<h3 class="panel-title">{% ifequal email.photo.id photo.id %}<i class="fa fa-check"></i>{% endifequal %} {% trans 'No image' %}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
<img style="max-height:100px;max-width:100px" src="/static/img/nobody/100.png">
</center>
</div>
</div>
</button>
</form>
</div>
<div style="height:8px"></div>
<a href="{% url 'upload_photo' %}" class="button">{% blocktrans %}Upload a new one{% endblocktrans %}</a>&nbsp;&nbsp;
<a href="{% url 'import_photo' email.pk %}" class="button">{% blocktrans %}Import from other services{% endblocktrans %}</a>
{% endif %} {% endif %}
<div style="height:40px"></div>
<div class="photo-grid">
<form action="{% url 'assign_photo_email' view.kwargs.email_id %}" method="post" class="photo-card">{% csrf_token %}
<button type="submit" name="photoNone" class="nobutton">
<div class="panel panel-tortin">
<div class="panel-heading">
<h3 class="panel-title">{% if email.photo.id == photo.id %}{% if not email.bluesky_handle %}<i class="fa fa-check"></i>{% endif %}{% endif %} {% trans 'No image' %}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
<img style="max-height:100px;max-width:100px" src="/static/img/nobody/100.png">
</center>
</div>
</div>
</button>
</form>
{% if email.bluesky_handle %}
<form action="{% url 'assign_photo_email' view.kwargs.email_id %}" method="post" class="photo-card">{% csrf_token %}
<input type="hidden" name="photo_id" value="bluesky">
<button type="submit" name="photoBluesky" class="nobutton">
<div class="panel panel-tortin">
<div class="panel-heading">
<h3 class="panel-title">{% if email.bluesky_handle %}<i class="fa fa-check"></i>{% endif %} {% trans "Bluesky" %}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
<img style="max-height:100px;max-width:100px" src="{% url "blueskyproxy" email.digest %}?size=100">
</center>
</div>
</div>
</button>
</form>
{% endif %}
</div>
<div class="action-buttons">
<a href="{% url 'upload_photo' %}" class="btn btn-primary">{% blocktrans %}Upload a new one{% endblocktrans %}</a>
<a href="{% url 'import_photo' %}" class="btn btn-secondary">{% blocktrans %}Import from other services{% endblocktrans %}</a>
</div>
<div style="margin-top: 2rem;">
<form action="{% url 'assign_bluesky_handle_to_email' view.kwargs.email_id %}" method="post">{% csrf_token %}
<div class="form-group">
<label for="id_bluesky_handle">{% trans "Bluesky handle" %}:</label>
{% if email.bluesky_handle %}
<input type="text" name="bluesky_handle" required value="{{ email.bluesky_handle }}" class="form-control" id="id_bluesky_handle">
{% else %}
<input type="text" name="bluesky_handle" required value="" placeholder="{% trans 'Bluesky handle' %}" class="form-control" id="id_bluesky_handle">
{% endif %}
</div>
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky Handle' %}</button>
</form>
</div>
{% endblock content %} {% endblock content %}

View File

@@ -4,78 +4,65 @@
{% block title %}{% blocktrans with openid.openid as openid_address %}Choose a photo for {{ openid_address }}{% endblocktrans %}{% endblock title %} {% block title %}{% blocktrans with openid.openid as openid_address %}Choose a photo for {{ openid_address }}{% endblocktrans %}{% endblock title %}
{% block content %} {% block content %}
<style>
.nobutton {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
</style>
<h1>{% blocktrans with openid.openid as openid_address %}Choose a photo for {{ openid_address }}{% endblocktrans %}</h1> <h1>{% blocktrans with openid.openid as openid_address %}Choose a photo for {{ openid_address }}{% endblocktrans %}</h1>
{% if user.photo_set.count %} {% if not user.photo_set.count %}
<p>{% trans 'Here are the pictures you have uploaded, click on the one you wish to associate with this openid address:' %}</p>
<div class="photo-grid">
{% for photo in user.photo_set.all %}
<form action="{% url 'assign_photo_openid' view.kwargs.openid_id %}" method="post" class="photo-card">{% csrf_token %}
<input type="hidden" name="photo_id" value="{{ photo.id }}">
<button type="submit" name="photo{{ photo.id }}" class="nobutton">
<div class="panel panel-tortin">
<div class="panel-heading">
<h3 class="panel-title">{% if openid.photo.id == photo.id %}<i class="fa fa-check"></i>{% endif %} {% trans 'Image' %} {{ forloop.counter }}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
<img style="max-height:100px;max-width:100px" src="{% url 'raw_image' photo.id %}">
</center>
</div>
</div>
</button>
</form>
{% endfor %}
</div>
{% endif %}
<div class="photo-grid"> {% url 'upload_photo' as upload_url %}
<form action="{% url 'assign_photo_openid' view.kwargs.openid_id %}" method="post" class="photo-card">{% csrf_token %} <h3>{% blocktrans %}You need to <a href="{{ upload_url }}">upload some photos</a> first!{% endblocktrans %}</h3>
<button type="submit" name="photoNone" class="nobutton">
<div class="panel panel-tortin"> <p><a href="{% url 'profile' %}" class="button">{% trans 'Back to your profile' %}</a></p>
<div class="panel-heading">
<h3 class="panel-title">{% if not openid.photo and not openid.bluesky_handle %}<i class="fa fa-check"></i>{% endif %} {% trans 'No image' %}</h3> {% else %}
</div>
<div class="panel-body" style="height:130px"> <p>{% trans 'Here are the pictures you have uploaded, click on the one you wish to associate with this openid address:' %}</p>
<center> <div class="row">
{% for photo in user.photo_set.all %}
<form action="{% url 'assign_photo_openid' view.kwargs.openid_id %}" method="post" style="float:left;margin-left:20px">{% csrf_token %}
<input type="hidden" name="photo_id" value="{{ photo.id }}">
<button type="submit" name="photo{{ photo.id }}" class="nobutton">
<div class="panel panel-tortin" style="width:132px;margin:0">
<div class="panel-heading">
<h3 class="panel-title">{% ifequal openid.photo.id photo.id %}<i class="fa fa-check"></i>{% endifequal %} {% trans 'Image' %} {{ forloop.counter }}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
<img style="max-height:100px;max-width:100px" src="{% url 'raw_image' photo.id %}">
</center>
</div>
</div>
</button>
</form>
{% endfor %}
<form action="{% url 'assign_photo_openid' view.kwargs.openid_id %}" method="post" style="float:left;margin-left:20px">{% csrf_token %}
<button type="submit" name="photoNone" class="nobutton">
<div class="panel panel-tortin" style="width:132px;margin:0">
<div class="panel-heading">
<h3 class="panel-title">{% ifequal openid.photo.id photo.id %}<i class="fa fa-check"></i>{% endifequal %} {% trans 'No image' %}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
<img style="max-height:100px;max-width:100px" src="/static/img/nobody/100.png"> <img style="max-height:100px;max-width:100px" src="/static/img/nobody/100.png">
</center> </center>
</div>
</div>
</button>
</form>
{% if openid.bluesky_handle %}
<form action="" class="photo-card">
<div class="panel panel-tortin">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-check"></i> {% trans "Bluesky" %}</h3>
</div>
<div class="panel-body" style="height:130px">
<center>
<img style="max-height:100px;max-width:100px" src="{% url "blueskyproxy" openid.digest %}?size=100">
</center>
</div>
</div>
</form>
{% endif %}
</div> </div>
</div>
</button>
</form>
</div>
<div style="height:8px"></div>
<a href="{% url 'upload_photo' %}" class="button">{% blocktrans %}upload a new one{% endblocktrans %}</a>
<div class="action-buttons"> {% endif %}
<a href="{% url 'upload_photo' %}" class="btn btn-primary">{% blocktrans %}upload a new one{% endblocktrans %}</a> <div style="height:40px"></div>
<a href="{% url 'import_photo' %}" class="btn btn-secondary">{% blocktrans %}Import from other services{% endblocktrans %}</a>
</div>
<div style="margin-top: 2rem;">
<form action="{% url 'assign_bluesky_handle_to_openid' view.kwargs.openid_id %}" method="post">{% csrf_token %}
<div class="form-group">
<label for="id_bluesky_handle">{% trans "Bluesky handle" %}:</label>
{% if openid.bluesky_handle %}
<input type="text" name="bluesky_handle" required value="{{ openid.bluesky_handle }}" class="form-control" id="id_bluesky_handle">
{% else %}
<input type="text" name="bluesky_handle" required value="" placeholder="{% trans 'Bluesky handle' %}" class="form-control" id="id_bluesky_handle">
{% endif %}
</div>
<button type="submit" class="btn btn-primary">{% trans 'Assign Bluesky Handle' %}</button>
</form>
</div>
{% endblock content %} {% endblock content %}

View File

@@ -1,318 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% load bootstrap4 %}
{% block title %}{% trans 'Avatar Gallery' %}{% endblock %}
{% block extra_css %}
<style>
.avatar-gallery-card {
transition: all 0.3s ease;
border: 1px solid #e9ecef;
}
.avatar-gallery-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
border-color: #007bff;
}
.avatar-image-container {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
}
.avatar-image {
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.gallery-section {
margin-bottom: 3rem;
}
.gallery-section h4 {
color: #495057;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
margin-bottom: 20px;
}
/* Fix button display issues - override base CSS */
.btn {
display: inline-block !important;
padding: 0.375rem 0.75rem !important;
margin-bottom: 0;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
text-align: center;
text-decoration: none;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: 0.25rem;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
min-width: auto !important;
}
.btn-sm {
padding: 0.25rem 0.5rem !important;
font-size: 0.875rem;
border-radius: 0.2rem;
min-width: auto !important;
}
.btn-outline-primary {
color: #007bff !important;
border-color: #007bff !important;
background-color: transparent !important;
}
.btn-outline-primary:hover {
color: #fff !important;
background-color: #007bff !important;
border-color: #007bff !important;
}
.btn-outline-secondary {
color: #6c757d !important;
border-color: #6c757d !important;
background-color: transparent !important;
}
.btn-outline-secondary:hover {
color: #fff !important;
background-color: #6c757d !important;
border-color: #6c757d !important;
}
/* Ensure FontAwesome icons display */
.fa {
font-family: "FontAwesome" !important;
font-weight: normal;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Suppress Firefox font warnings */
@font-face {
font-family: "FontAwesome";
src: url("../fonts/fontawesome-webfont.woff2?v=4.7.0") format("woff2"),
url("../fonts/fontawesome-webfont.woff?v=4.7.0") format("woff"),
url("../fonts/fontawesome-webfont.ttf?v=4.7.0") format("truetype"),
url("../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular") format("svg");
font-weight: normal;
font-style: normal;
font-display: swap;
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-12">
<h2>{% trans 'Avatar Gallery' %}</h2>
<p class="lead">
{% trans 'Browse recently generated avatars for inspiration. Click on any avatar to reuse its prompt.' %}
</p>
<!-- User's Own Avatars -->
{% if user_avatars %}
<div class="mb-4 gallery-section">
<h4><i class="fa fa-user"></i> {% trans 'Your Recent Avatars' %}</h4>
<div class="row">
{% for avatar in user_avatars %}
<div class="col-md-3 col-sm-4 col-6 mb-3">
<div class="card h-100 avatar-gallery-card">
<div class="card-body p-2">
<div class="avatar-image-container text-center d-flex justify-content-center align-items-center">
<img src="{% url 'raw_image' avatar.pk %}"
alt="Avatar"
class="img-fluid rounded avatar-image"
style="width: 120px; height: 120px; object-fit: contain;">
</div>
<div class="card-text">
<small class="text-muted">
{{ avatar.add_date|date:"M d, Y" }}
</small>
<p class="small mb-2" style="height: 40px; overflow: hidden;">
{{ avatar.ai_prompt|truncatechars:60 }}
</p>
<div class="d-flex justify-content-between">
<a href="{% url 'reuse_prompt' avatar.pk %}"
class="btn btn-sm btn-outline-primary">
<i class="fa fa-recycle"></i> {% trans 'Reuse' %}
</a>
<a href="{% url 'avatar_preview' avatar.pk %}"
class="btn btn-sm btn-outline-secondary">
<i class="fa fa-eye"></i> {% trans 'View' %}
</a>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Community Gallery -->
<div class="mb-4 gallery-section">
<h4><i class="fa fa-users"></i> {% trans 'Community Gallery' %}</h4>
<p class="text-muted">
{% trans 'Recently generated avatars from all users (last 30)' %}
</p>
{% if avatars %}
<div class="row">
{% for avatar in avatars %}
<div class="col-md-3 col-sm-4 col-6 mb-3">
<div class="card h-100 avatar-gallery-card">
<div class="card-body p-2">
<div class="avatar-image-container text-center d-flex justify-content-center align-items-center">
<img src="{% url 'raw_image' avatar.pk %}"
alt="Avatar"
class="img-fluid rounded avatar-image"
style="width: 120px; height: 120px; object-fit: contain;">
</div>
<div class="card-text">
<small class="text-muted">
{{ avatar.add_date|date:"M d, Y" }}
{% if avatar.user == user %}
<span class="badge badge-info">{% trans 'Yours' %}</span>
{% endif %}
</small>
<p class="small mb-2" style="height: 40px; overflow: hidden;">
{{ avatar.ai_prompt|truncatechars:60 }}
</p>
<div class="d-flex justify-content-between">
<a href="{% url 'reuse_prompt' avatar.pk %}"
class="btn btn-sm btn-outline-primary">
<i class="fa fa-recycle"></i> {% trans 'Reuse' %}
</a>
<a href="{% url 'avatar_preview' avatar.pk %}"
class="btn btn-sm btn-outline-secondary">
<i class="fa fa-eye"></i> {% trans 'View' %}
</a>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if is_paginated %}
<nav aria-label="Gallery pagination">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1">{% trans 'First' %}</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">{% trans 'Previous' %}</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">
{% trans 'Page' %} {{ page_obj.number }} {% trans 'of' %} {{ page_obj.paginator.num_pages }}
</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}">{% trans 'Next' %}</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">{% trans 'Last' %}</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="fa fa-image fa-3x text-muted mb-3"></i>
<h5>{% trans 'No Avatars Yet' %}</h5>
<p class="text-muted">
{% trans 'No AI-generated avatars have been created yet. Be the first to generate one!' %}
</p>
<a href="{% url 'generate_avatar' %}" class="btn btn-primary">
<i class="fa fa-magic"></i> {% trans 'Generate Avatar' %}
</a>
</div>
{% endif %}
</div>
<!-- Action Buttons -->
<div class="text-center mb-4">
<a href="{% url 'generate_avatar' %}" class="btn btn-primary btn-lg">
<i class="fa fa-magic"></i> {% trans 'Generate New Avatar' %}
</a>
<a href="{% url 'profile' %}" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
</a>
</div>
<!-- Tips -->
<div class="mt-4">
<h5>{% trans 'Gallery Tips' %}</h5>
<ul class="list-unstyled">
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Click "Reuse" to copy a prompt and modify it for your own avatar' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Click "View" to see the full avatar and assign it to your emails' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Use the gallery to find inspiration for your own avatar descriptions' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Your own avatars are shown at the top for quick access' %}
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add click animation to reuse buttons
const reuseButtons = document.querySelectorAll('a[href*="reuse_prompt"]');
reuseButtons.forEach(button => {
button.addEventListener('click', function() {
this.innerHTML = '<i class="fa fa-spinner fa-spin"></i> {% trans "Loading..." %}';
this.classList.add('disabled');
});
});
// Add click animation to view buttons
const viewButtons = document.querySelectorAll('a[href*="avatar_preview"]');
viewButtons.forEach(button => {
button.addEventListener('click', function() {
this.innerHTML = '<i class="fa fa-spinner fa-spin"></i> {% trans "Loading..." %}';
this.classList.add('disabled');
});
});
});
</script>
{% endblock %}

View File

@@ -1,198 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% load bootstrap4 %}
{% block title %}{% trans 'Avatar Preview' %}{% endblock %}
{% block extra_css %}
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-8 offset-md-2">
<h2>{% trans 'Avatar Preview' %}</h2>
<p class="lead">
{% trans 'Here\'s your generated avatar. You can refine it or assign it to your email addresses.' %}
</p>
<!-- Avatar Display -->
<div class="card mb-4">
<div class="card-body text-center">
<h4>{% trans 'Generated Avatar' %}</h4>
<div class="avatar-preview-container mb-3">
<img src="{{ photo_url }}" alt="Generated Avatar" class="img-fluid rounded" style="max-width: 400px; max-height: 400px;">
</div>
<p class="text-muted">
{% trans 'Generated on' %} {{ photo.add_date|date:"F d, Y \a\t H:i" }}
</p>
</div>
</div>
<!-- Refinement Form -->
<div class="card mb-4">
<div class="card-header">
<h5><i class="fa fa-magic"></i> {% trans 'Refine Avatar' %}</h5>
</div>
<div class="card-body">
<p class="text-muted">
{% trans 'Not satisfied with the result? Modify your description and generate a new avatar.' %}
</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<!-- Custom form rendering for better control -->
<div class="form-group">
<label for="id_prompt">{{ form.prompt.label }}</label>
<textarea name="prompt"
id="id_prompt"
class="form-control"
rows="3"
maxlength="500"
data-token-limit="77"
data-model="stable_diffusion"
placeholder="{{ form.prompt.field.widget.attrs.placeholder }}"
required>{{ form.prompt.value|default:'' }}</textarea>
<small class="form-text text-muted">
{{ form.prompt.help_text|safe }}
</small>
</div>
<div class="form-group">
<label for="id_model">{{ form.model.label }}</label>
<select name="model" id="id_model" class="form-control">
{% for value, label in form.model.field.choices %}
<option value="{{ value }}" {% if value == form.model.value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">{{ form.model.help_text }}</small>
</div>
<div class="form-group">
<label for="id_quality">{{ form.quality.label }}</label>
<select name="quality" id="id_quality" class="form-control">
{% for value, label in form.quality.field.choices %}
<option value="{{ value }}" {% if value == form.quality.value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">{{ form.quality.help_text }}</small>
</div>
<div class="form-group">
<label for="id_not_porn">{{ form.not_porn.label }}</label>
<input type="checkbox" name="not_porn" class="form-control" required id="id_not_porn" {% if form.not_porn.value %}checked{% endif %}>
</div>
<div class="form-group">
<label for="id_can_distribute">{{ form.can_distribute.label }}</label>
<input type="checkbox" name="can_distribute" class="form-control" required id="id_can_distribute" {% if form.can_distribute.value %}checked{% endif %}>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">
<i class="fa fa-refresh"></i> {% trans 'Regenerate Avatar' %}
</button>
</div>
</form>
</div>
</div>
<!-- Assignment Options -->
{% if confirmed_emails %}
<div class="card mb-4">
<div class="card-header">
<h5><i class="fa fa-envelope"></i> {% trans 'Assign to Email Addresses' %}</h5>
</div>
<div class="card-body">
<p class="text-muted">
{% trans 'Assign this avatar to one or more of your confirmed email addresses.' %}
</p>
<div class="list-group">
{% for email in confirmed_emails %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ email.email }}</strong>
{% if email.photo_id == photo.pk %}
<span class="badge badge-success ml-2">{% trans 'Currently assigned' %}</span>
{% endif %}
</div>
<div>
{% if email.photo_id != photo.pk %}
<a href="{% url 'assign_photo_email' email.pk %}?photo_id={{ photo.pk }}"
class="btn btn-sm btn-outline-primary">
<i class="fa fa-link"></i> {% trans 'Assign' %}
</a>
{% else %}
<span class="text-success">
<i class="fa fa-check"></i> {% trans 'Assigned' %}
</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<div class="card mb-4">
<div class="card-body text-center">
<h5>{% trans 'No Email Addresses' %}</h5>
<p class="text-muted">
{% trans 'You need to add and confirm email addresses before you can assign avatars to them.' %}
</p>
<a href="{% url 'add_email' %}" class="btn btn-primary">
<i class="fa fa-plus"></i> {% trans 'Add Email Address' %}
</a>
</div>
</div>
{% endif %}
<!-- Action Buttons -->
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<a href="{% url 'generate_avatar' %}" class="btn btn-outline-primary btn-block">
<i class="fa fa-plus"></i> {% trans 'Generate New Avatar' %}
</a>
</div>
<div class="col-md-6">
<a href="{% url 'profile' %}" class="btn btn-outline-secondary btn-block">
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
</a>
</div>
</div>
</div>
</div>
<!-- Tips -->
<div class="mt-4">
<h5>{% trans 'Tips for Better Results' %}</h5>
<ul class="list-unstyled">
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Be more specific: "A friendly robot with blue LED eyes and silver metallic body"' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Add style keywords: "cartoon style", "realistic", "anime", "pixel art"' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Include mood: "cheerful", "serious", "mysterious", "professional"' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Specify lighting: "soft lighting", "dramatic shadows", "bright and clear"' %}
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% endblock %}

View File

@@ -1,4 +1,4 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %} {% load i18n %}
{% load static %} {% load static %}
@@ -27,7 +27,7 @@
<button type="submit" class="btn btn-danger">{% trans 'Yes, delete all of my stuff' %}</button> <button type="submit" class="btn btn-danger">{% trans 'Yes, delete all of my stuff' %}</button>
&nbsp; &nbsp;
<a href="{% url 'profile' %}" class="btn btn-secondary">{% trans 'Cancel' %}</a> <button type="cancel" class="button" href="{% url 'profile' %}">{% trans 'Cancel' %}</button>
</form> </form>

View File

@@ -1,145 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% load bootstrap4 %}
{% block title %}{% trans 'Generate AI Avatar' %}{% endblock %}
{% block extra_css %}
{% endblock %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md-8 offset-md-2">
<h2>{% trans 'Generate AI Avatar' %}</h2>
<p class="lead">
{% trans 'Create a unique avatar using artificial intelligence. Describe what you want and our AI will generate it for you.' %}
</p>
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<!-- Custom form rendering for better control -->
<div class="form-group">
<label for="id_prompt">{{ form.prompt.label }}</label>
<textarea name="prompt"
id="id_prompt"
class="form-control"
rows="3"
maxlength="500"
data-token-limit="77"
data-model="stable_diffusion"
placeholder="{{ form.prompt.field.widget.attrs.placeholder }}"
required>{{ form.prompt.value|default:'' }}</textarea>
<small class="form-text text-muted">
{{ form.prompt.help_text|safe }}
</small>
</div>
<div class="form-group">
<label for="id_model">{{ form.model.label }}</label>
<select name="model" id="id_model" class="form-control">
{% for value, label in form.model.field.choices %}
<option value="{{ value }}" {% if value == form.model.value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">{{ form.model.help_text }}</small>
</div>
<div class="form-group">
<label for="id_quality">{{ form.quality.label }}</label>
<select name="quality" id="id_quality" class="form-control">
{% for value, label in form.quality.field.choices %}
<option value="{{ value }}" {% if value == form.quality.value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">{{ form.quality.help_text }}</small>
</div>
<div class="form-group">
<label for="id_not_porn">{{ form.not_porn.label }}</label>
<input type="checkbox" name="not_porn" class="form-control" required id="id_not_porn" {% if form.not_porn.value %}checked{% endif %}>
</div>
<div class="form-group">
<label for="id_can_distribute">{{ form.can_distribute.label }}</label>
<input type="checkbox" name="can_distribute" class="form-control" required id="id_can_distribute" {% if form.can_distribute.value %}checked{% endif %}>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fa fa-magic"></i> {% trans 'Generate Avatar' %}
</button>
<a href="{% url 'profile' %}" class="btn btn-secondary btn-lg">
{% trans 'Cancel' %}
</a>
</div>
</form>
</div>
</div>
<div class="mt-4">
<h4>{% trans 'Tips for Better Results' %}</h4>
<ul class="list-unstyled">
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Be specific about appearance, style, and mood' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Include details like hair color, clothing, or facial expressions' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Try different art styles: "cartoon", "realistic", "anime", "pixel art"' %}
</li>
<li><i class="fa fa-lightbulb-o text-warning"></i>
{% trans 'Keep descriptions appropriate for all ages' %}
</li>
</ul>
</div>
<div class="mt-4">
<h4>{% trans 'Example Prompts' %}</h4>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h6>{% trans 'Character Avatar' %}</h6>
<p class="text-muted small">
"A friendly robot with blue eyes and a silver body, cartoon style"
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h6>{% trans 'Abstract Avatar' %}</h6>
<p class="text-muted small">
"Colorful geometric shapes forming a face, modern art style"
</p>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4 alert alert-info">
<h5><i class="fa fa-info-circle"></i> {% trans 'Important Notes' %}</h5>
<ul class="mb-0">
<li>{% trans 'Avatar generation may take 30-60 seconds depending on server load' %}</li>
<li>{% trans 'Generated avatars are automatically saved to your account' %}</li>
<li>{% trans 'You can assign the generated avatar to any of your email addresses' %}</li>
<li>{% trans 'All generated content must be appropriate for all ages' %}</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% endblock %}

View File

@@ -1,405 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% load static %}
{% block title %}{% trans 'Avatar Generation Status' %}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-8 offset-md-2">
<h2>{% trans 'Avatar Generation Status' %}</h2>
<div class="card mb-4">
<div class="card-header">
<h5><i class="fa fa-magic"></i> {% trans 'Generation Task' %}</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>{% trans 'Prompt:' %}</strong></p>
<p class="text-muted">{{ task.prompt }}</p>
</div>
<div class="col-md-6">
<p><strong>{% trans 'Model:' %}</strong> {{ task.model|title }}</p>
<p><strong>{% trans 'Quality:' %}</strong> {{ task.quality|title }}</p>
<p><strong>{% trans 'Status:' %}</strong>
<span class="badge badge-{% if task.status == 'completed' %}success{% elif task.status == 'failed' %}danger{% elif task.status == 'processing' %}warning{% else %}secondary{% endif %} status-badge">
{{ task.get_status_display }}
{% if task.status == 'processing' %}
<i class="fa fa-spinner fa-spin ml-1"></i>
{% elif task.status == 'pending' %}
<i class="fa fa-clock ml-1"></i>
{% endif %}
</span>
<span class="live-indicator ml-2">
<i class="fa fa-circle text-success"></i>
<small class="text-muted">Live</small>
</span>
</p>
</div>
</div>
{% if task.status == 'processing' %}
<div class="mt-3">
<div class="progress-container">
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: {{ task.progress }}%"
aria-valuenow="{{ task.progress }}"
aria-valuemin="0"
aria-valuemax="100">
{{ task.progress }}%
</div>
</div>
<div class="progress-info mt-2">
<div class="row">
<div class="col-md-6">
<small class="text-muted">
<i class="fa fa-magic"></i> {% trans 'Generating your avatar...' %}
</small>
</div>
<div class="col-md-6 text-right">
<small class="text-muted">
<i class="fa fa-refresh fa-spin"></i>
<span class="last-updated">{% trans 'Updated just now' %}</span>
</small>
</div>
</div>
</div>
</div>
</div>
{% elif task.status == 'pending' %}
<div class="mt-3">
<div class="alert alert-info">
<i class="fa fa-clock"></i>
{% trans 'Your avatar is in the queue. Position:' %} <strong>{{ queue_position }}</strong>
{% if queue_length > 1 %}
{% trans 'out of' %} {{ queue_length }} {% trans 'tasks' %}
{% endif %}
</div>
<div class="progress">
<div class="progress-bar" role="progressbar" style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
0%
</div>
</div>
<small class="text-muted">{% trans 'Waiting in queue...' %}</small>
</div>
{% elif task.status == 'completed' %}
<div class="mt-3">
<div class="alert alert-success">
<i class="fa fa-check-circle"></i>
{% trans 'Avatar generated successfully!' %}
</div>
{% if task.generated_photo %}
<div class="text-center">
<a href="{% url 'avatar_preview' task.generated_photo.pk %}" class="btn btn-primary btn-lg">
<i class="fa fa-eye"></i> {% trans 'View Avatar' %}
</a>
</div>
{% endif %}
</div>
{% elif task.status == 'failed' %}
<div class="mt-3">
<div class="alert alert-danger">
<i class="fa fa-exclamation-triangle"></i>
{% trans 'Avatar generation failed.' %}
{% if task.error_message %}
<br><small>{{ task.error_message }}</small>
{% endif %}
</div>
<div class="text-center">
<a href="{% url 'generate_avatar' %}" class="btn btn-primary">
<i class="fa fa-redo"></i> {% trans 'Try Again' %}
</a>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Queue Information -->
<div class="card mb-4">
<div class="card-header">
<h5><i class="fa fa-list"></i> {% trans 'Queue Information' %}</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="text-center">
<h3 class="text-primary">{{ processing_count }}</h3>
<p class="text-muted">{% trans 'Currently Processing' %}</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<h3 class="text-warning">{{ queue_length }}</h3>
<p class="text-muted">{% trans 'Pending Tasks' %}</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center">
<h3 class="text-info">2</h3>
<p class="text-muted">{% trans 'Max Parallel Jobs' %}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Your Recent Tasks -->
{% if user_tasks %}
<div class="card mb-4">
<div class="card-header">
<h5><i class="fa fa-history"></i> {% trans 'Your Recent Tasks' %}</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>{% trans 'Prompt' %}</th>
<th>{% trans 'Status' %}</th>
<th>{% trans 'Created' %}</th>
<th>{% trans 'Actions' %}</th>
</tr>
</thead>
<tbody>
{% for user_task in user_tasks %}
<tr>
<td>
<span class="text-truncate d-inline-block" style="max-width: 200px;" title="{{ user_task.prompt }}">
{{ user_task.prompt }}
</span>
</td>
<td>
<span class="badge badge-{% if user_task.status == 'completed' %}success{% elif user_task.status == 'failed' %}danger{% elif user_task.status == 'processing' %}warning{% else %}secondary{% endif %}">
{{ user_task.get_status_display }}
</span>
</td>
<td>{{ user_task.add_date|date:"M d, H:i" }}</td>
<td>
{% if user_task.status == 'completed' and user_task.generated_photo %}
<a href="{% url 'avatar_preview' user_task.generated_photo.pk %}" class="btn btn-sm btn-outline-primary">
<i class="fa fa-eye"></i>
</a>
{% elif user_task.status == 'failed' %}
<a href="{% url 'generate_avatar' %}" class="btn btn-sm btn-outline-secondary">
<i class="fa fa-redo"></i>
</a>
{% elif user_task.status == 'pending' or user_task.status == 'processing' %}
<a href="{% url 'generation_status' user_task.pk %}" class="btn btn-sm btn-outline-info">
<i class="fa fa-info-circle"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="text-center mt-4">
<a href="{% url 'generate_avatar' %}" class="btn btn-primary btn-lg">
<i class="fa fa-plus-circle"></i> {% trans 'Generate New Avatar' %}
</a>
<a href="{% url 'profile' %}" class="btn btn-secondary btn-lg">
<i class="fa fa-user"></i> {% trans 'Back to Profile' %}
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const taskId = {{ task.pk }};
let refreshInterval;
function updateStatus() {
fetch(`/accounts/api/task_status/${taskId}/`)
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('Error fetching status:', data.error);
return;
}
// Update status badge with live indicators
const statusBadge = document.querySelector('.status-badge');
if (statusBadge) {
statusBadge.textContent = data.status_display;
statusBadge.className = 'badge badge-' +
(data.status === 'completed' ? 'success' :
data.status === 'failed' ? 'danger' :
data.status === 'processing' ? 'warning' : 'secondary') + ' status-badge';
// Add appropriate icons
const existingIcon = statusBadge.querySelector('i');
if (existingIcon) existingIcon.remove();
if (data.status === 'processing') {
statusBadge.innerHTML += ' <i class="fa fa-spinner fa-spin ml-1"></i>';
} else if (data.status === 'pending') {
statusBadge.innerHTML += ' <i class="fa fa-clock ml-1"></i>';
}
}
// Update live indicator
const liveIndicator = document.querySelector('.live-indicator i');
if (liveIndicator) {
liveIndicator.classList.remove('text-success', 'text-warning', 'text-danger');
if (data.status === 'completed') {
liveIndicator.classList.add('text-success');
} else if (data.status === 'failed') {
liveIndicator.classList.add('text-danger');
} else {
liveIndicator.classList.add('text-warning');
}
}
// Update last updated timestamp
const lastUpdated = document.querySelector('.last-updated');
if (lastUpdated) {
const now = new Date();
const timeString = now.toLocaleTimeString();
lastUpdated.textContent = `Updated at ${timeString}`;
}
// Update progress bar
const progressBar = document.querySelector('.progress-bar');
if (progressBar) {
progressBar.style.width = data.progress + '%';
progressBar.setAttribute('aria-valuenow', data.progress);
progressBar.textContent = data.progress + '%';
}
// Update queue information
const queueInfo = document.querySelector('.alert-info');
if (queueInfo && data.status === 'pending') {
queueInfo.innerHTML = `
<i class="fa fa-clock"></i>
Your avatar is in the queue. Position: <strong>${data.queue_position}</strong>
${data.queue_length > 1 ? `out of ${data.queue_length} tasks` : ''}
`;
}
// Update queue stats
const processingCount = document.querySelector('.text-primary');
const pendingCount = document.querySelector('.text-warning');
if (processingCount) processingCount.textContent = data.processing_count;
if (pendingCount) pendingCount.textContent = data.queue_length;
// Handle completion
if (data.status === 'completed') {
clearInterval(refreshInterval);
if (data.generated_photo_id) {
// Redirect to avatar preview
setTimeout(() => {
window.location.href = `/accounts/avatar_preview/${data.generated_photo_id}/`;
}, 2000);
}
}
// Handle failure
if (data.status === 'failed') {
clearInterval(refreshInterval);
const errorDiv = document.querySelector('.alert-danger');
if (errorDiv && data.error_message) {
errorDiv.innerHTML = `
<i class="fa fa-exclamation-triangle"></i>
Avatar generation failed.
<br><small>${data.error_message}</small>
`;
}
}
})
.catch(error => {
console.error('Error:', error);
});
}
// Start auto-refresh if task is pending or processing
const taskStatus = '{{ task.status }}';
if (taskStatus === 'pending' || taskStatus === 'processing') {
refreshInterval = setInterval(updateStatus, 1000); // Update every 1 second for live updates
}
// Add visual feedback for status changes
const statusBadge = document.querySelector('.badge');
if (statusBadge && taskStatus === 'processing') {
statusBadge.classList.add('pulse');
}
});
</script>
<style>
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.progress-bar-animated {
animation: progress-bar-stripes 1s linear infinite;
}
.live-indicator i {
animation: live-pulse 2s infinite;
}
@keyframes live-pulse {
0% { opacity: 1; }
50% { opacity: 0.3; }
100% { opacity: 1; }
}
.status-badge {
transition: all 0.3s ease;
}
.progress-container {
position: relative;
}
.progress-info {
font-size: 0.875rem;
}
.last-updated {
font-weight: 500;
color: #6c757d;
}
/* Enhanced progress bar */
.progress {
height: 25px;
background-color: #e9ecef;
border-radius: 12px;
overflow: hidden;
}
.progress-bar {
background: linear-gradient(45deg, #007bff, #0056b3);
border-radius: 12px;
font-weight: 600;
font-size: 0.875rem;
line-height: 25px;
}
@keyframes progress-bar-stripes {
0% { background-position: 1rem 0; }
100% { background-position: 0 0; }
}
</style>
{% endblock %}

View File

@@ -5,37 +5,37 @@
{% block content %} {% block content %}
<style> <style>
input[type=checkbox] {display:none} input[type=checkbox] {display:none}
input[type=checkbox] + label:before { input[type=checkbox] + label:before {
font-family: FontAwesome; font-family: FontAwesome;
display: inline-block; display: inline-block;
} }
input[type=checkbox] + label:before {content: "\f096"} input[type=checkbox] + label:before {content: "\f096"}
input[type=checkbox] + label:before {letter-spacing: 5px} input[type=checkbox] + label:before {letter-spacing: 5px}
input[type=checkbox]:checked + label:before {content: "\f046"} input[type=checkbox]:checked + label:before {content: "\f046"}
input[type=checkbox]:checked + label:before {letter-spacing: 3px} input[type=checkbox]:checked + label:before {letter-spacing: 3px}
</style> </style>
<h1>{% trans 'Import photo' %}</h1> <h1>{% trans 'Import photo' %}</h1>
{% if not email_id %} {% if not email_id %}
<div style="max-width:640px"> <div style="max-width:640px">
<form action="{% url 'import_photo' %}" method="get" id="check_mail_form"> <form action="{% url 'import_photo' %}" method="get" id="check_mail_form">
<div class="form-group"> <div class="form-group">
<label for="check_email_addr">{% trans 'Email Address' %}</label> <label for="check_email_addr">{% trans 'Email Address' %}</label>
<input type="text" name="check_email_addr" class="form-control" value="{{ email_addr }}"> <input type="text" name="check_email_addr" class="form-control" value="{{ email_addr }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<button type="submit" class="button">{% trans 'Check' %}</button> <button type="submit" class="button">{% trans 'Check' %}</button>
</div> </div>
</form> </form>
<script> <script>
document.getElementById('check_mail_form').onsubmit = document.getElementById('check_mail_form').onsubmit =
function(self) { function(self) {
window.location.href = "{% url 'import_photo' %}" + document.getElementsByName('check_email_addr')[0].value; window.location.href = "{% url 'import_photo' %}" + document.getElementsByName('check_email_addr')[0].value;
return false; return false;
}; };
</script> </script>
</div> </div>
{% endif %} {% endif %}
{% include '_import_photo_form.html' %} {% include '_import_photo_form.html' %}

View File

@@ -18,28 +18,24 @@
{% if form.password.errors %} {% if form.password.errors %}
<div class="alert alert-danger" role="alert">{{ form.password.errors }}</div> <div class="alert alert-danger" role="alert">{{ form.password.errors }}</div>
{% endif %} {% endif %}
<div class="form-container"> <div style="max-width:700px">
<form action="{% url 'login' %}" method="post" name="login"> <form action="{% url 'login' %}" method="post" name="login">
{% csrf_token %} {% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<div class="form-group"> <div class="form-group">
<label for="id_username" class="form-label">{% trans 'Username' %}</label> <label for="id_username">{% trans 'Username' %}:</label>
<input type="text" name="username" autofocus required class="form-control" id="id_username" placeholder="{% trans 'Enter your username' %}"> <input type="text" name="username" autofocus required class="form-control" id="id_username">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="id_password" class="form-label">{% trans 'Password' %}</label> <label for="id_password">{% trans 'Password' %}:</label>
<input type="password" name="password" class="form-control" required id="id_password" placeholder="{% trans 'Enter your password' %}"> <input type="password" name="password" class="form-control" required id="id_password">
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">{% trans 'Login' %}</button>
<a href="{% url 'openid-login' %}" class="btn btn-secondary">{% trans 'Login with OpenID' %}</a>
{% if with_fedora %}
<a href="{% url "social:begin" "fedora" %}" class="btn btn-secondary">{% trans 'Login with Fedora' %}</a>
{% endif %}
<a href="{% url 'new_account' %}" class="btn btn-secondary">{% trans 'Create new user' %}</a>
<a href="{% url 'password_reset' %}" class="btn btn-secondary">{% trans 'Password reset' %}</a>
</div> </div>
<button type="submit" class="button">{% trans 'Login' %}</button>
&nbsp;
<a href="{% url 'openid-login' %}" class="button">{% trans 'Login with OpenID' %}</a>
&nbsp;
<a href="{% url 'new_account' %}" class="button">{% trans 'Create new user' %}</a>
&nbsp;
<a href="{% url 'password_reset' %}" class="button">{% trans 'Password reset' %}</a>
</form> </form>
</div> </div>
<div style="height:40px"></div> <div style="height:40px"></div>

View File

@@ -1,4 +1,4 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load i18n %} {% load i18n %}
{% block title %}{% trans 'Create a new ivatar account' %}{% endblock title %} {% block title %}{% trans 'Create a new ivatar account' %}{% endblock title %}
@@ -16,25 +16,22 @@
{% if form.password2.errors %} {% if form.password2.errors %}
<div class="alert alert-danger" role="alert">{{ form.password2.errors }}</div> <div class="alert alert-danger" role="alert">{{ form.password2.errors }}</div>
{% endif %} {% endif %}
<div class="form-container">
<form action="{% url 'new_account' %}" method="post" name="newaccount"> <form action="{% url 'new_account' %}" method="post" name="newaccount">
{% csrf_token %} {% csrf_token %}
<div style="max-width:640px">
<div class="form-group"> <div class="form-group">
<label for="id_username" class="form-label">{% trans 'Username' %}</label> <label for="id_username">{% trans 'Username' %}:</label>
<input type="text" name="username" autofocus required class="form-control" id="id_username" placeholder="{% trans 'Choose a username' %}"> <input type="text" name="username" autofocus required class="form-control" id="id_username">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="id_password1" class="form-label">{% trans 'Password' %}</label> <label for="id_password1">{% trans 'Password' %}:</label>
<input type="password" name="password1" class="form-control" required id="id_password1" placeholder="{% trans 'Enter a secure password' %}"> <input type="password" name="password1" class="form-control" required id="id_password1">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="id_password2" class="form-label">{% trans 'Password confirmation' %}</label> <label for="id_password2">{% trans 'Password confirmation' %}:</label>
<input type="password" name="password2" class="form-control" required id="id_password2" placeholder="{% trans 'Confirm your password' %}"> <input type="password" name="password2" class="form-control" required id="id_password2">
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">{% trans 'Create account' %}</button>
<a href="/accounts/login/" class="btn btn-secondary">{% trans 'Login' %}</a>
</div> </div>
<button type="submit" class="button">{% trans 'Create account' %}</button> or <a href="/accounts/login/" class="button">{% trans 'Login' %}</a>
</form> </form>
</div> </div>
<div style="height:40px"></div> <div style="height:40px"></div>

View File

@@ -8,18 +8,17 @@
<h1>{% trans 'Reset password' %}</h1> <h1>{% trans 'Reset password' %}</h1>
<p>{% trans 'To continue with the password reset, enter one of the email addresses associated with your account.' %}</p> <p>{% trans 'To continue with the password reset, enter one of the email addresses associated with your account.' %}</p>
<div class="form-container"> <div style="max-width:640px">
<form action="" method="post" name="reset">{% csrf_token %} <form action="" method="post" name="reset">{% csrf_token %}
{{ form.email.errors }}
<div class="form-group"> <div class="form-group">
<label for="id_email" class="form-label">{% trans 'Email' %}</label> <label for="id_email">{% trans 'Email' %}:</label>
<input type="email" name="email" autofocus required class="form-control" id="id_email" placeholder="{% trans 'Enter your email address' %}"> <input type="text" name="email" autofocus required class="form-control" id="id_email">
</div> </div>
<div class="button-group"> <button type="submit" class="button">{% trans 'Reset my password' %}</button>&nbsp;
<button type="submit" class="btn btn-primary">{% trans 'Reset my password' %}</button> <button type="cancel" class="button" href="{% url 'profile' %}">{% trans 'Cancel' %}</button>
<a href="{% url 'profile' %}" class="btn btn-secondary">{% trans 'Cancel' %}</a>
</div>
</form> </form>
</div> </div>

View File

@@ -7,22 +7,17 @@
{% block content %} {% block content %}
<h1>{% trans 'Account settings' %}</h1> <h1>{% trans 'Account settings' %}</h1>
<div class="form-container"> <label for="id_username">{% trans 'Username' %}:</label>
<label for="id_username" class="form-label">{% trans 'Username' %}</label> <input type="text" name="username" class="form-control" id="id_username" disabled value="{{ user.username }}" style="max-width:600px;">
<input type="text" name="username" class="form-control" id="id_username" disabled value="{{ user.username }}">
<form action="{% url 'user_preference' %}" method="post">{% csrf_token %} <form action="{% url 'user_preference' %}" method="post">{% csrf_token %}
<div class="form-group"> <div class="form-group">
<label for="id_first_name" class="form-label">{% trans 'Firstname' %}</label> <label for="id_first_name">{% trans 'Firstname' %}:</label>
<input type="text" name="first_name" class="form-control" id="id_first_name" value="{{ user.first_name }}" placeholder="{% trans 'Enter your first name' %}"> <input type="text" name="first_name" class="form-control" id="id_first_name" value="{{ user.first_name }}" style="max-width:600px;">
</div> <label for="id_last_name">{% trans 'Lastname' %}:</label>
<div class="form-group"> <input type="text" name="last_name" class="form-control" id="id_last_name" value="{{ user.last_name }}" style="max-width:600px;">
<label for="id_last_name" class="form-label">{% trans 'Lastname' %}</label>
<input type="text" name="last_name" class="form-control" id="id_last_name" value="{{ user.last_name }}" placeholder="{% trans 'Enter your last name' %}"> <label for="id_email">{% trans 'E-mail address' %}:</label>
</div> <select name="email" class="form-control" id="id_email" style="max-width:600px;">
<div class="form-group">
<label for="id_email" class="form-label">{% trans 'E-mail address' %}</label>
<select name="email" class="form-control" id="id_email">
<option value="{{ user.email }}" selected>{{ user.email }}</option> <option value="{{ user.email }}" selected>{{ user.email }}</option>
{% for confirmed_email in user.confirmedemail_set.all %} {% for confirmed_email in user.confirmedemail_set.all %}
{% if user.email != confirmed_email.email %} {% if user.email != confirmed_email.email %}
@@ -32,11 +27,8 @@
</select> </select>
</div> </div>
<input type="hidden" name="theme" value="{{ user.userpreference.theme }}"/> <input type="hidden" name="theme" value="{{ user.userpreference.theme }}"/>
<div class="button-group"> <button type="submit" class="button">{% trans 'Save' %}</button>
<button type="submit" class="btn btn-primary">{% trans 'Save' %}</button>
</div>
</form> </form>
</div>
<!-- TODO: Language stuff not yet fully implemented; Esp. translations are only half-way there <!-- TODO: Language stuff not yet fully implemented; Esp. translations are only half-way there

View File

@@ -101,15 +101,7 @@
<form action="{% url 'remove_confirmed_email' email.id %}" method="post"> <form action="{% url 'remove_confirmed_email' email.id %}" method="post">
{% csrf_token %} {% csrf_token %}
<div id="email-conf-{{ forloop.counter }}" class="profile-container active"> <div id="email-conf-{{ forloop.counter }}" class="profile-container active">
<img title="{% trans 'Access count' %}: {{ email.access_count }}" <img title="{% trans 'Access count' %}: {{ email.access_count }}" src="{% if email.photo %}{% url 'raw_image' email.photo.id %}{% else %}{% static '/img/nobody/120.png' %}{% endif %}">
src="
{% if email.photo %}
{% url 'raw_image' email.photo.id %}
{% elif email.bluesky_handle %}
{% url 'blueskyproxy' email.digest %}
{% else %}
{% static '/img/nobody/120.png' %}
{% endif %}">
<h3 class="panel-title email-profile" title="{{ email.email }}"> <h3 class="panel-title email-profile" title="{{ email.email }}">
{{ email.email }} {{ email.email }}
</h3> </h3>
@@ -121,7 +113,7 @@
</li> </li>
<li class="email-delete"> <li class="email-delete">
<button type="submit" class="nobutton" onclick="return confirm('{% trans 'Are you sure that you want to delete this email address?' %}')"> <button type="submit" class="nobutton" onclick="return confirm('{% trans 'Are you sure that you want to delete this email address?' %}')">
Delete Email Address Delete Email Adress
</button> </button>
</li> </li>
</ul> </ul>
@@ -131,15 +123,7 @@
<form action="{% url 'remove_confirmed_email' email.id %}" method="post"> <form action="{% url 'remove_confirmed_email' email.id %}" method="post">
{% csrf_token %} {% csrf_token %}
<div id="email-conf-{{ forloop.counter }}" class="profile-container" onclick="add_active('email-conf-{{ forloop.counter }}')"> <div id="email-conf-{{ forloop.counter }}" class="profile-container" onclick="add_active('email-conf-{{ forloop.counter }}')">
<img title="{% trans 'Access count' %}: {{ email.access_count }}" <img title="{% trans 'Access count' %}: {{ email.access_count }}" src="{% if email.photo %}{% url 'raw_image' email.photo.id %}{% else %}{% static '/img/nobody/120.png' %}{% endif %}">
src="
{% if email.photo %}
{% url 'raw_image' email.photo.id %}
{% elif email.bluesky_handle %}
{% url 'blueskyproxy' email.digest %}
{% else %}
{% static '/img/nobody/120.png' %}
{% endif %}">
<h3 class="panel-title email-profile" title="{{ email.email }}"> <h3 class="panel-title email-profile" title="{{ email.email }}">
{{ email.email }} {{ email.email }}
</h3> </h3>
@@ -151,7 +135,7 @@
</li> </li>
<li class="email-delete"> <li class="email-delete">
<button type="submit" class="nobutton" onclick="return confirm('{% trans 'Are you sure that you want to delete this email address?' %}')"> <button type="submit" class="nobutton" onclick="return confirm('{% trans 'Are you sure that you want to delete this email address?' %}')">
Delete Email Address Delete Email Adress
</button> </button>
</li> </li>
</ul> </ul>
@@ -164,15 +148,7 @@
<form action="{% url 'remove_confirmed_openid' openid.id %}" method="post">{% csrf_token %} <form action="{% url 'remove_confirmed_openid' openid.id %}" method="post">{% csrf_token %}
<div> <div>
<div id="id-conf-{{ forloop.counter }}" class="profile-container active"> <div id="id-conf-{{ forloop.counter }}" class="profile-container active">
<img title="{% trans 'Access count' %}: {{ openid.access_count }}" <img title="{% trans 'Access count' %}: {{ openid.access_count }}" src="{% if openid.photo %}{% url 'raw_image' openid.photo.id %}{% else %}{% static '/img/nobody/120.png' %}{% endif %}">
src="
{% if openid.photo %}
{% url 'raw_image' openid.photo.id %}
{% elif openid.bluesky_handle %}
{% url 'blueskyproxy' openid.digest %}
{% else %}
{% static '/img/nobody/120.png' %}
{% endif %}">
<h3 class="panel-title email-profile" title="{{ openid.openid }}"> <h3 class="panel-title email-profile" title="{{ openid.openid }}">
{{ openid.openid }} {{ openid.openid }}
</h3> </h3>

View File

@@ -1,73 +0,0 @@
# -*- coding: utf-8 -*-
from unittest import mock
from django.test import TestCase
from django.contrib.auth.models import User
from ivatar.ivataraccount.auth import FedoraOpenIdConnect
from ivatar.ivataraccount.models import ConfirmedEmail
from django.test import override_settings
@override_settings(SOCIAL_AUTH_FEDORA_OIDC_ENDPOINT="https://id.example.com/")
class AuthFedoraTestCase(TestCase):
def _authenticate(self, response):
backend = FedoraOpenIdConnect()
pipeline = backend.strategy.get_pipeline(backend)
return backend.pipeline(pipeline, response=response)
def test_new_user(self):
"""Check that a Fedora user gets a ConfirmedEmail automatically."""
user = self._authenticate({"nickname": "testuser", "email": "test@example.com"})
self.assertEqual(user.confirmedemail_set.count(), 1)
self.assertEqual(user.confirmedemail_set.first().email, "test@example.com")
@mock.patch("ivatar.ivataraccount.auth.TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS", [])
def test_new_user_untrusted_backend(self):
"""Check that ConfirmedEmails aren't automatically created for untrusted backends."""
user = self._authenticate({"nickname": "testuser", "email": "test@example.com"})
self.assertEqual(user.confirmedemail_set.count(), 0)
def test_existing_user(self):
"""Checks that existing users are found."""
user = User.objects.create_user(
username="testuser",
password="password",
email="test@example.com",
first_name="test",
last_name="user",
)
auth_user = self._authenticate(
{"nickname": "testuser", "email": "test@example.com"}
)
self.assertEqual(auth_user, user)
# Only add ConfirmedEmails on account creation.
self.assertEqual(auth_user.confirmedemail_set.count(), 0)
def test_existing_user_with_confirmed_email(self):
"""Check that the authenticating user is found using their ConfirmedEmail."""
user = User.objects.create_user(
username="testuser1",
password="password",
email="first@example.com",
first_name="test",
last_name="user",
)
ConfirmedEmail.objects.create_confirmed_email(user, "second@example.com", False)
auth_user = self._authenticate(
{"nickname": "testuser2", "email": "second@example.com"}
)
self.assertEqual(auth_user, user)
def test_existing_confirmed_email(self):
"""Check that ConfirmedEmail isn't created twice."""
user = User.objects.create_user(
username="testuser",
password="password",
email="testuser@example.com",
first_name="test",
last_name="user",
)
ConfirmedEmail.objects.create_confirmed_email(user, user.email, False)
auth_user = self._authenticate({"nickname": user.username, "email": user.email})
self.assertEqual(auth_user, user)
self.assertEqual(auth_user.confirmedemail_set.count(), 1)

View File

@@ -2,13 +2,9 @@
""" """
Test our views in ivatar.ivataraccount.views and ivatar.views Test our views in ivatar.ivataraccount.views and ivatar.views
""" """
import contextlib
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
from urllib.parse import urlsplit from urllib.parse import urlsplit
from io import BytesIO from io import BytesIO
from contextlib import suppress
import io import io
import os import os
import gzip import gzip
@@ -17,10 +13,8 @@ import base64
import django import django
from django.test import TestCase from django.test import TestCase
from django.test import Client from django.test import Client
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.core import mail from django.core import mail
from django.core.cache import caches
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
import hashlib import hashlib
@@ -43,7 +37,6 @@ from ivatar.utils import random_string
TEST_IMAGE_FILE = os.path.join(settings.STATIC_ROOT, "img", "deadbeef.png") TEST_IMAGE_FILE = os.path.join(settings.STATIC_ROOT, "img", "deadbeef.png")
@override_settings()
class Tester(TestCase): # pylint: disable=too-many-public-methods class Tester(TestCase): # pylint: disable=too-many-public-methods
""" """
Main test class Main test class
@@ -53,7 +46,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
user = None user = None
username = random_string() username = random_string()
password = random_string() password = random_string()
email = "%s@%s.org" % (username, random_string()) email = "%s@%s.%s" % (username, random_string(), random_string(2))
# Dunno why random tld doesn't work, but I'm too lazy now to investigate # Dunno why random tld doesn't work, but I'm too lazy now to investigate
openid = "http://%s.%s.%s/" % (username, random_string(), "org") openid = "http://%s.%s.%s/" % (username, random_string(), "org")
first_name = random_string() first_name = random_string()
@@ -76,14 +69,6 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
first_name=self.first_name, first_name=self.first_name,
last_name=self.last_name, last_name=self.last_name,
) )
# Disable caching
settings.CACHES["default"] = {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
}
caches._settings = None
with suppress(AttributeError):
# clear the existing cache connection
delattr(caches._connections, "default")
def test_new_user(self): def test_new_user(self):
""" """
@@ -255,11 +240,9 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Confirm w/o verification key does not produce error message?", "Confirm w/o verification key does not produce error message?",
) )
def test_confirm_email_w_non_existing_auth_key( def test_confirm_email_w_inexisting_auth_key(self): # pylint: disable=invalid-name
self,
): # pylint: disable=invalid-name
""" """
Test confirmation with non existing auth key Test confirmation with inexisting auth key
""" """
self.login() self.login()
# Avoid sending out mails # Avoid sending out mails
@@ -281,7 +264,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
self.assertEqual( self.assertEqual(
str(list(response.context[0]["messages"])[-1]), str(list(response.context[0]["messages"])[-1]),
"Verification key does not exist", "Verification key does not exist",
"Confirm w/o non existing key does not produce error message?", "Confirm w/o inexisting key does not produce error message?",
) )
def test_remove_confirmed_email(self): def test_remove_confirmed_email(self):
@@ -369,7 +352,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
response = self.client.post( response = self.client.post(
reverse("add_email"), reverse("add_email"),
{ {
"email": "oliver@linux-kernel.at", # Wow, static :-[ "email": "oliver@linux-kernel.at", # Whohu, static :-[
}, },
) # Create test address ) # Create test address
unconfirmed = self.user.unconfirmedemail_set.first() unconfirmed = self.user.unconfirmedemail_set.first()
@@ -412,8 +395,8 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
self.assertEqual(response["Content-Type"], "image/jpg", "Content type wrong!?") self.assertEqual(response["Content-Type"], "image/jpg", "Content type wrong!?")
self.assertEqual( self.assertEqual(
bytes(response.content), response.content,
bytes(self.user.photo_set.first().data), self.user.photo_set.first().data,
"raw_image should return the same content as if we\ "raw_image should return the same content as if we\
read it directly from the DB", read it directly from the DB",
) )
@@ -435,7 +418,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Photo deletion did not work?", "Photo deletion did not work?",
) )
def test_delete_non_existing_photo(self): def test_delete_inexisting_photo(self):
""" """
test deleting the photo test deleting the photo
""" """
@@ -466,15 +449,15 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
) )
for i in range(max_num_unconfirmed + 1): for i in range(max_num_unconfirmed + 1):
response = self.client.post( # noqa: F841 response = self.client.post(
reverse("add_email"), reverse("add_email"),
{ {
"email": "%i.%s" % (i, self.email), "email": "%i.%s" % (i, self.email),
}, },
follow=True, follow=True,
) # Create test addresses + 1 too much ) # Create test addresses + 1 too much
return self._check_form_validity( self.assertFormError(
response, "Too many unconfirmed mail addresses!", "__all__" response, "form", None, "Too many unconfirmed mail addresses!"
) )
def test_add_mail_address_twice(self): def test_add_mail_address_twice(self):
@@ -487,15 +470,15 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend" settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend"
for _ in range(2): for _ in range(2):
response = self.client.post( # noqa: F841 response = self.client.post(
reverse("add_email"), reverse("add_email"),
{ {
"email": self.email, "email": self.email,
}, },
follow=True, follow=True,
) )
return self._check_form_validity( self.assertFormError(
response, "Address already added, currently unconfirmed", "email" response, "form", "email", "Address already added, currently unconfirmed"
) )
def test_add_already_confirmed_email_self(self): # pylint: disable=invalid-name def test_add_already_confirmed_email_self(self): # pylint: disable=invalid-name
@@ -506,16 +489,15 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
# Should set EMAIL_BACKEND, so no need to do it here # Should set EMAIL_BACKEND, so no need to do it here
self.test_confirm_email() self.test_confirm_email()
response = self.client.post( # noqa: F841 response = self.client.post(
reverse("add_email"), reverse("add_email"),
{ {
"email": self.email, "email": self.email,
}, },
follow=True, follow=True,
) )
self.assertFormError(
return self._check_form_validity( response, "form", "email", "Address already confirmed (by you)"
response, "Address already confirmed (by you)", "email"
) )
def test_add_already_confirmed_email_other(self): # pylint: disable=invalid-name def test_add_already_confirmed_email_other(self): # pylint: disable=invalid-name
@@ -533,16 +515,15 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
confirmedemail.user = otheruser confirmedemail.user = otheruser
confirmedemail.save() confirmedemail.save()
response = self.client.post( # noqa: F841 response = self.client.post(
reverse("add_email"), reverse("add_email"),
{ {
"email": self.email, "email": self.email,
}, },
follow=True, follow=True,
) )
self.assertFormError(
return self._check_form_validity( response, "form", "email", "Address already confirmed (by someone else)"
response, "Address already confirmed (by someone else)", "email"
) )
def test_remove_unconfirmed_non_existing_email( def test_remove_unconfirmed_non_existing_email(
@@ -583,21 +564,22 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
}, },
follow=True, follow=True,
) )
if not test_only_one: if test_only_one:
self.assertEqual(
self.user.photo_set.count(), 1, "there must be exactly one photo now!"
)
self.assertEqual(
str(list(response.context[0]["messages"])[-1]),
"Successfully uploaded",
"A valid image should return a success message!",
)
self.assertEqual(
self.user.photo_set.first().format,
"png",
"Format must be png, since we uploaded a png!",
)
else:
return response return response
self.assertEqual(
self.user.photo_set.count(), 1, "there must be exactly one photo now!"
)
self.assertEqual(
str(list(response.context[0]["messages"])[-1]),
"Successfully uploaded",
"A valid image should return a success message!",
)
self.assertEqual(
self.user.photo_set.first().format,
"png",
"Format must be png, since we uploaded a png!",
)
def test_upload_too_many_images(self): def test_upload_too_many_images(self):
""" """
@@ -688,61 +670,81 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
""" """
Test if gif is correctly detected and can be viewed Test if gif is correctly detected and can be viewed
""" """
self._extracted_from_test_upload_webp_image_5(
"broken.gif",
"GIF upload failed?!",
"gif",
"Format must be gif, since we uploaded a GIF!",
)
def test_upload_jpg_image(self):
"""
Test if jpg is correctly detected and can be viewed
"""
self._extracted_from_test_upload_webp_image_5(
"broken.jpg",
"JPEG upload failed?!",
"jpg",
"Format must be jpeg, since we uploaded a jpeg!",
)
def test_upload_webp_image(self):
"""
Test if webp is correctly detected and can be viewed
"""
self._extracted_from_test_upload_webp_image_5(
"broken.webp",
"WEBP upload failed?!",
"webp",
"Format must be webp, since we uploaded a webp!",
)
def _extracted_from_test_upload_webp_image_5(
self, filename, message1, format, message2
):
"""
Helper function for common checks for gif, jpg, webp
"""
self.login() self.login()
url = reverse("upload_photo") url = reverse("upload_photo")
with open(os.path.join(settings.STATIC_ROOT, "img", filename), "rb") as photo: # rb => Read binary
# Broken is _not_ broken - it's just an 'x' :-)
with open(
os.path.join(settings.STATIC_ROOT, "img", "broken.gif"), "rb"
) as photo:
response = self.client.post( response = self.client.post(
url, url,
{"photo": photo, "not_porn": True, "can_distribute": True}, {
"photo": photo,
"not_porn": True,
"can_distribute": True,
},
follow=True, follow=True,
) )
self.assertEqual( self.assertEqual(
str(list(response.context[0]["messages"])[0]), str(list(response.context[0]["messages"])[0]),
"Successfully uploaded", "Successfully uploaded",
message1, "GIF upload failed?!",
)
self.assertEqual(
self.user.photo_set.first().format,
"gif",
"Format must be gif, since we uploaded a GIF!",
) )
self.assertEqual(self.user.photo_set.first().format, format, message2)
self.test_confirm_email() self.test_confirm_email()
self.user.confirmedemail_set.first().photo = self.user.photo_set.first() self.user.confirmedemail_set.first().photo = self.user.photo_set.first()
urlobj = urlsplit( urlobj = urlsplit(
libravatar_url(email=self.user.confirmedemail_set.first().email) libravatar_url(
email=self.user.confirmedemail_set.first().email,
)
) )
url = f"{urlobj.path}?{urlobj.query}" url = "%s?%s" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200, "unable to fetch avatar?")
def test_upload_jpg_image(self):
"""
Test if jpg is correctly detected and can be viewed
"""
self.login()
url = reverse("upload_photo")
# rb => Read binary
# Broken is _not_ broken - it's just an 'x' :-)
with open(
os.path.join(settings.STATIC_ROOT, "img", "broken.jpg"), "rb"
) as photo:
response = self.client.post(
url,
{
"photo": photo,
"not_porn": True,
"can_distribute": True,
},
follow=True,
)
self.assertEqual(
str(list(response.context[0]["messages"])[0]),
"Successfully uploaded",
"JPEG upload failed?!",
)
self.assertEqual(
self.user.photo_set.first().format,
"jpg",
"Format must be jpeg, since we uploaded a jpeg!",
)
self.test_confirm_email()
self.user.confirmedemail_set.first().photo = self.user.photo_set.first()
urlobj = urlsplit(
libravatar_url(
email=self.user.confirmedemail_set.first().email,
)
)
url = "%s?%s" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True) response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200, "unable to fetch avatar?") self.assertEqual(response.status_code, 200, "unable to fetch avatar?")
@@ -754,7 +756,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
url = reverse("upload_photo") url = reverse("upload_photo")
# rb => Read binary # rb => Read binary
with open( with open(
os.path.join(settings.STATIC_ROOT, "img", "broken.tif"), "rb" os.path.join(settings.STATIC_ROOT, "img", "hackergotchi_test.tif"), "rb"
) as photo: ) as photo:
response = self.client.post( response = self.client.post(
url, url,
@@ -888,7 +890,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Assign non existing photo, does not return error message?", "Assign non existing photo, does not return error message?",
) )
def test_assign_photo_to_non_existing_mail(self): # pylint: disable=invalid-name def test_assign_photo_to_inexisting_mail(self): # pylint: disable=invalid-name
""" """
Test if assigning photo to mail address that doesn't exist returns Test if assigning photo to mail address that doesn't exist returns
the correct error message the correct error message
@@ -909,9 +911,9 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Assign non existing photo, does not return error message?", "Assign non existing photo, does not return error message?",
) )
def test_import_photo_with_non_existing_email(self): # pylint: disable=invalid-name def test_import_photo_with_inexisting_email(self): # pylint: disable=invalid-name
""" """
Test if import with non existing mail address returns Test if import with inexisting mail address returns
the correct error message the correct error message
""" """
self.login() self.login()
@@ -921,7 +923,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
self.assertEqual( self.assertEqual(
str(list(response.context[0]["messages"])[0]), str(list(response.context[0]["messages"])[0]),
"Address does not exist", "Address does not exist",
"Import photo with non existing mail id,\ "Import photo with inexisting mail id,\
does not return error message?", does not return error message?",
) )
@@ -941,24 +943,10 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
should return an error message!", should return an error message!",
) )
def _manual_confirm(self):
"""
Helper method to confirm manually, because testing is really hard
"""
# Manual confirm, since testing is _really_ hard!
unconfirmed = self.user.unconfirmedopenid_set.first()
confirmed = ConfirmedOpenId()
confirmed.user = unconfirmed.user
confirmed.ip_address = "127.0.0.1"
confirmed.openid = unconfirmed.openid
confirmed.save()
unconfirmed.delete()
def test_add_openid(self, confirm=True): def test_add_openid(self, confirm=True):
""" """
Test if adding an OpenID works Test if adding an OpenID works
""" """
self.login() self.login()
# Get page # Get page
response = self.client.get(reverse("add_openid")) response = self.client.get(reverse("add_openid"))
@@ -975,7 +963,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
self.assertEqual(response.status_code, 302, "OpenID must redirect") self.assertEqual(response.status_code, 302, "OpenID must redirect")
if confirm: if confirm:
self._manual_confirm() # Manual confirm, since testing is _really_ hard!
unconfirmed = self.user.unconfirmedopenid_set.first()
confirmed = ConfirmedOpenId()
confirmed.user = unconfirmed.user
confirmed.ip_address = "127.0.0.1"
confirmed.openid = unconfirmed.openid
confirmed.save()
unconfirmed.delete()
def test_add_openid_twice(self): def test_add_openid_twice(self):
""" """
@@ -1008,9 +1003,10 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"There must only be one unconfirmed ID!", "There must only be one unconfirmed ID!",
) )
self._check_form_validity( self.assertFormError(
response, "OpenID already added, but not confirmed yet!", "openid" response, "form", "openid", "OpenID already added, but not confirmed yet!"
) )
# Manual confirm, since testing is _really_ hard! # Manual confirm, since testing is _really_ hard!
unconfirmed = self.user.unconfirmedopenid_set.first() unconfirmed = self.user.unconfirmedopenid_set.first()
confirmed = ConfirmedOpenId() confirmed = ConfirmedOpenId()
@@ -1028,26 +1024,10 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
}, },
follow=True, follow=True,
) )
self.assertFormError(
return self._check_form_validity( response, "form", "openid", "OpenID already added and confirmed!"
response, "OpenID already added and confirmed!", "openid"
) )
def _check_form_validity(self, response, message, field):
"""
Helper method to check form, used in several test functions,
deduplicating code
"""
self.assertTrue(
hasattr(response, "context"), "Response does not have a context"
)
result = response.context.get("form")
self.assertIsNotNone(result, "No form found in response context")
self.assertFalse(result.is_valid(), "Form should not be valid")
self.assertIn(message, result.errors.get(field, []))
return result
def test_assign_photo_to_openid(self): def test_assign_photo_to_openid(self):
""" """
Test assignment of photo to openid Test assignment of photo to openid
@@ -1136,7 +1116,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Assign non existing photo, does not return error message?", "Assign non existing photo, does not return error message?",
) )
def test_assign_photo_to_openid_non_existing_openid( def test_assign_photo_to_openid_inexisting_openid(
self, self,
): # pylint: disable=invalid-name ): # pylint: disable=invalid-name
""" """
@@ -1213,9 +1193,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
"Removing unconfirmed mail does not work?", "Removing unconfirmed mail does not work?",
) )
def test_remove_unconfirmed_non_existing_openid( def test_remove_unconfirmed_inexisting_openid(self): # pylint: disable=invalid-name
self,
): # pylint: disable=invalid-name
""" """
Remove unconfirmed openid that doesn't exist Remove unconfirmed openid that doesn't exist
""" """
@@ -1228,7 +1206,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
self.assertEqual( self.assertEqual(
str(list(response.context[0]["messages"])[0]), str(list(response.context[0]["messages"])[0]),
"ID does not exist", "ID does not exist",
"Removing an non existing openid should return an error message", "Removing an inexisting openid should return an error message",
) )
def test_openid_redirect_view(self): def test_openid_redirect_view(self):
@@ -1276,7 +1254,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
size=size[0], size=size[0],
) )
) )
url = f"{urlobj.path}?{urlobj.query}" url = "%s?%s" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True) response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200, "unable to fetch avatar?") self.assertEqual(response.status_code, 200, "unable to fetch avatar?")
photodata = Image.open(BytesIO(response.content)) photodata = Image.open(BytesIO(response.content))
@@ -1293,15 +1271,15 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
size=80, size=80,
) )
) )
url = f"{urlobj.path}?{urlobj.query}" url = "%s?%s" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True) response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200, "unable to fetch avatar?") self.assertEqual(response.status_code, 200, "unable to fetch avatar?")
photodata = Image.open(BytesIO(response.content)) photodata = Image.open(BytesIO(response.content))
self.assertEqual(photodata.size, (80, 80), "Why is this not the correct size?") self.assertEqual(photodata.size, (80, 80), "Why is this not the correct size?")
def test_avatar_url_non_existing_mail_digest(self): # pylint: disable=invalid-name def test_avatar_url_inexisting_mail_digest(self): # pylint: disable=invalid-name
""" """
Test fetching avatar via non existing mail digest Test fetching avatar via inexisting mail digest
""" """
self.test_upload_image() self.test_upload_image()
self.test_confirm_email() self.test_confirm_email()
@@ -1317,42 +1295,20 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
hashlib.md5(addr.strip().lower().encode("utf-8")).hexdigest() hashlib.md5(addr.strip().lower().encode("utf-8")).hexdigest()
self.user.confirmedemail_set.first().delete() self.user.confirmedemail_set.first().delete()
url = f"{urlobj.path}?{urlobj.query}" url = "%s?%s" % (urlobj.path, urlobj.query)
self.client.get(url, follow=True) response = self.client.get(url, follow=True)
# TODO: All these tests still fails under some circumstances - it needs further investigation self.assertRedirects(
# self.assertEqual( response=response,
# response.redirect_chain[0][0], expected_url="/static/img/nobody/80.png",
# f"/gravatarproxy/{digest}?s=80", msg_prefix="Why does this not redirect to Gravatar?",
# "Doesn't redirect to Gravatar?", )
# )
# self.assertEqual(
# response.redirect_chain[0][1], 302, "Doesn't redirect with 302?"
# )
# self.assertEqual(
# response.redirect_chain[1][0],
# f"/avatar/{digest}?s=80&forcedefault=y",
# "Doesn't redirect with default forced on?",
# )
# self.assertEqual(
# response.redirect_chain[1][1], 302, "Doesn't redirect with 302?"
# )
# self.assertEqual(
# response.redirect_chain[2][0],
# "/static/img/nobody/80.png",
# "Doesn't redirect to static?",
# )
# self.assertRedirects(
# response=response,
# expected_url="/static/img/nobody/80.png",
# msg_prefix="Why does this not redirect to Gravatar?",
# )
# Eventually one should check if the data is the same # Eventually one should check if the data is the same
def test_avatar_url_non_existing_mail_digest_gravatarproxy_disabled( def test_avatar_url_inexisting_mail_digest_gravatarproxy_disabled(
self, self,
): # pylint: disable=invalid-name ): # pylint: disable=invalid-name
""" """
Test fetching avatar via non existing mail digest Test fetching avatar via inexisting mail digest
""" """
self.test_upload_image() self.test_upload_image()
self.test_confirm_email() self.test_confirm_email()
@@ -1365,26 +1321,20 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
# Simply delete it, then it digest is 'correct', but # Simply delete it, then it digest is 'correct', but
# the hash is no longer there # the hash is no longer there
self.user.confirmedemail_set.first().delete() self.user.confirmedemail_set.first().delete()
url = f"{urlobj.path}?{urlobj.query}&gravatarproxy=n" url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True) response = self.client.get(url, follow=True)
self.assertEqual( self.assertRedirects(
response.redirect_chain[0][0], response=response,
"/static/img/nobody/80.png", expected_url="/static/img/nobody/80.png",
"Doesn't redirect to static?", msg_prefix="Why does this not redirect to the default img?",
) )
# self.assertRedirects(
# response=response,
# expected_url="/static/img/nobody/80.png",
# msg_prefix="Why does this not redirect to the default img?",
# )
# Eventually one should check if the data is the same # Eventually one should check if the data is the same
def test_avatar_url_non_existing_mail_digest_w_default_mm( def test_avatar_url_inexisting_mail_digest_w_default_mm(
self, self,
): # pylint: disable=invalid-name ): # pylint: disable=invalid-name
""" """
Test fetching avatar via non existing mail digest and default 'mm' Test fetching avatar via inexisting mail digest and default 'mm'
""" """
urlobj = urlsplit( urlobj = urlsplit(
libravatar_url( libravatar_url(
@@ -1393,14 +1343,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default="mm", default="mm",
) )
) )
url = f"{urlobj.path}?{urlobj.query}" url = "%s?%s" % (urlobj.path, urlobj.query)
self.client.get(url, follow=False) self.client.get(url, follow=False)
def test_avatar_url_non_existing_mail_digest_w_default_mm_gravatarproxy_disabled( def test_avatar_url_inexisting_mail_digest_w_default_mm_gravatarproxy_disabled(
self, self,
): # pylint: disable=invalid-name ): # pylint: disable=invalid-name
""" """
Test fetching avatar via non existing mail digest and default 'mm' Test fetching avatar via inexisting mail digest and default 'mm'
""" """
urlobj = urlsplit( urlobj = urlsplit(
libravatar_url( libravatar_url(
@@ -1409,26 +1359,20 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default="mm", default="mm",
) )
) )
url = f"{urlobj.path}?{urlobj.query}&gravatarproxy=n" url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True) response = self.client.get(url, follow=True)
self.assertEqual( self.assertRedirects(
response.redirect_chain[0][0], response=response,
"/static/img/mm/80.png", expected_url="/static/img/mm/80.png",
"Doesn't redirect to static?", msg_prefix="Why does this not redirect to the default img?",
) )
# self.assertRedirects(
# response=response,
# expected_url="/static/img/mm/80.png",
# msg_prefix="Why does this not redirect to the default img?",
# )
# Eventually one should check if the data is the same # Eventually one should check if the data is the same
def test_avatar_url_non_existing_mail_digest_wo_default( def test_avatar_url_inexisting_mail_digest_wo_default(
self, self,
): # pylint: disable=invalid-name ): # pylint: disable=invalid-name
""" """
Test fetching avatar via non existing mail digest and default 'mm' Test fetching avatar via inexisting mail digest and default 'mm'
""" """
urlobj = urlsplit( urlobj = urlsplit(
libravatar_url( libravatar_url(
@@ -1436,43 +1380,20 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
size=80, size=80,
) )
) )
digest = hashlib.md5("asdf@company.local".lower().encode("utf-8")).hexdigest() url = "%s?%s" % (urlobj.path, urlobj.query)
url = f"{urlobj.path}?{urlobj.query}"
response = self.client.get(url, follow=True) response = self.client.get(url, follow=True)
self.assertEqual( self.assertRedirects(
response.redirect_chain[0][0], response=response,
f"/gravatarproxy/{digest}?s=80", expected_url="/static/img/nobody/80.png",
"Doesn't redirect to Gravatar?", msg_prefix="Why does this not redirect to the default img?",
) )
self.assertEqual(
response.redirect_chain[0][1], 302, "Doesn't redirect with 302?"
)
self.assertEqual(
response.redirect_chain[1][0],
f"/avatar/{digest}?s=80&forcedefault=y",
"Doesn't redirect with default forced on?",
)
self.assertEqual(
response.redirect_chain[1][1], 302, "Doesn't redirect with 302?"
)
self.assertEqual(
response.redirect_chain[2][0],
"/static/img/nobody/80.png",
"Doesn't redirect to static?",
)
# self.assertRedirects(
# response=response,
# expected_url="/static/img/nobody/80.png",
# msg_prefix="Why does this not redirect to the default img?",
# )
# Eventually one should check if the data is the same # Eventually one should check if the data is the same
def test_avatar_url_non_existing_mail_digest_wo_default_gravatarproxy_disabled( def test_avatar_url_inexisting_mail_digest_wo_default_gravatarproxy_disabled(
self, self,
): # pylint: disable=invalid-name ): # pylint: disable=invalid-name
""" """
Test fetching avatar via non existing mail digest and default 'mm' Test fetching avatar via inexisting mail digest and default 'mm'
""" """
urlobj = urlsplit( urlobj = urlsplit(
libravatar_url( libravatar_url(
@@ -1480,19 +1401,13 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
size=80, size=80,
) )
) )
url = f"{urlobj.path}?{urlobj.query}&gravatarproxy=n" url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True) response = self.client.get(url, follow=True)
self.assertEqual( self.assertRedirects(
response.redirect_chain[0][0], response=response,
"/static/img/nobody/80.png", expected_url="/static/img/nobody/80.png",
"Doesn't redirect to static?", msg_prefix="Why does this not redirect to the default img?",
) )
# self.assertRedirects(
# response=response,
# expected_url="/static/img/nobody/80.png",
# msg_prefix="Why does this not redirect to the default img?",
# )
# Eventually one should check if the data is the same # Eventually one should check if the data is the same
def test_avatar_url_default(self): # pylint: disable=invalid-name def test_avatar_url_default(self): # pylint: disable=invalid-name
@@ -1506,14 +1421,12 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default="/static/img/nobody.png", default="/static/img/nobody.png",
) )
) )
url = f"{urlobj.path}?{urlobj.query}" url = "%s?%s" % (urlobj.path, urlobj.query)
url += "&gravatarproxy=n" response = self.client.get(url, follow=True)
response = self.client.get(url, follow=False) self.assertRedirects(
self.assertEqual(response.status_code, 302, "Doesn't redirect with 302?") response=response,
self.assertEqual( expected_url="/static/img/nobody.png",
response["Location"], msg_prefix="Why does this not redirect to nobody img?",
"/static/img/nobody.png",
"Doesn't redirect to static img?",
) )
def test_avatar_url_default_gravatarproxy_disabled( def test_avatar_url_default_gravatarproxy_disabled(
@@ -1529,12 +1442,12 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default="/static/img/nobody.png", default="/static/img/nobody.png",
) )
) )
url = f"{urlobj.path}?{urlobj.query}&gravatarproxy=n" url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=True) response = self.client.get(url, follow=True)
self.assertEqual( self.assertRedirects(
response.redirect_chain[0][0], response=response,
"/static/img/nobody.png", expected_url="/static/img/nobody.png",
"Doesn't redirect to static?", msg_prefix="Why does this not redirect to the default img?",
) )
def test_avatar_url_default_external(self): # pylint: disable=invalid-name def test_avatar_url_default_external(self): # pylint: disable=invalid-name
@@ -1551,11 +1464,11 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default=default, default=default,
) )
) )
url = f"{urlobj.path}?{urlobj.query}" url = "%s?%s" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=False) response = self.client.get(url, follow=False)
self.assertRedirects( self.assertRedirects(
response=response, response=response,
expected_url=f"/gravatarproxy/fb7a6d7f11365642d44ba66dc57df56f?s={size}", expected_url="/gravatarproxy/fb7a6d7f11365642d44ba66dc57df56f?s=%s" % size,
fetch_redirect_response=False, fetch_redirect_response=False,
msg_prefix="Why does this not redirect to the default img?", msg_prefix="Why does this not redirect to the default img?",
) )
@@ -1572,7 +1485,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default=default, default=default,
) )
) )
url = f"{urlobj.path}?{urlobj.query}" url = "%s?%s" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=False) response = self.client.get(url, follow=False)
self.assertRedirects( self.assertRedirects(
response=response, response=response,
@@ -1596,7 +1509,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
default=default, default=default,
) )
) )
url = f"{urlobj.path}?{urlobj.query}&gravatarproxy=n" url = "%s?%s&gravatarproxy=n" % (urlobj.path, urlobj.query)
response = self.client.get(url, follow=False) response = self.client.get(url, follow=False)
self.assertRedirects( self.assertRedirects(
response=response, response=response,
@@ -1672,14 +1585,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
reverse("password_change"), reverse("password_change"),
{ {
"old_password": self.password, "old_password": self.password,
"new_password1": f"{self.password}.", "new_password1": self.password + ".",
"new_password2": self.password, "new_password2": self.password,
}, },
follow=True, follow=True,
) )
self.assertContains( self.assertContains(
response, response,
"The two password fields did", "The two password fields didn",
1, 1,
200, 200,
"Old password was entered incorrectly, site should raise an error", "Old password was entered incorrectly, site should raise an error",
@@ -1695,14 +1608,14 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
{ {
"old_password": self.password, "old_password": self.password,
"new_password1": self.password, "new_password1": self.password,
"new_password2": f"{self.password}.", "new_password2": self.password + ".",
}, },
follow=True, follow=True,
) )
self.assertContains( self.assertContains(
response, response,
"The two password fields did", "The two password fields didn",
1, 1,
200, 200,
"Old password as entered incorrectly, site should raise an error", "Old password as entered incorrectly, site should raise an error",
@@ -1753,7 +1666,7 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
) )
self.assertContains( self.assertContains(
response, response,
f"{self.first_name} {self.last_name}", self.first_name + " " + self.last_name,
1, 1,
200, 200,
"First and last name not correctly listed in profile page", "First and last name not correctly listed in profile page",
@@ -1917,105 +1830,4 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
Test if uploading export works Test if uploading export works
""" """
# Ensure we have data in place self.client.get(reverse("upload_export"))
self.test_export()
self.login()
self.client.get(reverse("export"))
response = self.client.post(
reverse("export"),
{},
follow=False,
)
self.assertIsInstance(response.content, bytes)
fh_gzip = gzip.open(BytesIO(response.content), "rb")
fh = BytesIO(response.content)
response = self._uploading_export_check(
fh_gzip, "Unable to parse file: Not a gzipped file"
)
response = self._uploading_export_check(fh, "Choose items to be imported")
self.assertContains(
response,
"asdf@asdf.local",
2,
200,
"Upload didn't work?",
)
def _uploading_export_check(self, fh, message):
"""
Helper function to upload an export
"""
result = self.client.post(
reverse("upload_export"),
data={"not_porn": "on", "can_distribute": "on", "export_file": fh},
follow=True,
)
fh.close()
self.assertEqual(result.status_code, 200, "Upload worked")
self.assertContains(result, message, 1, 200, "Upload didn't work?")
return result
def test_preferences_page(self):
"""
Test if preferences page works
"""
self.login()
self.client.get(reverse("user_preference"))
def test_delete_user(self):
"""
Test if deleting user profile works
"""
self.login()
self.client.get(reverse("delete"))
response = self.client.post(
reverse("delete"),
data={"password": self.password},
follow=True,
)
self.assertEqual(response.status_code, 200, "Deletion worked")
self.assertEqual(User.objects.count(), 0, "No user there any more")
def test_confirm_already_confirmed(self):
"""
Try to confirm a mail address that has been confirmed (by another user)
"""
# Add mail address (stays unconfirmed)
self.test_add_email()
# Create a second user that will conflict
user2 = User.objects.create_user(
username=f"{self.username}1",
password=self.password,
first_name=self.first_name,
last_name=self.last_name,
)
ConfirmedEmail.objects.create(
email=self.email,
user=user2,
)
# Just to be sure
self.assertEqual(
self.user.unconfirmedemail_set.first().email,
user2.confirmedemail_set.first().email,
"Mail not the same?",
)
# This needs to be caught
with contextlib.suppress(AssertionError):
self.test_confirm_email()
# Request a random page, so we can access the messages
response = self.client.get(reverse("profile"))
self.assertEqual(
str(list(response.context[0]["messages"])[0]),
"This mail address has been taken already and cannot be confirmed",
"This should return an error message!",
)

View File

@@ -1,247 +0,0 @@
# -*- coding: utf-8 -*-
"""
Test our views in ivatar.ivataraccount.views and ivatar.views
"""
import contextlib
# pylint: disable=too-many-lines
import os
import django
from django.test import TestCase
from django.test import Client
from django.urls import reverse
from django.contrib.auth.models import User
# from django.contrib.auth import authenticate
os.environ["DJANGO_SETTINGS_MODULE"] = "ivatar.settings"
django.setup()
# pylint: disable=wrong-import-position
from ivatar import settings
from ivatar.ivataraccount.models import ConfirmedOpenId, ConfirmedEmail
from ivatar.utils import random_string
from libravatar import libravatar_url
class Tester(TestCase): # pylint: disable=too-many-public-methods
"""
Main test class
"""
client = Client()
user = None
username = random_string()
password = random_string()
email = "%s@%s.%s" % (username, random_string(), random_string(2))
# Dunno why random tld doesn't work, but I'm too lazy now to investigate
openid = "http://%s.%s.%s/" % (username, random_string(), "org")
first_name = random_string()
last_name = random_string()
bsky_test_account = "libravatar.org"
def login(self):
"""
Login as user
"""
self.client.login(username=self.username, password=self.password)
def setUp(self):
"""
Prepare for tests.
- Create user
"""
self.user = User.objects.create_user(
username=self.username,
password=self.password,
first_name=self.first_name,
last_name=self.last_name,
)
settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend"
def create_confirmed_openid(self):
"""
Create a confirmed openid
"""
return ConfirmedOpenId.objects.create(
user=self.user,
ip_address="127.0.0.1",
openid=self.openid,
)
def create_confirmed_email(self):
"""
Create a confirmed email
"""
return ConfirmedEmail.objects.create(
email=self.email,
user=self.user,
)
# The following tests need to be moved over to the model tests
# and real web UI tests added
def test_bluesky_handle_for_mail_via_model_handle_does_not_exist(self):
"""
Add Bluesky handle to a confirmed mail address
"""
self.login()
confirmed = self.create_confirmed_email()
confirmed.set_bluesky_handle(self.bsky_test_account)
with contextlib.suppress(Exception):
confirmed.set_bluesky_handle(f"{self.bsky_test_account}1")
self.assertNotEqual(
confirmed.bluesky_handle,
f"{self.bsky_test_account}1",
"Setting Bluesky handle that doesn't exist works?",
)
def test_bluesky_handle_for_mail_via_model_handle_exists(self):
"""
Add Bluesky handle to a confirmed mail address
"""
self.login()
confirmed = self.create_confirmed_email()
confirmed.set_bluesky_handle(self.bsky_test_account)
self.assertEqual(
confirmed.bluesky_handle,
self.bsky_test_account,
"Setting Bluesky handle doesn't work?",
)
def test_bluesky_handle_for_openid_via_model_handle_does_not_exist(self):
"""
Add Bluesky handle to a confirmed openid address
"""
self.login()
confirmed = self.create_confirmed_openid()
confirmed.set_bluesky_handle(self.bsky_test_account)
with contextlib.suppress(Exception):
confirmed.set_bluesky_handle(f"{self.bsky_test_account}1")
self.assertNotEqual(
confirmed.bluesky_handle,
f"{self.bsky_test_account}1",
"Setting Bluesky handle that doesn't exist works?",
)
def test_bluesky_handle_for_openid_via_model_handle_exists(self):
"""
Add Bluesky handle to a confirmed openid address
"""
self.login()
confirmed = self.create_confirmed_openid()
confirmed.set_bluesky_handle(self.bsky_test_account)
self.assertEqual(
confirmed.bluesky_handle,
self.bsky_test_account,
"Setting Bluesky handle doesn't work?",
)
def test_bluesky_fetch_mail(self):
"""
Check if we can successfully fetch a Bluesky avatar via email
"""
self.login()
confirmed = self.create_confirmed_email()
confirmed.set_bluesky_handle(self.bsky_test_account)
lu = libravatar_url(confirmed.email, https=True)
lu = lu.replace("https://seccdn.libravatar.org/", reverse("home"))
response = self.client.get(lu)
# This is supposed to redirect to the Bluesky proxy
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], f"/blueskyproxy/{confirmed.digest}")
def test_bluesky_fetch_openid(self):
"""
Check if we can successfully fetch a Bluesky avatar via OpenID
"""
self.login()
confirmed = self.create_confirmed_openid()
confirmed.set_bluesky_handle(self.bsky_test_account)
lu = libravatar_url(openid=confirmed.openid, https=True)
lu = lu.replace("https://seccdn.libravatar.org/", reverse("home"))
response = self.client.get(lu)
# This is supposed to redirect to the Bluesky proxy
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], f"/blueskyproxy/{confirmed.digest}")
def test_assign_bluesky_handle_to_openid(self):
"""
Assign a Bluesky handle to an OpenID
"""
self.login()
confirmed = self.create_confirmed_openid()
self._assign_handle_to(
"assign_bluesky_handle_to_openid",
confirmed,
"Adding Bluesky handle to OpenID fails?",
)
def test_assign_bluesky_handle_to_email(self):
"""
Assign a Bluesky handle to an email
"""
self.login()
confirmed = self.create_confirmed_email()
self._assign_handle_to(
"assign_bluesky_handle_to_email",
confirmed,
"Adding Bluesky handle to Email fails?",
)
def _assign_handle_to(self, endpoint, confirmed, message):
"""
Helper method to assign a handle to reduce code duplication
Since the endpoints are similar, we can reuse the code
"""
url = reverse(endpoint, args=[confirmed.id])
response = self.client.post(
url, {"bluesky_handle": self.bsky_test_account}, follow=True
)
self.assertEqual(response.status_code, 200, message)
confirmed.refresh_from_db(fields=["bluesky_handle"])
self.assertEqual(
confirmed.bluesky_handle,
self.bsky_test_account,
"Setting Bluesky handle doesn't work?",
)
def test_assign_photo_to_mail_removes_bluesky_handle(self):
"""
Assign a Photo to a mail, removes Bluesky handle
"""
self.login()
confirmed = self.create_confirmed_email()
self._assign_bluesky_handle(confirmed, "assign_photo_email")
def test_assign_photo_to_openid_removes_bluesky_handle(self):
"""
Assign a Photo to a OpenID, removes Bluesky handle
"""
self.login()
confirmed = self.create_confirmed_openid()
self._assign_bluesky_handle(confirmed, "assign_photo_openid")
def _assign_bluesky_handle(self, confirmed, endpoint):
"""
Helper method to assign a Bluesky handle
Since the endpoints are similar, we can reuse the code
"""
confirmed.bluesky_handle = self.bsky_test_account
confirmed.save()
url = reverse(endpoint, args=[confirmed.id])
response = self.client.post(url, {"photoNone": True}, follow=True)
self.assertEqual(response.status_code, 200, "Unassigning Photo doesn't work?")
confirmed.refresh_from_db(fields=["bluesky_handle"])
self.assertEqual(
confirmed.bluesky_handle, None, "Removing Bluesky handle doesn't work?"
)

View File

@@ -2,7 +2,8 @@
""" """
URLs for ivatar.ivataraccount URLs for ivatar.ivataraccount
""" """
from django.urls import path, re_path from django.urls import path
from django.conf.urls import url
from django.contrib.auth.views import LogoutView from django.contrib.auth.views import LogoutView
from django.contrib.auth.views import ( from django.contrib.auth.views import (
@@ -20,21 +21,12 @@ from .views import RemoveUnconfirmedOpenIDView, RemoveConfirmedOpenIDView
from .views import ImportPhotoView, RawImageView, DeletePhotoView from .views import ImportPhotoView, RawImageView, DeletePhotoView
from .views import UploadPhotoView, AssignPhotoOpenIDView from .views import UploadPhotoView, AssignPhotoOpenIDView
from .views import AddOpenIDView, RedirectOpenIDView, ConfirmOpenIDView from .views import AddOpenIDView, RedirectOpenIDView, ConfirmOpenIDView
from .views import AssignBlueskyHandleToEmailView, AssignBlueskyHandleToOpenIdView
from .views import CropPhotoView from .views import CropPhotoView
from .views import UserPreferenceView, UploadLibravatarExportView from .views import UserPreferenceView, UploadLibravatarExportView
from .views import ResendConfirmationMailView from .views import ResendConfirmationMailView
from .views import IvatarLoginView from .views import IvatarLoginView
from .views import DeleteAccountView from .views import DeleteAccountView
from .views import ExportView from .views import ExportView
from .views import (
GenerateAvatarView,
AvatarPreviewView,
AvatarGalleryView,
ReusePromptView,
GenerationStatusView,
task_status_api,
)
# Define URL patterns, self documenting # Define URL patterns, self documenting
# To see the fancy, colorful evaluation of these use: # To see the fancy, colorful evaluation of these use:
@@ -80,116 +72,82 @@ urlpatterns = [ # pylint: disable=invalid-name
), ),
path("delete/", DeleteAccountView.as_view(), name="delete"), path("delete/", DeleteAccountView.as_view(), name="delete"),
path("profile/", ProfileView.as_view(), name="profile"), path("profile/", ProfileView.as_view(), name="profile"),
re_path( url(
"profile/(?P<profile_username>.+)", "profile/(?P<profile_username>.+)",
ProfileView.as_view(), ProfileView.as_view(),
name="profile_with_profile_username", name="profile_with_profile_username",
), ),
path("generate_avatar/", GenerateAvatarView.as_view(), name="generate_avatar"),
re_path(
r"generation_status/(?P<task_id>\d+)/",
GenerationStatusView.as_view(),
name="generation_status",
),
re_path(
r"api/task_status/(?P<task_id>\d+)/", task_status_api, name="task_status_api"
),
re_path(
r"avatar_preview/(?P<photo_id>\d+)/",
AvatarPreviewView.as_view(),
name="avatar_preview",
),
path("avatar_gallery/", AvatarGalleryView.as_view(), name="avatar_gallery"),
re_path(
r"reuse_prompt/(?P<photo_id>\d+)/",
ReusePromptView.as_view(),
name="reuse_prompt",
),
path("add_email/", AddEmailView.as_view(), name="add_email"), path("add_email/", AddEmailView.as_view(), name="add_email"),
path("add_openid/", AddOpenIDView.as_view(), name="add_openid"), path("add_openid/", AddOpenIDView.as_view(), name="add_openid"),
path("upload_photo/", UploadPhotoView.as_view(), name="upload_photo"), path("upload_photo/", UploadPhotoView.as_view(), name="upload_photo"),
path("password_set/", PasswordSetView.as_view(), name="password_set"), path("password_set/", PasswordSetView.as_view(), name="password_set"),
re_path( url(
r"remove_unconfirmed_openid/(?P<openid_id>\d+)", r"remove_unconfirmed_openid/(?P<openid_id>\d+)",
RemoveUnconfirmedOpenIDView.as_view(), RemoveUnconfirmedOpenIDView.as_view(),
name="remove_unconfirmed_openid", name="remove_unconfirmed_openid",
), ),
re_path( url(
r"remove_confirmed_openid/(?P<openid_id>\d+)", r"remove_confirmed_openid/(?P<openid_id>\d+)",
RemoveConfirmedOpenIDView.as_view(), RemoveConfirmedOpenIDView.as_view(),
name="remove_confirmed_openid", name="remove_confirmed_openid",
), ),
re_path( url(
r"openid_redirection/(?P<openid_id>\d+)", r"openid_redirection/(?P<openid_id>\d+)",
RedirectOpenIDView.as_view(), RedirectOpenIDView.as_view(),
name="openid_redirection", name="openid_redirection",
), ),
re_path( url(
r"confirm_openid/(?P<openid_id>\w+)", r"confirm_openid/(?P<openid_id>\w+)",
ConfirmOpenIDView.as_view(), ConfirmOpenIDView.as_view(),
name="confirm_openid", name="confirm_openid",
), ),
re_path( url(
r"confirm_email/(?P<verification_key>\w+)", r"confirm_email/(?P<verification_key>\w+)",
ConfirmEmailView.as_view(), ConfirmEmailView.as_view(),
name="confirm_email", name="confirm_email",
), ),
re_path( url(
r"remove_unconfirmed_email/(?P<email_id>\d+)", r"remove_unconfirmed_email/(?P<email_id>\d+)",
RemoveUnconfirmedEmailView.as_view(), RemoveUnconfirmedEmailView.as_view(),
name="remove_unconfirmed_email", name="remove_unconfirmed_email",
), ),
re_path( url(
r"remove_confirmed_email/(?P<email_id>\d+)", r"remove_confirmed_email/(?P<email_id>\d+)",
RemoveConfirmedEmailView.as_view(), RemoveConfirmedEmailView.as_view(),
name="remove_confirmed_email", name="remove_confirmed_email",
), ),
re_path( url(
r"assign_photo_email/(?P<email_id>\d+)", r"assign_photo_email/(?P<email_id>\d+)",
AssignPhotoEmailView.as_view(), AssignPhotoEmailView.as_view(),
name="assign_photo_email", name="assign_photo_email",
), ),
re_path( url(
r"assign_photo_openid/(?P<openid_id>\d+)", r"assign_photo_openid/(?P<openid_id>\d+)",
AssignPhotoOpenIDView.as_view(), AssignPhotoOpenIDView.as_view(),
name="assign_photo_openid", name="assign_photo_openid",
), ),
re_path( url(r"import_photo/$", ImportPhotoView.as_view(), name="import_photo"),
r"assign_bluesky_handle_to_email/(?P<email_id>\d+)", url(
AssignBlueskyHandleToEmailView.as_view(),
name="assign_bluesky_handle_to_email",
),
re_path(
r"assign_bluesky_handle_to_openid/(?P<open_id>\d+)",
AssignBlueskyHandleToOpenIdView.as_view(),
name="assign_bluesky_handle_to_openid",
),
re_path(r"import_photo/$", ImportPhotoView.as_view(), name="import_photo"),
re_path(
r"import_photo/(?P<email_addr>[\w.+-]+@[\w.]+.[\w.]+)", r"import_photo/(?P<email_addr>[\w.+-]+@[\w.]+.[\w.]+)",
ImportPhotoView.as_view(), ImportPhotoView.as_view(),
name="import_photo", name="import_photo",
), ),
re_path( url(
r"import_photo/(?P<email_id>\d+)", r"import_photo/(?P<email_id>\d+)",
ImportPhotoView.as_view(), ImportPhotoView.as_view(),
name="import_photo", name="import_photo",
), ),
re_path( url(r"delete_photo/(?P<pk>\d+)", DeletePhotoView.as_view(), name="delete_photo"),
r"delete_photo/(?P<pk>\d+)", DeletePhotoView.as_view(), name="delete_photo" url(r"raw_image/(?P<pk>\d+)", RawImageView.as_view(), name="raw_image"),
), url(r"crop_photo/(?P<pk>\d+)", CropPhotoView.as_view(), name="crop_photo"),
re_path(r"raw_image/(?P<pk>\d+)", RawImageView.as_view(), name="raw_image"), url(r"pref/$", UserPreferenceView.as_view(), name="user_preference"),
re_path(r"crop_photo/(?P<pk>\d+)", CropPhotoView.as_view(), name="crop_photo"), url(r"upload_export/$", UploadLibravatarExportView.as_view(), name="upload_export"),
re_path(r"pref/$", UserPreferenceView.as_view(), name="user_preference"), url(
re_path(
r"upload_export/$", UploadLibravatarExportView.as_view(), name="upload_export"
),
re_path(
r"upload_export/(?P<save>save)$", r"upload_export/(?P<save>save)$",
UploadLibravatarExportView.as_view(), UploadLibravatarExportView.as_view(),
name="upload_export", name="upload_export",
), ),
re_path( url(
r"resend_confirmation_mail/(?P<email_id>\d+)", r"resend_confirmation_mail/(?P<email_id>\d+)",
ResendConfirmationMailView.as_view(), ResendConfirmationMailView.as_view(),
name="resend_confirmation_mail", name="resend_confirmation_mail",

View File

@@ -2,12 +2,10 @@
""" """
View classes for ivatar/ivataraccount/ View classes for ivatar/ivataraccount/
""" """
from io import BytesIO from io import BytesIO
from ivatar.utils import urlopen, Bluesky from urllib.request import urlopen
import base64 import base64
import binascii import binascii
import contextlib
from xml.sax import saxutils from xml.sax import saxutils
import gzip import gzip
@@ -21,9 +19,8 @@ from django.utils.decorators import method_decorator
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.contrib import messages from django.contrib import messages
from django.views.generic.edit import FormView, UpdateView from django.views.generic.edit import FormView, UpdateView
from django.views.generic.base import View, TemplateView, RedirectView from django.views.generic.base import View, TemplateView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from django.views.generic.list import ListView
from django.contrib.auth import authenticate, login from django.contrib.auth import authenticate, login
from django.contrib.auth.forms import UserCreationForm, SetPasswordForm from django.contrib.auth.forms import UserCreationForm, SetPasswordForm
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
@@ -31,11 +28,9 @@ from django.contrib.auth.views import (
PasswordResetView as PasswordResetViewOriginal, PasswordResetView as PasswordResetViewOriginal,
) )
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.http import HttpResponseRedirect, HttpResponse, JsonResponse from django.http import HttpResponseRedirect, HttpResponse
from django.urls import reverse_lazy, reverse from django.urls import reverse_lazy, reverse
from django.shortcuts import render from django.shortcuts import render
from django.views.decorators.http import require_http_methods
import logging
from django_openid_auth.models import UserOpenID from django_openid_auth.models import UserOpenID
from openid import oidutil from openid import oidutil
@@ -51,23 +46,17 @@ from ivatar.settings import (
MAX_PHOTO_SIZE, MAX_PHOTO_SIZE,
JPEG_QUALITY, JPEG_QUALITY,
AVATAR_MAX_SIZE, AVATAR_MAX_SIZE,
SOCIAL_AUTH_FEDORA_KEY,
) )
from .gravatar import get_photo as get_gravatar_photo from .gravatar import get_photo as get_gravatar_photo
from .forms import AddEmailForm, UploadPhotoForm, AddOpenIDForm from .forms import AddEmailForm, UploadPhotoForm, AddOpenIDForm
from .forms import UpdatePreferenceForm, UploadLibravatarExportForm from .forms import UpdatePreferenceForm, UploadLibravatarExportForm
from .forms import DeleteAccountForm, GenerateAvatarForm from .forms import DeleteAccountForm
from .models import UnconfirmedEmail, ConfirmedEmail, Photo from .models import UnconfirmedEmail, ConfirmedEmail, Photo
from .models import UnconfirmedOpenId, ConfirmedOpenId, DjangoOpenIDStore from .models import UnconfirmedOpenId, ConfirmedOpenId, DjangoOpenIDStore
from .models import UserPreference from .models import UserPreference
from .models import file_format from .models import file_format
from .read_libravatar_export import read_gzdata as libravatar_read_gzdata from .read_libravatar_export import read_gzdata as libravatar_read_gzdata
from ivatar.ai_service import generate_avatar_image, AIServiceError
from ivatar.tasks import generate_avatar_task, update_queue_positions
from ivatar.ivataraccount.models import GenerationTask
logger = logging.getLogger(__name__)
def openid_logging(message, level=0): def openid_logging(message, level=0):
@@ -98,8 +87,23 @@ class CreateView(SuccessMessageMixin, FormView):
# If the username looks like a mail address, automagically # If the username looks like a mail address, automagically
# add it as unconfirmed mail and set it also as user's # add it as unconfirmed mail and set it also as user's
# email address # email address
with contextlib.suppress(Exception): try:
self._extracted_from_form_valid_(form, user) # This will error out if it's not a valid address
valid = validate_email(form.cleaned_data["username"])
user.email = valid.email
user.save()
# The following will also error out if it already exists
unconfirmed = UnconfirmedEmail()
unconfirmed.email = valid.email
unconfirmed.user = user
unconfirmed.save()
unconfirmed.send_confirmation_mail(
url=self.request.build_absolute_uri("/")[:-1]
)
# In any exception cases, we just skip it
except Exception: # pylint: disable=broad-except
pass
login(self.request, user) login(self.request, user)
pref = UserPreference.objects.create( pref = UserPreference.objects.create(
user_id=user.pk user_id=user.pk
@@ -108,26 +112,13 @@ class CreateView(SuccessMessageMixin, FormView):
return HttpResponseRedirect(reverse_lazy("profile")) return HttpResponseRedirect(reverse_lazy("profile"))
return HttpResponseRedirect(reverse_lazy("login")) # pragma: no cover return HttpResponseRedirect(reverse_lazy("login")) # pragma: no cover
def _extracted_from_form_valid_(self, form, user):
# This will error out if it's not a valid address
valid = validate_email(form.cleaned_data["username"])
user.email = valid.email
user.save()
# The following will also error out if it already exists
unconfirmed = UnconfirmedEmail()
unconfirmed.email = valid.email
unconfirmed.user = user
unconfirmed.save()
unconfirmed.send_confirmation_mail(
url=self.request.build_absolute_uri("/")[:-1]
)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
""" """
Handle get for create view Handle get for create view
""" """
if request.user and request.user.is_authenticated: if request.user:
return HttpResponseRedirect(reverse_lazy("profile")) if request.user.is_authenticated:
return HttpResponseRedirect(reverse_lazy("profile"))
return super().get(self, request, args, kwargs) return super().get(self, request, args, kwargs)
@@ -216,13 +207,6 @@ class ConfirmEmailView(SuccessMessageMixin, TemplateView):
messages.error(request, _("Verification key does not exist")) messages.error(request, _("Verification key does not exist"))
return HttpResponseRedirect(reverse_lazy("profile")) return HttpResponseRedirect(reverse_lazy("profile"))
if ConfirmedEmail.objects.filter(email=unconfirmed.email).count() > 0:
messages.error(
request,
_("This mail address has been taken already and cannot be confirmed"),
)
return HttpResponseRedirect(reverse_lazy("profile"))
# TODO: Check for a reasonable expiration time in unconfirmed email # TODO: Check for a reasonable expiration time in unconfirmed email
(confirmed_id, external_photos) = ConfirmedEmail.objects.create_confirmed_email( (confirmed_id, external_photos) = ConfirmedEmail.objects.create_confirmed_email(
@@ -284,30 +268,19 @@ class AssignPhotoEmailView(SuccessMessageMixin, TemplateView):
if "photoNone" in request.POST: if "photoNone" in request.POST:
email.photo = None email.photo = None
email.bluesky_handle = None
elif "photoBluesky" in request.POST:
# Keep the existing Bluesky handle, clear the photo
email.photo = None
# Don't clear bluesky_handle - keep it as is
else: else:
if "photo_id" not in request.POST: if "photo_id" not in request.POST:
messages.error(request, _("Invalid request [photo_id] missing")) messages.error(request, _("Invalid request [photo_id] missing"))
return HttpResponseRedirect(reverse_lazy("profile")) return HttpResponseRedirect(reverse_lazy("profile"))
if request.POST["photo_id"] == "bluesky": try:
# Handle Bluesky photo selection photo = self.model.objects.get( # pylint: disable=no-member
email.photo = None id=request.POST["photo_id"], user=request.user
# Don't clear bluesky_handle - keep it as is )
else: except self.model.DoesNotExist: # pylint: disable=no-member
try: messages.error(request, _("Photo does not exist"))
photo = self.model.objects.get( # pylint: disable=no-member return HttpResponseRedirect(reverse_lazy("profile"))
id=request.POST["photo_id"], user=request.user email.photo = photo
)
except self.model.DoesNotExist: # pylint: disable=no-member
messages.error(request, _("Photo does not exist"))
return HttpResponseRedirect(reverse_lazy("profile"))
email.photo = photo
email.bluesky_handle = None
email.save() email.save()
messages.success(request, _("Successfully changed photo")) messages.success(request, _("Successfully changed photo"))
@@ -357,7 +330,6 @@ class AssignPhotoOpenIDView(SuccessMessageMixin, TemplateView):
messages.error(request, _("Photo does not exist")) messages.error(request, _("Photo does not exist"))
return HttpResponseRedirect(reverse_lazy("profile")) return HttpResponseRedirect(reverse_lazy("profile"))
openid.photo = photo openid.photo = photo
openid.bluesky_handle = None
openid.save() openid.save()
messages.success(request, _("Successfully changed photo")) messages.success(request, _("Successfully changed photo"))
@@ -371,116 +343,6 @@ class AssignPhotoOpenIDView(SuccessMessageMixin, TemplateView):
return data return data
@method_decorator(login_required, name="dispatch")
class AssignBlueskyHandleToEmailView(SuccessMessageMixin, TemplateView):
"""
View class for assigning a Bluesky handle to an email address
"""
def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""
Handle post request - assign bluesky handle to email
"""
try:
email = ConfirmedEmail.objects.get(user=request.user, id=kwargs["email_id"])
except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member
messages.error(request, _("Invalid request"))
return HttpResponseRedirect(reverse_lazy("profile"))
if "bluesky_handle" not in request.POST:
messages.error(request, _("Invalid request [bluesky_handle] missing"))
return HttpResponseRedirect(reverse_lazy("profile"))
bluesky_handle = request.POST["bluesky_handle"]
try:
bs = Bluesky()
bs.get_avatar(bluesky_handle)
except Exception as e:
messages.error(request, _(f"Handle '{bluesky_handle}' not found: {e}"))
return HttpResponseRedirect(
reverse_lazy(
"assign_photo_email", kwargs={"email_id": int(kwargs["email_id"])}
)
)
try:
email.set_bluesky_handle(bluesky_handle)
except Exception as e:
messages.error(request, _(f"Error: {e}"))
return HttpResponseRedirect(
reverse_lazy(
"assign_photo_email", kwargs={"email_id": int(kwargs["email_id"])}
)
)
email.photo = None
email.save()
messages.success(request, _("Successfully assigned Bluesky handle"))
return HttpResponseRedirect(reverse_lazy("profile"))
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data["email"] = ConfirmedEmail.objects.get(pk=kwargs["email_id"])
return data
@method_decorator(login_required, name="dispatch")
class AssignBlueskyHandleToOpenIdView(SuccessMessageMixin, TemplateView):
"""
View class for assigning a Bluesky handle to an email address
"""
def post(self, request, *args, **kwargs): # pylint: disable=unused-argument
"""
Handle post request - assign bluesky handle to email
"""
try:
openid = ConfirmedOpenId.objects.get(
user=request.user, id=kwargs["open_id"]
)
except ConfirmedOpenId.DoesNotExist: # pylint: disable=no-member
messages.error(request, _("Invalid request"))
return HttpResponseRedirect(reverse_lazy("profile"))
if "bluesky_handle" not in request.POST:
messages.error(request, _("Invalid request [bluesky_handle] missing"))
return HttpResponseRedirect(reverse_lazy("profile"))
bluesky_handle = request.POST["bluesky_handle"]
try:
bs = Bluesky()
bs.get_avatar(bluesky_handle)
except Exception as e:
messages.error(request, _(f"Handle '{bluesky_handle}' not found: {e}"))
return HttpResponseRedirect(
reverse_lazy(
"assign_photo_openid", kwargs={"openid_id": int(kwargs["open_id"])}
)
)
try:
openid.set_bluesky_handle(bluesky_handle)
except Exception as e:
messages.error(request, _(f"Error: {e}"))
return HttpResponseRedirect(
reverse_lazy(
"assign_photo_openid", kwargs={"openid_id": int(kwargs["open_id"])}
)
)
openid.photo = None
openid.save()
messages.success(request, _("Successfully assigned Bluesky handle"))
return HttpResponseRedirect(reverse_lazy("profile"))
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data["openid"] = ConfirmedOpenId.objects.get(pk=kwargs["open_id"])
return data
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
class ImportPhotoView(SuccessMessageMixin, TemplateView): class ImportPhotoView(SuccessMessageMixin, TemplateView):
""" """
@@ -501,25 +363,29 @@ class ImportPhotoView(SuccessMessageMixin, TemplateView):
messages.error(self.request, _("Address does not exist")) messages.error(self.request, _("Address does not exist"))
return context return context
if addr := kwargs.get("email_addr", None): addr = kwargs.get("email_addr", None)
if gravatar := get_gravatar_photo(addr):
if addr:
gravatar = get_gravatar_photo(addr)
if gravatar:
context["photos"].append(gravatar) context["photos"].append(gravatar)
if libravatar_service_url := libravatar_url( libravatar_service_url = libravatar_url(
email=addr, email=addr,
default=404, default=404,
size=AVATAR_MAX_SIZE, size=AVATAR_MAX_SIZE,
): )
if libravatar_service_url:
try: try:
urlopen(libravatar_service_url) urlopen(libravatar_service_url)
except OSError as exc: except OSError as exc:
print(f"Exception caught during photo import: {exc}") print("Exception caught during photo import: {}".format(exc))
else: else:
context["photos"].append( context["photos"].append(
{ {
"service_url": libravatar_service_url, "service_url": libravatar_service_url,
"thumbnail_url": f"{libravatar_service_url}&s=80", "thumbnail_url": libravatar_service_url + "&s=80",
"image_url": f"{libravatar_service_url}&s=512", "image_url": libravatar_service_url + "&s=512",
"width": 80, "width": 80,
"height": 80, "height": 80,
"service_name": "Libravatar", "service_name": "Libravatar",
@@ -538,7 +404,7 @@ class ImportPhotoView(SuccessMessageMixin, TemplateView):
imported = None imported = None
email_id = kwargs.get("email_id", request.POST.get("email_id", None)) email_id = kwargs.get("email_id", request.POST.get("email_id", None))
addr = kwargs.get("email", request.POST.get("email_addr", None)) addr = kwargs.get("emali_addr", request.POST.get("email_addr", None))
if email_id: if email_id:
email = ConfirmedEmail.objects.filter(id=email_id, user=request.user) email = ConfirmedEmail.objects.filter(id=email_id, user=request.user)
@@ -588,9 +454,9 @@ class RawImageView(DetailView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
photo = self.model.objects.get(pk=kwargs["pk"]) # pylint: disable=no-member photo = self.model.objects.get(pk=kwargs["pk"]) # pylint: disable=no-member
if photo.user.id != request.user.id and not request.user.is_staff: if not photo.user.id == request.user.id and not request.user.is_staff:
return HttpResponseRedirect(reverse_lazy("home")) return HttpResponseRedirect(reverse_lazy("home"))
return HttpResponse(BytesIO(photo.data), content_type=f"image/{photo.format}") return HttpResponse(BytesIO(photo.data), content_type="image/%s" % photo.format)
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
@@ -666,16 +532,17 @@ class AddOpenIDView(SuccessMessageMixin, FormView):
success_url = reverse_lazy("profile") success_url = reverse_lazy("profile")
def form_valid(self, form): def form_valid(self, form):
if openid_id := form.save(self.request.user): openid_id = form.save(self.request.user)
# At this point we have an unconfirmed OpenID, but if not openid_id:
# we do not add the message, that we successfully added it,
# since this is misleading
return HttpResponseRedirect(
reverse_lazy("openid_redirection", args=[openid_id])
)
else:
return render(self.request, self.template_name, {"form": form}) return render(self.request, self.template_name, {"form": form})
# At this point we have an unconfirmed OpenID, but
# we do not add the message, that we successfully added it,
# since this is misleading
return HttpResponseRedirect(
reverse_lazy("openid_redirection", args=[openid_id])
)
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
class RemoveUnconfirmedOpenIDView(View): class RemoveUnconfirmedOpenIDView(View):
@@ -725,7 +592,7 @@ class RemoveConfirmedOpenIDView(View):
openidobj.delete() openidobj.delete()
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
# Why it is not there? # Why it is not there?
print(f"How did we get here: {exc}") print("How did we get here: %s" % exc)
openid.delete() openid.delete()
messages.success(request, _("ID removed")) messages.success(request, _("ID removed"))
except self.model.DoesNotExist: # pylint: disable=no-member except self.model.DoesNotExist: # pylint: disable=no-member
@@ -762,7 +629,7 @@ class RedirectOpenIDView(View):
try: try:
auth_request = openid_consumer.begin(user_url) auth_request = openid_consumer.begin(user_url)
except consumer.DiscoveryFailure as exc: except consumer.DiscoveryFailure as exc:
messages.error(request, _(f"OpenID discovery failed: {exc}")) messages.error(request, _("OpenID discovery failed: %s" % exc))
return HttpResponseRedirect(reverse_lazy("profile")) return HttpResponseRedirect(reverse_lazy("profile"))
except UnicodeDecodeError as exc: # pragma: no cover except UnicodeDecodeError as exc: # pragma: no cover
msg = _( msg = _(
@@ -774,7 +641,7 @@ class RedirectOpenIDView(View):
"message": exc, "message": exc,
} }
) )
print(f"message: {msg}") print("message: %s" % msg)
messages.error(request, msg) messages.error(request, msg)
if auth_request is None: # pragma: no cover if auth_request is None: # pragma: no cover
@@ -908,13 +775,19 @@ class CropPhotoView(TemplateView):
} }
email = openid = None email = openid = None
if "email" in request.POST: if "email" in request.POST:
with contextlib.suppress(ConfirmedEmail.DoesNotExist): try:
email = ConfirmedEmail.objects.get(email=request.POST["email"]) email = ConfirmedEmail.objects.get(email=request.POST["email"])
except ConfirmedEmail.DoesNotExist: # pylint: disable=no-member
pass # Ignore automatic assignment
if "openid" in request.POST: if "openid" in request.POST:
with contextlib.suppress(ConfirmedOpenId.DoesNotExist): try:
openid = ConfirmedOpenId.objects.get( # pylint: disable=no-member openid = ConfirmedOpenId.objects.get( # pylint: disable=no-member
openid=request.POST["openid"] openid=request.POST["openid"]
) )
except ConfirmedOpenId.DoesNotExist: # pylint: disable=no-member
pass # Ignore automatic assignment
return photo.perform_crop(request, dimensions, email, openid) return photo.perform_crop(request, dimensions, email, openid)
@@ -950,14 +823,14 @@ class UserPreferenceView(FormView, UpdateView):
if request.POST["email"] not in addresses: if request.POST["email"] not in addresses:
messages.error( messages.error(
self.request, self.request,
_(f'Mail address not allowed: {request.POST["email"]}'), _("Mail address not allowed: %s" % request.POST["email"]),
) )
else: else:
self.request.user.email = request.POST["email"] self.request.user.email = request.POST["email"]
self.request.user.save() self.request.user.save()
messages.info(self.request, _("Mail address changed.")) messages.info(self.request, _("Mail address changed."))
except Exception as e: # pylint: disable=broad-except except Exception as e: # pylint: disable=broad-except
messages.error(self.request, _(f"Error setting new mail address: {e}")) messages.error(self.request, _("Error setting new mail address: %s" % e))
try: try:
if request.POST["first_name"] or request.POST["last_name"]: if request.POST["first_name"] or request.POST["last_name"]:
@@ -969,7 +842,7 @@ class UserPreferenceView(FormView, UpdateView):
messages.info(self.request, _("Last name changed.")) messages.info(self.request, _("Last name changed."))
self.request.user.save() self.request.user.save()
except Exception as e: # pylint: disable=broad-except except Exception as e: # pylint: disable=broad-except
messages.error(self.request, _(f"Error setting names: {e}")) messages.error(self.request, _("Error setting names: %s" % e))
return HttpResponseRedirect(reverse_lazy("user_preference")) return HttpResponseRedirect(reverse_lazy("user_preference"))
@@ -1037,14 +910,15 @@ class UploadLibravatarExportView(SuccessMessageMixin, FormView):
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
# DEBUG # DEBUG
print( print(
f"Exception during adding mail address ({email}): {exc}" "Exception during adding mail address (%s): %s"
% (email, exc)
) )
if arg.startswith("photo"): if arg.startswith("photo"):
try: try:
data = base64.decodebytes(bytes(request.POST[arg], "utf-8")) data = base64.decodebytes(bytes(request.POST[arg], "utf-8"))
except binascii.Error as exc: except binascii.Error as exc:
print(f"Cannot decode photo: {exc}") print("Cannot decode photo: %s" % exc)
continue continue
try: try:
pilobj = Image.open(BytesIO(data)) pilobj = Image.open(BytesIO(data))
@@ -1058,7 +932,7 @@ class UploadLibravatarExportView(SuccessMessageMixin, FormView):
photo.data = out.read() photo.data = out.read()
photo.save() photo.save()
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
print(f"Exception during save: {exc}") print("Exception during save: %s" % exc)
continue continue
return HttpResponseRedirect(reverse_lazy("profile")) return HttpResponseRedirect(reverse_lazy("profile"))
@@ -1078,7 +952,7 @@ class UploadLibravatarExportView(SuccessMessageMixin, FormView):
}, },
) )
except Exception as e: except Exception as e:
messages.error(self.request, _(f"Unable to parse file: {e}")) messages.error(self.request, _("Unable to parse file: %s" % e))
return HttpResponseRedirect(reverse_lazy("upload_export")) return HttpResponseRedirect(reverse_lazy("upload_export"))
@@ -1104,12 +978,13 @@ class ResendConfirmationMailView(View):
try: try:
email.send_confirmation_mail(url=request.build_absolute_uri("/")[:-1]) email.send_confirmation_mail(url=request.build_absolute_uri("/")[:-1])
messages.success( messages.success(
request, f'{_("Confirmation mail sent to")}: {email.email}' request, "%s: %s" % (_("Confirmation mail sent to"), email.email)
) )
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
messages.error( messages.error(
request, request,
f'{_("Unable to send confirmation email for")} {email.email}: {exc}', "%s %s: %s"
% (_("Unable to send confirmation email for"), email.email, exc),
) )
return HttpResponseRedirect(reverse_lazy("profile")) return HttpResponseRedirect(reverse_lazy("profile"))
@@ -1127,18 +1002,9 @@ class IvatarLoginView(LoginView):
""" """
if request.user: if request.user:
if request.user.is_authenticated: if request.user.is_authenticated:
# Respect the 'next' parameter if present
next_url = request.GET.get("next")
if next_url:
return HttpResponseRedirect(next_url)
return HttpResponseRedirect(reverse_lazy("profile")) return HttpResponseRedirect(reverse_lazy("profile"))
return super().get(self, request, args, kwargs) return super().get(self, request, args, kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["with_fedora"] = SOCIAL_AUTH_FEDORA_KEY is not None
return context
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
class ProfileView(TemplateView): class ProfileView(TemplateView):
@@ -1152,9 +1018,12 @@ class ProfileView(TemplateView):
if "profile_username" in kwargs: if "profile_username" in kwargs:
if not request.user.is_staff: if not request.user.is_staff:
return HttpResponseRedirect(reverse_lazy("profile")) return HttpResponseRedirect(reverse_lazy("profile"))
with contextlib.suppress(Exception): try:
u = User.objects.get(username=kwargs["profile_username"]) u = User.objects.get(username=kwargs["profile_username"])
request.user = u request.user = u
except Exception: # pylint: disable=broad-except
pass
self._confirm_claimed_openid() self._confirm_claimed_openid()
return super().get(self, request, args, kwargs) return super().get(self, request, args, kwargs)
@@ -1185,7 +1054,7 @@ class ProfileView(TemplateView):
openid=openids.first().claimed_id openid=openids.first().claimed_id
).exists(): ).exists():
return return
print(f"need to confirm: {openids.first()}") print("need to confirm: %s" % openids.first())
confirmed = ConfirmedOpenId() confirmed = ConfirmedOpenId()
confirmed.user = self.request.user confirmed.user = self.request.user
confirmed.ip_address = get_client_ip(self.request)[0] confirmed.ip_address = get_client_ip(self.request)[0]
@@ -1203,7 +1072,7 @@ class PasswordResetView(PasswordResetViewOriginal):
Since we have the mail addresses in ConfirmedEmail model, Since we have the mail addresses in ConfirmedEmail model,
we need to set the email on the user object in order for the we need to set the email on the user object in order for the
PasswordResetView class to pick up the correct user. PasswordResetView class to pick up the correct user.
In case we have the mail address in the User object, we still In case we have the mail address in the User objecct, we still
need to assign a random password in order for PasswordResetView need to assign a random password in order for PasswordResetView
class to pick up the user - else it will silently do nothing. class to pick up the user - else it will silently do nothing.
""" """
@@ -1211,22 +1080,26 @@ class PasswordResetView(PasswordResetViewOriginal):
user = None user = None
# Try to find the user via the normal user class # Try to find the user via the normal user class
# TODO: How to handle the case that multiple user accounts try:
# could have the same password set? user = User.objects.get(email=request.POST["email"])
user = User.objects.filter(email=request.POST["email"]).first() except ObjectDoesNotExist:
pass
# If we didn't find the user in the previous step, # If we didn't find the user in the previous step,
# try the ConfirmedEmail class instead. # try the ConfirmedEmail class instead.
# If we find the user there, we need to set the mail # If we find the user there, we need to set the mail
# attribute on the user object accordingly # attribute on the user object accordingly
if not user: if not user:
with contextlib.suppress(ObjectDoesNotExist): try:
confirmed_email = ConfirmedEmail.objects.get( confirmed_email = ConfirmedEmail.objects.get(
email=request.POST["email"] email=request.POST["email"]
) )
user = confirmed_email.user user = confirmed_email.user
user.email = confirmed_email.email user.email = confirmed_email.email
user.save() user.save()
except ObjectDoesNotExist:
pass
# If we found the user, set a random password. Else, the # If we found the user, set a random password. Else, the
# ResetPasswordView class will silently ignore the password # ResetPasswordView class will silently ignore the password
# reset request # reset request
@@ -1266,6 +1139,7 @@ class DeleteAccountView(SuccessMessageMixin, FormView):
messages.error(request, _("No password given")) messages.error(request, _("No password given"))
return HttpResponseRedirect(reverse_lazy("delete")) return HttpResponseRedirect(reverse_lazy("delete"))
raise _("No password given")
# should delete all confirmed/unconfirmed/photo objects # should delete all confirmed/unconfirmed/photo objects
request.user.delete() request.user.delete()
return super().post(self, request, args, kwargs) return super().post(self, request, args, kwargs)
@@ -1288,7 +1162,7 @@ class ExportView(SuccessMessageMixin, TemplateView):
Handle real export Handle real export
""" """
SCHEMA_ROOT = "https://www.libravatar.org/schemas/export/0.2" SCHEMA_ROOT = "https://www.libravatar.org/schemas/export/0.2"
SCHEMA_XSD = f"{SCHEMA_ROOT}/export.xsd" SCHEMA_XSD = "%s/export.xsd" % SCHEMA_ROOT
def xml_header(): def xml_header():
return ( return (
@@ -1370,363 +1244,8 @@ class ExportView(SuccessMessageMixin, TemplateView):
bytesobj.seek(0) bytesobj.seek(0)
response = HttpResponse(content_type="application/gzip") response = HttpResponse(content_type="application/gzip")
response[ response["Content-Disposition"] = (
"Content-Disposition" 'attachment; filename="libravatar-export_%s.xml.gz"' % user.username
] = f'attachment; filename="libravatar-export_{user.username}.xml.gz"' )
response.write(bytesobj.read()) response.write(bytesobj.read())
return response return response
class GenerateAvatarView(SuccessMessageMixin, FormView):
"""
View for generating avatars using AI text-to-image
"""
template_name = "generate_avatar.html"
form_class = GenerateAvatarForm
success_message = _("Avatar generated successfully!")
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_initial(self):
"""Pre-populate form with reused prompt if available"""
initial = super().get_initial()
# Check for reused prompt from gallery
reuse_prompt = self.request.session.get("reuse_prompt", "")
if reuse_prompt:
initial["prompt"] = reuse_prompt
# Clear the reused prompt from session
del self.request.session["reuse_prompt"]
return initial
def form_valid(self, form):
"""
Handle form submission and queue avatar generation
"""
try:
# Get form data
prompt = form.cleaned_data["prompt"]
model = form.cleaned_data["model"]
quality = form.cleaned_data["quality"]
# Create generation task
task = GenerationTask.objects.create(
user=self.request.user,
prompt=prompt,
model=model,
quality=quality,
allow_nsfw=False, # Always false - no NSFW override allowed
status="pending",
)
# Update queue positions
update_queue_positions.delay()
# Queue the generation task
celery_task = generate_avatar_task.delay(
task_id=task.pk,
user_id=self.request.user.pk,
prompt=prompt,
model=model,
quality=quality,
allow_nsfw=False, # Always false - no NSFW override allowed
)
# Store task ID
task.task_id = celery_task.id
task.save()
# Store prompt in session for refinement
self.request.session["last_avatar_prompt"] = prompt
self.request.session["user_consent_given"] = True
messages.success(
self.request,
_("Avatar generation queued! You'll be notified when it's ready."),
)
# Redirect to task status page
return HttpResponseRedirect(
reverse_lazy("generation_status", kwargs={"task_id": task.pk})
)
except Exception as e:
logger.error(f"Unexpected error in avatar generation: {e}")
messages.error(
self.request, _("An unexpected error occurred. Please try again.")
)
return self.form_invalid(form)
class GenerationStatusView(TemplateView):
"""
View for showing avatar generation status and progress
"""
template_name = "generation_status.html"
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
task_id = kwargs.get("task_id")
try:
task = GenerationTask.objects.get(pk=task_id, user=self.request.user)
context["task"] = task
# Get queue information
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
"add_date"
)
processing_tasks = GenerationTask.objects.filter(status="processing")
context["queue_length"] = pending_tasks.count()
context["processing_count"] = processing_tasks.count()
context["queue_position"] = task.queue_position
# Get user's other tasks
user_tasks = GenerationTask.objects.filter(user=self.request.user).order_by(
"-add_date"
)[:5]
context["user_tasks"] = user_tasks
except GenerationTask.DoesNotExist:
messages.error(self.request, _("Generation task not found."))
return HttpResponseRedirect(reverse_lazy("profile"))
return context
class AvatarPreviewView(SuccessMessageMixin, FormView):
"""
View for previewing generated avatars and allowing refinements
"""
template_name = "avatar_preview.html"
form_class = GenerateAvatarForm
success_message = _("Avatar regenerated successfully!")
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_context_data(self, **kwargs):
"""Add photo and related data to context"""
context = super().get_context_data(**kwargs)
try:
photo = Photo.objects.get(
pk=self.kwargs["photo_id"], user=self.request.user
)
context["photo"] = photo
context["photo_url"] = reverse("raw_image", kwargs={"pk": photo.pk})
# Get user's confirmed emails for assignment
context["confirmed_emails"] = self.request.user.confirmedemail_set.all()
except Photo.DoesNotExist:
messages.error(self.request, _("Avatar not found."))
return HttpResponseRedirect(reverse_lazy("profile"))
return context
def get_initial(self):
"""Pre-populate form with current prompt if available"""
initial = super().get_initial()
initial["model"] = "stable_diffusion"
initial["quality"] = "medium"
# Try to get the prompt from the session or URL parameters
prompt = self.request.session.get("last_avatar_prompt", "")
if prompt:
initial["prompt"] = prompt
# Pre-check consent checkboxes since user already gave consent
if self.request.session.get("user_consent_given", False):
initial["not_porn"] = True
initial["can_distribute"] = True
return initial
def form_valid(self, form):
"""
Handle refinement - generate new avatar with modified prompt
"""
try:
# Generate new avatar with refined prompt
prompt = form.cleaned_data["prompt"]
model = form.cleaned_data["model"]
quality = form.cleaned_data["quality"]
generated_image = generate_avatar_image(
prompt=prompt,
model=model,
size=(512, 512),
quality=quality,
allow_nsfw=False, # Always false - no NSFW override allowed
)
# Convert PIL image to bytes
img_buffer = BytesIO()
generated_image.save(img_buffer, format="PNG")
img_data = img_buffer.getvalue()
# Create new Photo object
new_photo = Photo()
new_photo.user = self.request.user
new_photo.ip_address = get_client_ip(self.request)[0]
new_photo.data = img_data
new_photo.format = "png"
# Store AI generation metadata
new_photo.ai_generated = True
new_photo.ai_prompt = prompt
new_photo.ai_model = model
new_photo.ai_quality = "medium" # Default quality
new_photo.save()
# Store the new prompt and preserve consent in session for further refinement
self.request.session["last_avatar_prompt"] = prompt
self.request.session["user_consent_given"] = True
messages.success(
self.request,
_(
"Avatar regenerated successfully! You can refine it further or assign it to your email addresses."
),
)
# Redirect to preview the new avatar
return HttpResponseRedirect(
reverse_lazy("avatar_preview", kwargs={"photo_id": new_photo.pk})
)
except Photo.DoesNotExist:
messages.error(self.request, _("Original avatar not found."))
return HttpResponseRedirect(reverse_lazy("profile"))
except AIServiceError as e:
messages.error(
self.request,
_("Failed to regenerate avatar: %(error)s") % {"error": str(e)},
)
return self.form_invalid(form)
except Exception as e:
messages.error(
self.request,
_("An unexpected error occurred: %(error)s") % {"error": str(e)},
)
return self.form_invalid(form)
class AvatarGalleryView(ListView):
"""
View for displaying a gallery of recent AI-generated avatars
"""
template_name = "avatar_gallery.html"
context_object_name = "avatars"
paginate_by = 12 # Show 12 avatars per page
@method_decorator(login_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def get_queryset(self):
"""Get the last 30 AI-generated avatars from all users, excluding invalid ones"""
return (
Photo.objects.filter(
ai_generated=True,
ai_prompt__isnull=False,
ai_invalid=False, # Exclude invalid images
)
.exclude(ai_prompt="")
.order_by("-add_date")[:30]
)
def get_context_data(self, **kwargs):
"""Add additional context data"""
context = super().get_context_data(**kwargs)
# Add user's own avatars for quick access (filtered for validity)
context["user_avatars"] = (
Photo.objects.filter(
user=self.request.user,
ai_generated=True,
ai_prompt__isnull=False,
ai_invalid=False, # Exclude invalid images
)
.exclude(ai_prompt="")
.order_by("-add_date")[:10]
)
return context
class ReusePromptView(RedirectView):
"""
View to reuse a prompt from the gallery
"""
permanent = False
def get_redirect_url(self, *args, **kwargs):
"""Redirect to generate avatar page with pre-filled prompt"""
try:
photo = Photo.objects.get(
pk=kwargs["photo_id"], ai_generated=True, ai_prompt__isnull=False
)
# Store the prompt in session for the generate form
self.request.session["reuse_prompt"] = photo.ai_prompt
# Redirect to generate avatar page
return reverse_lazy("generate_avatar")
except Photo.DoesNotExist:
messages.error(self.request, _("Avatar not found."))
return reverse_lazy("avatar_gallery")
@method_decorator(login_required, name="dispatch")
@require_http_methods(["GET"])
def task_status_api(request, task_id):
"""
API endpoint to get task status for AJAX requests
"""
try:
task = GenerationTask.objects.get(pk=task_id, user=request.user)
# Get queue information
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
"add_date"
)
processing_tasks = GenerationTask.objects.filter(status="processing")
data = {
"status": task.status,
"progress": task.progress,
"queue_position": task.queue_position,
"queue_length": pending_tasks.count(),
"processing_count": processing_tasks.count(),
"error_message": task.error_message,
"generated_photo_id": task.generated_photo.pk
if task.generated_photo
else None,
"status_display": task.get_status_display(),
}
return JsonResponse(data)
except GenerationTask.DoesNotExist:
return JsonResponse({"error": "Task not found"}, status=404)
except Exception as e:
logger.error(f"Error in task status API: {e}")
return JsonResponse({"error": "Internal server error"}, status=500)

View File

@@ -4,40 +4,6 @@ Middleware classes
""" """
from django.utils.deprecation import MiddlewareMixin from django.utils.deprecation import MiddlewareMixin
from django.middleware.locale import LocaleMiddleware
class CustomLocaleMiddleware(LocaleMiddleware):
"""
Middleware that extends LocaleMiddleware to skip Vary header processing for image URLs
"""
def process_response(self, request, response):
# Check if this is an image-related URL
path = request.path
if any(
path.startswith(prefix)
for prefix in ["/avatar/", "/gravatarproxy/", "/blueskyproxy/"]
):
# Delete Vary from header if exists
if "Vary" in response:
del response["Vary"]
# Extract hash from URL path for ETag
# URLs are like /avatar/{hash}, /gravatarproxy/{hash}, /blueskyproxy/{hash}
path_parts = path.strip("/").split("/")
if len(path_parts) >= 2:
hash_value = path_parts[1] # Get the hash part
response["Etag"] = f'"{hash_value}"'
else:
# Fallback to content hash if we can't extract from URL
response["Etag"] = f'"{hash(response.content)}"'
# Skip the parent's process_response to avoid adding Accept-Language to Vary
return response
# For all other URLs, use the parent's behavior
return super().process_response(request, response)
class MultipleProxyMiddleware( class MultipleProxyMiddleware(

View File

@@ -32,8 +32,6 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"social_django",
"django_celery_results",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -51,7 +49,7 @@ ROOT_URLCONF = "ivatar.urls"
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")], "DIRS": [],
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
"context_processors": [ "context_processors": [
@@ -59,10 +57,7 @@ TEMPLATES = [
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"django.template.context_processors.i18n",
"social_django.context_processors.login_redirect",
], ],
"debug": DEBUG,
}, },
}, },
] ]
@@ -77,7 +72,6 @@ DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"), "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
"ATOMIC_REQUESTS": True,
} }
} }
@@ -91,9 +85,6 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
{ {
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", # noqa "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", # noqa
"OPTIONS": {
"min_length": 6,
},
}, },
{ {
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", # noqa "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", # noqa
@@ -103,70 +94,6 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
# Password Hashing (more secure)
PASSWORD_HASHERS = [
# This isn't working in older Python environments
# "django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
]
# Security Settings
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
CSRF_COOKIE_SECURE = not DEBUG
SESSION_COOKIE_SECURE = not DEBUG
if not DEBUG:
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Social authentication
TRUST_EMAIL_FROM_SOCIAL_AUTH_BACKENDS = ["fedora"]
SOCIAL_AUTH_PIPELINE = (
# Get the information we can about the user and return it in a simple
# format to create the user instance later. In some cases the details are
# already part of the auth response from the provider, but sometimes this
# could hit a provider API.
"social_core.pipeline.social_auth.social_details",
# Get the social uid from whichever service we're authing thru. The uid is
# the unique identifier of the given user in the provider.
"social_core.pipeline.social_auth.social_uid",
# Verifies that the current auth process is valid within the current
# project, this is where emails and domains whitelists are applied (if
# defined).
"social_core.pipeline.social_auth.auth_allowed",
# Checks if the current social-account is already associated in the site.
"social_core.pipeline.social_auth.social_user",
# Make up a username for this person, appends a random string at the end if
# there's any collision.
"social_core.pipeline.user.get_username",
# Send a validation email to the user to verify its email address.
# Disabled by default.
# 'social_core.pipeline.mail.mail_validation',
# Associates the current social details with another user account with
# a similar email address. Disabled by default.
"social_core.pipeline.social_auth.associate_by_email",
# Associates the current social details with an existing user account with
# a matching ConfirmedEmail.
"ivatar.ivataraccount.auth.associate_by_confirmed_email",
# Create a user account if we haven't found one yet.
"social_core.pipeline.user.create_user",
# Create the record that associates the social account with the user.
"social_core.pipeline.social_auth.associate_user",
# Populate the extra_data field in the social record with the values
# specified by settings (and the default ones like access_token, etc).
"social_core.pipeline.social_auth.load_extra_data",
# Update the user record with any changed info from the auth service.
"social_core.pipeline.user.user_details",
# Create the ConfirmedEmail if appropriate.
"ivatar.ivataraccount.auth.add_confirmed_email",
)
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/ # https://docs.djangoproject.com/en/2.0/topics/i18n/
@@ -189,4 +116,4 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static")
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
from config import * # pylint: disable=wildcard-import,wrong-import-position,unused-wildcard-import # noqa from config import * # pylint: disable=wildcard-import,wrong-import-position,unused-wildcard-import

File diff suppressed because it is too large Load Diff

View File

@@ -1,586 +0,0 @@
#surly-badge {
font-family: sans-serif !important;
font-weight: 400 !important;
width: 134px !important;
height: 164px !important;
text-align: center !important;
display: -webkit-box !important;
display: -ms-flexbox !important;
display: flex !important;
-webkit-box-orient: vertical !important;
-webkit-box-direction: normal !important;
-ms-flex-direction: column !important;
flex-direction: column !important;
-webkit-box-align: center !important;
-ms-flex-align: center !important;
align-items: center !important;
position: relative !important;
background-size: contain !important;
background-repeat: no-repeat !important;
background-position: top center !important;
-webkit-box-sizing: content-box !important;
box-sizing: content-box !important;
padding: 8px 15px 0 !important;
}
#surly-badge p {
margin: 0 !important;
}
#surly-badge.surly-badge_black-blue {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 164 150'%3E%3Cpath style='fill:none;stroke:%23fff;stroke-width:2;stroke-miterlimit:10;' d='M16.05,22.74c0-7.61,6.16-13.76,13.76-13.76 M16.05,22.74v127.15 M29.02,140.85l-12.52,8.2 M28.47,141.01 h105.78 M134.25,141.01c7.61,0,13.76-6.16,13.76-13.76 M148.01,21.03v106.22 M29.81,8.97h106.2'/%3E%3Cpath style='stroke:%233273f6;stroke-width:2;stroke-miterlimit:10;' d='M148.01,18.02V-0.02 M138.99,9h18.04'/%3E%3C/svg%3E");
color: #fff !important;
}
#surly-badge.surly-badge_black-blue .surly-badge__header-text {
color: #3273f6 !important;
}
#surly-badge.surly-badge_black-blue .surly-badge__header-title {
color: #fff !important;
}
#surly-badge.surly-badge_black-blue .surly-badge__tag {
background-color: #3273f6 !important;
border-bottom: 1px solid #707070 !important;
}
#surly-badge.surly-badge_black-blue .surly-badge__tag-text {
color: #fff !important;
}
#surly-badge.surly-badge_black-blue .surly-badge__tag::before {
border-color: transparent #7f8ca5 transparent transparent !important;
}
#surly-badge.surly-badge_black-blue .surly-badge__tag::after {
border-color: transparent transparent transparent #7f8ca5 !important;
}
#surly-badge.surly-badge_black-blue .surly-badge__footer-link {
color: #3273f6 !important;
}
#surly-badge.surly-badge_black-blue .surly-badge__footer-title {
color: #fff !important;
}
#surly-badge.surly-badge_black-blue .surly-badge__footer-text {
color: #fff !important;
}
#surly-badge.surly-badge_black-gradient {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 164 150'%3E%3Cpath style='fill:none;stroke:%23fff;stroke-width:2;stroke-miterlimit:10;' d='M16.05,22.74c0-7.61,6.16-13.76,13.76-13.76 M16.05,22.74v127.15 M29.02,140.85l-12.52,8.2 M28.47,141.01 h105.78 M134.25,141.01c7.61,0,13.76-6.16,13.76-13.76 M148.01,21.03v106.22 M29.81,8.97h106.2'/%3E%3Cpath style='stroke:%23ff715e;stroke-width:2;stroke-miterlimit:10;' d='M148.01,18.02V-0.02 M138.99,9h18.04'/%3E%3C/svg%3E");
color: #fff !important;
}
#surly-badge.surly-badge_black-gradient .surly-badge__header-text {
color: #3273f6 !important;
background: #ff715e !important;
background-image: -webkit-gradient(
linear,
left top,
right top,
from(#ff715e),
to(#00a8ff)
) !important;
background-image: -o-linear-gradient(
left,
#ff715e 0%,
#00a8ff 100%
) !important;
background-image: linear-gradient(90deg, #ff715e 0%, #00a8ff 100%) !important;
background-size: 100% !important;
-webkit-background-clip: text !important;
-moz-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
-moz-text-fill-color: transparent !important;
}
#surly-badge.surly-badge_black-gradient .surly-badge__header-title {
color: #fff !important;
}
#surly-badge.surly-badge_black-gradient .surly-badge__tag {
background: #ff715e !important;
background: -webkit-gradient(
linear,
left top,
right top,
from(#ff715e),
to(#00a8ff)
) !important;
background: -o-linear-gradient(left, #ff715e 0%, #00a8ff 100%) !important;
background: linear-gradient(90deg, #ff715e 0%, #00a8ff 100%) !important;
border-bottom: 1px solid #707070 !important;
}
#surly-badge.surly-badge_black-gradient .surly-badge__tag-text {
color: #fff !important;
}
#surly-badge.surly-badge_black-gradient .surly-badge__tag::before {
border-color: transparent #914339 transparent transparent !important;
}
#surly-badge.surly-badge_black-gradient .surly-badge__tag::after {
border-color: transparent transparent transparent #3b7696 !important;
}
#surly-badge.surly-badge_black-gradient .surly-badge__footer-link {
color: #ff715e !important;
}
#surly-badge.surly-badge_black-gradient .surly-badge__footer-title {
color: #fff !important;
}
#surly-badge.surly-badge_black-gradient .surly-badge__footer-text {
color: #fff !important;
}
#surly-badge.surly-badge_black-red {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 164 150'%3E%3Cpath style='fill:none;stroke:%23fff;stroke-width:2;stroke-miterlimit:10;' d='M16.05,22.74c0-7.61,6.16-13.76,13.76-13.76 M16.05,22.74v127.15 M29.02,140.85l-12.52,8.2 M28.47,141.01 h105.78 M134.25,141.01c7.61,0,13.76-6.16,13.76-13.76 M148.01,21.03v106.22 M29.81,8.97h106.2'/%3E%3Cpath style='stroke:%23ff715e;stroke-width:2;stroke-miterlimit:10;' d='M148.01,18.02V-0.02 M138.99,9h18.04'/%3E%3C/svg%3E");
color: #fff !important;
}
#surly-badge.surly-badge_black-red .surly-badge__header-text {
color: #ff715e !important;
}
#surly-badge.surly-badge_black-red .surly-badge__header-title {
color: #fff !important;
}
#surly-badge.surly-badge_black-red .surly-badge__tag {
background-color: #ff715e !important;
border-bottom: 1px solid #707070 !important;
}
#surly-badge.surly-badge_black-red .surly-badge__tag-text {
color: #fff !important;
}
#surly-badge.surly-badge_black-red .surly-badge__tag::before {
border-color: transparent #914339 transparent transparent !important;
}
#surly-badge.surly-badge_black-red .surly-badge__tag::after {
border-color: transparent transparent transparent #914339 !important;
}
#surly-badge.surly-badge_black-red .surly-badge__footer-link {
color: #ff715e !important;
}
#surly-badge.surly-badge_black-red .surly-badge__footer-title {
color: #fff !important;
}
#surly-badge.surly-badge_black-red .surly-badge__footer-text {
color: #fff !important;
}
#surly-badge.surly-badge_black-white {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 164 150'%3E%3Cpath style='fill:none;stroke:%23fff;stroke-width:2;stroke-miterlimit:10;' d='M16.05,22.74c0-7.61,6.16-13.76,13.76-13.76 M16.05,22.74v127.15 M29.02,140.85l-12.52,8.2 M28.47,141.01 h105.78 M134.25,141.01c7.61,0,13.76-6.16,13.76-13.76 M148.01,21.03v106.22 M29.81,8.97h106.2'/%3E%3Cpath style='stroke:%23fff;stroke-width:2;stroke-miterlimit:10;' d='M148.01,18.02V-0.02 M138.99,9h18.04'/%3E%3C/svg%3E");
color: #fff !important;
}
#surly-badge.surly-badge_black-white .surly-badge__header-text {
color: #fff !important;
}
#surly-badge.surly-badge_black-white .surly-badge__header-title {
color: #fff !important;
}
#surly-badge.surly-badge_black-white .surly-badge__tag {
background-color: #fff !important;
border-bottom: 1px solid #707070 !important;
}
#surly-badge.surly-badge_black-white .surly-badge__tag-text {
color: #000 !important;
}
#surly-badge.surly-badge_black-white .surly-badge__tag::before {
border-color: transparent #707070 transparent transparent !important;
}
#surly-badge.surly-badge_black-white .surly-badge__tag::after {
border-color: transparent transparent transparent #707070 !important;
}
#surly-badge.surly-badge_black-white .surly-badge__footer-link {
color: #fff !important;
}
#surly-badge.surly-badge_black-white .surly-badge__footer-title {
color: #fff !important;
}
#surly-badge.surly-badge_black-white .surly-badge__footer-text {
color: #fff !important;
}
#surly-badge.surly-badge_white-blue {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 164 150'%3E%3Cpath style='fill:none;stroke:%232e2e2e;stroke-width:2;stroke-miterlimit:10;' d='M16.05,22.74c0-7.61,6.16-13.76,13.76-13.76 M16.05,22.74v127.15 M29.02,140.85l-12.52,8.2 M28.47,141.01 h105.78 M134.25,141.01c7.61,0,13.76-6.16,13.76-13.76 M148.01,21.03v106.22 M29.81,8.97h106.2'/%3E%3Cpath style='stroke:%2302a7fd;stroke-width:2;stroke-miterlimit:10;' d='M148.01,18.02V-0.02 M138.99,9h18.04'/%3E%3C/svg%3E");
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-blue .surly-badge__header-text {
color: #02a7fd !important;
}
#surly-badge.surly-badge_white-blue .surly-badge__header-title {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-blue .surly-badge__tag {
background-color: #02a7fd !important;
border-bottom: 1px solid #707070 !important;
}
#surly-badge.surly-badge_white-blue .surly-badge__tag-text {
color: #fff !important;
}
#surly-badge.surly-badge_white-blue .surly-badge__tag::before {
border-color: transparent #3b7696 transparent transparent !important;
}
#surly-badge.surly-badge_white-blue .surly-badge__tag::after {
border-color: transparent transparent transparent #3b7696 !important;
}
#surly-badge.surly-badge_white-blue .surly-badge__footer-link {
color: #02a7fd !important;
}
#surly-badge.surly-badge_white-blue .surly-badge__footer-title {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-blue .surly-badge__footer-text {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-gradient {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 164 150'%3E%3Cpath style='fill:none;stroke:%232e2e2e;stroke-width:2;stroke-miterlimit:10;' d='M16.05,22.74c0-7.61,6.16-13.76,13.76-13.76 M16.05,22.74v127.15 M29.02,140.85l-12.52,8.2 M28.47,141.01 h105.78 M134.25,141.01c7.61,0,13.76-6.16,13.76-13.76 M148.01,21.03v106.22 M29.81,8.97h106.2'/%3E%3Cpath style='stroke:%23ff5741;stroke-width:2;stroke-miterlimit:10;' d='M148.01,18.02V-0.02 M138.99,9h18.04'/%3E%3C/svg%3E");
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-gradient .surly-badge__header-text {
color: #ff5741 !important;
background: #ff715e !important;
background-image: -webkit-gradient(
linear,
left top,
right top,
from(#ff715e),
to(#00a8ff)
) !important;
background-image: -o-linear-gradient(
left,
#ff715e 0%,
#00a8ff 100%
) !important;
background-image: linear-gradient(90deg, #ff715e 0%, #00a8ff 100%) !important;
background-size: 100% !important;
-webkit-background-clip: text !important;
-moz-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
-moz-text-fill-color: transparent !important;
}
#surly-badge.surly-badge_white-gradient .surly-badge__header-title {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-gradient .surly-badge__tag {
background: #ff715e !important;
background: -webkit-gradient(
linear,
left top,
right top,
from(#ff715e),
to(#00a8ff)
) !important;
background: -o-linear-gradient(left, #ff715e 0%, #00a8ff 100%) !important;
background: linear-gradient(90deg, #ff715e 0%, #00a8ff 100%) !important;
border-bottom: 1px solid #707070 !important;
}
#surly-badge.surly-badge_white-gradient .surly-badge__tag-text {
color: #fff !important;
}
#surly-badge.surly-badge_white-gradient .surly-badge__tag::before {
border-color: transparent #914339 transparent transparent !important;
}
#surly-badge.surly-badge_white-gradient .surly-badge__tag::after {
border-color: transparent transparent transparent #3b7696 !important;
}
#surly-badge.surly-badge_white-gradient .surly-badge__footer-link {
color: #ff5741 !important;
}
#surly-badge.surly-badge_white-gradient .surly-badge__footer-title {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-gradient .surly-badge__footer-text {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-red {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 164 150'%3E%3Cpath style='fill:none;stroke:%232e2e2e;stroke-width:2;stroke-miterlimit:10;' d='M16.05,22.74c0-7.61,6.16-13.76,13.76-13.76 M16.05,22.74v127.15 M29.02,140.85l-12.52,8.2 M28.47,141.01 h105.78 M134.25,141.01c7.61,0,13.76-6.16,13.76-13.76 M148.01,21.03v106.22 M29.81,8.97h106.2'/%3E%3Cpath style='stroke:%23ff715e;stroke-width:2;stroke-miterlimit:10;' d='M148.01,18.02V-0.02 M138.99,9h18.04'/%3E%3C/svg%3E");
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-red .surly-badge__header-text {
color: #ff715e !important;
}
#surly-badge.surly-badge_white-red .surly-badge__header-title {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-red .surly-badge__tag {
background-color: #ff715e !important;
border-bottom: 1px solid #707070 !important;
}
#surly-badge.surly-badge_white-red .surly-badge__tag-text {
color: #fff !important;
}
#surly-badge.surly-badge_white-red .surly-badge__tag::before {
border-color: transparent #914339 transparent transparent !important;
}
#surly-badge.surly-badge_white-red .surly-badge__tag::after {
border-color: transparent transparent transparent #914339 !important;
}
#surly-badge.surly-badge_white-red .surly-badge__footer-link {
color: #ff715e !important;
}
#surly-badge.surly-badge_white-red .surly-badge__footer-title {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-red .surly-badge__footer-text {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-black {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 164 150'%3E%3Cpath style='fill:none;stroke:%232e2e2e;stroke-width:2;stroke-miterlimit:10;' d='M16.05,22.74c0-7.61,6.16-13.76,13.76-13.76 M16.05,22.74v127.15 M29.02,140.85l-12.52,8.2 M28.47,141.01 h105.78 M134.25,141.01c7.61,0,13.76-6.16,13.76-13.76 M148.01,21.03v106.22 M29.81,8.97h106.2'/%3E%3Cpath style='stroke:%23707070;stroke-width:2;stroke-miterlimit:10;' d='M148.01,18.02V-0.02 M138.99,9h18.04'/%3E%3C/svg%3E");
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-black .surly-badge__header-text {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-black .surly-badge__header-title {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-black .surly-badge__tag {
background-color: #2e2e2e !important;
border-bottom: 1px solid #707070 !important;
}
#surly-badge.surly-badge_white-black .surly-badge__tag-text {
color: #fff !important;
}
#surly-badge.surly-badge_white-black .surly-badge__tag::before {
border-color: transparent #707070 transparent transparent !important;
}
#surly-badge.surly-badge_white-black .surly-badge__tag::after {
border-color: transparent transparent transparent #707070 !important;
}
#surly-badge.surly-badge_white-black .surly-badge__footer-link {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-black .surly-badge__footer-title {
color: #2e2e2e !important;
}
#surly-badge.surly-badge_white-black .surly-badge__footer-text {
color: #2e2e2e !important;
}
#surly-badge .surly-badge__header {
position: relative !important;
z-index: 10 !important;
padding: 12px 6px 0 !important;
}
#surly-badge .surly-badge__header-title {
font-family: sans-serif !important;
font-size: 12px !important;
font-weight: 600 !important;
text-transform: uppercase !important;
line-height: 1 !important;
float: none !important;
text-align: center !important;
padding: 0 !important;
margin: 0 !important;
margin-bottom: 6px !important;
}
#surly-badge .surly-badge__header-text {
font-size: 40px !important;
font-weight: 700 !important;
text-transform: uppercase !important;
line-height: 33px !important;
float: none !important;
padding: 0 !important;
margin: 0 !important;
margin-bottom: 4px !important;
}
#surly-badge .surly-badge__tag {
height: 18px !important;
width: calc(100% + 26px) !important;
display: -webkit-box !important;
display: -ms-flexbox !important;
display: flex !important;
-webkit-box-align: center !important;
-ms-flex-align: center !important;
align-items: center !important;
position: relative !important;
z-index: 10 !important;
-ms-flex-negative: 0 !important;
flex-shrink: 0 !important;
padding: 0 2px !important;
}
#surly-badge .surly-badge__tag-text {
font-size: 10px !important;
font-weight: 500 !important;
cursor: pointer !important;
white-space: nowrap !important;
overflow: hidden !important;
width: 100% !important;
float: none !important;
-o-text-overflow: ellipsis !important;
text-overflow: ellipsis !important;
line-height: initial !important;
text-decoration: none !important;
padding: 0 0 !important;
}
#surly-badge .surly-badge__tag::before,
#surly-badge .surly-badge__tag::after {
content: "" !important;
display: block !important;
position: absolute !important;
width: 0 !important;
height: 0 !important;
border-style: solid !important;
}
#surly-badge .surly-badge__tag::before {
border-width: 0 15px 15px 0 !important;
left: 0 !important;
bottom: -15px !important;
}
#surly-badge .surly-badge__tag::after {
border-width: 15px 0 0 15px !important;
right: 0 !important;
top: -15px !important;
}
#surly-badge .surly-badge__footer {
position: relative !important;
z-index: 10 !important;
white-space: nowrap !important;
width: 100% !important;
padding-top: 6px !important;
}
#surly-badge .surly-badge__footer-title {
font-family: sans-serif !important;
font-size: 15px !important;
font-weight: 600 !important;
text-transform: uppercase !important;
overflow: hidden !important;
-o-text-overflow: ellipsis !important;
text-overflow: ellipsis !important;
letter-spacing: -0.5px !important;
line-height: 1 !important;
float: none !important;
text-align: center !important;
-webkit-box-sizing: border-box !important;
box-sizing: border-box !important;
padding: 0 12px !important;
margin: 0 !important;
margin-bottom: 5px !important;
}
#surly-badge .surly-badge__footer-text {
font-size: 13px !important;
font-weight: 500 !important;
line-height: 1 !important;
float: none !important;
text-align: center !important;
padding: 0 !important;
margin: 0 !important;
}
#surly-badge .surly-badge__footer-link {
font-size: 13px !important;
cursor: pointer !important;
text-decoration: underline !important;
line-height: initial !important;
display: inline-block !important;
float: none !important;
}
#surly-badge .surly-badge__date {
font-size: 16px !important;
font-weight: 600 !important;
-webkit-box-flex: 1 !important;
-ms-flex-positive: 1 !important;
flex-grow: 1 !important;
display: -webkit-box !important;
display: -ms-flexbox !important;
display: flex !important;
-webkit-box-align: end !important;
-ms-flex-align: end !important;
align-items: flex-end !important;
line-height: 1 !important;
text-align: center !important;
float: none !important;
}
#surly-badge br {
display: none !important;
}
.surly__id_56263329.surly-badge_white-blue {
margin: 0 auto !important;
}
.surly__id_135641946#surly-badge {
padding-top: 6px !important;
}
.surly__id_135641946#surly-badge .surly-badge__footer {
line-height: 1 !important;
}
.surly__id_135641946#surly-badge .surly-badge__footer-title {
margin-bottom: 2px !important;
}

View File

@@ -18,6 +18,7 @@ Identica: @fmarier
Location: Wellington, New Zealand Location: Wellington, New Zealand
Developer: Jonathan Harker Developer: Jonathan Harker
Site: https://www.ohloh.net/accounts/jonathanharker
Identica: @jonathanharker Identica: @jonathanharker
Developer: Brett Wilkins Developer: Brett Wilkins
@@ -32,6 +33,7 @@ Site: http://blogs.ijw.co.nz/chris
Twitter: @ijw_chrisf Twitter: @ijw_chrisf
Library maintainer (PHP): Melissa Draper Library maintainer (PHP): Melissa Draper
Site: http://www.meldraweb.com/
Twitter: @elkbuntu Twitter: @elkbuntu
Identica: @elkbuntu Identica: @elkbuntu
@@ -67,6 +69,7 @@ Site: http://hendry.iki.fi/
Twitter: @kaihendry Twitter: @kaihendry
Name: Lars Wirzenius Name: Lars Wirzenius
Site: http://braawi.com/
Identica: @liw Identica: @liw
Name: Olly Betts Name: Olly Betts

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -1,143 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 841.9 967.3" style="enable-background:new 0 0 841.9 967.3;" xml:space="preserve">
<style type="text/css">
.st0{display:none;}
.st1{display:inline;fill:url(#SVGID_1_);}
.st2{fill:url(#SVGID_2_);stroke:#000000;stroke-miterlimit:10;}
.st3{fill:#FFFFFF;}
</style>
<g id="_x36_eck" class="st0">
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="89.9133" y1="153.4554" x2="750.3038" y2="813.8458">
<stop offset="8.207178e-03" style="stop-color:#3C5DA8"/>
<stop offset="0.2203" style="stop-color:#3C62AC"/>
<stop offset="0.5531" style="stop-color:#3C70B7"/>
<stop offset="0.9629" style="stop-color:#3E88C8"/>
<stop offset="1" style="stop-color:#3E8ACA"/>
</linearGradient>
<polygon class="st1" points="420.9,0 840.1,241.1 839.4,724.8 419.3,967.3 0.1,726.2 0.9,242.5 "/>
</g>
<g id="bg">
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="-31.3529" y1="31.3529" x2="873.2426" y2="935.9484">
<stop offset="8.207178e-03" style="stop-color:#3C5DA8"/>
<stop offset="0.2203" style="stop-color:#3C62AC"/>
<stop offset="0.5531" style="stop-color:#3C70B7"/>
<stop offset="0.9629" style="stop-color:#3E88C8"/>
<stop offset="1" style="stop-color:#3E8ACA"/>
</linearGradient>
<rect class="st2" width="841.9" height="967.3"/>
</g>
<g id="libravatar.org">
<g>
<path class="st3" d="M142.8,652.2v74.3h-12.4v-74.3H142.8z"/>
<path class="st3" d="M169.7,660.3c0,1.1-0.2,2.1-0.6,3c-0.4,0.9-1,1.8-1.7,2.5c-0.7,0.7-1.6,1.3-2.5,1.7c-1,0.4-2,0.6-3.1,0.6
c-1.1,0-2.1-0.2-3-0.6c-1-0.4-1.8-1-2.5-1.7c-0.7-0.7-1.3-1.5-1.7-2.5c-0.4-0.9-0.6-1.9-0.6-3c0-1.1,0.2-2.1,0.6-3.1
c0.4-1,1-1.8,1.7-2.5c0.7-0.7,1.5-1.3,2.5-1.7c0.9-0.4,2-0.6,3-0.6c1.1,0,2.1,0.2,3.1,0.6c1,0.4,1.8,1,2.5,1.7
c0.7,0.7,1.3,1.5,1.7,2.5C169.5,658.1,169.7,659.2,169.7,660.3z M168,675.2v51.3h-12.4v-51.3H168z"/>
<path class="st3" d="M192.9,681.5c2.1-2.2,4.4-3.9,6.9-5.2c2.5-1.3,5.4-1.9,8.6-1.9c3,0,5.6,0.6,8,1.8c2.4,1.2,4.4,2.8,6.1,5
c1.7,2.2,3,4.8,3.9,7.9c0.9,3.1,1.3,6.6,1.3,10.5c0,4.2-0.5,8-1.5,11.4c-1,3.4-2.5,6.3-4.4,8.7c-1.9,2.4-4.2,4.3-6.9,5.6
c-2.7,1.3-5.7,2-9,2c-1.6,0-3-0.2-4.4-0.5c-1.3-0.3-2.5-0.8-3.6-1.3c-1.1-0.6-2.1-1.3-3-2.1c-0.9-0.8-1.8-1.7-2.6-2.7l-0.5,3.4
c-0.2,0.9-0.5,1.5-1,1.9c-0.5,0.4-1.1,0.5-2,0.5h-8.1v-74.3h12.4V681.5z M192.9,713.2c1.4,1.7,2.9,2.9,4.6,3.6s3.4,1,5.2,1
c1.9,0,3.6-0.3,5.1-1c1.5-0.7,2.8-1.8,3.9-3.2c1.1-1.5,1.9-3.3,2.5-5.5c0.6-2.2,0.8-4.9,0.8-8.1c0-5.6-0.9-9.7-2.8-12.2
c-1.9-2.5-4.5-3.8-8-3.8c-2.4,0-4.4,0.5-6.2,1.6c-1.8,1.1-3.5,2.6-5,4.6V713.2z"/>
<path class="st3" d="M249,684.1c1.6-3.1,3.5-5.5,5.7-7.2c2.2-1.8,4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2c-1-0.2-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7s-1.6,1.7-2.2,2.7c-0.6,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.1,0.2,2.6,0.7s0.8,1.3,1,2.4
L249,684.1z"/>
<path class="st3" d="M272.8,682.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1c1.6,1.8,2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9c-1.8-0.6-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7
c-0.6-0.5-1.1-1-1.4-1.6L272.8,682.4z M301.1,704.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7c-1.5,0.7-2.5,1.5-3.1,2.5
c-0.6,0.9-1,2-1,3c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V704.6z"/>
<path class="st3" d="M369.8,675.2l-20.4,51.3h-11.2l-20.3-51.3h10.2c0.9,0,1.7,0.2,2.3,0.6c0.6,0.4,1,1,1.2,1.7l9.9,27.4
c0.5,1.6,1,3.2,1.4,4.7c0.4,1.5,0.8,3.1,1,4.6c0.3-1.5,0.7-3.1,1.1-4.6c0.4-1.5,0.9-3.1,1.5-4.7l10.1-27.4
c0.2-0.7,0.6-1.2,1.2-1.7c0.6-0.4,1.3-0.6,2.1-0.6H369.8z"/>
<path class="st3" d="M373.9,682.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1c1.6,1.8,2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9c-1.8-0.6-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7
c-0.6-0.5-1.1-1-1.4-1.6L373.9,682.4z M402.2,704.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7c-1.5,0.7-2.5,1.5-3.1,2.5
c-0.6,0.9-1,2-1,3c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V704.6z"/>
<path class="st3" d="M442.2,727.3c-4.5,0-7.9-1.3-10.3-3.8c-2.4-2.5-3.6-6-3.6-10.4v-28.6h-5.2c-0.7,0-1.2-0.2-1.7-0.7
c-0.5-0.4-0.7-1.1-0.7-2V677l8.2-1.4l2.6-14c0.2-0.7,0.5-1.2,1-1.5c0.5-0.4,1.1-0.5,1.8-0.5h6.4v16.2h13.7v8.8h-13.7v27.8
c0,1.6,0.4,2.8,1.2,3.8c0.8,0.9,1.9,1.3,3.2,1.3c0.8,0,1.4-0.1,1.9-0.3c0.5-0.2,1-0.4,1.4-0.6c0.4-0.2,0.7-0.4,1-0.6
c0.3-0.2,0.6-0.3,0.9-0.3c0.4,0,0.7,0.1,0.9,0.3c0.2,0.2,0.5,0.5,0.8,0.8l3.7,6c-1.8,1.5-3.9,2.6-6.2,3.4
C447.1,726.9,444.7,727.3,442.2,727.3z"/>
<path class="st3" d="M461.1,682.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1s2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9s-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7s-1.1-1-1.5-1.6
L461.1,682.4z M489.4,704.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7s-2.5,1.5-3.1,2.5c-0.6,0.9-1,2-1,3
c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V704.6z"/>
<path class="st3" d="M524.7,684.1c1.6-3.1,3.5-5.5,5.7-7.2s4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2s-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7c-0.9,0.8-1.6,1.7-2.2,2.7c-0.7,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.2,0.2,2.7,0.7
s0.8,1.3,1,2.4L524.7,684.1z"/>
<path class="st3" d="M544.6,719.7c0-1,0.2-2,0.6-3c0.4-0.9,0.9-1.7,1.6-2.4c0.7-0.7,1.5-1.2,2.4-1.6s1.9-0.6,3-0.6s2.1,0.2,3,0.6
c0.9,0.4,1.7,0.9,2.4,1.6c0.7,0.7,1.2,1.5,1.6,2.4c0.4,0.9,0.6,1.9,0.6,3c0,1.1-0.2,2.1-0.6,3c-0.4,0.9-0.9,1.7-1.6,2.4
c-0.7,0.7-1.5,1.2-2.4,1.6c-0.9,0.4-1.9,0.6-3,0.6s-2.1-0.2-3-0.6s-1.7-0.9-2.4-1.6c-0.7-0.7-1.2-1.5-1.6-2.4
C544.8,721.8,544.6,720.8,544.6,719.7z"/>
<path class="st3" d="M592.9,674.4c3.8,0,7.3,0.6,10.4,1.8c3.1,1.2,5.8,3,8,5.2c2.2,2.3,3.9,5,5.1,8.3c1.2,3.3,1.8,6.9,1.8,11
c0,4.1-0.6,7.7-1.8,11c-1.2,3.3-2.9,6-5.1,8.3c-2.2,2.3-4.9,4.1-8,5.3c-3.1,1.2-6.6,1.8-10.4,1.8c-3.8,0-7.3-0.6-10.5-1.8
c-3.1-1.2-5.8-3-8-5.3c-2.2-2.3-3.9-5.1-5.2-8.3c-1.2-3.3-1.8-6.9-1.8-11c0-4,0.6-7.7,1.8-11c1.2-3.3,2.9-6,5.2-8.3
c2.2-2.3,4.9-4,8-5.2C585.6,675,589,674.4,592.9,674.4z M592.9,717.8c4.3,0,7.4-1.4,9.5-4.3c2-2.9,3.1-7.1,3.1-12.6
c0-5.5-1-9.8-3.1-12.6c-2.1-2.9-5.2-4.4-9.5-4.4c-4.3,0-7.5,1.5-9.6,4.4c-2.1,2.9-3.1,7.1-3.1,12.6s1,9.7,3.1,12.6
C585.3,716.3,588.5,717.8,592.9,717.8z"/>
<path class="st3" d="M639.3,684.1c1.6-3.1,3.5-5.5,5.7-7.2s4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2s-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7c-0.9,0.8-1.6,1.7-2.2,2.7c-0.7,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.2,0.2,2.7,0.7
s0.8,1.3,1,2.4L639.3,684.1z"/>
<path class="st3" d="M709.4,677v4.6c0,1.5-0.9,2.4-2.6,2.7l-4.6,0.8c0.7,1.8,1.1,3.7,1.1,5.8c0,2.5-0.5,4.8-1.5,6.9
c-1,2-2.4,3.8-4.2,5.2c-1.8,1.4-3.9,2.5-6.4,3.3c-2.5,0.8-5.1,1.2-7.9,1.2c-1,0-2,0-2.9-0.2c-0.9-0.1-1.9-0.2-2.8-0.4
c-1.6,1-2.4,2-2.4,3.2c0,1,0.5,1.8,1.4,2.3c1,0.5,2.2,0.8,3.8,1c1.6,0.2,3.3,0.3,5.3,0.4s4.1,0.2,6.2,0.3s4.2,0.5,6.2,0.9
s3.8,1.1,5.3,2c1.6,0.9,2.8,2.1,3.8,3.7c0.9,1.6,1.4,3.5,1.4,6c0,2.3-0.6,4.5-1.7,6.6c-1.1,2.1-2.7,4-4.9,5.7
c-2.1,1.7-4.7,3-7.8,4c-3.1,1-6.6,1.5-10.5,1.5c-3.9,0-7.2-0.4-10.1-1.1c-2.9-0.8-5.2-1.8-7.2-3c-1.9-1.2-3.3-2.7-4.2-4.3
s-1.4-3.3-1.4-5.1c0-2.4,0.7-4.4,2.2-6c1.5-1.6,3.4-2.9,6-3.9c-1.4-0.7-2.4-1.6-3.2-2.8c-0.8-1.2-1.2-2.7-1.2-4.6
c0-0.8,0.1-1.6,0.4-2.4c0.3-0.8,0.7-1.6,1.2-2.4c0.5-0.8,1.2-1.6,2.1-2.3c0.8-0.7,1.8-1.4,2.9-1.9c-2.6-1.4-4.6-3.3-6.1-5.6
c-1.5-2.3-2.2-5.1-2.2-8.2c0-2.5,0.5-4.8,1.5-6.9c1-2.1,2.4-3.8,4.2-5.2c1.8-1.5,4-2.6,6.4-3.3s5.2-1.2,8.1-1.2
c2.2,0,4.3,0.2,6.2,0.7c1.9,0.5,3.7,1.1,5.3,2H709.4z M697.1,728.8c0-1-0.3-1.8-0.9-2.5c-0.6-0.6-1.4-1.1-2.5-1.5
c-1-0.3-2.2-0.6-3.6-0.8c-1.4-0.2-2.9-0.3-4.4-0.4c-1.5-0.1-3.1-0.2-4.8-0.2c-1.7-0.1-3.3-0.2-4.8-0.4c-1.4,0.8-2.5,1.7-3.3,2.7
c-0.8,1-1.3,2.2-1.3,3.6c0,0.9,0.2,1.7,0.7,2.5s1.2,1.5,2.2,2c1,0.6,2.3,1,3.8,1.3c1.6,0.3,3.5,0.5,5.8,0.5c2.3,0,4.3-0.2,6-0.5
c1.7-0.3,3-0.8,4.1-1.4c1.1-0.6,1.9-1.4,2.4-2.2C696.8,730.7,697.1,729.8,697.1,728.8z M683.1,699.6c1.5,0,2.9-0.2,4-0.6
c1.1-0.4,2.1-1,2.8-1.7c0.8-0.7,1.3-1.6,1.7-2.7c0.4-1,0.6-2.2,0.6-3.4c0-2.5-0.8-4.5-2.3-6c-1.5-1.5-3.8-2.2-6.8-2.2
s-5.3,0.7-6.8,2.2c-1.5,1.5-2.3,3.5-2.3,6c0,1.2,0.2,2.3,0.6,3.3c0.4,1,1,1.9,1.7,2.7s1.7,1.3,2.8,1.8
C680.3,699.4,681.6,699.6,683.1,699.6z"/>
</g>
</g>
<g id="icon">
<path id="path2852" class="st3" d="M314.8,180.9c-12.6,0-24,2-34.1,5.8c-10.1,3.8-18.3,9.2-25.3,16.4c-7,7.2-12.3,15.7-15.8,25.9
c-3.8,10.2-5.4,21.8-5.7,34.5c0,11.9,1.6,22.5,4.4,31.7c2.8,9.2,7,17.4,12,23.9c4.7,6.5,10.4,11.9,17.1,16c6.3,4.1,13,7.2,19.9,8.5
v2.4c-9.8,2.4-19,5.5-26.9,9.9s-14.8,9.9-20.5,17.1c-5.7,6.8-10.1,15.4-13,25.2c-2.8,9.9-4.4,21.1-4.4,34.5c0,16,1.9,30,6,42.3
c4.1,12.3,9.8,22.5,17.1,31c7.3,8.2,16.4,14.7,27.5,19.1c10.7,4.4,23.1,6.5,36.3,6.5s24.6-1.4,34.4-4.1c9.5-2.7,17.7-5.5,24.6-8.5
v112.6h37V513.2h29.1v118.4h37V519c7,3.1,14.8,6.1,24.6,8.5c9.8,2.7,21.2,4.1,34.4,4.1c13.6,0,25.6-2,36.3-6.5
c10.7-4.4,19.9-10.6,27.5-19.1c7.3-8.2,13-18.8,17.1-31s6-26.6,6-42.3c0-13.3-1.6-24.9-4.4-34.5c-2.8-9.9-7.3-18.4-13-25.2
c-5.7-7.2-12.3-12.6-20.5-17.1c-7.9-4.4-17.1-7.5-26.9-9.9v-2.4c7-1.7,13.6-4.4,19.9-8.5c6.6-4.1,12.3-9.6,17.1-16
c5.1-6.8,8.8-14.7,12-23.9c2.8-9.2,4.4-19.8,4.4-31.7c0-12.6-1.9-24.2-5.7-34.5c-3.5-10.2-8.8-18.8-15.8-25.9
c-7-7.2-15.2-12.6-25.3-16.4c-9.8-3.8-21.2-5.8-34.1-5.8c-13.6,0-25.9,2-37.3,6.1c-11.1,4.1-20.5,10.6-28.8,20.1
c-7.9,9.2-14.2,21.5-18.6,36.8c-3.5,12.3-5.7,26.6-6.3,43.3h-29.7c-0.6-16.7-2.8-31-6.3-43.3c-4.4-15.4-10.7-27.6-18.6-36.8
c-7.9-9.6-17.4-16-28.8-20.1C340.7,183,328,180.9,314.8,180.9z M418.7,187.8c-3.2,0-6.3,0.7-9.5,2.4c-2.8,1.4-5.4,3.1-7.6,5.5
c-2.2,2.4-4.1,5.1-5.4,8.5c-1.3,3.1-1.9,6.5-1.9,10.2c0,3.4,0.6,6.8,1.9,9.9c1.3,3.1,3.2,5.8,5.4,8.2c2.2,2.4,4.7,4.4,7.6,5.8
c2.8,1.4,6,2,9.5,2c3.2,0,6.3-0.7,9.2-2c2.8-1.4,5.7-3.4,7.9-5.8c2.2-2.4,3.8-5.1,5.1-8.2c1.3-3.1,1.9-6.5,1.9-9.9
c0-3.4-0.6-6.8-1.9-10.2c-1.3-3.1-2.8-6.1-5.1-8.5c-2.2-2.4-4.7-4.1-7.9-5.5C425,188.4,421.9,187.8,418.7,187.8z M315.1,200
c8.2,0,15.8,1.7,22.4,4.8c6.6,3.1,12.3,8.5,17.1,16.4c4.7,7.8,8.2,18.1,10.7,31.4c2.5,13,3.5,29.7,3.5,49.5v199.2
c-7.9,2.7-16.4,5.1-25.9,6.8c-9.5,1.7-19.6,2.4-30.3,2.4c-16.1,0-28.4-6.1-37.3-18.8c-8.8-12.6-13.3-32.1-13.3-58.3
c0-11.9,1.3-22.5,3.8-31.7c2.5-9.6,6.3-17.4,11.7-23.9s12.3-11.6,20.9-15.4c8.5-3.8,19-5.8,31.3-6.1v-18.8
c-10.4-0.3-19.3-2-26.5-5.1c-7.3-3.1-13.3-7.8-18-14c-4.7-6.1-7.9-13.6-10.1-22.5c-2.2-9.2-3.2-19.8-3.2-31.7
c0-9.6,0.9-18.4,2.5-26.3c1.6-7.8,4.1-14.7,7.9-20.1c3.5-5.5,7.9-9.9,13.3-13C301.2,201.4,307.5,200,315.1,200z M525.5,200
c7.6,0,14.2,1.7,19.6,4.4c5.4,3.1,9.8,7.2,13.3,13c3.5,5.5,6.3,12.3,7.9,20.1c1.9,7.8,2.5,16.7,2.5,26.3c0,11.9-0.9,22.5-3.2,31.7
c-2.2,8.9-5.4,16.4-10.1,22.5c-4.7,6.1-10.4,10.9-18,14c-7.3,3.1-16.1,4.8-26.5,5.1v18.8c12.3,0.3,22.7,2.4,31.3,6.1
c8.5,3.8,15.5,8.9,20.9,15.4s9.5,14.7,11.7,23.9c2.5,9.2,3.8,19.8,3.8,31.7c0,26.3-4.4,45.7-13.3,58.3
c-8.8,12.6-21.2,18.8-37.3,18.8c-10.7,0-20.9-0.7-30.3-2.4c-7.6-1.4-14.8-3.1-21.5-5.1v-0.7l-3.5-0.7c-0.3,0-0.9-0.3-1.3-0.3V301.7
c0-20.1,1.3-36.5,3.5-49.5c2.5-13,6-23.5,10.7-31.4c4.7-7.8,10.4-13.3,17.1-16.4C509.7,201.7,517.3,200,525.5,200z"/>
</g>
<g id="Ebene_5">
</g>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,143 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 841.9 967.3" style="enable-background:new 0 0 841.9 967.3;" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
.st1{display:none;}
.st2{display:inline;fill:url(#SVGID_2_);stroke:#000000;stroke-miterlimit:10;}
.st3{fill:#FFFFFF;}
</style>
<g id="_x36_eck">
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="89.9133" y1="153.4554" x2="750.3038" y2="813.8458">
<stop offset="8.207178e-03" style="stop-color:#3C5DA8"/>
<stop offset="0.2203" style="stop-color:#3C62AC"/>
<stop offset="0.5531" style="stop-color:#3C70B7"/>
<stop offset="0.9629" style="stop-color:#3E88C8"/>
<stop offset="1" style="stop-color:#3E8ACA"/>
</linearGradient>
<polygon class="st0" points="420.9,0 840.1,241.1 839.4,724.8 419.3,967.3 0.1,726.2 0.9,242.5 "/>
</g>
<g id="bg" class="st1">
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="-31.3529" y1="31.3529" x2="873.2426" y2="935.9484">
<stop offset="8.207178e-03" style="stop-color:#3C5DA8"/>
<stop offset="0.2203" style="stop-color:#3C62AC"/>
<stop offset="0.5531" style="stop-color:#3C70B7"/>
<stop offset="0.9629" style="stop-color:#3E88C8"/>
<stop offset="1" style="stop-color:#3E8ACA"/>
</linearGradient>
<rect class="st2" width="841.9" height="967.3"/>
</g>
<g id="libravatar.org">
<g>
<path class="st3" d="M142.8,652.2v74.3h-12.4v-74.3H142.8z"/>
<path class="st3" d="M169.7,660.3c0,1.1-0.2,2.1-0.6,3c-0.4,0.9-1,1.8-1.7,2.5c-0.7,0.7-1.6,1.3-2.5,1.7c-1,0.4-2,0.6-3.1,0.6
c-1.1,0-2.1-0.2-3-0.6c-1-0.4-1.8-1-2.5-1.7c-0.7-0.7-1.3-1.5-1.7-2.5c-0.4-0.9-0.6-1.9-0.6-3c0-1.1,0.2-2.1,0.6-3.1
c0.4-1,1-1.8,1.7-2.5c0.7-0.7,1.5-1.3,2.5-1.7c0.9-0.4,2-0.6,3-0.6c1.1,0,2.1,0.2,3.1,0.6c1,0.4,1.8,1,2.5,1.7
c0.7,0.7,1.3,1.5,1.7,2.5C169.5,658.1,169.7,659.2,169.7,660.3z M168,675.2v51.3h-12.4v-51.3H168z"/>
<path class="st3" d="M192.9,681.5c2.1-2.2,4.4-3.9,6.9-5.2c2.5-1.3,5.4-1.9,8.6-1.9c3,0,5.6,0.6,8,1.8c2.4,1.2,4.4,2.8,6.1,5
c1.7,2.2,3,4.8,3.9,7.9c0.9,3.1,1.3,6.6,1.3,10.5c0,4.2-0.5,8-1.5,11.4c-1,3.4-2.5,6.3-4.4,8.7c-1.9,2.4-4.2,4.3-6.9,5.6
c-2.7,1.3-5.7,2-9,2c-1.6,0-3-0.2-4.4-0.5c-1.3-0.3-2.5-0.8-3.6-1.3c-1.1-0.6-2.1-1.3-3-2.1c-0.9-0.8-1.8-1.7-2.6-2.7l-0.5,3.4
c-0.2,0.9-0.5,1.5-1,1.9c-0.5,0.4-1.1,0.5-2,0.5h-8.1v-74.3h12.4V681.5z M192.9,713.2c1.4,1.7,2.9,2.9,4.6,3.6s3.4,1,5.2,1
c1.9,0,3.6-0.3,5.1-1c1.5-0.7,2.8-1.8,3.9-3.2c1.1-1.5,1.9-3.3,2.5-5.5c0.6-2.2,0.8-4.9,0.8-8.1c0-5.6-0.9-9.7-2.8-12.2
c-1.9-2.5-4.5-3.8-8-3.8c-2.4,0-4.4,0.5-6.2,1.6c-1.8,1.1-3.5,2.6-5,4.6V713.2z"/>
<path class="st3" d="M249,684.1c1.6-3.1,3.5-5.5,5.7-7.2c2.2-1.8,4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2c-1-0.2-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7s-1.6,1.7-2.2,2.7c-0.6,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.1,0.2,2.6,0.7s0.8,1.3,1,2.4
L249,684.1z"/>
<path class="st3" d="M272.8,682.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1c1.6,1.8,2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9c-1.8-0.6-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7
c-0.6-0.5-1.1-1-1.4-1.6L272.8,682.4z M301.1,704.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7c-1.5,0.7-2.5,1.5-3.1,2.5
c-0.6,0.9-1,2-1,3c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V704.6z"/>
<path class="st3" d="M369.8,675.2l-20.4,51.3h-11.2l-20.3-51.3h10.2c0.9,0,1.7,0.2,2.3,0.6c0.6,0.4,1,1,1.2,1.7l9.9,27.4
c0.5,1.6,1,3.2,1.4,4.7c0.4,1.5,0.8,3.1,1,4.6c0.3-1.5,0.7-3.1,1.1-4.6c0.4-1.5,0.9-3.1,1.5-4.7l10.1-27.4
c0.2-0.7,0.6-1.2,1.2-1.7c0.6-0.4,1.3-0.6,2.1-0.6H369.8z"/>
<path class="st3" d="M373.9,682.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1c1.6,1.8,2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9c-1.8-0.6-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7
c-0.6-0.5-1.1-1-1.4-1.6L373.9,682.4z M402.2,704.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7c-1.5,0.7-2.5,1.5-3.1,2.5
c-0.6,0.9-1,2-1,3c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V704.6z"/>
<path class="st3" d="M442.2,727.3c-4.5,0-7.9-1.3-10.3-3.8c-2.4-2.5-3.6-6-3.6-10.4v-28.6h-5.2c-0.7,0-1.2-0.2-1.7-0.7
c-0.5-0.4-0.7-1.1-0.7-2V677l8.2-1.4l2.6-14c0.2-0.7,0.5-1.2,1-1.5c0.5-0.4,1.1-0.5,1.8-0.5h6.4v16.2h13.7v8.8h-13.7v27.8
c0,1.6,0.4,2.8,1.2,3.8c0.8,0.9,1.9,1.3,3.2,1.3c0.8,0,1.4-0.1,1.9-0.3c0.5-0.2,1-0.4,1.4-0.6c0.4-0.2,0.7-0.4,1-0.6
c0.3-0.2,0.6-0.3,0.9-0.3c0.4,0,0.7,0.1,0.9,0.3c0.2,0.2,0.5,0.5,0.8,0.8l3.7,6c-1.8,1.5-3.9,2.6-6.2,3.4
C447.1,726.9,444.7,727.3,442.2,727.3z"/>
<path class="st3" d="M461.1,682.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1s2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9s-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7s-1.1-1-1.5-1.6
L461.1,682.4z M489.4,704.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7s-2.5,1.5-3.1,2.5c-0.6,0.9-1,2-1,3
c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V704.6z"/>
<path class="st3" d="M524.7,684.1c1.6-3.1,3.5-5.5,5.7-7.2s4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2s-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7c-0.9,0.8-1.6,1.7-2.2,2.7c-0.7,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.2,0.2,2.7,0.7
s0.8,1.3,1,2.4L524.7,684.1z"/>
<path class="st3" d="M544.6,719.7c0-1,0.2-2,0.6-3c0.4-0.9,0.9-1.7,1.6-2.4c0.7-0.7,1.5-1.2,2.4-1.6s1.9-0.6,3-0.6s2.1,0.2,3,0.6
c0.9,0.4,1.7,0.9,2.4,1.6c0.7,0.7,1.2,1.5,1.6,2.4c0.4,0.9,0.6,1.9,0.6,3c0,1.1-0.2,2.1-0.6,3c-0.4,0.9-0.9,1.7-1.6,2.4
c-0.7,0.7-1.5,1.2-2.4,1.6c-0.9,0.4-1.9,0.6-3,0.6s-2.1-0.2-3-0.6s-1.7-0.9-2.4-1.6c-0.7-0.7-1.2-1.5-1.6-2.4
C544.8,721.8,544.6,720.8,544.6,719.7z"/>
<path class="st3" d="M592.9,674.4c3.8,0,7.3,0.6,10.4,1.8c3.1,1.2,5.8,3,8,5.2c2.2,2.3,3.9,5,5.1,8.3c1.2,3.3,1.8,6.9,1.8,11
c0,4.1-0.6,7.7-1.8,11c-1.2,3.3-2.9,6-5.1,8.3c-2.2,2.3-4.9,4.1-8,5.3c-3.1,1.2-6.6,1.8-10.4,1.8c-3.8,0-7.3-0.6-10.5-1.8
c-3.1-1.2-5.8-3-8-5.3c-2.2-2.3-3.9-5.1-5.2-8.3c-1.2-3.3-1.8-6.9-1.8-11c0-4,0.6-7.7,1.8-11c1.2-3.3,2.9-6,5.2-8.3
c2.2-2.3,4.9-4,8-5.2C585.6,675,589,674.4,592.9,674.4z M592.9,717.8c4.3,0,7.4-1.4,9.5-4.3c2-2.9,3.1-7.1,3.1-12.6
c0-5.5-1-9.8-3.1-12.6c-2.1-2.9-5.2-4.4-9.5-4.4c-4.3,0-7.5,1.5-9.6,4.4c-2.1,2.9-3.1,7.1-3.1,12.6s1,9.7,3.1,12.6
C585.3,716.3,588.5,717.8,592.9,717.8z"/>
<path class="st3" d="M639.3,684.1c1.6-3.1,3.5-5.5,5.7-7.2s4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2s-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7c-0.9,0.8-1.6,1.7-2.2,2.7c-0.7,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.2,0.2,2.7,0.7
s0.8,1.3,1,2.4L639.3,684.1z"/>
<path class="st3" d="M709.4,677v4.6c0,1.5-0.9,2.4-2.6,2.7l-4.6,0.8c0.7,1.8,1.1,3.7,1.1,5.8c0,2.5-0.5,4.8-1.5,6.9
c-1,2-2.4,3.8-4.2,5.2c-1.8,1.4-3.9,2.5-6.4,3.3c-2.5,0.8-5.1,1.2-7.9,1.2c-1,0-2,0-2.9-0.2c-0.9-0.1-1.9-0.2-2.8-0.4
c-1.6,1-2.4,2-2.4,3.2c0,1,0.5,1.8,1.4,2.3c1,0.5,2.2,0.8,3.8,1c1.6,0.2,3.3,0.3,5.3,0.4s4.1,0.2,6.2,0.3s4.2,0.5,6.2,0.9
s3.8,1.1,5.3,2c1.6,0.9,2.8,2.1,3.8,3.7c0.9,1.6,1.4,3.5,1.4,6c0,2.3-0.6,4.5-1.7,6.6c-1.1,2.1-2.7,4-4.9,5.7
c-2.1,1.7-4.7,3-7.8,4c-3.1,1-6.6,1.5-10.5,1.5c-3.9,0-7.2-0.4-10.1-1.1c-2.9-0.8-5.2-1.8-7.2-3c-1.9-1.2-3.3-2.7-4.2-4.3
s-1.4-3.3-1.4-5.1c0-2.4,0.7-4.4,2.2-6c1.5-1.6,3.4-2.9,6-3.9c-1.4-0.7-2.4-1.6-3.2-2.8c-0.8-1.2-1.2-2.7-1.2-4.6
c0-0.8,0.1-1.6,0.4-2.4c0.3-0.8,0.7-1.6,1.2-2.4c0.5-0.8,1.2-1.6,2.1-2.3c0.8-0.7,1.8-1.4,2.9-1.9c-2.6-1.4-4.6-3.3-6.1-5.6
c-1.5-2.3-2.2-5.1-2.2-8.2c0-2.5,0.5-4.8,1.5-6.9c1-2.1,2.4-3.8,4.2-5.2c1.8-1.5,4-2.6,6.4-3.3s5.2-1.2,8.1-1.2
c2.2,0,4.3,0.2,6.2,0.7c1.9,0.5,3.7,1.1,5.3,2H709.4z M697.1,728.8c0-1-0.3-1.8-0.9-2.5c-0.6-0.6-1.4-1.1-2.5-1.5
c-1-0.3-2.2-0.6-3.6-0.8c-1.4-0.2-2.9-0.3-4.4-0.4c-1.5-0.1-3.1-0.2-4.8-0.2c-1.7-0.1-3.3-0.2-4.8-0.4c-1.4,0.8-2.5,1.7-3.3,2.7
c-0.8,1-1.3,2.2-1.3,3.6c0,0.9,0.2,1.7,0.7,2.5s1.2,1.5,2.2,2c1,0.6,2.3,1,3.8,1.3c1.6,0.3,3.5,0.5,5.8,0.5c2.3,0,4.3-0.2,6-0.5
c1.7-0.3,3-0.8,4.1-1.4c1.1-0.6,1.9-1.4,2.4-2.2C696.8,730.7,697.1,729.8,697.1,728.8z M683.1,699.6c1.5,0,2.9-0.2,4-0.6
c1.1-0.4,2.1-1,2.8-1.7c0.8-0.7,1.3-1.6,1.7-2.7c0.4-1,0.6-2.2,0.6-3.4c0-2.5-0.8-4.5-2.3-6c-1.5-1.5-3.8-2.2-6.8-2.2
s-5.3,0.7-6.8,2.2c-1.5,1.5-2.3,3.5-2.3,6c0,1.2,0.2,2.3,0.6,3.3c0.4,1,1,1.9,1.7,2.7s1.7,1.3,2.8,1.8
C680.3,699.4,681.6,699.6,683.1,699.6z"/>
</g>
</g>
<g id="icon">
<path id="path2852" class="st3" d="M314.8,180.9c-12.6,0-24,2-34.1,5.8c-10.1,3.8-18.3,9.2-25.3,16.4c-7,7.2-12.3,15.7-15.8,25.9
c-3.8,10.2-5.4,21.8-5.7,34.5c0,11.9,1.6,22.5,4.4,31.7c2.8,9.2,7,17.4,12,23.9c4.7,6.5,10.4,11.9,17.1,16c6.3,4.1,13,7.2,19.9,8.5
v2.4c-9.8,2.4-19,5.5-26.9,9.9s-14.8,9.9-20.5,17.1c-5.7,6.8-10.1,15.4-13,25.2c-2.8,9.9-4.4,21.1-4.4,34.5c0,16,1.9,30,6,42.3
c4.1,12.3,9.8,22.5,17.1,31c7.3,8.2,16.4,14.7,27.5,19.1c10.7,4.4,23.1,6.5,36.3,6.5s24.6-1.4,34.4-4.1c9.5-2.7,17.7-5.5,24.6-8.5
v112.6h37V513.2h29.1v118.4h37V519c7,3.1,14.8,6.1,24.6,8.5c9.8,2.7,21.2,4.1,34.4,4.1c13.6,0,25.6-2,36.3-6.5
c10.7-4.4,19.9-10.6,27.5-19.1c7.3-8.2,13-18.8,17.1-31s6-26.6,6-42.3c0-13.3-1.6-24.9-4.4-34.5c-2.8-9.9-7.3-18.4-13-25.2
c-5.7-7.2-12.3-12.6-20.5-17.1c-7.9-4.4-17.1-7.5-26.9-9.9v-2.4c7-1.7,13.6-4.4,19.9-8.5c6.6-4.1,12.3-9.6,17.1-16
c5.1-6.8,8.8-14.7,12-23.9c2.8-9.2,4.4-19.8,4.4-31.7c0-12.6-1.9-24.2-5.7-34.5c-3.5-10.2-8.8-18.8-15.8-25.9
c-7-7.2-15.2-12.6-25.3-16.4c-9.8-3.8-21.2-5.8-34.1-5.8c-13.6,0-25.9,2-37.3,6.1c-11.1,4.1-20.5,10.6-28.8,20.1
c-7.9,9.2-14.2,21.5-18.6,36.8c-3.5,12.3-5.7,26.6-6.3,43.3h-29.7c-0.6-16.7-2.8-31-6.3-43.3c-4.4-15.4-10.7-27.6-18.6-36.8
c-7.9-9.6-17.4-16-28.8-20.1C340.7,183,328,180.9,314.8,180.9z M418.7,187.8c-3.2,0-6.3,0.7-9.5,2.4c-2.8,1.4-5.4,3.1-7.6,5.5
c-2.2,2.4-4.1,5.1-5.4,8.5c-1.3,3.1-1.9,6.5-1.9,10.2c0,3.4,0.6,6.8,1.9,9.9c1.3,3.1,3.2,5.8,5.4,8.2c2.2,2.4,4.7,4.4,7.6,5.8
c2.8,1.4,6,2,9.5,2c3.2,0,6.3-0.7,9.2-2c2.8-1.4,5.7-3.4,7.9-5.8c2.2-2.4,3.8-5.1,5.1-8.2c1.3-3.1,1.9-6.5,1.9-9.9
c0-3.4-0.6-6.8-1.9-10.2c-1.3-3.1-2.8-6.1-5.1-8.5c-2.2-2.4-4.7-4.1-7.9-5.5C425,188.4,421.9,187.8,418.7,187.8z M315.1,200
c8.2,0,15.8,1.7,22.4,4.8c6.6,3.1,12.3,8.5,17.1,16.4c4.7,7.8,8.2,18.1,10.7,31.4c2.5,13,3.5,29.7,3.5,49.5v199.2
c-7.9,2.7-16.4,5.1-25.9,6.8c-9.5,1.7-19.6,2.4-30.3,2.4c-16.1,0-28.4-6.1-37.3-18.8c-8.8-12.6-13.3-32.1-13.3-58.3
c0-11.9,1.3-22.5,3.8-31.7c2.5-9.6,6.3-17.4,11.7-23.9s12.3-11.6,20.9-15.4c8.5-3.8,19-5.8,31.3-6.1v-18.8
c-10.4-0.3-19.3-2-26.5-5.1c-7.3-3.1-13.3-7.8-18-14c-4.7-6.1-7.9-13.6-10.1-22.5c-2.2-9.2-3.2-19.8-3.2-31.7
c0-9.6,0.9-18.4,2.5-26.3c1.6-7.8,4.1-14.7,7.9-20.1c3.5-5.5,7.9-9.9,13.3-13C301.2,201.4,307.5,200,315.1,200z M525.5,200
c7.6,0,14.2,1.7,19.6,4.4c5.4,3.1,9.8,7.2,13.3,13c3.5,5.5,6.3,12.3,7.9,20.1c1.9,7.8,2.5,16.7,2.5,26.3c0,11.9-0.9,22.5-3.2,31.7
c-2.2,8.9-5.4,16.4-10.1,22.5c-4.7,6.1-10.4,10.9-18,14c-7.3,3.1-16.1,4.8-26.5,5.1v18.8c12.3,0.3,22.7,2.4,31.3,6.1
c8.5,3.8,15.5,8.9,20.9,15.4s9.5,14.7,11.7,23.9c2.5,9.2,3.8,19.8,3.8,31.7c0,26.3-4.4,45.7-13.3,58.3
c-8.8,12.6-21.2,18.8-37.3,18.8c-10.7,0-20.9-0.7-30.3-2.4c-7.6-1.4-14.8-3.1-21.5-5.1v-0.7l-3.5-0.7c-0.3,0-0.9-0.3-1.3-0.3V301.7
c0-20.1,1.3-36.5,3.5-49.5c2.5-13,6-23.5,10.7-31.4c4.7-7.8,10.4-13.3,17.1-16.4C509.7,201.7,517.3,200,525.5,200z"/>
</g>
<g id="Ebene_5">
</g>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,132 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 841.9 967.3" style="enable-background:new 0 0 841.9 967.3;" xml:space="preserve">
<style type="text/css">
.st0{display:none;}
.st1{display:inline;fill:#0885C7;}
.st2{fill:#0885C7;stroke:#000000;stroke-miterlimit:10;}
.st3{fill:#FFFFFF;}
.st4{display:none;opacity:0.25;}
</style>
<g id="_x36_eck" class="st0">
<polygon class="st1" points="421.9,0 841.1,241.1 840.4,724.8 420.3,967.3 1.1,726.2 1.9,242.5 "/>
</g>
<g id="bg">
<rect class="st2" width="841.9" height="967.3"/>
</g>
<g id="libravatar.org">
<g>
<path class="st3" d="M142.8,666.2v74.3h-12.4v-74.3H142.8z"/>
<path class="st3" d="M169.7,674.3c0,1.1-0.2,2.1-0.6,3c-0.4,0.9-1,1.8-1.7,2.5c-0.7,0.7-1.6,1.3-2.5,1.7c-1,0.4-2,0.6-3.1,0.6
c-1.1,0-2.1-0.2-3-0.6c-1-0.4-1.8-1-2.5-1.7c-0.7-0.7-1.3-1.5-1.7-2.5c-0.4-0.9-0.6-1.9-0.6-3c0-1.1,0.2-2.1,0.6-3.1
c0.4-1,1-1.8,1.7-2.5c0.7-0.7,1.5-1.3,2.5-1.7c0.9-0.4,2-0.6,3-0.6c1.1,0,2.1,0.2,3.1,0.6c1,0.4,1.8,1,2.5,1.7
c0.7,0.7,1.3,1.5,1.7,2.5C169.5,672.1,169.7,673.2,169.7,674.3z M168,689.2v51.3h-12.4v-51.3H168z"/>
<path class="st3" d="M192.9,695.5c2.1-2.2,4.4-3.9,6.9-5.2c2.5-1.3,5.4-1.9,8.6-1.9c3,0,5.6,0.6,8,1.8c2.4,1.2,4.4,2.8,6.1,5
c1.7,2.2,3,4.8,3.9,7.9c0.9,3.1,1.3,6.6,1.3,10.5c0,4.2-0.5,8-1.5,11.4c-1,3.4-2.5,6.3-4.4,8.7c-1.9,2.4-4.2,4.3-6.9,5.6
c-2.7,1.3-5.7,2-9,2c-1.6,0-3-0.2-4.4-0.5c-1.3-0.3-2.5-0.8-3.6-1.3c-1.1-0.6-2.1-1.3-3-2.1c-0.9-0.8-1.8-1.7-2.6-2.7l-0.5,3.4
c-0.2,0.9-0.5,1.5-1,1.9c-0.5,0.4-1.1,0.5-2,0.5h-8.1v-74.3h12.4V695.5z M192.9,727.2c1.4,1.7,2.9,2.9,4.6,3.6s3.4,1,5.2,1
c1.9,0,3.6-0.3,5.1-1c1.5-0.7,2.8-1.8,3.9-3.2c1.1-1.5,1.9-3.3,2.5-5.5c0.6-2.2,0.8-4.9,0.8-8.1c0-5.6-0.9-9.7-2.8-12.2
c-1.9-2.5-4.5-3.8-8-3.8c-2.4,0-4.4,0.5-6.2,1.6c-1.8,1.1-3.5,2.6-5,4.6V727.2z"/>
<path class="st3" d="M249,698.1c1.6-3.1,3.5-5.5,5.7-7.2c2.2-1.8,4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2c-1-0.2-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7s-1.6,1.7-2.2,2.7c-0.6,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.1,0.2,2.6,0.7s0.8,1.3,1,2.4
L249,698.1z"/>
<path class="st3" d="M272.8,696.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1c1.6,1.8,2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9c-1.8-0.6-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7
c-0.6-0.5-1.1-1-1.4-1.6L272.8,696.4z M301.1,718.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7c-1.5,0.7-2.5,1.5-3.1,2.5
c-0.6,0.9-1,2-1,3c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V718.6z"/>
<path class="st3" d="M369.8,689.2l-20.4,51.3h-11.2l-20.3-51.3h10.2c0.9,0,1.7,0.2,2.3,0.6c0.6,0.4,1,1,1.2,1.7l9.9,27.4
c0.5,1.6,1,3.2,1.4,4.7c0.4,1.5,0.8,3.1,1,4.6c0.3-1.5,0.7-3.1,1.1-4.6c0.4-1.5,0.9-3.1,1.5-4.7l10.1-27.4
c0.2-0.7,0.6-1.2,1.2-1.7c0.6-0.4,1.3-0.6,2.1-0.6H369.8z"/>
<path class="st3" d="M373.9,696.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1c1.6,1.8,2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9c-1.8-0.6-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7
c-0.6-0.5-1.1-1-1.4-1.6L373.9,696.4z M402.2,718.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7c-1.5,0.7-2.5,1.5-3.1,2.5
c-0.6,0.9-1,2-1,3c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V718.6z"/>
<path class="st3" d="M442.2,741.3c-4.5,0-7.9-1.3-10.3-3.8c-2.4-2.5-3.6-6-3.6-10.4v-28.6h-5.2c-0.7,0-1.2-0.2-1.7-0.7
c-0.5-0.4-0.7-1.1-0.7-2V691l8.2-1.4l2.6-14c0.2-0.7,0.5-1.2,1-1.5c0.5-0.4,1.1-0.5,1.8-0.5h6.4v16.2h13.7v8.8h-13.7v27.8
c0,1.6,0.4,2.8,1.2,3.8c0.8,0.9,1.9,1.3,3.2,1.3c0.8,0,1.4-0.1,1.9-0.3c0.5-0.2,1-0.4,1.4-0.6c0.4-0.2,0.7-0.4,1-0.6
c0.3-0.2,0.6-0.3,0.9-0.3c0.4,0,0.7,0.1,0.9,0.3c0.2,0.2,0.5,0.5,0.8,0.8l3.7,6c-1.8,1.5-3.9,2.6-6.2,3.4
C447.1,740.9,444.7,741.3,442.2,741.3z"/>
<path class="st3" d="M461.1,696.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1s2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9s-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7s-1.1-1-1.5-1.6
L461.1,696.4z M489.4,718.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7s-2.5,1.5-3.1,2.5c-0.6,0.9-1,2-1,3
c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V718.6z"/>
<path class="st3" d="M524.7,698.1c1.6-3.1,3.5-5.5,5.7-7.2s4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2s-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7c-0.9,0.8-1.6,1.7-2.2,2.7c-0.7,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.2,0.2,2.7,0.7
s0.8,1.3,1,2.4L524.7,698.1z"/>
<path class="st3" d="M544.6,733.7c0-1,0.2-2,0.6-3c0.4-0.9,0.9-1.7,1.6-2.4c0.7-0.7,1.5-1.2,2.4-1.6s1.9-0.6,3-0.6s2.1,0.2,3,0.6
c0.9,0.4,1.7,0.9,2.4,1.6c0.7,0.7,1.2,1.5,1.6,2.4c0.4,0.9,0.6,1.9,0.6,3c0,1.1-0.2,2.1-0.6,3c-0.4,0.9-0.9,1.7-1.6,2.4
c-0.7,0.7-1.5,1.2-2.4,1.6c-0.9,0.4-1.9,0.6-3,0.6s-2.1-0.2-3-0.6s-1.7-0.9-2.4-1.6c-0.7-0.7-1.2-1.5-1.6-2.4
C544.8,735.8,544.6,734.8,544.6,733.7z"/>
<path class="st3" d="M592.9,688.4c3.8,0,7.3,0.6,10.4,1.8c3.1,1.2,5.8,3,8,5.2c2.2,2.3,3.9,5,5.1,8.3c1.2,3.3,1.8,6.9,1.8,11
c0,4.1-0.6,7.7-1.8,11c-1.2,3.3-2.9,6-5.1,8.3c-2.2,2.3-4.9,4.1-8,5.3c-3.1,1.2-6.6,1.8-10.4,1.8c-3.8,0-7.3-0.6-10.5-1.8
c-3.1-1.2-5.8-3-8-5.3c-2.2-2.3-3.9-5.1-5.2-8.3c-1.2-3.3-1.8-6.9-1.8-11c0-4,0.6-7.7,1.8-11c1.2-3.3,2.9-6,5.2-8.3
c2.2-2.3,4.9-4,8-5.2C585.6,689,589,688.4,592.9,688.4z M592.9,731.8c4.3,0,7.4-1.4,9.5-4.3c2-2.9,3.1-7.1,3.1-12.6
c0-5.5-1-9.8-3.1-12.6c-2.1-2.9-5.2-4.4-9.5-4.4c-4.3,0-7.5,1.5-9.6,4.4c-2.1,2.9-3.1,7.1-3.1,12.6s1,9.7,3.1,12.6
C585.3,730.3,588.5,731.8,592.9,731.8z"/>
<path class="st3" d="M639.3,698.1c1.6-3.1,3.5-5.5,5.7-7.2s4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2s-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7c-0.9,0.8-1.6,1.7-2.2,2.7c-0.7,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.2,0.2,2.7,0.7
s0.8,1.3,1,2.4L639.3,698.1z"/>
<path class="st3" d="M709.4,691v4.6c0,1.5-0.9,2.4-2.6,2.7l-4.6,0.8c0.7,1.8,1.1,3.7,1.1,5.8c0,2.5-0.5,4.8-1.5,6.9
c-1,2-2.4,3.8-4.2,5.2c-1.8,1.4-3.9,2.5-6.4,3.3c-2.5,0.8-5.1,1.2-7.9,1.2c-1,0-2,0-2.9-0.2c-0.9-0.1-1.9-0.2-2.8-0.4
c-1.6,1-2.4,2-2.4,3.2c0,1,0.5,1.8,1.4,2.3c1,0.5,2.2,0.8,3.8,1c1.6,0.2,3.3,0.3,5.3,0.4s4.1,0.2,6.2,0.3s4.2,0.5,6.2,0.9
s3.8,1.1,5.3,2c1.6,0.9,2.8,2.1,3.8,3.7c0.9,1.6,1.4,3.5,1.4,6c0,2.3-0.6,4.5-1.7,6.6c-1.1,2.1-2.7,4-4.9,5.7
c-2.1,1.7-4.7,3-7.8,4c-3.1,1-6.6,1.5-10.5,1.5c-3.9,0-7.2-0.4-10.1-1.1c-2.9-0.8-5.2-1.8-7.2-3c-1.9-1.2-3.3-2.7-4.2-4.3
s-1.4-3.3-1.4-5.1c0-2.4,0.7-4.4,2.2-6c1.5-1.6,3.4-2.9,6-3.9c-1.4-0.7-2.4-1.6-3.2-2.8c-0.8-1.2-1.2-2.7-1.2-4.6
c0-0.8,0.1-1.6,0.4-2.4c0.3-0.8,0.7-1.6,1.2-2.4c0.5-0.8,1.2-1.6,2.1-2.3c0.8-0.7,1.8-1.4,2.9-1.9c-2.6-1.4-4.6-3.3-6.1-5.6
c-1.5-2.3-2.2-5.1-2.2-8.2c0-2.5,0.5-4.8,1.5-6.9c1-2.1,2.4-3.8,4.2-5.2c1.8-1.5,4-2.6,6.4-3.3s5.2-1.2,8.1-1.2
c2.2,0,4.3,0.2,6.2,0.7c1.9,0.5,3.7,1.1,5.3,2H709.4z M697.1,742.8c0-1-0.3-1.8-0.9-2.5c-0.6-0.6-1.4-1.1-2.5-1.5
c-1-0.3-2.2-0.6-3.6-0.8c-1.4-0.2-2.9-0.3-4.4-0.4c-1.5-0.1-3.1-0.2-4.8-0.2c-1.7-0.1-3.3-0.2-4.8-0.4c-1.4,0.8-2.5,1.7-3.3,2.7
c-0.8,1-1.3,2.2-1.3,3.6c0,0.9,0.2,1.7,0.7,2.5s1.2,1.5,2.2,2c1,0.6,2.3,1,3.8,1.3c1.6,0.3,3.5,0.5,5.8,0.5c2.3,0,4.3-0.2,6-0.5
c1.7-0.3,3-0.8,4.1-1.4c1.1-0.6,1.9-1.4,2.4-2.2C696.8,744.7,697.1,743.8,697.1,742.8z M683.1,713.6c1.5,0,2.9-0.2,4-0.6
c1.1-0.4,2.1-1,2.8-1.7c0.8-0.7,1.3-1.6,1.7-2.7c0.4-1,0.6-2.2,0.6-3.4c0-2.5-0.8-4.5-2.3-6c-1.5-1.5-3.8-2.2-6.8-2.2
s-5.3,0.7-6.8,2.2c-1.5,1.5-2.3,3.5-2.3,6c0,1.2,0.2,2.3,0.6,3.3c0.4,1,1,1.9,1.7,2.7s1.7,1.3,2.8,1.8
C680.3,713.4,681.6,713.6,683.1,713.6z"/>
</g>
</g>
<g id="icon">
<path id="path2852" class="st3" d="M312.8,190.9c-12.6,0-24,2-34.1,5.8c-10.1,3.8-18.3,9.2-25.3,16.4c-7,7.2-12.3,15.7-15.8,25.9
c-3.8,10.2-5.4,21.8-5.7,34.5c0,11.9,1.6,22.5,4.4,31.7c2.8,9.2,7,17.4,12,23.9c4.7,6.5,10.4,11.9,17.1,16c6.3,4.1,13,7.2,19.9,8.5
v2.4c-9.8,2.4-19,5.5-26.9,9.9s-14.8,9.9-20.5,17.1c-5.7,6.8-10.1,15.4-13,25.2c-2.8,9.9-4.4,21.1-4.4,34.5c0,16,1.9,30,6,42.3
c4.1,12.3,9.8,22.5,17.1,31c7.3,8.2,16.4,14.7,27.5,19.1c10.7,4.4,23.1,6.5,36.3,6.5s24.6-1.4,34.4-4.1c9.5-2.7,17.7-5.5,24.6-8.5
v112.6h37V523.2h29.1v118.4h37V529c7,3.1,14.8,6.1,24.6,8.5c9.8,2.7,21.2,4.1,34.4,4.1c13.6,0,25.6-2,36.3-6.5
c10.7-4.4,19.9-10.6,27.5-19.1c7.3-8.2,13-18.8,17.1-31s6-26.6,6-42.3c0-13.3-1.6-24.9-4.4-34.5c-2.8-9.9-7.3-18.4-13-25.2
c-5.7-7.2-12.3-12.6-20.5-17.1c-7.9-4.4-17.1-7.5-26.9-9.9v-2.4c7-1.7,13.6-4.4,19.9-8.5c6.6-4.1,12.3-9.6,17.1-16
c5.1-6.8,8.8-14.7,12-23.9c2.8-9.2,4.4-19.8,4.4-31.7c0-12.6-1.9-24.2-5.7-34.5c-3.5-10.2-8.8-18.8-15.8-25.9
c-7-7.2-15.2-12.6-25.3-16.4c-9.8-3.8-21.2-5.8-34.1-5.8c-13.6,0-25.9,2-37.3,6.1c-11.1,4.1-20.5,10.6-28.8,20.1
c-7.9,9.2-14.2,21.5-18.6,36.8c-3.5,12.3-5.7,26.6-6.3,43.3h-29.7c-0.6-16.7-2.8-31-6.3-43.3c-4.4-15.4-10.7-27.6-18.6-36.8
c-7.9-9.6-17.4-16-28.8-20.1C338.7,193,326,190.9,312.8,190.9z M416.7,197.8c-3.2,0-6.3,0.7-9.5,2.4c-2.8,1.4-5.4,3.1-7.6,5.5
c-2.2,2.4-4.1,5.1-5.4,8.5c-1.3,3.1-1.9,6.5-1.9,10.2c0,3.4,0.6,6.8,1.9,9.9c1.3,3.1,3.2,5.8,5.4,8.2c2.2,2.4,4.7,4.4,7.6,5.8
c2.8,1.4,6,2,9.5,2c3.2,0,6.3-0.7,9.2-2c2.8-1.4,5.7-3.4,7.9-5.8c2.2-2.4,3.8-5.1,5.1-8.2c1.3-3.1,1.9-6.5,1.9-9.9
c0-3.4-0.6-6.8-1.9-10.2c-1.3-3.1-2.8-6.1-5.1-8.5c-2.2-2.4-4.7-4.1-7.9-5.5C423,198.4,419.9,197.8,416.7,197.8z M313.1,210
c8.2,0,15.8,1.7,22.4,4.8c6.6,3.1,12.3,8.5,17.1,16.4c4.7,7.8,8.2,18.1,10.7,31.4c2.5,13,3.5,29.7,3.5,49.5v199.2
c-7.9,2.7-16.4,5.1-25.9,6.8c-9.5,1.7-19.6,2.4-30.3,2.4c-16.1,0-28.4-6.1-37.3-18.8c-8.8-12.6-13.3-32.1-13.3-58.3
c0-11.9,1.3-22.5,3.8-31.7c2.5-9.6,6.3-17.4,11.7-23.9s12.3-11.6,20.9-15.4c8.5-3.8,19-5.8,31.3-6.1v-18.8
c-10.4-0.3-19.3-2-26.5-5.1c-7.3-3.1-13.3-7.8-18-14c-4.7-6.1-7.9-13.6-10.1-22.5c-2.2-9.2-3.2-19.8-3.2-31.7
c0-9.6,0.9-18.4,2.5-26.3c1.6-7.8,4.1-14.7,7.9-20.1c3.5-5.5,7.9-9.9,13.3-13C299.2,211.4,305.5,210,313.1,210z M523.5,210
c7.6,0,14.2,1.7,19.6,4.4c5.4,3.1,9.8,7.2,13.3,13c3.5,5.5,6.3,12.3,7.9,20.1c1.9,7.8,2.5,16.7,2.5,26.3c0,11.9-0.9,22.5-3.2,31.7
c-2.2,8.9-5.4,16.4-10.1,22.5c-4.7,6.1-10.4,10.9-18,14c-7.3,3.1-16.1,4.8-26.5,5.1v18.8c12.3,0.3,22.7,2.4,31.3,6.1
c8.5,3.8,15.5,8.9,20.9,15.4s9.5,14.7,11.7,23.9c2.5,9.2,3.8,19.8,3.8,31.7c0,26.3-4.4,45.7-13.3,58.3
c-8.8,12.6-21.2,18.8-37.3,18.8c-10.7,0-20.9-0.7-30.3-2.4c-7.6-1.4-14.8-3.1-21.5-5.1v-0.7l-3.5-0.7c-0.3,0-0.9-0.3-1.3-0.3V311.7
c0-20.1,1.3-36.5,3.5-49.5c2.5-13,6-23.5,10.7-31.4c4.7-7.8,10.4-13.3,17.1-16.4C507.7,211.7,515.3,210,523.5,210z"/>
</g>
<g id="Ebene_5">
</g>
<g id="Ebene_6" class="st4">
</g>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,132 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 841.9 967.3" style="enable-background:new 0 0 841.9 967.3;" xml:space="preserve">
<style type="text/css">
.st0{fill:#0885C7;}
.st1{display:none;}
.st2{display:inline;fill:#0885C7;stroke:#000000;stroke-miterlimit:10;}
.st3{fill:#FFFFFF;}
.st4{display:none;opacity:0.25;}
</style>
<g id="_x36_eck">
<polygon class="st0" points="421.9,0 841.1,241.1 840.4,724.8 420.3,967.3 1.1,726.2 1.9,242.5 "/>
</g>
<g id="bg" class="st1">
<rect class="st2" width="841.9" height="967.3"/>
</g>
<g id="libravatar.org">
<g>
<path class="st3" d="M142.8,666.2v74.3h-12.4v-74.3H142.8z"/>
<path class="st3" d="M169.7,674.3c0,1.1-0.2,2.1-0.6,3c-0.4,0.9-1,1.8-1.7,2.5c-0.7,0.7-1.6,1.3-2.5,1.7c-1,0.4-2,0.6-3.1,0.6
c-1.1,0-2.1-0.2-3-0.6c-1-0.4-1.8-1-2.5-1.7c-0.7-0.7-1.3-1.5-1.7-2.5c-0.4-0.9-0.6-1.9-0.6-3c0-1.1,0.2-2.1,0.6-3.1
c0.4-1,1-1.8,1.7-2.5c0.7-0.7,1.5-1.3,2.5-1.7c0.9-0.4,2-0.6,3-0.6c1.1,0,2.1,0.2,3.1,0.6c1,0.4,1.8,1,2.5,1.7
c0.7,0.7,1.3,1.5,1.7,2.5C169.5,672.1,169.7,673.2,169.7,674.3z M168,689.2v51.3h-12.4v-51.3H168z"/>
<path class="st3" d="M192.9,695.5c2.1-2.2,4.4-3.9,6.9-5.2c2.5-1.3,5.4-1.9,8.6-1.9c3,0,5.6,0.6,8,1.8c2.4,1.2,4.4,2.8,6.1,5
c1.7,2.2,3,4.8,3.9,7.9c0.9,3.1,1.3,6.6,1.3,10.5c0,4.2-0.5,8-1.5,11.4c-1,3.4-2.5,6.3-4.4,8.7c-1.9,2.4-4.2,4.3-6.9,5.6
c-2.7,1.3-5.7,2-9,2c-1.6,0-3-0.2-4.4-0.5c-1.3-0.3-2.5-0.8-3.6-1.3c-1.1-0.6-2.1-1.3-3-2.1c-0.9-0.8-1.8-1.7-2.6-2.7l-0.5,3.4
c-0.2,0.9-0.5,1.5-1,1.9c-0.5,0.4-1.1,0.5-2,0.5h-8.1v-74.3h12.4V695.5z M192.9,727.2c1.4,1.7,2.9,2.9,4.6,3.6s3.4,1,5.2,1
c1.9,0,3.6-0.3,5.1-1c1.5-0.7,2.8-1.8,3.9-3.2c1.1-1.5,1.9-3.3,2.5-5.5c0.6-2.2,0.8-4.9,0.8-8.1c0-5.6-0.9-9.7-2.8-12.2
c-1.9-2.5-4.5-3.8-8-3.8c-2.4,0-4.4,0.5-6.2,1.6c-1.8,1.1-3.5,2.6-5,4.6V727.2z"/>
<path class="st3" d="M249,698.1c1.6-3.1,3.5-5.5,5.7-7.2c2.2-1.8,4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2c-1-0.2-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7s-1.6,1.7-2.2,2.7c-0.6,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.1,0.2,2.6,0.7s0.8,1.3,1,2.4
L249,698.1z"/>
<path class="st3" d="M272.8,696.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1c1.6,1.8,2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9c-1.8-0.6-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7
c-0.6-0.5-1.1-1-1.4-1.6L272.8,696.4z M301.1,718.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7c-1.5,0.7-2.5,1.5-3.1,2.5
c-0.6,0.9-1,2-1,3c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V718.6z"/>
<path class="st3" d="M369.8,689.2l-20.4,51.3h-11.2l-20.3-51.3h10.2c0.9,0,1.7,0.2,2.3,0.6c0.6,0.4,1,1,1.2,1.7l9.9,27.4
c0.5,1.6,1,3.2,1.4,4.7c0.4,1.5,0.8,3.1,1,4.6c0.3-1.5,0.7-3.1,1.1-4.6c0.4-1.5,0.9-3.1,1.5-4.7l10.1-27.4
c0.2-0.7,0.6-1.2,1.2-1.7c0.6-0.4,1.3-0.6,2.1-0.6H369.8z"/>
<path class="st3" d="M373.9,696.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1c1.6,1.8,2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9c-1.8-0.6-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7
c-0.6-0.5-1.1-1-1.4-1.6L373.9,696.4z M402.2,718.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7c-1.5,0.7-2.5,1.5-3.1,2.5
c-0.6,0.9-1,2-1,3c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V718.6z"/>
<path class="st3" d="M442.2,741.3c-4.5,0-7.9-1.3-10.3-3.8c-2.4-2.5-3.6-6-3.6-10.4v-28.6h-5.2c-0.7,0-1.2-0.2-1.7-0.7
c-0.5-0.4-0.7-1.1-0.7-2V691l8.2-1.4l2.6-14c0.2-0.7,0.5-1.2,1-1.5c0.5-0.4,1.1-0.5,1.8-0.5h6.4v16.2h13.7v8.8h-13.7v27.8
c0,1.6,0.4,2.8,1.2,3.8c0.8,0.9,1.9,1.3,3.2,1.3c0.8,0,1.4-0.1,1.9-0.3c0.5-0.2,1-0.4,1.4-0.6c0.4-0.2,0.7-0.4,1-0.6
c0.3-0.2,0.6-0.3,0.9-0.3c0.4,0,0.7,0.1,0.9,0.3c0.2,0.2,0.5,0.5,0.8,0.8l3.7,6c-1.8,1.5-3.9,2.6-6.2,3.4
C447.1,740.9,444.7,741.3,442.2,741.3z"/>
<path class="st3" d="M461.1,696.4c5.9-5.4,13-8.1,21.3-8.1c3,0,5.7,0.5,8,1.5c2.4,1,4.4,2.4,6,4.1s2.9,3.8,3.7,6.3
c0.9,2.4,1.3,5.1,1.3,8v32.4h-5.6c-1.2,0-2.1-0.2-2.7-0.5c-0.6-0.3-1.1-1.1-1.5-2.1l-1.1-3.7c-1.3,1.2-2.6,2.2-3.8,3.1
c-1.2,0.9-2.5,1.6-3.8,2.2c-1.3,0.6-2.8,1.1-4.3,1.4c-1.5,0.3-3.2,0.5-5,0.5c-2.2,0-4.2-0.3-6-0.9s-3.4-1.5-4.8-2.6
c-1.3-1.2-2.4-2.6-3.1-4.3c-0.7-1.7-1.1-3.8-1.1-6c0-1.3,0.2-2.6,0.6-3.9c0.4-1.3,1.1-2.5,2.1-3.7c1-1.2,2.3-2.3,3.8-3.3
c1.6-1,3.5-1.9,5.8-2.7c2.3-0.8,4.9-1.4,8-1.9c3-0.5,6.5-0.8,10.4-0.9v-3c0-3.4-0.7-6-2.2-7.6c-1.5-1.6-3.6-2.5-6.4-2.5
c-2,0-3.7,0.2-5,0.7c-1.3,0.5-2.5,1-3.5,1.6s-1.9,1.1-2.7,1.6c-0.8,0.5-1.7,0.7-2.7,0.7c-0.9,0-1.6-0.2-2.2-0.7s-1.1-1-1.5-1.6
L461.1,696.4z M489.4,718.6c-3.6,0.2-6.6,0.5-9,0.9c-2.4,0.4-4.4,1-5.9,1.7s-2.5,1.5-3.1,2.5c-0.6,0.9-1,2-1,3
c0,2.2,0.6,3.7,1.9,4.7s3,1.4,5,1.4c2.5,0,4.7-0.5,6.6-1.4c1.9-0.9,3.7-2.3,5.4-4.2V718.6z"/>
<path class="st3" d="M524.7,698.1c1.6-3.1,3.5-5.5,5.7-7.2s4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2s-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7c-0.9,0.8-1.6,1.7-2.2,2.7c-0.7,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.2,0.2,2.7,0.7
s0.8,1.3,1,2.4L524.7,698.1z"/>
<path class="st3" d="M544.6,733.7c0-1,0.2-2,0.6-3c0.4-0.9,0.9-1.7,1.6-2.4c0.7-0.7,1.5-1.2,2.4-1.6s1.9-0.6,3-0.6s2.1,0.2,3,0.6
c0.9,0.4,1.7,0.9,2.4,1.6c0.7,0.7,1.2,1.5,1.6,2.4c0.4,0.9,0.6,1.9,0.6,3c0,1.1-0.2,2.1-0.6,3c-0.4,0.9-0.9,1.7-1.6,2.4
c-0.7,0.7-1.5,1.2-2.4,1.6c-0.9,0.4-1.9,0.6-3,0.6s-2.1-0.2-3-0.6s-1.7-0.9-2.4-1.6c-0.7-0.7-1.2-1.5-1.6-2.4
C544.8,735.8,544.6,734.8,544.6,733.7z"/>
<path class="st3" d="M592.9,688.4c3.8,0,7.3,0.6,10.4,1.8c3.1,1.2,5.8,3,8,5.2c2.2,2.3,3.9,5,5.1,8.3c1.2,3.3,1.8,6.9,1.8,11
c0,4.1-0.6,7.7-1.8,11c-1.2,3.3-2.9,6-5.1,8.3c-2.2,2.3-4.9,4.1-8,5.3c-3.1,1.2-6.6,1.8-10.4,1.8c-3.8,0-7.3-0.6-10.5-1.8
c-3.1-1.2-5.8-3-8-5.3c-2.2-2.3-3.9-5.1-5.2-8.3c-1.2-3.3-1.8-6.9-1.8-11c0-4,0.6-7.7,1.8-11c1.2-3.3,2.9-6,5.2-8.3
c2.2-2.3,4.9-4,8-5.2C585.6,689,589,688.4,592.9,688.4z M592.9,731.8c4.3,0,7.4-1.4,9.5-4.3c2-2.9,3.1-7.1,3.1-12.6
c0-5.5-1-9.8-3.1-12.6c-2.1-2.9-5.2-4.4-9.5-4.4c-4.3,0-7.5,1.5-9.6,4.4c-2.1,2.9-3.1,7.1-3.1,12.6s1,9.7,3.1,12.6
C585.3,730.3,588.5,731.8,592.9,731.8z"/>
<path class="st3" d="M639.3,698.1c1.6-3.1,3.5-5.5,5.7-7.2s4.8-2.6,7.8-2.6c2.4,0,4.3,0.5,5.7,1.5l-0.8,9.2
c-0.2,0.6-0.4,1-0.7,1.3c-0.3,0.2-0.7,0.4-1.3,0.4c-0.5,0-1.2-0.1-2.2-0.2s-1.9-0.2-2.9-0.2c-1.4,0-2.6,0.2-3.6,0.6
c-1.1,0.4-2,1-2.9,1.7c-0.9,0.8-1.6,1.7-2.2,2.7c-0.7,1.1-1.3,2.3-1.8,3.7v31.6h-12.4v-51.3h7.2c1.3,0,2.2,0.2,2.7,0.7
s0.8,1.3,1,2.4L639.3,698.1z"/>
<path class="st3" d="M709.4,691v4.6c0,1.5-0.9,2.4-2.6,2.7l-4.6,0.8c0.7,1.8,1.1,3.7,1.1,5.8c0,2.5-0.5,4.8-1.5,6.9
c-1,2-2.4,3.8-4.2,5.2c-1.8,1.4-3.9,2.5-6.4,3.3c-2.5,0.8-5.1,1.2-7.9,1.2c-1,0-2,0-2.9-0.2c-0.9-0.1-1.9-0.2-2.8-0.4
c-1.6,1-2.4,2-2.4,3.2c0,1,0.5,1.8,1.4,2.3c1,0.5,2.2,0.8,3.8,1c1.6,0.2,3.3,0.3,5.3,0.4s4.1,0.2,6.2,0.3s4.2,0.5,6.2,0.9
s3.8,1.1,5.3,2c1.6,0.9,2.8,2.1,3.8,3.7c0.9,1.6,1.4,3.5,1.4,6c0,2.3-0.6,4.5-1.7,6.6c-1.1,2.1-2.7,4-4.9,5.7
c-2.1,1.7-4.7,3-7.8,4c-3.1,1-6.6,1.5-10.5,1.5c-3.9,0-7.2-0.4-10.1-1.1c-2.9-0.8-5.2-1.8-7.2-3c-1.9-1.2-3.3-2.7-4.2-4.3
s-1.4-3.3-1.4-5.1c0-2.4,0.7-4.4,2.2-6c1.5-1.6,3.4-2.9,6-3.9c-1.4-0.7-2.4-1.6-3.2-2.8c-0.8-1.2-1.2-2.7-1.2-4.6
c0-0.8,0.1-1.6,0.4-2.4c0.3-0.8,0.7-1.6,1.2-2.4c0.5-0.8,1.2-1.6,2.1-2.3c0.8-0.7,1.8-1.4,2.9-1.9c-2.6-1.4-4.6-3.3-6.1-5.6
c-1.5-2.3-2.2-5.1-2.2-8.2c0-2.5,0.5-4.8,1.5-6.9c1-2.1,2.4-3.8,4.2-5.2c1.8-1.5,4-2.6,6.4-3.3s5.2-1.2,8.1-1.2
c2.2,0,4.3,0.2,6.2,0.7c1.9,0.5,3.7,1.1,5.3,2H709.4z M697.1,742.8c0-1-0.3-1.8-0.9-2.5c-0.6-0.6-1.4-1.1-2.5-1.5
c-1-0.3-2.2-0.6-3.6-0.8c-1.4-0.2-2.9-0.3-4.4-0.4c-1.5-0.1-3.1-0.2-4.8-0.2c-1.7-0.1-3.3-0.2-4.8-0.4c-1.4,0.8-2.5,1.7-3.3,2.7
c-0.8,1-1.3,2.2-1.3,3.6c0,0.9,0.2,1.7,0.7,2.5s1.2,1.5,2.2,2c1,0.6,2.3,1,3.8,1.3c1.6,0.3,3.5,0.5,5.8,0.5c2.3,0,4.3-0.2,6-0.5
c1.7-0.3,3-0.8,4.1-1.4c1.1-0.6,1.9-1.4,2.4-2.2C696.8,744.7,697.1,743.8,697.1,742.8z M683.1,713.6c1.5,0,2.9-0.2,4-0.6
c1.1-0.4,2.1-1,2.8-1.7c0.8-0.7,1.3-1.6,1.7-2.7c0.4-1,0.6-2.2,0.6-3.4c0-2.5-0.8-4.5-2.3-6c-1.5-1.5-3.8-2.2-6.8-2.2
s-5.3,0.7-6.8,2.2c-1.5,1.5-2.3,3.5-2.3,6c0,1.2,0.2,2.3,0.6,3.3c0.4,1,1,1.9,1.7,2.7s1.7,1.3,2.8,1.8
C680.3,713.4,681.6,713.6,683.1,713.6z"/>
</g>
</g>
<g id="icon">
<path id="path2852" class="st3" d="M312.8,190.9c-12.6,0-24,2-34.1,5.8c-10.1,3.8-18.3,9.2-25.3,16.4c-7,7.2-12.3,15.7-15.8,25.9
c-3.8,10.2-5.4,21.8-5.7,34.5c0,11.9,1.6,22.5,4.4,31.7c2.8,9.2,7,17.4,12,23.9c4.7,6.5,10.4,11.9,17.1,16c6.3,4.1,13,7.2,19.9,8.5
v2.4c-9.8,2.4-19,5.5-26.9,9.9s-14.8,9.9-20.5,17.1c-5.7,6.8-10.1,15.4-13,25.2c-2.8,9.9-4.4,21.1-4.4,34.5c0,16,1.9,30,6,42.3
c4.1,12.3,9.8,22.5,17.1,31c7.3,8.2,16.4,14.7,27.5,19.1c10.7,4.4,23.1,6.5,36.3,6.5s24.6-1.4,34.4-4.1c9.5-2.7,17.7-5.5,24.6-8.5
v112.6h37V523.2h29.1v118.4h37V529c7,3.1,14.8,6.1,24.6,8.5c9.8,2.7,21.2,4.1,34.4,4.1c13.6,0,25.6-2,36.3-6.5
c10.7-4.4,19.9-10.6,27.5-19.1c7.3-8.2,13-18.8,17.1-31s6-26.6,6-42.3c0-13.3-1.6-24.9-4.4-34.5c-2.8-9.9-7.3-18.4-13-25.2
c-5.7-7.2-12.3-12.6-20.5-17.1c-7.9-4.4-17.1-7.5-26.9-9.9v-2.4c7-1.7,13.6-4.4,19.9-8.5c6.6-4.1,12.3-9.6,17.1-16
c5.1-6.8,8.8-14.7,12-23.9c2.8-9.2,4.4-19.8,4.4-31.7c0-12.6-1.9-24.2-5.7-34.5c-3.5-10.2-8.8-18.8-15.8-25.9
c-7-7.2-15.2-12.6-25.3-16.4c-9.8-3.8-21.2-5.8-34.1-5.8c-13.6,0-25.9,2-37.3,6.1c-11.1,4.1-20.5,10.6-28.8,20.1
c-7.9,9.2-14.2,21.5-18.6,36.8c-3.5,12.3-5.7,26.6-6.3,43.3h-29.7c-0.6-16.7-2.8-31-6.3-43.3c-4.4-15.4-10.7-27.6-18.6-36.8
c-7.9-9.6-17.4-16-28.8-20.1C338.7,193,326,190.9,312.8,190.9z M416.7,197.8c-3.2,0-6.3,0.7-9.5,2.4c-2.8,1.4-5.4,3.1-7.6,5.5
c-2.2,2.4-4.1,5.1-5.4,8.5c-1.3,3.1-1.9,6.5-1.9,10.2c0,3.4,0.6,6.8,1.9,9.9c1.3,3.1,3.2,5.8,5.4,8.2c2.2,2.4,4.7,4.4,7.6,5.8
c2.8,1.4,6,2,9.5,2c3.2,0,6.3-0.7,9.2-2c2.8-1.4,5.7-3.4,7.9-5.8c2.2-2.4,3.8-5.1,5.1-8.2c1.3-3.1,1.9-6.5,1.9-9.9
c0-3.4-0.6-6.8-1.9-10.2c-1.3-3.1-2.8-6.1-5.1-8.5c-2.2-2.4-4.7-4.1-7.9-5.5C423,198.4,419.9,197.8,416.7,197.8z M313.1,210
c8.2,0,15.8,1.7,22.4,4.8c6.6,3.1,12.3,8.5,17.1,16.4c4.7,7.8,8.2,18.1,10.7,31.4c2.5,13,3.5,29.7,3.5,49.5v199.2
c-7.9,2.7-16.4,5.1-25.9,6.8c-9.5,1.7-19.6,2.4-30.3,2.4c-16.1,0-28.4-6.1-37.3-18.8c-8.8-12.6-13.3-32.1-13.3-58.3
c0-11.9,1.3-22.5,3.8-31.7c2.5-9.6,6.3-17.4,11.7-23.9s12.3-11.6,20.9-15.4c8.5-3.8,19-5.8,31.3-6.1v-18.8
c-10.4-0.3-19.3-2-26.5-5.1c-7.3-3.1-13.3-7.8-18-14c-4.7-6.1-7.9-13.6-10.1-22.5c-2.2-9.2-3.2-19.8-3.2-31.7
c0-9.6,0.9-18.4,2.5-26.3c1.6-7.8,4.1-14.7,7.9-20.1c3.5-5.5,7.9-9.9,13.3-13C299.2,211.4,305.5,210,313.1,210z M523.5,210
c7.6,0,14.2,1.7,19.6,4.4c5.4,3.1,9.8,7.2,13.3,13c3.5,5.5,6.3,12.3,7.9,20.1c1.9,7.8,2.5,16.7,2.5,26.3c0,11.9-0.9,22.5-3.2,31.7
c-2.2,8.9-5.4,16.4-10.1,22.5c-4.7,6.1-10.4,10.9-18,14c-7.3,3.1-16.1,4.8-26.5,5.1v18.8c12.3,0.3,22.7,2.4,31.3,6.1
c8.5,3.8,15.5,8.9,20.9,15.4s9.5,14.7,11.7,23.9c2.5,9.2,3.8,19.8,3.8,31.7c0,26.3-4.4,45.7-13.3,58.3
c-8.8,12.6-21.2,18.8-37.3,18.8c-10.7,0-20.9-0.7-30.3-2.4c-7.6-1.4-14.8-3.1-21.5-5.1v-0.7l-3.5-0.7c-0.3,0-0.9-0.3-1.3-0.3V311.7
c0-20.1,1.3-36.5,3.5-49.5c2.5-13,6-23.5,10.7-31.4c4.7-7.8,10.4-13.3,17.1-16.4C507.7,211.7,515.3,210,523.5,210z"/>
</g>
<g id="Ebene_5">
</g>
<g id="Ebene_6" class="st4">
</g>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,182 +0,0 @@
# -*- coding: utf-8 -*-
"""
Celery tasks for avatar generation
"""
import logging
from celery import shared_task
from django.contrib.auth.models import User
from io import BytesIO
from ivatar.ai_service import generate_avatar_image, AIServiceError
from ivatar.ivataraccount.models import GenerationTask, Photo
logger = logging.getLogger(__name__)
@shared_task(bind=True, name="ivatar.tasks.generate_avatar_task")
def generate_avatar_task(self, task_id, user_id, prompt, model, quality, allow_nsfw):
"""
Background task to generate avatar images
Args:
task_id: GenerationTask ID
user_id: User ID
prompt: Avatar description
model: AI model to use
quality: Generation quality
allow_nsfw: Whether to allow NSFW content
Returns:
dict: Task result with photo_id or error
"""
try:
# Get the task object
task = GenerationTask.objects.get(pk=task_id)
# Update task status
task.status = "processing"
task.task_id = self.request.id
task.progress = 10
task.save()
logger.info(f"Starting avatar generation task {task_id} for user {user_id}")
# Update progress
self.update_state(
state="PROGRESS",
meta={"progress": 20, "status": "Initializing generation..."},
)
task.progress = 20
task.save()
# Generate the avatar image
self.update_state(
state="PROGRESS", meta={"progress": 30, "status": "Generating image..."}
)
task.progress = 30
task.save()
generated_image = generate_avatar_image(
prompt=prompt,
model=model,
size=(512, 512),
quality=quality,
allow_nsfw=allow_nsfw,
)
# Update progress
self.update_state(
state="PROGRESS", meta={"progress": 70, "status": "Processing image..."}
)
task.progress = 70
task.save()
# Convert PIL image to bytes
img_buffer = BytesIO()
generated_image.save(img_buffer, format="PNG")
img_data = img_buffer.getvalue()
# Get user
user = User.objects.get(pk=user_id)
# Create Photo object
photo = Photo()
photo.user = user
photo.ip_address = "127.0.0.1" # Default IP for background tasks
photo.data = img_data
photo.format = "png"
# Store AI generation metadata
photo.ai_generated = True
photo.ai_prompt = prompt
photo.ai_model = model
photo.ai_quality = quality
photo.save()
# Update progress
self.update_state(
state="PROGRESS", meta={"progress": 90, "status": "Saving avatar..."}
)
task.progress = 90
task.save()
# Update task with completed status
task.status = "completed"
task.progress = 100
task.generated_photo = photo
task.save()
logger.info(
f"Completed avatar generation task {task_id}, created photo {photo.pk}"
)
return {"status": "completed", "photo_id": photo.pk, "task_id": task_id}
except AIServiceError as e:
logger.error(f"AI service error in task {task_id}: {e}")
task.status = "failed"
task.error_message = str(e)
task.progress = 0
task.save()
return {"status": "failed", "error": str(e), "task_id": task_id}
except Exception as e:
logger.error(f"Unexpected error in task {task_id}: {e}")
task.status = "failed"
task.error_message = str(e)
task.progress = 0
task.save()
return {
"status": "failed",
"error": f"Unexpected error: {str(e)}",
"task_id": task_id,
}
@shared_task(name="ivatar.tasks.update_queue_positions")
def update_queue_positions():
"""
Update queue positions for pending tasks
"""
try:
pending_tasks = GenerationTask.objects.filter(status="pending").order_by(
"add_date"
)
for index, task in enumerate(pending_tasks):
task.queue_position = index + 1
task.save()
logger.info(f"Updated queue positions for {len(pending_tasks)} pending tasks")
except Exception as e:
logger.error(f"Error updating queue positions: {e}")
@shared_task(name="ivatar.tasks.cleanup_old_tasks")
def cleanup_old_tasks():
"""
Clean up old completed/failed tasks
"""
try:
from django.utils import timezone
from datetime import timedelta
# Delete tasks older than 7 days
cutoff_date = timezone.now() - timedelta(days=7)
old_tasks = GenerationTask.objects.filter(
add_date__lt=cutoff_date, status__in=["completed", "failed", "cancelled"]
)
count = old_tasks.count()
old_tasks.delete()
logger.info(f"Cleaned up {count} old tasks")
except Exception as e:
logger.error(f"Error cleaning up old tasks: {e}")

View File

@@ -37,7 +37,6 @@ class Tester(TestCase):
self.assertEqual(pil_format("jpeg"), "JPEG") self.assertEqual(pil_format("jpeg"), "JPEG")
self.assertEqual(pil_format("png"), "PNG") self.assertEqual(pil_format("png"), "PNG")
self.assertEqual(pil_format("gif"), "GIF") self.assertEqual(pil_format("gif"), "GIF")
self.assertEqual(pil_format("webp"), "WEBP")
self.assertEqual(pil_format("abc"), None) self.assertEqual(pil_format("abc"), None)
def test_userprefs_str(self): def test_userprefs_str(self):

View File

@@ -5,7 +5,7 @@ Test our utils from ivatar.utils
from django.test import TestCase from django.test import TestCase
from ivatar.utils import is_trusted_url, openid_variations from ivatar.utils import openid_variations
class Tester(TestCase): class Tester(TestCase):
@@ -45,73 +45,3 @@ class Tester(TestCase):
self.assertEqual(openid_variations(openid3)[1], openid1) self.assertEqual(openid_variations(openid3)[1], openid1)
self.assertEqual(openid_variations(openid3)[2], openid2) self.assertEqual(openid_variations(openid3)[2], openid2)
self.assertEqual(openid_variations(openid3)[3], openid3) 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/"
}
])
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/"
}
])
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/"
}
])
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/"
}
])
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/"
}
])
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/"
}
])
self.assertFalse(test_url_prefix_false)

View File

@@ -2,23 +2,16 @@
""" """
Test our views in ivatar.ivataraccount.views and ivatar.views Test our views in ivatar.ivataraccount.views and ivatar.views
""" """
import contextlib
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
import os import os
import json import json
import django import django
from django.urls import reverse
from django.test import TestCase from django.test import TestCase
from django.test import Client from django.test import Client
from django.contrib.auth.models import User from django.contrib.auth.models import User
from ivatar.utils import random_string, Bluesky
BLUESKY_APP_PASSWORD = None from ivatar.utils import random_string
BLUESKY_IDENTIFIER = None
with contextlib.suppress(Exception):
from settings import BLUESKY_APP_PASSWORD, BLUESKY_IDENTIFIER
os.environ["DJANGO_SETTINGS_MODULE"] = "ivatar.settings" os.environ["DJANGO_SETTINGS_MODULE"] = "ivatar.settings"
django.setup() django.setup()
@@ -56,17 +49,12 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
""" """
Test incorrect digest Test incorrect digest
""" """
response = self.client.get("/avatar/" + "x" * 65, follow=True) response = self.client.get("/avatar/%s" % "x" * 65, follow=True)
self.assertEqual( self.assertRedirects(
response.redirect_chain[2][0], response=response,
"/static/img/nobody/80.png", expected_url="/static/img/deadbeef.png",
"Doesn't redirect to static?", msg_prefix="Why does an invalid hash not redirect to deadbeef?",
) )
# self.assertRedirects(
# response=response,
# expected_url="/static/img/nobody/80.png",
# msg_prefix="Why does an invalid hash not redirect to deadbeef?",
# )
def test_stats(self): def test_stats(self):
""" """
@@ -83,31 +71,3 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
j["unconfirmed_openids"], 0, "unconfirmed openids count incorrect" j["unconfirmed_openids"], 0, "unconfirmed openids count incorrect"
) )
self.assertEqual(j["avatars"], 0, "avatars count incorrect") self.assertEqual(j["avatars"], 0, "avatars count incorrect")
def test_logout(self):
"""
Test if logout works correctly
"""
self.login()
response = self.client.get(reverse("logout"), follow=True)
self.assertEqual(
response.status_code, 405, "logout with get should lead to http error 405"
)
response = self.client.post(reverse("logout"), follow=True)
self.assertEqual(response.status_code, 200, "logout with post should logout")
def test_Bluesky_client(self):
"""
Bluesky client needs credentials, so it's limited with testing here now
"""
if BLUESKY_APP_PASSWORD and BLUESKY_IDENTIFIER:
b = Bluesky()
profile = b.get_profile("libravatar.org")
self.assertEqual(profile["handle"], "libravata.org")
# As long as I don't change my avatar, this should stay the same
self.assertEqual(
profile["avatar"],
"https://cdn.bsky.app/img/avatar/plain/did:plc:35jdu26cjgsc5vdbsaqiuw4a/bafkreidgtubihcdwcr72s5nag2ohcnwhhbg2zabw4jtxlhmtekrm6t5f4y@jpeg",
)
self.assertEqual(True, True)

View File

@@ -66,37 +66,38 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<div class="form-container"> <div style="max-width:640px">
<form method="post" name="check"> <form method="post" name="check">
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group"><label for="id_mail">{% trans 'E-Mail' %}</label>
<label for="id_mail" class="form-label">{% trans 'E-Mail' %}</label> <input type="email" name="mail" maxlength="254" minlength="6" class="form-control" placeholder="{% trans 'E-Mail' %}" {% if form.mail.value %} value="{{ form.mail.value }}" {% endif %} id="id_mail"></div>
<input type="email" name="mail" maxlength="254" minlength="6" class="form-control" placeholder="{% trans 'E-Mail' %}" {% if form.mail.value %} value="{{ form.mail.value }}" {% endif %} id="id_mail"> <div class="form-group"><label for="id_openid">{% trans 'OpenID' %}</label>
</div> <input type="text" name="openid" maxlength="255" minlength="11" class="form-control" placeholder="{% trans 'OpenID' %}" {% if form.openid.value %} value="{{ form.openid.value }}" {% endif %} id="id_openid"></div>
<div class="form-group"> <div class="form-group"><label for="id_size">{% trans 'Size' %}</label>
<label for="id_openid" class="form-label">{% trans 'OpenID' %}</label> <input type="number" name="size" min="5" max="512" class="form-control" placeholder="{% trans 'Size' %}" {% if form.size.value %} value="{{ form.size.value }}" {% else %} value="100" {% endif %} required id="id_size"></div>
<input type="text" name="openid" maxlength="255" minlength="11" class="form-control" placeholder="{% trans 'OpenID' %}" {% if form.openid.value %} value="{{ form.openid.value }}" {% endif %} id="id_openid"> {% if form.default_url.errors %}
</div> <div class="alert alert-danger" role="alert">{{ form.default_url.errors }}</div>
<div class="form-group"> {% endif %}
<label for="id_size" class="form-label">{% trans 'Size' %}</label> <div class="form-group"><label for="id_default_url">{% trans 'Default URL or special keyword' %}</label>
<input type="number" name="size" min="5" max="512" class="form-control" placeholder="{% trans 'Size' %}" {% if form.size.value %} value="{{ form.size.value }}" {% else %} value="100" {% endif %} required id="id_size"> <input type="text" name="default_url" class="form-control" placeholder="{% trans 'Default' %}" {% if form.default_url.value %} value="{{ form.default_url.value }}" {% endif %} id="id_default_url"></div>
</div> {% if form.default_opt.errors %}
<div class="form-group"> <div class="alert alert-danger" role="alert">{{ form.default_opt.errors }}</div>
<label for="id_default_url" class="form-label">{% trans 'Default URL or special keyword' %}</label> {% endif %}
<input type="text" name="default_url" class="form-control" placeholder="{% trans 'Default' %}" {% if form.default_url.value %} value="{{ form.default_url.value }}" {% endif %} id="id_default_url">
</div> <div class="form-group"><label for="id_default_opt">{% trans 'Default (special keyword)' %}</label>
<div class="form-group">
<label class="form-label">{% trans 'Default (special keyword)' %}</label>
{% for opt in form.default_opt.field.choices %} {% for opt in form.default_opt.field.choices %}
<div class="form-check"> <div class="radio" {% if forloop.counter|divisibleby:2 %}even{% else %}odd{% endif %}>
<input type="radio" name="default_opt" value="{{ opt.0 }}" class="form-check-input" id="default_opt-{{ opt.0 }}" {% if form.default_opt.value == opt.0 %}checked{% endif %}> <input type="radio" name="default_opt" value="{{ opt.0 }}"
<label for="default_opt-{{ opt.0 }}" class="form-check-label">{{ opt.1 }}</label> id="default_opt-{{ opt.0 }}"
{% if form.default_opt.value == opt.0 %}checked{% endif %}
>
<label for="default_opt-{{ opt.0 }}">{{ opt.1 }}</label>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="button-group"> <div class="form-group">
<button type="submit" class="btn btn-primary">{% trans 'Check' %}</button> <button type="submit" class="button">{% trans 'Check' %}</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -48,109 +48,16 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods
password=self.password, password=self.password,
) )
def test_check_mail(self): def test_check(self):
""" """
Test check page Test check page
""" """
self.login()
response = self.client.get(reverse("tools_check")) response = self.client.get(reverse("tools_check"))
self.assertEqual(response.status_code, 200, "no 200 ok?") self.assertEqual(response.status_code, 200, "no 200 ok?")
response = self.client.post(
reverse("tools_check"),
data={"mail": "test@test.com", "size": "85"},
follow=True,
)
self.assertContains(
response,
'value="test@test.com"',
1,
200,
"Value not set again!?",
)
self.assertContains(
response,
"b642b4217b34b1e8d3bd915fc65c4452",
3,
200,
"Wrong md5 hash!?",
)
self.assertContains(
response,
"f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a",
3,
200,
"Wrong sha256 hash!?",
)
self.assertContains(
response,
'value="85"',
1,
200,
"Size should be set based on post params!?",
)
def test_check_openid(self):
"""
Test check page
"""
self.login()
response = self.client.get(reverse("tools_check"))
self.assertEqual(response.status_code, 200, "no 200 ok?")
response = self.client.post(
reverse("tools_check"),
data={"openid": "https://test.com", "size": "85"},
follow=True,
)
self.assertContains(
response,
'value="https://test.com"',
1,
200,
"Value not set again!?",
)
self.assertContains(
response,
"396936bd0bf0603d6784b65d03e96dae90566c36b62661f28d4116c516524bcc",
3,
200,
"Wrong sha256 hash!?",
)
self.assertContains(
response,
'value="85"',
1,
200,
"Size should be set based on post params!?",
)
def test_check_domain(self): def test_check_domain(self):
""" """
Test check domain page Test check domain page
""" """
self.login()
response = self.client.get(reverse("tools_check_domain")) response = self.client.get(reverse("tools_check_domain"))
self.assertEqual(response.status_code, 200, "no 200 ok?") self.assertEqual(response.status_code, 200, "no 200 ok?")
response = self.client.post(
reverse("tools_check_domain"),
data={"domain": "linux-kernel.at"},
follow=True,
)
self.assertEqual(response.status_code, 200, "no 200 ok?")
self.assertContains(
response,
"http://avatars.linux-kernel.at",
2,
200,
"Not responing with right URL!?",
)
self.assertContains(
response,
"https://avatars.linux-kernel.at",
2,
200,
"Not responing with right URL!?",
)

View File

@@ -3,11 +3,11 @@
ivatar/tools URL configuration ivatar/tools URL configuration
""" """
from django.urls import path, re_path from django.conf.urls import url
from .views import CheckView, CheckDomainView from .views import CheckView, CheckDomainView
urlpatterns = [ # pylint: disable=invalid-name urlpatterns = [ # pylint: disable=invalid-name
path("check/", CheckView.as_view(), name="tools_check"), url("check/", CheckView.as_view(), name="tools_check"),
path("check_domain/", CheckDomainView.as_view(), name="tools_check_domain"), url("check_domain/", CheckDomainView.as_view(), name="tools_check_domain"),
re_path("check_domain$", CheckDomainView.as_view(), name="tools_check_domain"), url("check_domain$", CheckDomainView.as_view(), name="tools_check_domain"),
] ]

View File

@@ -16,7 +16,7 @@ from libravatar import libravatar_url, parse_user_identity
from libravatar import SECURE_BASE_URL as LIBRAVATAR_SECURE_BASE_URL from libravatar import SECURE_BASE_URL as LIBRAVATAR_SECURE_BASE_URL
from libravatar import BASE_URL as LIBRAVATAR_BASE_URL from libravatar import BASE_URL as LIBRAVATAR_BASE_URL
from ivatar.settings import SECURE_BASE_URL, BASE_URL, SITE_NAME, DEBUG from ivatar.settings import SECURE_BASE_URL, BASE_URL
from .forms import ( from .forms import (
CheckDomainForm, CheckDomainForm,
CheckForm, CheckForm,
@@ -33,9 +33,10 @@ class CheckDomainView(FormView):
success_url = reverse("tools_check_domain") success_url = reverse("tools_check_domain")
def form_valid(self, form): def form_valid(self, form):
result = {}
super().form_valid(form) super().form_valid(form)
domain = form.cleaned_data["domain"] domain = form.cleaned_data["domain"]
result = {"avatar_server_http": lookup_avatar_server(domain, False)} result["avatar_server_http"] = lookup_avatar_server(domain, False)
if result["avatar_server_http"]: if result["avatar_server_http"]:
result["avatar_server_http_ipv4"] = lookup_ip_address( result["avatar_server_http_ipv4"] = lookup_ip_address(
result["avatar_server_http"], False result["avatar_server_http"], False
@@ -79,6 +80,8 @@ class CheckView(FormView):
mail_hash = None mail_hash = None
mail_hash256 = None mail_hash256 = None
openid_hash = None openid_hash = None
size = 80
super().form_valid(form) super().form_valid(form)
if form.cleaned_data["default_url"]: if form.cleaned_data["default_url"]:
@@ -91,7 +94,8 @@ class CheckView(FormView):
else: else:
default_url = None default_url = None
size = form.cleaned_data["size"] if "size" in form.cleaned_data else 80 if "size" in form.cleaned_data:
size = form.cleaned_data["size"]
if form.cleaned_data["mail"]: if form.cleaned_data["mail"]:
mailurl = libravatar_url( mailurl = libravatar_url(
email=form.cleaned_data["mail"], size=size, default=default_url email=form.cleaned_data["mail"], size=size, default=default_url
@@ -117,7 +121,7 @@ class CheckView(FormView):
if not form.cleaned_data["openid"].startswith( if not form.cleaned_data["openid"].startswith(
"http://" "http://"
) and not form.cleaned_data["openid"].startswith("https://"): ) and not form.cleaned_data["openid"].startswith("https://"):
form.cleaned_data["openid"] = f'http://{form.cleaned_data["openid"]}' form.cleaned_data["openid"] = "http://%s" % form.cleaned_data["openid"]
openidurl = libravatar_url( openidurl = libravatar_url(
openid=form.cleaned_data["openid"], size=size, default=default_url openid=form.cleaned_data["openid"], size=size, default=default_url
) )
@@ -135,35 +139,6 @@ class CheckView(FormView):
openid=form.cleaned_data["openid"], email=None openid=form.cleaned_data["openid"], email=None
)[0] )[0]
if "DEVELOPMENT" in SITE_NAME and DEBUG:
if mailurl:
mailurl = mailurl.replace(
"https://avatars.linux-kernel.at",
f"http://{self.request.get_host()}",
)
if mailurl_secure:
mailurl_secure = mailurl_secure.replace(
"https://avatars.linux-kernel.at",
f"http://{self.request.get_host()}",
)
if mailurl_secure_256:
mailurl_secure_256 = mailurl_secure_256.replace(
"https://avatars.linux-kernel.at",
f"http://{self.request.get_host()}",
)
if openidurl:
openidurl = openidurl.replace(
"https://avatars.linux-kernel.at",
f"http://{self.request.get_host()}",
)
if openidurl_secure:
openidurl_secure = openidurl_secure.replace(
"https://avatars.linux-kernel.at",
f"http://{self.request.get_host()}",
)
print(mailurl, openidurl, mailurl_secure, mailurl_secure_256, openidurl_secure)
return render( return render(
self.request, self.request,
self.template_name, self.template_name,
@@ -197,15 +172,15 @@ def lookup_avatar_server(domain, https):
service_name = None service_name = None
if https: if https:
service_name = f"_avatars-sec._tcp.{domain}" service_name = "_avatars-sec._tcp.%s" % domain
else: else:
service_name = f"_avatars._tcp.{domain}" service_name = "_avatars._tcp.%s" % domain
DNS.DiscoverNameServers() DNS.DiscoverNameServers()
try: try:
dns_request = DNS.Request(name=service_name, qtype="SRV").req() dns_request = DNS.Request(name=service_name, qtype="SRV").req()
except DNS.DNSError as message: except DNS.DNSError as message:
print(f"DNS Error: {message} ({domain})") print("DNS Error: %s (%s)" % (message, domain))
return None return None
if dns_request.header["status"] == "NXDOMAIN": if dns_request.header["status"] == "NXDOMAIN":
@@ -213,7 +188,7 @@ def lookup_avatar_server(domain, https):
return None return None
if dns_request.header["status"] != "NOERROR": if dns_request.header["status"] != "NOERROR":
print(f'DNS Error: status={dns_request.header["status"]} ({domain})') print("DNS Error: status=%s (%s)" % (dns_request.header["status"], domain))
return None return None
records = [] records = []
@@ -238,7 +213,7 @@ def lookup_avatar_server(domain, https):
target, port = srv_hostname(records) target, port = srv_hostname(records)
if target and ((https and port != 443) or (not https and port != 80)): if target and ((https and port != 443) or (not https and port != 80)):
return f"{target}:{port}" return "%s:%s" % (target, port)
return target return target
@@ -268,7 +243,7 @@ def srv_hostname(records):
# Take care - this if is only a if, if the above if # Take care - this if is only a if, if the above if
# uses continue at the end. else it should be an elsif # uses continue at the end. else it should be an elsif
if ret["priority"] < top_priority: if ret["priority"] < top_priority:
# reset the priority (ret has higher priority) # reset the aretay (ret has higher priority)
top_priority = ret["priority"] top_priority = ret["priority"]
total_weight = 0 total_weight = 0
priority_records = [] priority_records = []
@@ -278,7 +253,7 @@ def srv_hostname(records):
if ret["weight"] > 0: if ret["weight"] > 0:
priority_records.append((total_weight, ret)) priority_records.append((total_weight, ret))
else: else:
# zero-weight elements must come first # zero-weigth elements must come first
priority_records.insert(0, (0, ret)) priority_records.insert(0, (0, ret))
if len(priority_records) == 1: if len(priority_records) == 1:
@@ -310,11 +285,11 @@ def lookup_ip_address(hostname, ipv6):
else: else:
dns_request = DNS.Request(name=hostname, qtype=DNS.Type.A).req() dns_request = DNS.Request(name=hostname, qtype=DNS.Type.A).req()
except DNS.DNSError as message: except DNS.DNSError as message:
print(f"DNS Error: {message} ({hostname})") print("DNS Error: %s (%s)" % (message, hostname))
return None return None
if dns_request.header["status"] != "NOERROR": if dns_request.header["status"] != "NOERROR":
print(f'DNS Error: status={dns_request.header["status"]} ({hostname})') print("DNS Error: status=%s (%s)" % (dns_request.header["status"], hostname))
return None return None
for answer in dns_request.answers: for answer in dns_request.answers:
@@ -325,5 +300,9 @@ def lookup_ip_address(hostname, ipv6):
): ):
continue # skip CNAME records continue # skip CNAME records
return inet_ntop(AF_INET6, answer["data"]) if ipv6 else answer["data"] if ipv6:
return inet_ntop(AF_INET6, answer["data"])
return answer["data"]
return None return None

View File

@@ -2,88 +2,76 @@
""" """
ivatar URL configuration ivatar URL configuration
""" """
import contextlib
from django.contrib import admin from django.contrib import admin
from django.urls import path, include, re_path from django.urls import path, include
from django.conf.urls import url
from django.conf.urls.static import static from django.conf.urls.static import static
from django.views.generic import TemplateView, RedirectView from django.views.generic import TemplateView, RedirectView
from ivatar import settings from ivatar import settings
from .views import AvatarImageView, StatsView from .views import AvatarImageView, GravatarProxyView, StatsView
from .views import GravatarProxyView, BlueskyProxyView
urlpatterns = [ # pylint: disable=invalid-name urlpatterns = [ # pylint: disable=invalid-name
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
path("openid/", include("django_openid_auth.urls")), url("openid/", include("django_openid_auth.urls")),
path("auth/", include("social_django.urls", namespace="social")), url("tools/", include("ivatar.tools.urls")),
path("tools/", include("ivatar.tools.urls")), url(r"avatar/(?P<digest>\w{64})", AvatarImageView.as_view(), name="avatar_view"),
re_path( url(r"avatar/(?P<digest>\w{32})", AvatarImageView.as_view(), name="avatar_view"),
r"avatar/(?P<digest>\w{64})", AvatarImageView.as_view(), name="avatar_view" url(r"avatar/$", AvatarImageView.as_view(), name="avatar_view"),
), url(
re_path(
r"avatar/(?P<digest>\w{32})", AvatarImageView.as_view(), name="avatar_view"
),
re_path(r"avatar/$", AvatarImageView.as_view(), name="avatar_view"),
re_path(
r"avatar/(?P<digest>\w*)", r"avatar/(?P<digest>\w*)",
RedirectView.as_view(url="/static/img/deadbeef.png"), RedirectView.as_view(url="/static/img/deadbeef.png"),
name="invalid_hash", name="invalid_hash",
), ),
re_path( url(
r"gravatarproxy/(?P<digest>\w*)", r"gravatarproxy/(?P<digest>\w*)",
GravatarProxyView.as_view(), GravatarProxyView.as_view(),
name="gravatarproxy", name="gravatarproxy",
), ),
re_path( url(
r"blueskyproxy/(?P<digest>\w*)",
BlueskyProxyView.as_view(),
name="blueskyproxy",
),
path(
"description/", "description/",
TemplateView.as_view(template_name="description.html"), TemplateView.as_view(template_name="description.html"),
name="description", name="description",
), ),
# The following two are TODO TODO TODO TODO TODO # The following two are TODO TODO TODO TODO TODO
path( url(
"run_your_own/", "run_your_own/",
TemplateView.as_view(template_name="run_your_own.html"), TemplateView.as_view(template_name="run_your_own.html"),
name="run_your_own", name="run_your_own",
), ),
path( url(
"features/", "features/",
TemplateView.as_view(template_name="features.html"), TemplateView.as_view(template_name="features.html"),
name="features", name="features",
), ),
path( url(
"security/", "security/",
TemplateView.as_view(template_name="security.html"), TemplateView.as_view(template_name="security.html"),
name="security", name="security",
), ),
path( url("privacy/", TemplateView.as_view(template_name="privacy.html"), name="privacy"),
"privacy/", TemplateView.as_view(template_name="privacy.html"), name="privacy" url("contact/", TemplateView.as_view(template_name="contact.html"), name="contact"),
),
path(
"contact/", TemplateView.as_view(template_name="contact.html"), name="contact"
),
path("talk_to_us/", RedirectView.as_view(url="/contact"), name="talk_to_us"), path("talk_to_us/", RedirectView.as_view(url="/contact"), name="talk_to_us"),
path("stats/", StatsView.as_view(), name="stats"), url("stats/", StatsView.as_view(), name="stats"),
] ]
MAINTENANCE = False MAINTENANCE = False
with contextlib.suppress(Exception): try:
if settings.MAINTENANCE: if settings.MAINTENANCE:
MAINTENANCE = True MAINTENANCE = True
except: # pylint: disable=bare-except
pass
if MAINTENANCE: if MAINTENANCE:
urlpatterns.append( urlpatterns.append(
path("", TemplateView.as_view(template_name="maintenance.html"), name="home") url("", TemplateView.as_view(template_name="maintenance.html"), name="home")
) )
urlpatterns.insert(3, path("accounts/", RedirectView.as_view(url="/"))) urlpatterns.insert(3, url("accounts/", RedirectView.as_view(url="/")))
else: else:
urlpatterns.append( urlpatterns.append(
path("", TemplateView.as_view(template_name="home.html"), name="home") url("", TemplateView.as_view(template_name="home.html"), name="home")
) )
urlpatterns.insert(3, path("accounts/", include("ivatar.ivataraccount.urls"))) urlpatterns.insert(3, url("accounts/", include("ivatar.ivataraccount.urls")))
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@@ -2,103 +2,9 @@
""" """
Simple module providing reusable random_string function Simple module providing reusable random_string function
""" """
import contextlib
import random import random
import string import string
from io import BytesIO from PIL import Image, ImageDraw
from PIL import Image, ImageDraw, ImageSequence
from urllib.parse import urlparse
import requests
from ivatar.settings import DEBUG, URL_TIMEOUT
from urllib.request import urlopen as urlopen_orig
BLUESKY_IDENTIFIER = None
BLUESKY_APP_PASSWORD = None
with contextlib.suppress(Exception):
from ivatar.settings import BLUESKY_IDENTIFIER, BLUESKY_APP_PASSWORD
def urlopen(url, timeout=URL_TIMEOUT):
ctx = None
if DEBUG:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return urlopen_orig(url, timeout=timeout, context=ctx)
class Bluesky:
"""
Handle Bluesky client access
"""
identifier = ""
app_password = ""
service = "https://bsky.social"
session = None
def __init__(
self,
identifier: str = BLUESKY_IDENTIFIER,
app_password: str = BLUESKY_APP_PASSWORD,
service: str = "https://bsky.social",
):
self.identifier = identifier
self.app_password = app_password
self.service = service
def login(self):
"""
Login to Bluesky
"""
auth_response = requests.post(
f"{self.service}/xrpc/com.atproto.server.createSession",
json={"identifier": self.identifier, "password": self.app_password},
)
auth_response.raise_for_status()
self.session = auth_response.json()
def normalize_handle(self, handle: str) -> str:
"""
Return the normalized handle for given handle
"""
# Normalize Bluesky handle in case someone enters an '@' at the beginning
while handle.startswith("@"):
handle = handle[1:]
# Remove trailing spaces or spaces at the beginning
while handle.startswith(" "):
handle = handle[1:]
while handle.endswith(" "):
handle = handle[:-1]
return handle
def get_profile(self, handle: str) -> str:
if not self.session:
self.login()
profile_response = None
try:
profile_response = requests.get(
f"{self.service}/xrpc/app.bsky.actor.getProfile",
headers={"Authorization": f'Bearer {self.session["accessJwt"]}'},
params={"actor": handle},
)
profile_response.raise_for_status()
except Exception as exc:
print(f"Bluesky profile fetch failed with HTTP error: {exc}")
return None
return profile_response.json()
def get_avatar(self, handle: str):
"""
Get avatar URL for a handle
"""
profile = self.get_profile(handle)
return profile["avatar"] if profile else None
def random_string(length=10): def random_string(length=10):
@@ -124,12 +30,12 @@ def openid_variations(openid):
if openid.startswith("https://"): if openid.startswith("https://"):
openid = openid.replace("https://", "http://") openid = openid.replace("https://", "http://")
if openid[-1] != "/": if openid[-1] != "/":
openid = f"{openid}/" openid = openid + "/"
# http w/o trailing slash # http w/o trailing slash
var1 = openid[:-1] var1 = openid[0:-1]
var2 = openid.replace("http://", "https://") var2 = openid.replace("http://", "https://")
var3 = var2[:-1] var3 = var2[0:-1]
return (openid, var1, var2, var3) return (openid, var1, var2, var3)
@@ -147,43 +53,43 @@ def mm_ng(
idhash = "e0" idhash = "e0"
# How large is the circle? # How large is the circle?
circle_size = size * 0.6 circlesize = size * 0.6
# Coordinates for the circle # Coordinates for the circle
start_x = int(size * 0.2) start_x = int(size * 0.2)
end_x = start_x + circle_size end_x = start_x + circlesize
start_y = int(size * 0.05) start_y = int(size * 0.05)
end_y = start_y + circle_size end_y = start_y + circlesize
# All are the same, based on the input hash # All are the same, based on the input hash
# this should always result in a "gray-ish" background # this should always result in a "gray-ish" background
red = idhash[:2] red = idhash[0:2]
green = idhash[:2] green = idhash[0:2]
blue = idhash[:2] blue = idhash[0:2]
# Add some red (i/a) and make sure it's not over 255 # Add some red (i/a) and make sure it's not over 255
red = hex(int(red, 16) + add_red).replace("0x", "") red = hex(int(red, 16) + add_red).replace("0x", "")
if int(red, 16) > 255: if int(red, 16) > 255:
red = "ff" red = "ff"
if len(red) == 1: if len(red) == 1:
red = f"0{red}" red = "0%s" % red
# Add some green (i/a) and make sure it's not over 255 # Add some green (i/a) and make sure it's not over 255
green = hex(int(green, 16) + add_green).replace("0x", "") green = hex(int(green, 16) + add_green).replace("0x", "")
if int(green, 16) > 255: if int(green, 16) > 255:
green = "ff" green = "ff"
if len(green) == 1: if len(green) == 1:
green = f"0{green}" green = "0%s" % green
# Add some blue (i/a) and make sure it's not over 255 # Add some blue (i/a) and make sure it's not over 255
blue = hex(int(blue, 16) + add_blue).replace("0x", "") blue = hex(int(blue, 16) + add_blue).replace("0x", "")
if int(blue, 16) > 255: if int(blue, 16) > 255:
blue = "ff" blue = "ff"
if len(blue) == 1: if len(blue) == 1:
blue = f"0{blue}" blue = "0%s" % blue
# Assemble the bg color "string" in web notation. Eg. '#d3d3d3' # Assemable the bg color "string" in webnotation. Eg. '#d3d3d3'
bg_color = f"#{red}{green}{blue}" bg_color = "#" + red + green + blue
# Image # Image
image = Image.new("RGB", (size, size)) image = Image.new("RGB", (size, size))
@@ -198,7 +104,7 @@ def mm_ng(
# Draw MMs 'body' # Draw MMs 'body'
draw.polygon( draw.polygon(
( (
(start_x + circle_size / 2, size / 2.5), (start_x + circlesize / 2, size / 2.5),
(size * 0.15, size), (size * 0.15, size),
(size - size * 0.15, size), (size - size * 0.15, size),
), ),
@@ -206,70 +112,3 @@ def mm_ng(
) )
return image return image
def is_trusted_url(url, url_filters):
"""
Check if a URL is valid and considered a trusted URL.
If the URL is malformed, returns False.
Based on: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/events/UrlFilter
"""
(scheme, netloc, path, params, query, fragment) = urlparse(url)
for ufilter in url_filters:
if "schemes" in ufilter:
schemes = ufilter["schemes"]
if scheme not in schemes:
continue
if "host_equals" in ufilter:
host_equals = ufilter["host_equals"]
if netloc != host_equals:
continue
if "host_suffix" in ufilter:
host_suffix = ufilter["host_suffix"]
if not netloc.endswith(host_suffix):
continue
if "path_prefix" in ufilter:
path_prefix = ufilter["path_prefix"]
if not path.startswith(path_prefix):
continue
if "url_prefix" in ufilter:
url_prefix = ufilter["url_prefix"]
if not url.startswith(url_prefix):
continue
return True
return False
def resize_animated_gif(input_pil: Image, size: list) -> BytesIO:
def _thumbnail_frames(image):
for frame in ImageSequence.Iterator(image):
new_frame = frame.copy()
new_frame.thumbnail(size)
yield new_frame
frames = list(_thumbnail_frames(input_pil))
output = BytesIO()
output_image = frames[0]
output_image.save(
output,
format="gif",
save_all=True,
optimize=False,
append_images=frames[1:],
disposal=input_pil.disposal_method,
**input_pil.info,
)
return output

View File

@@ -2,12 +2,10 @@
""" """
views under / views under /
""" """
import contextlib
from io import BytesIO from io import BytesIO
from os import path from os import path
import hashlib import hashlib
from ivatar.utils import urlopen, Bluesky from urllib.request import urlopen
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from ssl import SSLError from ssl import SSLError
from django.views.generic.base import TemplateView, View from django.views.generic.base import TemplateView, View
@@ -36,7 +34,9 @@ from .ivataraccount.models import ConfirmedEmail, ConfirmedOpenId
from .ivataraccount.models import UnconfirmedEmail, UnconfirmedOpenId from .ivataraccount.models import UnconfirmedEmail, UnconfirmedOpenId
from .ivataraccount.models import Photo from .ivataraccount.models import Photo
from .ivataraccount.models import pil_format, file_format from .ivataraccount.models import pil_format, file_format
from .utils import is_trusted_url, mm_ng, resize_animated_gif from .utils import mm_ng
URL_TIMEOUT = 5 # in seconds
def get_size(request, size=DEFAULT_AVATAR_SIZE): def get_size(request, size=DEFAULT_AVATAR_SIZE):
@@ -49,11 +49,17 @@ def get_size(request, size=DEFAULT_AVATAR_SIZE):
if "size" in request.GET: if "size" in request.GET:
sizetemp = request.GET["size"] sizetemp = request.GET["size"]
if sizetemp: if sizetemp:
if sizetemp not in ["", "0"]: if sizetemp != "" and sizetemp is not None and sizetemp != "0":
with contextlib.suppress(ValueError): try:
if int(sizetemp) > 0: if int(sizetemp) > 0:
size = int(sizetemp) size = int(sizetemp)
size = min(size, int(AVATAR_MAX_SIZE)) # Should we receive something we cannot convert to int, leave
# the user with the default value of 80
except ValueError:
pass
if size > int(AVATAR_MAX_SIZE):
size = int(AVATAR_MAX_SIZE)
return size return size
@@ -115,9 +121,9 @@ class AvatarImageView(TemplateView):
# Check the cache first # Check the cache first
if CACHE_RESPONSE: if CACHE_RESPONSE:
if centry := caches["filesystem"].get(uri): centry = caches["filesystem"].get(uri)
# For DEBUG purpose only if centry:
# print('Cached entry for %s' % uri) # For DEBUG purpose only print('Cached entry for %s' % uri)
return HttpResponse( return HttpResponse(
centry["content"], centry["content"],
content_type=centry["content_type"], content_type=centry["content_type"],
@@ -135,19 +141,16 @@ class AvatarImageView(TemplateView):
if "default" in request.GET: if "default" in request.GET:
default = request.GET["default"] default = request.GET["default"]
if default is not None: # Check if default starts with an URL scheme and if it does,
if TRUSTED_DEFAULT_URLS is None: # check if it's trusted
print("Query parameter `default` is disabled.") # Check for :// (schema)
if default is not None and default.find("://") > 0:
# Check if it's trusted, if not, reset to None
if not any(x in default for x in TRUSTED_DEFAULT_URLS):
print(
"Default URL is not in trusted URLs: '%s' ; Kicking it!" % default
)
default = None 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(
f"Default URL is not in trusted URLs: '{default}'; Kicking it!"
)
default = None
if "f" in request.GET: if "f" in request.GET:
if request.GET["f"] == "y": if request.GET["f"] == "y":
@@ -171,23 +174,20 @@ class AvatarImageView(TemplateView):
obj = model.objects.get(digest_sha256=kwargs["digest"]) obj = model.objects.get(digest_sha256=kwargs["digest"])
except ObjectDoesNotExist: except ObjectDoesNotExist:
model = ConfirmedOpenId model = ConfirmedOpenId
with contextlib.suppress(Exception): try:
d = kwargs["digest"] # pylint: disable=invalid-name d = kwargs["digest"] # pylint: disable=invalid-name
# OpenID is tricky. http vs. https, versus trailing slash or not # OpenID is tricky. http vs. https, versus trailing slash or not
# However, some users eventually have added their variations already # However, some users eventually have added their variations already
# and therefore we need to use filter() and first() # and therfore we need to use filter() and first()
obj = model.objects.filter( obj = model.objects.filter(
Q(digest=d) Q(digest=d)
| Q(alt_digest1=d) | Q(alt_digest1=d)
| Q(alt_digest2=d) | Q(alt_digest2=d)
| Q(alt_digest3=d) | Q(alt_digest3=d)
).first() ).first()
# Handle the special case of Bluesky except Exception: # pylint: disable=bare-except
if obj: pass
if obj.bluesky_handle:
return HttpResponseRedirect(
reverse_lazy("blueskyproxy", args=[kwargs["digest"]])
)
# If that mail/openid doesn't exist, or has no photo linked to it # If that mail/openid doesn't exist, or has no photo linked to it
if not obj or not obj.photo or forcedefault: if not obj or not obj.photo or forcedefault:
gravatar_url = ( gravatar_url = (
@@ -209,7 +209,7 @@ class AvatarImageView(TemplateView):
) )
# Ensure we do not convert None to string 'None' # Ensure we do not convert None to string 'None'
if default: if default:
url += f"&default={default}" url += "&default=%s" % default
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
# Return the default URL, as specified, or 404 Not Found, if default=404 # Return the default URL, as specified, or 404 Not Found, if default=404
@@ -219,7 +219,7 @@ class AvatarImageView(TemplateView):
url = ( url = (
reverse_lazy("gravatarproxy", args=[kwargs["digest"]]) reverse_lazy("gravatarproxy", args=[kwargs["digest"]])
+ "?s=%i" % size + "?s=%i" % size
+ f"&default={default}&f=y" + "&default=%s&f=y" % default
) )
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
@@ -229,25 +229,46 @@ class AvatarImageView(TemplateView):
if str(default) == "monsterid": if str(default) == "monsterid":
monsterdata = BuildMonster(seed=kwargs["digest"], size=(size, size)) monsterdata = BuildMonster(seed=kwargs["digest"], size=(size, size))
data = BytesIO() data = BytesIO()
return self._return_cached_png(monsterdata, data, uri) monsterdata.save(data, "PNG", quality=JPEG_QUALITY)
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
if str(default) == "robohash": if str(default) == "robohash":
roboset = request.GET.get("robohash") or "any" roboset = "any"
if request.GET.get("robohash"):
roboset = request.GET.get("robohash")
robohash = Robohash(kwargs["digest"]) robohash = Robohash(kwargs["digest"])
robohash.assemble(roboset=roboset, sizex=size, sizey=size) robohash.assemble(roboset=roboset, sizex=size, sizey=size)
data = BytesIO() data = BytesIO()
robohash.img.save(data, format="png") robohash.img.save(data, format="png")
return self._return_cached_response(data, uri) data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
if str(default) == "retro": if str(default) == "retro":
identicon = Identicon.render(kwargs["digest"]) identicon = Identicon.render(kwargs["digest"])
data = BytesIO() data = BytesIO()
img = Image.open(BytesIO(identicon)) img = Image.open(BytesIO(identicon))
img = img.resize((size, size), Image.LANCZOS) img = img.resize((size, size), Image.ANTIALIAS)
return self._return_cached_png(img, data, uri) img.save(data, "PNG", quality=JPEG_QUALITY)
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
if str(default) == "pagan": if str(default) == "pagan":
paganobj = pagan.Avatar(kwargs["digest"]) paganobj = pagan.Avatar(kwargs["digest"])
data = BytesIO() data = BytesIO()
img = paganobj.img.resize((size, size), Image.LANCZOS) img = paganobj.img.resize((size, size), Image.ANTIALIAS)
return self._return_cached_png(img, data, uri) img.save(data, "PNG", quality=JPEG_QUALITY)
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
if str(default) == "identicon": if str(default) == "identicon":
p = Pydenticon5() # pylint: disable=invalid-name p = Pydenticon5() # pylint: disable=invalid-name
# In order to make use of the whole 32 bytes digest, we need to redigest them. # In order to make use of the whole 32 bytes digest, we need to redigest them.
@@ -256,35 +277,52 @@ class AvatarImageView(TemplateView):
).hexdigest() ).hexdigest()
img = p.draw(newdigest, size, 0) img = p.draw(newdigest, size, 0)
data = BytesIO() data = BytesIO()
return self._return_cached_png(img, data, uri) img.save(data, "PNG", quality=JPEG_QUALITY)
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
if str(default) == "mmng": if str(default) == "mmng":
mmngimg = mm_ng(idhash=kwargs["digest"], size=size) mmngimg = mm_ng(idhash=kwargs["digest"], size=size)
data = BytesIO() data = BytesIO()
return self._return_cached_png(mmngimg, data, uri) mmngimg.save(data, "PNG", quality=JPEG_QUALITY)
if str(default) in {"mm", "mp"}: data.seek(0)
return self._redirect_static_w_size("mm", size) response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
return response
if str(default) == "mm" or str(default) == "mp":
# If mm is explicitly given, we need to catch that
static_img = path.join(
"static", "img", "mm", "%s%s" % (str(size), ".png")
)
if not path.isfile(static_img):
# We trust this exists!!!
static_img = path.join("static", "img", "mm", "512.png")
# We trust static/ is mapped to /static/
return HttpResponseRedirect("/" + static_img)
return HttpResponseRedirect(default) return HttpResponseRedirect(default)
return self._redirect_static_w_size("nobody", size) static_img = path.join(
"static", "img", "nobody", "%s%s" % (str(size), ".png")
)
if not path.isfile(static_img):
# We trust this exists!!!
static_img = path.join("static", "img", "nobody", "512.png")
# We trust static/ is mapped to /static/
return HttpResponseRedirect("/" + static_img)
imgformat = obj.photo.format imgformat = obj.photo.format
photodata = Image.open(BytesIO(obj.photo.data)) photodata = Image.open(BytesIO(obj.photo.data))
# If the image is smaller than what was requested, we need
data = BytesIO() # to use the function resize
if photodata.size[0] < size or photodata.size[1] < size:
# Animated GIFs need additional handling photodata = photodata.resize((size, size), Image.ANTIALIAS)
if imgformat == "gif" and photodata.is_animated:
# Debug only
# print("Object is animated and has %i frames" % photodata.n_frames)
data = resize_animated_gif(photodata, (size, size))
else: else:
# If the image is smaller than what was requested, we need photodata.thumbnail((size, size), Image.ANTIALIAS)
# to use the function resize data = BytesIO()
if photodata.size[0] < size or photodata.size[1] < size: photodata.save(data, pil_format(imgformat), quality=JPEG_QUALITY)
photodata = photodata.resize((size, size), Image.LANCZOS)
else:
photodata.thumbnail((size, size), Image.LANCZOS)
photodata.save(data, pil_format(imgformat), quality=JPEG_QUALITY)
data.seek(0) data.seek(0)
obj.photo.access_count += 1 obj.photo.access_count += 1
obj.photo.save() obj.photo.save()
@@ -292,36 +330,10 @@ class AvatarImageView(TemplateView):
obj.save() obj.save()
if imgformat == "jpg": if imgformat == "jpg":
imgformat = "jpeg" imgformat = "jpeg"
response = CachingHttpResponse(uri, data, content_type=f"image/{imgformat}") response = CachingHttpResponse(uri, data, content_type="image/%s" % imgformat)
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
# Remove Vary header for images since language doesn't matter
response["Vary"] = ""
return response return response
def _redirect_static_w_size(self, arg0, size):
"""
Helper method to redirect to static image with size i/a
"""
# If mm is explicitly given, we need to catch that
static_img = path.join("static", "img", arg0, f"{str(size)}.png")
if not path.isfile(static_img):
# We trust this exists!!!
static_img = path.join("static", "img", arg0, "512.png")
# We trust static/ is mapped to /static/
return HttpResponseRedirect(f"/{static_img}")
def _return_cached_response(self, data, uri):
data.seek(0)
response = CachingHttpResponse(uri, data, content_type="image/png")
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
# Remove Vary header for images since language doesn't matter
response["Vary"] = ""
return response
def _return_cached_png(self, arg0, data, uri):
arg0.save(data, "PNG", quality=JPEG_QUALITY)
return self._return_cached_response(data, uri)
class GravatarProxyView(View): class GravatarProxyView(View):
""" """
@@ -344,16 +356,19 @@ class GravatarProxyView(View):
+ "&forcedefault=y" + "&forcedefault=y"
) )
if default is not None: if default is not None:
url += f"&default={default}" url += "&default=%s" % default
return HttpResponseRedirect(url) return HttpResponseRedirect(url)
size = get_size(request) size = get_size(request)
gravatarimagedata = None gravatarimagedata = None
default = None default = None
with contextlib.suppress(Exception): try:
if str(request.GET["default"]) != "None": if str(request.GET["default"]) != "None":
default = request.GET["default"] default = request.GET["default"]
except Exception: # pylint: disable=bare-except
pass
if str(default) != "wavatar": if str(default) != "wavatar":
# This part is special/hackish # This part is special/hackish
# Check if the image returned by Gravatar is their default image, if so, # Check if the image returned by Gravatar is their default image, if so,
@@ -368,39 +383,40 @@ class GravatarProxyView(View):
# print("Cached Gravatar response: Default.") # print("Cached Gravatar response: Default.")
return redir_default(default) return redir_default(default)
try: try:
urlopen(gravatar_test_url) urlopen(gravatar_test_url, timeout=URL_TIMEOUT)
except HTTPError as exc: except HTTPError as exc:
if exc.code == 404: if exc.code == 404:
cache.set(gravatar_test_url, "default", 60) cache.set(gravatar_test_url, "default", 60)
else: else:
print(f"Gravatar test url fetch failed: {exc}") print("Gravatar test url fetch failed: %s" % exc)
return redir_default(default) return redir_default(default)
gravatar_url = ( gravatar_url = (
"https://secure.gravatar.com/avatar/" + kwargs["digest"] + "?s=%i" % size "https://secure.gravatar.com/avatar/" + kwargs["digest"] + "?s=%i" % size
) )
if default: if default:
gravatar_url += f"&d={default}" gravatar_url += "&d=%s" % default
try: try:
if cache.get(gravatar_url) == "err": if cache.get(gravatar_url) == "err":
print(f"Cached Gravatar fetch failed with URL error: {gravatar_url}") print("Cached Gravatar fetch failed with URL error: %s" % gravatar_url)
return redir_default(default) return redir_default(default)
gravatarimagedata = urlopen(gravatar_url) gravatarimagedata = urlopen(gravatar_url, timeout=URL_TIMEOUT)
except HTTPError as exc: except HTTPError as exc:
if exc.code not in [404, 503]: if exc.code != 404 and exc.code != 503:
print( print(
f"Gravatar fetch failed with an unexpected {exc.code} HTTP error: {gravatar_url}" "Gravatar fetch failed with an unexpected %s HTTP error: %s"
% (exc.code, gravatar_url)
) )
cache.set(gravatar_url, "err", 30) cache.set(gravatar_url, "err", 30)
return redir_default(default) return redir_default(default)
except URLError as exc: except URLError as exc:
print(f"Gravatar fetch failed with URL error: {exc.reason}") print("Gravatar fetch failed with URL error: %s" % exc.reason)
cache.set(gravatar_url, "err", 30) cache.set(gravatar_url, "err", 30)
return redir_default(default) return redir_default(default)
except SSLError as exc: except SSLError as exc:
print(f"Gravatar fetch failed with SSL error: {exc.reason}") print("Gravatar fetch failed with SSL error: %s" % exc.reason)
cache.set(gravatar_url, "err", 30) cache.set(gravatar_url, "err", 30)
return redir_default(default) return redir_default(default)
try: try:
@@ -408,135 +424,13 @@ class GravatarProxyView(View):
img = Image.open(data) img = Image.open(data)
data.seek(0) data.seek(0)
response = HttpResponse( response = HttpResponse(
data.read(), content_type=f"image/{file_format(img.format)}" data.read(), content_type="image/%s" % file_format(img.format)
) )
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
# Remove Vary header for images since language doesn't matter
response["Vary"] = ""
return response return response
except ValueError as exc: except ValueError as exc:
print(f"Value error: {exc}") print("Value error: %s" % exc)
return redir_default(default)
# We shouldn't reach this point... But make sure we do something
return redir_default(default)
class BlueskyProxyView(View):
"""
Proxy request to Bluesky and return the image from there
"""
def get(
self, request, *args, **kwargs
): # pylint: disable=too-many-branches,too-many-statements,too-many-locals,no-self-use,unused-argument,too-many-return-statements
"""
Override get from parent class
"""
def redir_default(default=None):
url = (
reverse_lazy("avatar_view", args=[kwargs["digest"]])
+ "?s=%i" % size
+ "&forcedefault=y"
)
if default is not None:
url += f"&default={default}"
return HttpResponseRedirect(url)
size = get_size(request)
print(size)
blueskyimagedata = None
default = None
with contextlib.suppress(Exception):
if str(request.GET["default"]) != "None":
default = request.GET["default"]
identity = None
# First check for email, as this is the most common
try:
identity = ConfirmedEmail.objects.filter(
Q(digest=kwargs["digest"]) | Q(digest_sha256=kwargs["digest"])
).first()
except Exception as exc:
print(exc)
# If no identity is found in the email table, try the openid table
if not identity:
try:
identity = ConfirmedOpenId.objects.filter(
Q(digest=kwargs["digest"])
| Q(alt_digest1=kwargs["digest"])
| Q(alt_digest2=kwargs["digest"])
| Q(alt_digest3=kwargs["digest"])
).first()
except Exception as exc:
print(exc)
# If still no identity is found, redirect to the default
if not identity:
return redir_default(default)
bs = Bluesky()
bluesky_url = None
# Try with the cache first
with contextlib.suppress(Exception):
if cache.get(identity.bluesky_handle):
bluesky_url = cache.get(identity.bluesky_handle)
if not bluesky_url:
try:
bluesky_url = bs.get_avatar(identity.bluesky_handle)
cache.set(identity.bluesky_handle, bluesky_url)
except Exception: # pylint: disable=bare-except
return redir_default(default)
try:
if cache.get(bluesky_url) == "err":
print(f"Cached Bluesky fetch failed with URL error: {bluesky_url}")
return redir_default(default)
blueskyimagedata = urlopen(bluesky_url)
except HTTPError as exc:
if exc.code not in [404, 503]:
print(
f"Bluesky fetch failed with an unexpected {exc.code} HTTP error: {bluesky_url}"
)
cache.set(bluesky_url, "err", 30)
return redir_default(default)
except URLError as exc:
print(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}")
cache.set(bluesky_url, "err", 30)
return redir_default(default)
try:
data = BytesIO(blueskyimagedata.read())
img = Image.open(data)
img_format = img.format
if max(img.size) > size:
aspect = img.size[0] / float(img.size[1])
if aspect > 1:
new_size = (size, int(size / aspect))
else:
new_size = (int(size * aspect), size)
img = img.resize(new_size)
data = BytesIO()
img.save(data, format=img_format)
data.seek(0)
response = HttpResponse(
data.read(), content_type=f"image/{file_format(format)}"
)
response["Cache-Control"] = "max-age=%i" % CACHE_IMAGES_MAX_AGE
# Remove Vary header for images since language doesn't matter
response["Vary"] = ""
return response
except ValueError as exc:
print(f"Value error: {exc}")
return redir_default(default) return redir_default(default)
# We shouldn't reach this point... But make sure we do something # We shouldn't reach this point... But make sure we do something

View File

@@ -1,3 +0,0 @@
[build-system]
requires = ['setuptools>=40.8.0', 'wheel']
build-backend = 'setuptools.build_meta:__legacy__'

View File

@@ -1,8 +1,7 @@
autopep8 autopep8
bcrypt bcrypt
celery
defusedxml defusedxml
Django>=4.2.16 Django < 4.0
django-anymail[mailgun] django-anymail[mailgun]
django-auth-ldap django-auth-ldap
django-bootstrap4 django-bootstrap4
@@ -10,19 +9,17 @@ django-coverage-plugin
django-extensions django-extensions
django-ipware django-ipware
django-user-accounts django-user-accounts
django_celery_results
dnspython==2.2.0
email-validator email-validator
fabric fabric
flake8-respect-noqa flake8-respect-noqa
git+https://github.com/daboth/pagan.git
git+https://github.com/ercpe/pydenticon5.git git+https://github.com/ercpe/pydenticon5.git
git+https://github.com/flavono123/identicon.git git+https://github.com/flavono123/identicon.git
git+https://github.com/necaris/python3-openid.git
git+https://github.com/ofalk/django-openid-auth git+https://github.com/ofalk/django-openid-auth
git+https://github.com/ofalk/monsterid.git git+https://github.com/ofalk/monsterid.git
git+https://github.com/ofalk/Robohash.git@devel git+https://github.com/ofalk/Robohash.git@devel
mysqlclient
notsetuptools notsetuptools
pagan
Pillow Pillow
pip pip
psycopg2-binary psycopg2-binary
@@ -30,10 +27,11 @@ py3dns
pydocstyle pydocstyle
pyLibravatar pyLibravatar
pylint pylint
pymemcache
PyMySQL PyMySQL
python-coveralls python-coveralls
python-language-server python-language-server
python-memcached
python3-openid
pytz pytz
rope rope
setuptools setuptools

View File

@@ -1,33 +0,0 @@
[metadata]
name = libravatar
version = 1.7.0
description = A Django application implementing libravatar.org
long_description = file: README.md
url = https://libravatar.org
author = Oliver Falk
author_email = oliver@linux-kernel.at
license = GPLv3
classifiers =
Environment :: Web Environment
Framework :: Django
Framework :: Django :: 3.2
Framework :: Django :: 4.0
Framework :: Django :: 4.1
License :: OSI Approved :: GNU General Public License v3 (GPLv3)
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Topic :: Internet :: WWW/HTTP
Topic :: Internet :: WWW/HTTP :: Dynamic Content
[options]
include_package_data = true
packages = find:
python_requires = >=3.8
install_requires =
Django >= 3.2

View File

@@ -1,6 +0,0 @@
# -*- coding: utf-8 -*-
from setuptools import setup, find_packages
setup(
packages=find_packages(),
)

View File

@@ -12,15 +12,13 @@
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink"> <ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li> <li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li>
<li><a href="{% url 'generate_avatar' %}"><i class="fa fa-fw fa-magic" aria-hidden="true"></i> {% trans 'Generate AI Avatar' %}</a></li>
<li><a href="{% url 'avatar_gallery' %}"><i class="fa fa-fw fa-th" aria-hidden="true"></i> {% trans 'Avatar Gallery' %}</a></li>
<li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li> <li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li>
<li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li> <li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li>
<li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li> <li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li>
<li><a href="{% url 'export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Download your libravatar data' %}</a></li> <li><a href="{% url 'export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Download your libravatar data' %}</a></li>
<li><a href="{% url 'password_change' %}"><i class="fa fa-fw fa-key" aria-hidden="true"></i> {% trans 'Change password' %}</a></li> <li><a href="{% url 'password_change' %}"><i class="fa fa-fw fa-key" aria-hidden="true"></i> {% trans 'Change password' %}</a></li>
<li><a href="{% url 'password_reset' %}"><i class="fa fa-fw fa-unlock-alt" aria-hidden="true"></i> {% trans 'Reset password' %}</a></li> <li><a href="{% url 'password_reset' %}"><i class="fa fa-fw fa-unlock-alt" aria-hidden="true"></i> {% trans 'Reset password' %}</a></li>
<li>{% include '_account_logout.html' %}</li> <li><a href="{% url 'logout' %}"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> {% trans 'Logout' %}</a></li>
{% else %} {% else %}
<li><a href="{% url 'login' %}"><i class="fa fa-fw fa-sign-in" aria-hidden="true"></i> {% trans 'Local' %}</a></li> <li><a href="{% url 'login' %}"><i class="fa fa-fw fa-sign-in" aria-hidden="true"></i> {% trans 'Local' %}</a></li>
<li><a href="{% url 'new_account' %}"><i class="fa fa-fw fa-user-plus" aria-hidden="true"></i> {% trans 'Create account' %}</a></li> <li><a href="{% url 'new_account' %}"><i class="fa fa-fw fa-user-plus" aria-hidden="true"></i> {% trans 'Create account' %}</a></li>

View File

@@ -1,6 +0,0 @@
{% load i18n %}
<form id="logoutform" method="POST" action="{% url 'logout' %}">
{% csrf_token %}
<input type="hidden"/>
</form>
<a href="#" onClick="document.getElementById('logoutform').submit()"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> {% trans 'Logout' %}</a>

View File

@@ -1,7 +1,6 @@
{% load static %} {% load static %}
{% load i18n %}<!DOCTYPE HTML> {% load i18n %}<!DOCTYPE HTML>
{% include 'header.html' %} {% include 'header.html' %}
<link rel="stylesheet" href="{% static "css/surly-badges.local.css" %}" type="text/css">
<title>{{ site_name }} :: {% block title %}{% trans 'Freeing the Web, one face at a time!' %}{% endblock title %}</title> <title>{{ site_name }} :: {% block title %}{% trans 'Freeing the Web, one face at a time!' %}{% endblock title %}</title>
{% spaceless %} {% spaceless %}

View File

@@ -10,17 +10,12 @@
<div class="hero"> <div class="hero">
<div class="container"> <div class="container">
{% if site_name == 'Libravatar DEVELOPMENT' %}
<div class="dev-indicator">DEVELOPMENT</div>
{% endif %}
<header> <header>
<h1 id='app'>{% if site_name == 'Libravatar DEVELOPMENT' %}Libravatar{% else %}{{ site_name }}{% endif %}</h1> <h1 id='app'>{{ site_name }}</h1>
<h2>{% trans 'freeing the web one face at a time' %}</h2> <h2>{% trans 'freeing the web one face at a time' %}</h2>
{% if user.is_anonymous %} {% if user.is_anonymous %}
<div class="btn-group"> <a href="/accounts/login/" class="btn btn-lg btn-primary">{% trans 'Login' %}</a>&nbsp;
<a href="/accounts/login/" class="btn btn-lg btn-primary">{% trans 'Login' %}</a> <a href="/accounts/new/" class="btn btn-lg btn-primary">{% trans 'Sign up' %}</a>&nbsp;
<a href="/accounts/new/" class="btn btn-lg btn-primary">{% trans 'Sign up' %}</a>
</div>
{% else %} {% else %}
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-lg btn-primary dropdown-toggle" href="#" id="account_dropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <a class="btn btn-lg btn-primary dropdown-toggle" href="#" id="account_dropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -28,23 +23,21 @@
</a> </a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink"> <ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li> <li><a href="{% url 'profile' %}"><i class="fa fa-fw fa-image" aria-hidden="true"></i> {% trans 'Profile' %}</a></li>
<li><a href="{% url 'generate_avatar' %}"><i class="fa fa-fw fa-magic" aria-hidden="true"></i> {% trans 'Generate AI Avatar' %}</a></li>
<li><a href="{% url 'avatar_gallery' %}"><i class="fa fa-fw fa-th" aria-hidden="true"></i> {% trans 'Avatar Gallery' %}</a></li>
<li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li> <li><a href="{% url 'user_preference' %}"><i class="fa fa-fw fa-cog" aria-hidden="true"></i> {% trans 'Preferences' %}</a></li>
<li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li> <li><a href="{% url 'import_photo' %}"><i class="fa fa-fw fa-envelope-square" aria-hidden="true"></i> {% trans 'Import photo via mail address' %}</a></li>
<li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li> <li><a href="{% url 'upload_export' %}"><i class="fa fa-fw fa-file-archive-o" aria-hidden="true"></i> {% trans 'Import libravatar XML export' %}</a></li>
<li><a href="{% url 'password_change' %}"><i class="fa fa-fw fa-key" aria-hidden="true"></i> {% trans 'Change password' %}</a></li> <li><a href="{% url 'password_change' %}"><i class="fa fa-fw fa-key" aria-hidden="true"></i> {% trans 'Change password' %}</a></li>
<li><a href="{% url 'password_reset' %}"><i class="fa fa-fw fa-unlock-alt" aria-hidden="true"></i> {% trans 'Reset password' %}</a></li> <li><a href="{% url 'password_reset' %}"><i class="fa fa-fw fa-unlock-alt" aria-hidden="true"></i> {% trans 'Reset password' %}</a></li>
<li>{% include '_account_logout.html' %}</li> <li><a href="{% url 'logout' %}"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> {% trans 'Logout' %}</a></li>
{% if user.is_staff %} {% if user.is_staff %}
<li> <li>
<a href="{% url 'admin:index' %}" target="_new"><i class="fa fa-fw fa-user-secret" aria-hidden="true"></i> {% trans 'Admin' %}</a> <a href="{% url 'admin:index' %}" target="_new"><i class="fa fa-fw fa-user-secret" aria-hidden="true"></i> {% trans 'Admin' %}</a>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
<a href="/tools/check/" class="btn btn-lg btn-primary">{% trans 'Check' %}</a>
</div> </div>
{% endif %} {% endif %}
&nbsp;<a href="/tools/check/" class="btn btn-lg btn-primary">{% trans 'Check' %}</a>&nbsp;
</header> </header>
</div> </div>
</div> </div>
@@ -95,11 +88,5 @@
<br/> <br/>
<a href="https://gandi.net/" target="_new" ><img src="{% static '/img/gandi_logo.png' %}" height="60" title="Gandi" alt="{% trans 'Gandi Logo' %}"></a> <a href="https://gandi.net/" target="_new" ><img src="{% static '/img/gandi_logo.png' %}" height="60" title="Gandi" alt="{% trans 'Gandi Logo' %}"></a>
</div> </div>
<div style="padding-bottom:10px;" class="text-center">
<h3>{% trans 'Awards' %}</h3>
<br/>
{% include 'surely-badge.html' %}
</div>
</section> </section>
{% endblock %} {% endblock %}

Some files were not shown because too many files have changed in this diff Show More