Initial commit!

This commit is contained in:
2024-09-10 11:23:04 +02:00
commit c40ee0a6bf
42 changed files with 1704 additions and 0 deletions

141
.gitignore vendored Normal file
View File

@@ -0,0 +1,141 @@
local_settings.py
.DS_Store
deploy.sh
*.log
*.pot
*.pyc
__pycache__
db.sqlite3
media
# Backup files #
*.bak
# If you are using PyCharm #
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# File-based project format
*.iws
# IntelliJ
out/
# JIRA plugin
atlassian-ide-plugin.xml
# Python #
*.py[cod]
*$py.class
# Distribution / packaging
.Python build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.whl
*.egg-info/
.installed.cfg
*.egg
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
.pytest_cache/
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery
celerybeat-schedule.*
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# mkdocs documentation
/site
# mypy
.mypy_cache/
# Sublime Text #
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
*.sublime-workspace
*.sublime-project
# sftp configuration file
sftp-config.json
# Package control specific files Package
Control.last-run
Control.ca-list
Control.ca-bundle
Control.system-ca-bundle
GitHub.sublime-settings
# Visual Studio Code #
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

26
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="db" uuid="d4f4c993-c2b6-4406-85ea-233301abec3d">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/db.sqlite3</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
<libraries>
<library>
<url>file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.43.0/org/xerial/sqlite-jdbc/3.43.0.0/sqlite-jdbc-3.43.0.0.jar</url>
</library>
</libraries>
</data-source>
<data-source source="LOCAL" name="Django default" uuid="e61fd68b-4d90-4b06-89a6-d54890b094a6">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<remarks>$PROJECT_DIR$/kakigoori/settings.py</remarks>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/db.sqlite3</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -0,0 +1,21 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="1">
<item index="0" class="java.lang.String" itemvalue="PyMuPDF" />
</list>
</value>
</option>
</inspection_tool>
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="E722" />
</list>
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

30
.idea/kakigoori.iml generated Normal file
View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>
<option name="rootFolder" value="$MODULE_DIR$" />
<option name="settingsModule" value="kakigoori/settings.py" />
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
<option name="trackFilePattern" value="migrations" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/env" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
</list>
</option>
</component>
</module>

9
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="enabledOnReformat" value="true" />
<option name="enabledOnSave" value="true" />
<option name="sdkName" value="Python 3.12 (kakigoori)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (kakigoori)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/kakigoori.iml" filepath="$PROJECT_DIR$/.idea/kakigoori.iml" />
</modules>
</component>
</project>

6
.idea/ruff.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RuffConfigService">
<option name="globalRuffLspExecutablePath" value="/opt/homebrew/bin/ruff" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

25
.idea/watcherTasks.xml generated Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<TaskOptions isEnabled="true">
<option name="arguments" value="$FileName$:$FileNameWithoutExtension$.css" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="scss" />
<option name="immediateSync" value="true" />
<option name="name" value="SCSS" />
<option name="output" value="$FileNameWithoutExtension$.css:$FileNameWithoutExtension$.css.map" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="sass" />
<option name="runOnExternalChanges" value="true" />
<option name="scopeName" value="Project Files" />
<option name="trackOnlyRoot" value="true" />
<option name="workingDir" value="$FileDir$" />
<envs />
</TaskOptions>
</component>
</project>

0
images/__init__.py Normal file
View File

3
images/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
images/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ImagesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "images"

56
images/decorators.py Normal file
View File

