Initial commit!
This commit is contained in:
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal 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
8
.idea/.gitignore
generated
vendored
Normal 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
26
.idea/dataSources.xml
generated
Normal 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>
|
21
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
21
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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>
|
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
30
.idea/kakigoori.iml
generated
Normal 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="<map/>" />
|
||||
<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
9
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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
6
.idea/ruff.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
25
.idea/watcherTasks.xml
generated
Normal 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
0
images/__init__.py
Normal file
3
images/admin.py
Normal file
3
images/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
6
images/apps.py
Normal file
6
images/apps.py
Normal 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
56
images/decorators.py
Normal 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
|
0
images/management/__init__.py
Normal file
0
images/management/__init__.py
Normal file
0
images/management/commands/__init__.py
Normal file
0
images/management/commands/__init__.py
Normal file
35
images/management/commands/create_tasks.py
Normal file
35
images/management/commands/create_tasks.py
Normal 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()
|
215
images/management/commands/upgrade_images.py
Normal file
215
images/management/commands/upgrade_images.py
Normal 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
|
39
images/migrations/0001_initial.py
Normal file
39
images/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
@@ -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),
|
||||
),
|
||||
]
|
@@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
@@ -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",
|
||||
),
|
||||
]
|
@@ -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",
|
||||
),
|
||||
]
|
40
images/migrations/0006_imagevarianttask.py
Normal file
40
images/migrations/0006_imagevarianttask.py
Normal 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"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
0
images/migrations/__init__.py
Normal file
0
images/migrations/__init__.py
Normal file
138
images/models.py
Normal file
138
images/models.py
Normal 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
3
images/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
34
images/urls.py
Normal file
34
images/urls.py
Normal 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
17
images/utils.py
Normal 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
241
images/views.py
Normal 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
0
kakigoori/__init__.py
Normal file
16
kakigoori/asgi.py
Normal file
16
kakigoori/asgi.py
Normal 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
119
kakigoori/settings.py
Normal 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
22
kakigoori/urls.py
Normal 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
16
kakigoori/wsgi.py
Normal 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
22
manage.py
Executable 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
18
requirements.txt
Normal 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
85
static/css/index.css
Normal 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
1
static/css/index.css.map
Normal 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
84
static/css/index.scss
Normal 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
96
templates/index.html
Normal 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>
|
Reference in New Issue
Block a user