@@ -0,0 +1,56 @@
from functools import wraps
from typing import Optional
from django.http import JsonResponse, HttpResponseForbidden
from images.models import Image, AuthorizationKey
def get_image(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
image_id = kwargs["image_id"]
del kwargs["image_id"]
image = Image.objects.filter(id=image_id).first()
if image is None:
return JsonResponse({"error": "Image not found"}, status=404)
return func(request=request, image=image, *args, **kwargs)
return wrapper
def can_upload_image(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
if "Authorization" not in request.headers:
return HttpResponseForbidden()
authorization_key: Optional[AuthorizationKey] = AuthorizationKey.objects.filter(
id=request.headers["Authorization"], can_upload_image=True
).first()
if not authorization_key:
return HttpResponseForbidden()
return func(request, *args, **kwargs)
return wrapper
def can_upload_variant(func):
@wraps(func)
def wrapper(request, *args, **kwargs):
if "Authorization" not in request.headers:
return HttpResponseForbidden()
authorization_key: Optional[AuthorizationKey] = AuthorizationKey.objects.filter(
id=request.headers["Authorization"], can_upload_variant=True
).first()
if not authorization_key:
return HttpResponseForbidden()
return func(request, *args, **kwargs)
return wrapper

View File

View File

View File

@@ -0,0 +1,35 @@
from django.core.management.base import BaseCommand
from images.models import Image, ImageVariant, ImageVariantTask
class Command(BaseCommand):
def handle(self, *args, **options):
images = Image.objects.filter(model_version=2).all()
images_len = len(images)
for index, image in enumerate(images):
print(f"Image {index}/{images_len}")
variants = ImageVariant.objects.filter(image=image).all()
variant_sizes = list(set(map(lambda x: (x.width, x.height), variants)))
for image_type in ["avif", "webp", "jpegli"]:
for variant_size in variant_sizes:
avif_variant = [
x
for x in variants
if x.width == variant_size[0]
and x.height == variant_size[1]
and x.file_type == image_type
]
if not avif_variant:
ImageVariantTask(
image=image,
height=variant_size[1],
width=variant_size[0],
original_file_type=image.imagevariant_set.filter(
is_full_size=True, file_type__in=["jpg", "png"]
)
.first()
.file_type,
file_type=image_type,
).save()

View File

@@ -0,0 +1,215 @@
import sys
from io import BytesIO
from PIL import Image as PILImage
import boto3
from botocore.config import Config
from django.core.management.base import BaseCommand, CommandError
from images.models import Image, ImageVariant
class Command(BaseCommand):
def get_b2_resource(self, endpoint, key_id, application_key):
b2 = boto3.resource(
service_name="s3",
endpoint_url=endpoint,
aws_access_key_id=key_id,
aws_secret_access_key=application_key,
config=Config(signature_version="s3v4"),
)
bucket = b2.Bucket("kakigoori")
return bucket
def handle(self, *args, **options):
images = Image.objects.filter(model_version=1).order_by("-creation_date").all()
images_len = len(images)
progress = 1
print(f"{images_len} images found")
for image in images:
print(f"Image {progress}/{images_len}")
try:
print("Upgrading image %s" % image.id)
bucket = self.get_b2_resource(
"https://s3.eu-central-003.backblazeb2.com",
"0032dcec6092f3e0000000021",
"K0039Xb1GE/An9P1ccK2B+19pXrKnnU",
)
print("Getting original image...")
original_image = BytesIO()
bucket.download_fileobj(
f"{image.backblaze_filepath}/original.jpg", original_image
)
original_image.seek(0)
with PILImage.open(original_image) as im:
image.height = im.height
image.width = im.width
image.save()
print("Copying original images...")
bucket.copy(
{
"Bucket": "kakigoori",
"Key": f"{image.backblaze_filepath}/original.jpg",
},
f"{image.backblaze_filepath}/{image.width}-{image.height}/image.jpg",
)
bucket.copy(
{
"Bucket": "kakigoori",
"Key": f"{image.backblaze_filepath}/thumbnail.jpg",
},
f"{image.backblaze_filepath}/{image.thumbnail_size[0]}-{image.thumbnail_size[1]}/image.jpg",
)
delete_objects_list = [f"{image.backblaze_filepath}/thumbnail.jpg"]
ImageVariant(
image=image,
height=image.height,
width=image.width,
is_full_size=True,
file_type="jpg",
).save()
ImageVariant(
image=image,
height=image.thumbnail_size[1],
width=image.thumbnail_size[0],
is_full_size=False,
file_type="jpg",
).save()
if image.is_jpegli_available:
print("Copying JPEGLI images...")
bucket.copy(
{
"Bucket": "kakigoori",
"Key": f"{image.backblaze_filepath}/jpegli.jpg",
},
f"{image.backblaze_filepath}/{image.width}-{image.height}/jpegli.jpg",
)
bucket.copy(
{
"Bucket": "kakigoori",
"Key": f"{image.backblaze_filepath}/thumbnail_jpegli.jpg",
},
f"{image.backblaze_filepath}/{image.thumbnail_size[0]}-{image.thumbnail_size[1]}/jpegli.jpg",
)
ImageVariant(
image=image,
height=image.height,
width=image.width,
is_full_size=True,
file_type="jpegli",
).save()
ImageVariant(
image=image,
height=image.thumbnail_size[0],
width=image.thumbnail_size[1],
is_full_size=False,
file_type="jpegli",
).save()
delete_objects_list += [
f"{image.backblaze_filepath}/jpegli.jpg",
f"{image.backblaze_filepath}/thumbnail_jpegli.jpg",
]
if image.is_avif_available:
print("Copying AVIF images...")
bucket.copy(
{
"Bucket": "kakigoori",
"Key": f"{image.backblaze_filepath}/optimized.avif",
},
f"{image.backblaze_filepath}/{image.width}-{image.height}/image.avif",
)
bucket.copy(
{
"Bucket": "kakigoori",
"Key": f"{image.backblaze_filepath}/thumbnail.avif",
},
f"{image.backblaze_filepath}/{image.thumbnail_size[0]}-{image.thumbnail_size[1]}/image.avif",
)
ImageVariant(
image=image,
height=image.height,
width=image.width,
is_full_size=True,
file_type="avif",
).save()
ImageVariant(
image=image,
height=image.thumbnail_size[0],
width=image.thumbnail_size[1],
is_full_size=False,
file_type="avif",
).save()
delete_objects_list += [
f"{image.backblaze_filepath}/optimized.avif",
f"{image.backblaze_filepath}/thumbnail.avif",
]
if image.is_webp_available:
print("Copying WebP images...")
bucket.copy(
{
"Bucket": "kakigoori",
"Key": f"{image.backblaze_filepath}/optimized.webp",
},
f"{image.backblaze_filepath}/{image.width}-{image.height}/image.webp",
)
bucket.copy(
{
"Bucket": "kakigoori",
"Key": f"{image.backblaze_filepath}/thumbnail.webp",
},
f"{image.backblaze_filepath}/{image.thumbnail_size[0]}-{image.thumbnail_size[1]}/image.webp",
)
ImageVariant(
image=image,
height=image.height,
width=image.width,
is_full_size=True,
file_type="webp",
).save()
ImageVariant(
image=image,
height=image.thumbnail_size[0],
width=image.thumbnail_size[1],
is_full_size=False,
file_type="webp",
).save()
delete_objects_list += [
f"{image.backblaze_filepath}/optimized.webp",
f"{image.backblaze_filepath}/thumbnail.webp",
]
print("Saving...")
image.model_version = 2
image.save()
print("Deleting old files...")
bucket.delete_objects(
Delete={"Objects": [{"Key": x} for x in delete_objects_list]}
)
progress += 1
except KeyboardInterrupt:
sys.exit(1)
except:
progress += 1
pass

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.0 on 2023-12-08 09:24
import django.utils.timezone
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Image",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
("uploaded", models.BooleanField(default=False)),
("original_name", models.CharField(max_length=150)),
("original_mime_type", models.CharField(max_length=10)),
("original_md5", models.CharField(max_length=32)),
("is_mozjpeg_available", models.BooleanField(default=False)),
("is_webp_available", models.BooleanField(default=False)),
("is_avif_available", models.BooleanField(default=False)),
],
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.0 on 2024-04-08 09:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('images', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='image',
name='is_mozjpeg_available',
),
migrations.AddField(
model_name='image',
name='is_jpegli_available',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,50 @@
# Generated by Django 5.0 on 2024-06-22 13:56
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('images', '0002_remove_image_is_mozjpeg_available_and_more'),
]
operations = [
migrations.CreateModel(
name='AuthorizationKeys',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=150)),
('can_upload', models.BooleanField(default=False)),
('can_convert', models.BooleanField(default=False)),
],
),
migrations.AddField(
model_name='image',
name='height',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='image',
name='model_version',
field=models.IntegerField(default=1),
),
migrations.AddField(
model_name='image',
name='width',
field=models.IntegerField(default=0),
),
migrations.CreateModel(
name='ImageVariant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('height', models.IntegerField()),
('width', models.IntegerField()),
('is_full_size', models.BooleanField(default=False)),
('file_type', models.CharField(max_length=10)),
('image', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='images.image')),
],
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.0 on 2024-06-25 09:50
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("images", "0003_authorizationkeys_image_height_image_model_version_and_more"),
]
operations = [
migrations.RenameModel(
old_name="AuthorizationKeys",
new_name="AuthorizationKey",
),
migrations.RenameField(
model_name="authorizationkey",
old_name="can_convert",
new_name="can_upload_variant",
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0 on 2024-06-25 09:50
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("images", "0004_rename_authorizationkeys_authorizationkey_and_more"),
]
operations = [
migrations.RenameField(
model_name="authorizationkey",
old_name="can_upload",
new_name="can_upload_image",
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 5.0 on 2024-06-26 21:34
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("images", "0005_rename_can_upload_authorizationkey_can_upload_image"),
]
operations = [
migrations.CreateModel(
name="ImageVariantTask",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("height", models.IntegerField()),
("width", models.IntegerField()),
("original_file_type", models.CharField(max_length=10)),
("file_type", models.CharField(max_length=10)),
(
"image",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="images.image"
),
),
],
),
]

View File

138
images/models.py Normal file
View File

@@ -0,0 +1,138 @@
import uuid
from io import BytesIO
from django.db import models
from django.utils import timezone
from images.utils import get_b2_resource
from PIL import Image as PILImage, ImageOps
class Image(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
creation_date = models.DateTimeField(default=timezone.now)
uploaded = models.BooleanField(default=False)
original_name = models.CharField(max_length=150)
original_mime_type = models.CharField(max_length=10)
original_md5 = models.CharField(max_length=32)
is_webp_available = models.BooleanField(default=False)
is_avif_available = models.BooleanField(default=False)
is_jpegli_available = models.BooleanField(default=False)
model_version = models.IntegerField(default=1)
height = models.IntegerField(default=0)
width = models.IntegerField(default=0)
@property
def thumbnail_size(self):
if self.height > self.width:
return int(600 * self.width / self.height), 600
else:
return 600, int(600 * self.height / self.width)
@property
def backblaze_filepath(self):
return f"{self.id.hex[:2]}/{self.id.hex[2:4]}/{self.id.hex}"
def create_variant_tasks(self, width, height, original_file_type):
ImageVariantTask(
image=self,
height=height,
width=width,
original_file_type=original_file_type,
file_type="avif",
).save()
ImageVariantTask(
image=self,
height=height,
width=width,
original_file_type=original_file_type,
file_type="webp",
).save()
ImageVariantTask(
image=self,
height=height,
width=width,
original_file_type=original_file_type,
file_type="jpegli",
).save()
def create_variant(self, width, height):
bucket = get_b2_resource()
original_image = BytesIO()
original_variant = self.imagevariant_set.filter(
is_full_size=True, file_type__in=["jpg", "png"]
).first()
bucket.download_fileobj(
f"{self.backblaze_filepath}/{original_variant.width}-{original_variant.height}/image.{original_variant.file_type}",
original_image,
)
original_image.seek(0)
resized_image = BytesIO()
file_extension = "jpg"
with PILImage.open(original_image) as im:
ImageOps.exif_transpose(im, in_place=True)
im.thumbnail((width, height))
if im.has_transparency_data:
try:
im.save(resized_image, "PNG")
file_extension = "png"
except OSError:
im.convert("RGB").save(resized_image, "JPEG")
else:
try:
im.save(resized_image, "JPEG")
except OSError:
im.convert("RGB").save(resized_image, "JPEG")
resized_image.seek(0)
bucket.upload_fileobj(
resized_image,
f"{self.backblaze_filepath}/{width}-{height}/image.{file_extension}",
)
image_variant = ImageVariant(
image=self,
height=height,
width=width,
is_full_size=False,
file_type=file_extension,
)
image_variant.save()
self.create_variant_tasks(width, height, file_extension)
return image_variant
class ImageVariant(models.Model):
image = models.ForeignKey(Image, on_delete=models.CASCADE)
height = models.IntegerField()
width = models.IntegerField()
is_full_size = models.BooleanField(default=False)
file_type = models.CharField(max_length=10)
class ImageVariantTask(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
image = models.ForeignKey(Image, on_delete=models.CASCADE)
height = models.IntegerField()
width = models.IntegerField()
original_file_type = models.CharField(max_length=10)
file_type = models.CharField(max_length=10)
class AuthorizationKey(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=150)
can_upload_image = models.BooleanField(default=False)
can_upload_variant = models.BooleanField(default=False)

3
images/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

34
images/urls.py Normal file
View File

@@ -0,0 +1,34 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
path("upload", views.upload, name="images.upload"),
path(
"conversion_tasks/upload_variant",
views.upload_variant,
name="images.upload_variant",
),
path(
"conversion_tasks/<image_type>",
views.image_type_optimization_needed,
name="images.avif_optimization_needed",
),
path("<uuid:image_id>/<str:image_type>", views.get, name="images.get"),
path(
"<uuid:image_id>/<str:image_type>/thumbnail",
views.get_thumbnail,
name="images.get_thumbnail",
),
path(
"<uuid:image_id>/height/<int:height>/<str:image_type>",
views.get_image_with_height,
name="images.get_image_with_height",
),
path(
"<uuid:image_id>/width/<int:width>/<str:image_type>",
views.get_image_with_width,
name="images.get_image_with_width",
),
]

17
images/utils.py Normal file
View File

@@ -0,0 +1,17 @@
import boto3
from botocore.config import Config
from django.conf import settings
def get_b2_resource():
b2 = boto3.resource(
service_name="s3",
endpoint_url=settings.S3_ENDPOINT,
aws_access_key_id=settings.S3_KEY_ID,
aws_secret_access_key=settings.S3_ACCESS_KEY,
config=Config(signature_version="s3v4"),
)
bucket = b2.Bucket(settings.S3_BUCKET)
return bucket

241
images/views.py Normal file
View File

@@ -0,0 +1,241 @@
import hashlib
from PIL import Image as PILImage
from PIL import JpegImagePlugin
from django.conf import settings
from django.http import (
JsonResponse,
HttpResponseBadRequest,
HttpResponseNotFound,
)
from django.shortcuts import redirect, render
from django.views.decorators.csrf import csrf_exempt
from images.decorators import (
get_image,
can_upload_variant,
can_upload_image,
)
from images.models import Image, ImageVariant, ImageVariantTask
from images.utils import get_b2_resource
JpegImagePlugin._getmp = lambda x: None
def index(request):
return render(request, "index.html")
@csrf_exempt
@can_upload_image
def upload(request):
file = request.FILES["file"]
bucket = get_b2_resource()
file_md5_hash = hashlib.file_digest(file, "md5").hexdigest()
file.seek(0)
with PILImage.open(file) as im:
height, width = (im.height, im.width)
if im.format == "JPEG":
file_extension = "jpg"
elif im.format == "PNG":
file_extension = "png"
else:
return {"created": False, "error": "Uploaded file should be JPEG or PNG"}
file.seek(0)
same_md5_image = Image.objects.filter(original_md5=file_md5_hash).first()
if same_md5_image:
return {"created": False, "id": same_md5_image.id}
image = Image(
original_name=file.name,
original_mime_type=file.content_type,
original_md5=file_md5_hash,
height=height,
width=width,
model_version=2,
)
image.save()
bucket.upload_fileobj(
file, f"{image.backblaze_filepath}/{width}-{height}/image.{file_extension}"
)
ImageVariant.objects.get_or_create(
image=image,
height=height,
width=width,
file_type=file_extension,
is_full_size=True,
)
image.create_variant_tasks(image.width, image.height, file_extension)
image.uploaded = True
image.save()
return JsonResponse({"created": True, "id": image.id})
@can_upload_variant
def image_type_optimization_needed(request, image_type):
tasks = ImageVariantTask.objects.filter(file_type=image_type).all()
return JsonResponse(
{
"variants": [
{
"image_id": task.image.id,
"task_id": task.id,
"height": task.height,
"width": task.width,
"file_type": task.original_file_type,
}
for task in tasks
]
}
)
@csrf_exempt
@can_upload_variant
def upload_variant(request):
if "task_id" not in request.POST:
return HttpResponseBadRequest()
task = ImageVariantTask.objects.filter(id=request.POST["task_id"]).first()
if not task:
return HttpResponseNotFound()
image = task.image
height = task.height
width = task.width
file_type = task.file_type
file = request.FILES["file"]
if file_type == "jpegli":
file_name = "jpegli.jpg"
else:
file_name = "image." + file_type
upload_path = f"{image.backblaze_filepath}/{width}-{height}/{file_name}"
bucket = get_b2_resource()
bucket.upload_fileobj(file, upload_path)
ImageVariant.objects.get_or_create(
image=image,
height=height,
width=width,
file_type=file_type,
is_full_size=(height == image.height and width == image.width),
)
task.delete()
return JsonResponse({"status": "ok"})
def image_with_size(request, image, width, height, image_type):
image_variants = ImageVariant.objects.filter(
image=image, height=height, width=width
)
if image_type != "auto":
if image_type == "original":
image_variants = image_variants.filter(file_type__in=["jpg", "png"])
else:
image_variants = image_variants.filter(file_type=image_type)
variants = image_variants.all()
if not variants:
if image_type != "auto" and image_type != "original":
return JsonResponse({"error": "Image version not available"}, status=404)
else:
image_variant = image.create_variant(width, height)
return redirect(
f"{settings.S3_PUBLIC_BASE_PATH}/{image.backblaze_filepath}/{width}-{height}/image.{image_variant.file_type}"
)
if image_type == "auto":
variants_preferred_order = ["avif", "webp", "jpegli", "jpg", "png"]
elif image_type == "original":
variants_preferred_order = ["jpg", "png"]
else:
variants_preferred_order = [image_type]
accept_header = request.headers.get("Accept", default="")
for file_type in variants_preferred_order:
if (
file_type == "avif"
and image_type == "auto"
and "image/avif" not in accept_header
):
continue
if (
file_type == "webp"
and image_type == "auto"
and "image/webp" not in accept_header
):
continue
variant = [x for x in image_variants if x.file_type == file_type]
if not variant:
continue
variant = variant[0]
if file_type == "jpegli":
file_name = "jpegli.jpg"
else:
file_name = "image." + file_type
return redirect(
f"{settings.S3_PUBLIC_BASE_PATH}/{image.backblaze_filepath}/{variant.width}-{variant.height}/{file_name}"
)
return HttpResponseNotFound()
@get_image
def get_image_with_height(request, image, height, image_type):
if height >= image.height:
height = image.height
width = image.width
else:
width = int(height * image.width / image.height)
return image_with_size(request, image, width, height, image_type)
@get_image
def get_image_with_width(request, image, width, image_type):
if width >= image.width:
width = image.width
height = image.height
else:
height = int(width * image.height / image.width)
return image_with_size(request, image, width, height, image_type)
@get_image
def get(request, image, image_type):
return image_with_size(request, image, image.width, image.height, image_type)
@get_image
def get_thumbnail(request, image, image_type):
width, height = image.thumbnail_size
return image_with_size(request, image, width, height, image_type)

0
kakigoori/__init__.py Normal file
View File

16
kakigoori/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for kakigoori project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "kakigoori.settings")
application = get_asgi_application()

119
kakigoori/settings.py Normal file
View File

@@ -0,0 +1,119 @@
"""
Django settings for kakigoori project.
Generated by 'django-admin startproject' using Django 4.2.5.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"images.apps.ImagesConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "kakigoori.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "kakigoori.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
from .local_settings import *

22
kakigoori/urls.py Normal file
View File

@@ -0,0 +1,22 @@
"""
URL configuration for kakigoori project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
# from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("", include("images.urls")),
]

16
kakigoori/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for kakigoori project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "kakigoori.settings")
application = get_wsgi_application()

22
manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "kakigoori.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

18
requirements.txt Normal file
View File

@@ -0,0 +1,18 @@
asgiref==3.7.2
boto3==1.33.10
botocore==1.33.10
certifi==2023.11.17
charset-normalizer==3.3.2
Django==5.0
gunicorn==21.2.0
idna==3.6
jmespath==1.0.1
packaging==23.2
Pillow==10.1.0
python-dateutil==2.8.2
requests==2.31.0
s3transfer==0.8.2
sentry-sdk==1.38.0
six==1.16.0
sqlparse==0.4.4
urllib3==2.0.7

85
static/css/index.css Normal file
View File

@@ -0,0 +1,85 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
background-color: #fdf6e3;
margin: 0;
}
.container {
width: 95%;
margin: 0 auto;
}
@media (min-width: 576px) {
.container {
width: 540px;
}
}
@media (min-width: 768px) {
.container {
width: 720px;
}
}
@media (min-width: 992px) {
.container {
width: 960px;
}
}
@media (min-width: 1200px) {
.container {
width: 1140px;
}
}
@media (min-width: 1400px) {
.container {
width: 1320px;
}
}
header {
display: flex;
justify-content: center;
align-items: center;
margin-top: 2rem;
gap: 2rem;
padding: 3rem 1rem;
margin-bottom: 3rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
flex-wrap: wrap;
}
header div:has(img) {
display: flex;
justify-content: center;
align-items: center;
}
header img {
width: 80%;
max-width: 300px;
}
main {
margin-bottom: 5rem;
}
main #examples-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
main #examples-grid p > strong {
word-break: break-all;
}
@media (max-width: 768px) {
main #examples-grid {
grid-template-columns: 1fr;
}
}
main #examples-grid img {
max-width: 100%;
}
main details {
margin-bottom: 1rem;
}
main hr {
border: 1px solid rgba(0, 0, 0, 0.1);
}
/*# sourceMappingURL=index.css.map */

1
static/css/index.css.map Normal file
View File

@@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["index.scss"],"names":[],"mappings":"AAAA;EACC;EACA;EACA;;;AAGD;EACC;EACA;;AAEA;EAJD;IAKE;;;AAGD;EARD;IASE;;;AAGD;EAZD;IAaE;;;AAGD;EAhBD;IAiBE;;;AAGD;EApBD;IAqBE;;;;AAIF;EACC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACC;EACA;EACA;;AAGD;EACC;EACA;;;AAIF;EACC;;AAEA;EACC;EACA;EACA;EACA;;AAEA;EACC;;AAGD;EAVD;IAWE;;;AAGD;EACC;;AAIF;EACC;;AAGD;EACC","file":"index.css"}

84
static/css/index.scss Normal file
View File

@@ -0,0 +1,84 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
background-color: #fdf6e3;
margin: 0;
}
.container {
width: 95%;
margin: 0 auto;
@media (min-width: 576px) {
width: 540px;
}
@media (min-width: 768px) {
width: 720px;
}
@media (min-width: 992px) {
width: 960px;
}
@media (min-width: 1200px) {
width: 1140px;
}
@media (min-width: 1400px) {
width: 1320px;
}
}
header {
display: flex;
justify-content: center;
align-items: center;
margin-top: 2rem;
gap: 2rem;
padding: 3rem 1rem;
margin-bottom: 3rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
flex-wrap: wrap;
div:has(img) {
display: flex;
justify-content: center;
align-items: center;
}
img {
width: 80%;
max-width: 300px;
}
}
main {
margin-bottom: 5rem;
#examples-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 2rem;
p > strong {
word-break: break-all;
}
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
img {
max-width: 100%;
}
}
details {
margin-bottom: 1rem;
}
hr {
border: 1px solid rgba(0, 0, 0, 0.1);
}
}

96
templates/index.html Normal file
View File

@@ -0,0 +1,96 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kakigoori</title>
<link rel="stylesheet" type="text/css" href="{% static 'css/index.css' %}">
</head>
<body>
<header>
<div>
<img src="https://Kakigoori.dev/bcf6fdd9-3855-4f4d-9b5a-0791e14e29c7/auto">
</div>
<div>
<h1>Kakigoori</h1>
<p>Image distribution for the web, simplified</p>
<p>A project by Remilia Da Costa Faro</p>
</div>
</header>
<main>
<div class="container">
<h2>Upload an image once, use it everywhere on the web</h2>
<p>
Kakigoori is an image management service to publish images on the web. Upload it once, and Kakigoori will create versions of the image, optimized for the web (AVIF, WebP, Jpegli). You can also request for custom sized images, of which the JPG/PNG versions will be generated on the fly, and the other versions will be created within a few minutes.
</p>
<p>
Kakigoori's images are stored by Backblaze and distributed by Cloudflare. No matter where you are in the world, images stored by Kakigoori will display as fast as possible.
</p>
<hr>
<h2>Examples</h2>
<p>I uploaded a picture on Kakigoori, and the system attributed the ID 12ca0232-8515-4366-84d4-fa10a2e15069 to it!</p>
<div id="examples-grid">
<p><strong>https://kakigoori.dev/12ca0232-8515-4366-84d4-fa10a2e15069/auto</strong><br><em>(Will display AVIF images first if available and compatible with your browser, or WebP images, or Jpegli images, or Jpeg images)</em></p>
<img src="https://kakigoori.dev/12ca0232-8515-4366-84d4-fa10a2e15069/auto">
<p><strong>https://kakigoori.dev/12ca0232-8515-4366-84d4-fa10a2e15069/avif</strong><br><em>(Will request the AVIF version of the image, or will return a 404 error if the version is not available. You can replace avif here with webp or jpegli)</em></p>
<img src="https://kakigoori.dev/12ca0232-8515-4366-84d4-fa10a2e15069/avif">
<p><strong>https://kakigoori.dev/12ca0232-8515-4366-84d4-fa10a2e15069/original</strong><br><em>(Will request the original image that was uploaded on Kakigoori)</em></p>
<img src="https://kakigoori.dev/12ca0232-8515-4366-84d4-fa10a2e15069/original">
<p><strong>https://kakigoori.dev/12ca0232-8515-4366-84d4-fa10a2e15069/height/300/auto</strong><br><em>(Will request the image with an height of 300px, and will request the AVIF/WebP/Jpegli/Jpeg/PNG image depending on what is compatible with the browser and what's available)</em></p>
<img src="https://kakigoori.dev/12ca0232-8515-4366-84d4-fa10a2e15069/height/300/auto">
<p><strong>https://kakigoori.dev/12ca0232-8515-4366-84d4-fa10a2e15069/width/300/auto</strong><br><em>(Same thing, but with a width of 300px!)</em></p>
<img src="https://kakigoori.dev/12ca0232-8515-4366-84d4-fa10a2e15069/width/300/auto">
</div>
<hr>
<h2>Q&A</h2>
<details>
<summary>Who is using Kakigoori?</summary>
My websites (remilia.ch and ecdfeaa2.moe) all have their images stored on Kakigoori! I also use Kakigoori on some projects I worked on professionaly.
</details>
<details>
<summary>Can I upload images to Kakigoori?</summary>
Well... currently, I'm kind of concerned about opening upload to anyone (I wouldn't want to have Backblaze sending me a nasty bill for using too much storage, or having them send me a gentle email to ask me why the fuck I'm hosting illicit content ^^).<br>
So, my default answer is no, but if you're really interested to use Kakigoori, either self-host Kakigoori yourself, or if you want to use this hosted version and you're a friendly person, please send me an email to <a href="mailto:remilia@remilia.ch">remilia@remilia.ch</a> and I will see what I can do for you!
</details>
<details>
<summary>How private is Kakigoori?</summary>
I'm not storing IPs, I'm not doing statistics, I'm not showing you any cookie banner because I'm not storing any, so on my side, I would say it's pretty compliant with privacy standards.<br>The only people that may know about you loading images from Kakigoori is Cloudflare, but at this point, with how much market share they have over the big websites of the Internet, they already know enough abut you ^^
</details>
<details>
<summary>Is Kakigoori open-source?</summary>
Yes! Please see <a href="https://github.com/ecdfeaa2/kakigoori">the GitHub project for that!</a>
</details>
<details>
<summary>Who is behind Kakigoori?</summary>
I'm Remilia Da Costa Faro, your friendly nearby software developer and non-binary trans woman!
</details>
<details>
<summary>... why the name Kakigoori?</summary>
I was hungry when I created the project, and it was warm enough that I wanted some form of kakigoori.<br>
Oh, and about the sunflowers?<br>
They're pretty, I love them! (That's the only reason)
</details>
<details>
<summary>How can I contact you if I want?</summary>
My email is always open (<a href="mailto:remilia@remilia.ch">remilia@remilia.ch</a>). Do note I may be a bit slow to reply, as replying to people scares me a bit ^^.
</details>
</div>
</main>
</body>
</html>