Files
NoN-Site/nonpacks/library/views.py
T
2026-08-04 19:05:38 -05:00

1240 lines
45 KiB
Python

import json
import mimetypes
import posixpath
import re
import zipfile
from pathlib import Path
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Count, F, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.text import slugify
from common.markdown import render_markdown
from .forms import ContributorForm, ProjectForm, VersionForm
from .models import (
FileIndex,
Project,
ProjectAsset,
ProjectContributor,
ProjectDraft,
Tag,
TagCategory,
TagList,
TempUpload,
Version,
VersionFile,
slugify_tag,
)
from .storage import delete_file_index, move_file_index, refresh_file_index, store_temp_file
from .zips import (
inject_animationframework,
parse_mods_manifest,
read_animation_manifest,
read_logical_manifest,
read_models_manifest,
read_pack_meta,
read_pack_mcmeta,
)
def _open_indexed_file(file_index):
"""Return an open binary file handle for the indexed file (404-safe)."""
stored = file_index.stored_path
if not stored or posixpath.isabs(stored) or '..' in stored.split('/'):
raise Http404
file_path = (Path(settings.MEDIA_ROOT) / stored).resolve()
if not file_path.is_relative_to(Path(settings.MEDIA_ROOT).resolve()):
raise Http404
if not file_path.is_file():
raise Http404
return open(file_path, 'rb')
def _unsatisfiable(content_length):
resp = HttpResponse(status=416)
resp['Content-Range'] = f'bytes */{content_length}'
return resp
def file_request(request, file_id):
"""Serve an indexed file behind the gate, honouring HTTP byte ranges.
GET /api/files/<uuid>/ → full file (200)
Range: bytes=0-99 → partial content (206)
Range: bytes=<start>- → tail from <start>
Range: bytes=-<suffix> → last <suffix> bytes
?download=1 → Content-Disposition: attachment
"""
file_index = get_object_or_404(FileIndex, uuid=file_id)
fh = _open_indexed_file(file_index)
content_type = file_index.content_type or 'application/octet-stream'
content_length = file_index.size or 0
disposition = ''
if request.GET.get('download') == '1':
filename = file_index.original_filename or posixpath.basename(file_index.stored_path)
disposition = f'attachment; filename="{filename}"'
range_header = request.META.get('HTTP_RANGE', '').strip()
if not range_header or not range_header.lower().startswith('bytes='):
response = FileResponse(fh, content_type=content_type)
response['Accept-Ranges'] = 'bytes'
response['Content-Length'] = str(content_length)
if disposition:
response['Content-Disposition'] = disposition
return response
try:
start, _, end = range_header[6:].partition('-')
start = int(start) if start else None
end = int(end) if end else None
except ValueError:
return _unsatisfiable(content_length)
if start is None and end is None:
return _unsatisfiable(content_length)
if start is None:
suffix = end
if suffix <= 0:
return _unsatisfiable(content_length)
start = max(content_length - suffix, 0)
end = content_length - 1
elif end is None:
end = content_length - 1
if start < 0 or start >= content_length or end < start:
return _unsatisfiable(content_length)
end = min(end, content_length - 1)
length = end - start + 1
fh.seek(start)
def chunk_generator():
try:
remaining = length
while remaining > 0:
data = fh.read(min(8192, remaining))
if not data:
break
remaining -= len(data)
yield data
finally:
fh.close()
part = StreamingHttpResponse(chunk_generator(), content_type=content_type)
part['Content-Range'] = f'bytes {start}-{end}/{content_length}'
part['Accept-Ranges'] = 'bytes'
part['Content-Length'] = str(length)
part.status_code = 206
if disposition:
part['Content-Disposition'] = disposition
return part
def _require_can_edit(request, project):
if not project.can_edit(request.user):
raise HttpResponseForbidden('You do not have permission to edit this project.')
return True
def _unique_project_slug(title):
base = slugify(title)[:100] or 'project'
slug, n = base, 1
while Project.objects.filter(slug=slug).exists():
n += 1
slug = f'{base}-{n}'
return slug
def _tags_from_text(text):
"""Parse a raw tag string ('general:furry loader:fabric 1.20.1') into
(category_slug, tag_name) tuples. Unprefixed names default to general."""
pairs = []
for part in (text or '').replace(',', ' ').split():
if ':' in part:
category_slug, _, name = part.partition(':')
else:
category_slug, name = 'general', part
name = slugify_tag(name)
if not name or not category_slug:
continue
pairs.append((category_slug, name))
return pairs
def _managed_creator_names(project):
"""Slugified usernames of the owner + contributors — the creator tags that
sync_creator_tags manages automatically. Creator tags that are NOT backed
by an account (e.g. an offline artist) are treated as manual and stay
editable/removable."""
names = {slugify_tag(project.owner.username)}
names.update(
slugify_tag(c.user.username)
for c in project.contributors.select_related('user')
)
return names
def _apply_tags(project, raw_tags, actor):
"""Replace a project's manual tags (auto-creating new ones). Account-backed
creator tags (owner + contributors) are managed by sync_creator_tags and
preserved; any other creator tag is editable like any other manual tag."""
creator_cat = TagCategory.objects.filter(slug='creator').first()
to_delete = project.tag_links.all()
if creator_cat is not None:
to_delete = to_delete.exclude(
tag__category=creator_cat,
tag__name__in=_managed_creator_names(project),
)
to_delete.delete()
for category_slug, name in _tags_from_text(raw_tags):
category = TagCategory.objects.filter(slug=category_slug).first()
if category is None:
category = TagCategory.objects.filter(slug='general').first()
if category is None:
continue
tag, _ = Tag.objects.get_or_create(
name=name, category=category, defaults={'created_by': actor},
)
TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor})
def _current_tags(project):
"""Serialize a project's editable tags back to 'category:name' form.
Account-backed creator tags (owner + contributors) are auto-managed and
hidden; manual creator tags (no matching account) remain visible so they
can be removed."""
creator_cat = TagCategory.objects.filter(slug='creator').first()
qs = project.tag_links.select_related('tag__category').all()
if creator_cat is not None:
qs = qs.exclude(
tag__category=creator_cat,
tag__name__in=_managed_creator_names(project),
)
return ' '.join(f'{tl.tag.category.slug}:{tl.tag.name}' for tl in qs)
def browse(request):
q = request.GET.get('q', '').strip()
category = request.GET.get('category', '').strip()
sort = request.GET.get('sort', 'recent')
tag_names = [slugify_tag(t) for t in request.GET.getlist('tag') if t.strip()]
projects = (
Project.objects.select_related('owner', 'thumbnail')
.prefetch_related('tag_links__tag__category', 'versions')
)
if q:
projects = projects.filter(
Q(title__icontains=q)
| Q(summary__icontains=q)
| Q(description__icontains=q)
| Q(tag_links__tag__name__icontains=q)
).distinct()
if category and category in dict(Project.CATEGORY_CHOICES):
projects = projects.filter(category=category)
else:
category = ''
for name in tag_names:
projects = projects.filter(tag_links__tag__name=name)
if sort == 'downloads':
projects = projects.annotate(_downloads=Sum('versions__downloads')).order_by(
F('_downloads').desc(nulls_last=True)
)
elif sort == 'name':
projects = projects.order_by('title')
else:
projects = projects.order_by('-created_at')
# Preserve the query params when toggling filters.
def _qs(**overrides):
params = request.GET.copy()
for key, value in overrides.items():
if value is None:
params.pop(key, None)
else:
params.setlist(key, value if isinstance(value, list) else [value])
return params.urlencode()
category_menu = [('', 'All', f'?{_qs(category=None)}')]
for value, label in Project.CATEGORY_CHOICES:
category_menu.append((value, label, f'?{_qs(category=value)}'))
tag_groups = []
for cat in TagCategory.objects.all().order_by('slug'):
tags = (
Tag.objects.filter(category=cat)
.annotate(count=Count('project_links'))
.filter(count__gt=0)
.order_by('-count')[:25]
)
if not tags:
continue
items = []
for tag in tags:
active = tag.name in tag_names
if active:
remaining = [t for t in tag_names if t != tag.name]
url = f'?{_qs(tag=remaining)}'
else:
url = f'?{_qs(tag=tag_names + [tag.name])}'
items.append({
'name': tag.name,
'color': cat.color,
'count': tag.count,
'active': active,
'url': url,
})
tag_groups.append({'category': cat, 'items': items})
paginator = Paginator(projects, 12)
page_obj = paginator.get_page(request.GET.get('page'))
context = {
'projects': page_obj,
'q': q,
'category': category,
'sort': sort,
'active_tags': tag_names,
'category_menu': category_menu,
'tag_groups': tag_groups,
'query_string': _qs(),
}
return render(request, 'library/browse.html', context)
def project_detail(request, slug):
project = get_object_or_404(
Project.objects.select_related('owner', 'thumbnail'), slug=slug,
)
versions = project.versions.prefetch_related('files__file').all()
assets = project.assets.select_related('file').all()
tags = project.tag_links.select_related('tag__category').all()
can_edit = project.can_edit(request.user)
guide_docs = []
animation_manifest = None
models_manifest = None
logical_manifest = None
mods_manifest = None
anim_stats = None
latest = versions.first()
if project.category == 'guide' and latest is not None:
guide_docs = [vf for vf in latest.files.all() if vf.is_markdown]
if latest is not None:
animation_manifest = latest.animation_manifest or None
models_manifest = latest.models_manifest or None
logical_manifest = latest.logical_manifest or None
mods_manifest = latest.mods_manifest or None
if animation_manifest:
anims = animation_manifest.get('animations') or []
entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player')
solo_count = sum(1 for a in anims if a.get('type') == 'Solo')
anim_stats = {
'total': len(anims),
'entity': entity_count,
'player': len(anims) - entity_count - solo_count,
'solo': solo_count,
'flagged': sum(1 for a in anims if a.get('problem_tags')),
'tags': sorted({t for a in anims for t in (a.get('content_tags') or [])}),
}
context = {
'project': project,
'versions': versions,
'assets': assets,
'tags': tags,
'can_edit': can_edit,
'guide_docs': guide_docs,
'animation_manifest': animation_manifest,
'models_manifest': models_manifest,
'logical_manifest': logical_manifest,
'mods_manifest': mods_manifest,
'anim_stats': anim_stats,
}
return render(request, 'library/project_detail.html', context)
def pack_asset(request, slug, version_id, asset_path):
"""Serve a named member from a stored version zip (gated), used for the
Models/Textures + Logical tabs and the model viewer."""
version = get_object_or_404(
Version.objects.select_related('project'),
pk=version_id, project__slug=slug,
)
if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'):
raise Http404
vf = (
version.files.select_related('file').filter(kind='release').first()
or version.files.select_related('file').first()
)
if vf is None:
raise Http404
with zipfile.ZipFile(_stored_path(vf.file)) as zf:
if asset_path not in zf.namelist():
raise Http404
data = zf.read(asset_path)
content_type = mimetypes.guess_type(asset_path)[0] or 'application/octet-stream'
return HttpResponse(data, content_type=content_type)
@login_required
def api_packs_latest(request, namespace, pack_id):
"""Auto-updater endpoint: newest version for a NoN pack id (namespace:pack_id)."""
animation_id = f'{namespace}:{pack_id}'
version = (
Version.objects.filter(animation_id=animation_id)
.select_related('project').prefetch_related('files__file')
.order_by('-created_at').first()
)
if version is None:
return JsonResponse({'detail': 'Not found'}, status=404)
release = next(
(vf for vf in version.files.all() if vf.kind == 'release'),
version.files.first(),
)
if release is None:
return JsonResponse({'detail': 'No file'}, status=404)
download_url = request.build_absolute_uri(
reverse('library:file_request', args=[release.file.uuid]) + '?download=1'
)
return JsonResponse({
'namespace': namespace,
'pack_id': pack_id,
'name': version.project.title,
'version': version.version_name,
'pack_format': version.pack_format,
'created_at': version.created_at.isoformat(),
'download_url': download_url,
})
def guide_doc(request, slug, version_id, file_uuid):
"""Render one markdown guide document server-side (used by the switcher)."""
version = get_object_or_404(
Version.objects.select_related('project'),
pk=version_id, project__slug=slug,
)
vf = get_object_or_404(
version.files.select_related('file'), file__uuid=file_uuid,
)
if not vf.is_markdown:
raise Http404
fh = _open_indexed_file(vf.file)
try:
text = fh.read().decode('utf-8', 'replace')
finally:
fh.close()
return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')
DRAFT_KEYS = (
'title', 'summary', 'category', 'description',
'tags', 'version_name', 'changelog',
)
def _serialize_temp(upload):
return {
'uuid': str(upload.uuid),
'kind': upload.kind,
'filename': upload.file.original_filename,
'content_type': upload.file.content_type,
'url': reverse('library:file_request', args=[upload.file.uuid]),
}
def _pending_uploads(user):
return list(
TempUpload.objects.filter(user=user, status='pending')
.select_related('file').order_by('created_at')
)
def _pending_kind(user, kind):
return list(
TempUpload.objects.filter(user=user, status='pending', kind=kind)
.select_related('file').order_by('created_at')
)
def _latest_pending(user, kind):
return (
TempUpload.objects.filter(user=user, status='pending', kind=kind)
.select_related('file').order_by('created_at').last()
)
def _adopt_temp(upload, project_pk, subdir, kind):
"""Move a temp upload under project_<pk>/ and return the FileIndex."""
base = posixpath.basename(upload.file.stored_path)
new_path = f'project_{project_pk}/{subdir}/{base}' if subdir else f'project_{project_pk}/{base}'
return move_file_index(upload.file, new_path, kind=kind)
def _classify_version_file(file_index, category):
"""Pick a VersionFile.kind from the uploaded file's extension."""
name = (file_index.original_filename or '').lower()
if name.endswith('.md'):
return 'markdown'
if name.endswith('.png'):
return 'png'
return 'release'
def _stored_path(file_index):
"""Resolve an indexed file's stored_path to an absolute filesystem path."""
return Path(settings.MEDIA_ROOT) / file_index.stored_path
ANIMATION_API_WARNING = (
'Warning: your pack is incompatible with the update-check API endpoint and '
'may not work with applications that check for updates. Add an '
"'animationframework' block to pack.mcmeta, e.g.: "
'{"pack":{"pack_format":64},"animationframework":{"id":"yourname:yourpack",'
'"name":"Your Pack","author":"You","version":"1.0.0","description":"..."}}'
)
def _apply_content_tags(project, tag_names, actor):
"""Auto-create a content:<tag> for every distinct animation content tag."""
content_cat = TagCategory.objects.filter(slug='content').first()
if content_cat is None:
return
for name in tag_names:
name = slugify_tag(name)
if not name:
continue
tag, _ = Tag.objects.get_or_create(
name=name, category=content_cat, defaults={'created_by': actor},
)
TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor})
def _established_animation_id(project):
"""The animationframework.id of the project's newest version (its identity)."""
latest = project.versions.first()
return latest.animation_id if latest is not None else ''
def _valid_id_component(value):
return bool(re.match(r'^[a-zA-Z0-9_.-]+$', value or ''))
def _animation_fields(project, version_name, animation_id):
return {
'id': animation_id,
'name': project.title,
'author': project.owner.username,
'version': version_name,
'description': project.summary or project.title,
}
def _inject_animation_metadata(request, project, version_name, temp_uploads):
"""For NoN-pack zips missing an animationframework.id, rewrite the temp zip
to add the block. Returns (errors, injected_count)."""
if project.category != 'non_pack':
return [], 0
established = _established_animation_id(project)
default_ns = slugify(project.owner.username) or 'site'
default_pid = f'project_{project.pk}'
ns = (request.POST.get('af_namespace') or default_ns).strip()
pid = (request.POST.get('af_pack_id') or default_pid).strip()
create_pack = {
'pack_format': 64,
'supported_formats': [64, 81],
'min_format': 64,
'max_format': 81,
'description': project.title,
}
errors = []
injected = 0
for temp in temp_uploads:
if not (temp.file.original_filename or '').lower().endswith('.zip'):
continue
path = _stored_path(temp.file)
meta = read_pack_meta(path)
current = meta.get('animation_id') if meta else None
if current:
if established and current != established and not request.POST.get('confirm_replace_identity'):
errors.append(
'The uploaded pack has a different animationframework.id than this project. '
'Check "replace pack identity" to accept it.'
)
continue
desired = established or f'{ns}:{pid}'
if not _valid_id_component(ns) or not _valid_id_component(pid):
errors.append('Animation ID must only use letters, numbers, underscores, dots or dashes.')
break
if inject_animationframework(
path, _animation_fields(project, version_name, desired), create_pack,
):
refresh_file_index(temp.file)
injected += 1
return errors, injected
def _animation_meta_for_upload(request, project):
"""Render hints for the version-upload page's Animation ID section."""
if project.category != 'non_pack':
return {'show': False}
established = _established_animation_id(project)
default_ns = slugify(project.owner.username) or 'site'
default_pid = f'project_{project.pk}'
show_fields = False
show_confirm = False
for temp in _pending_kind(request.user, 'version'):
if not (temp.file.original_filename or '').lower().endswith('.zip'):
continue
meta = read_pack_meta(_stored_path(temp.file))
current = meta.get('animation_id') if meta else None
if not current and not established:
show_fields = True
if current and established and current != established:
show_confirm = True
if established:
ns, _, pid = established.partition(':')
default_ns = ns or default_ns
default_pid = pid or default_pid
return {
'show': show_fields or show_confirm,
'show_fields': show_fields,
'show_confirm': show_confirm,
'established': established,
'namespace': default_ns,
'pack_id': default_pid,
}
def _reparse_version(version):
"""Re-derive a version's metadata (id + manifests) from its release zip."""
release = version.files.filter(kind='release').first()
if release is None:
return
path = _stored_path(release.file)
meta = read_pack_meta(path)
if meta and meta.get('animation_id'):
version.animation_id = meta['animation_id']
else:
version.animation_id = ''
version.animation_manifest = read_animation_manifest(path) or {}
version.models_manifest = read_models_manifest(path) or {}
version.logical_manifest = read_logical_manifest(path) or {}
fmt, desc = read_pack_mcmeta(path)
if fmt is not None:
version.pack_format = fmt
version.pack_description = desc
version.save()
def _finalize_version(project, version_name, changelog, temp_uploads, actor):
"""Adopt pending 'version' temp uploads into a new Version as VersionFiles,
capturing category/content-specific metadata (pack.mcmeta, mods, animation,
models, logical manifests). Returns (version, missing_animation_id)."""
version = Version.objects.create(
project=project,
version_name=version_name,
changelog=changelog,
)
pack_format = None
pack_description = ''
mods_manifest = {}
animation_id = ''
animation_manifest = None
models_manifest = None
logical_manifest = None
missing_animation_id = False
for temp in temp_uploads:
index = _adopt_temp(temp, project.pk, 'versions', 'version')
VersionFile.objects.create(
version=version,
file=index,
kind=_classify_version_file(index, project.category),
)
temp.status = 'used'
temp.save(update_fields=['status'])
is_zip = (index.original_filename or '').lower().endswith('.zip')
if not is_zip:
continue
path = _stored_path(index)
if project.category == 'non_pack' and pack_format is None:
fmt, desc = read_pack_mcmeta(path)
if fmt is not None:
pack_format = fmt
pack_description = desc
if not animation_id:
meta = read_pack_meta(path)
if meta and meta.get('animation_id'):
animation_id = meta['animation_id']
if animation_manifest is None:
animation_manifest = read_animation_manifest(path)
if models_manifest is None:
models_manifest = read_models_manifest(path)
if logical_manifest is None:
logical_manifest = read_logical_manifest(path)
if not mods_manifest:
manifest = parse_mods_manifest(path, index.original_filename)
if manifest.get('files'):
mods_manifest = manifest
update_fields = []
if pack_format is not None:
version.pack_format = pack_format
version.pack_description = pack_description
update_fields += ['pack_format', 'pack_description']
if mods_manifest:
version.mods_manifest = mods_manifest
update_fields.append('mods_manifest')
if animation_id:
version.animation_id = animation_id
update_fields.append('animation_id')
if animation_manifest is not None:
version.animation_manifest = animation_manifest
update_fields.append('animation_manifest')
if not animation_id:
missing_animation_id = True
if models_manifest is not None:
version.models_manifest = models_manifest
update_fields.append('models_manifest')
if logical_manifest is not None:
version.logical_manifest = logical_manifest
update_fields.append('logical_manifest')
if update_fields:
version.save(update_fields=update_fields)
if animation_manifest is not None:
_apply_content_tags(
project, animation_manifest.get('content_tags') or [], actor,
)
return version, missing_animation_id
def _form_values(*forms):
out = {}
for form in forms:
for name in form.fields:
out[name] = form[name].value() or ''
return out
@login_required
def project_create(request):
project_form = ProjectForm()
version_form = VersionForm()
if request.method == 'POST':
project_form = ProjectForm(request.POST)
version_form = VersionForm(request.POST)
if project_form.is_valid() and version_form.is_valid():
data = project_form.cleaned_data
version_files = _pending_kind(request.user, 'version')
if not version_files:
version_form.add_error('version_name', 'Upload at least one version file before creating the project.')
else:
project = Project.objects.create(
slug=_unique_project_slug(data['title']),
title=data['title'],
summary=data['summary'],
description=data['description'],
category=data['category'],
owner=request.user,
)
thumb = _latest_pending(request.user, 'thumbnail')
if thumb is not None:
project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail')
thumb.status = 'used'
thumb.save(update_fields=['status'])
project.save(update_fields=['thumbnail'])
_inject_animation_metadata(
request, project, version_form.cleaned_data['version_name'], version_files,
)
_, missing_animation_id = _finalize_version(
project,
version_form.cleaned_data['version_name'],
version_form.cleaned_data['changelog'],
version_files,
request.user,
)
if missing_animation_id:
messages.warning(request, ANIMATION_API_WARNING)
for temp in TempUpload.objects.filter(
user=request.user, status='pending', kind='media',
).select_related('file').order_by('created_at'):
index = _adopt_temp(temp, project.pk, 'gallery', 'asset')
ProjectAsset.objects.create(
project=project, file=index,
caption=request.POST.get(f'caption_{temp.uuid}', '')[:128],
uploaded_by=request.user,
)
temp.status = 'used'
temp.save(update_fields=['status'])
_apply_tags(project, data['tags'], request.user)
project.sync_creator_tags(actor=request.user)
ProjectDraft.objects.filter(user=request.user).delete()
messages.success(request, 'Project created.')
return redirect('library:project_detail', slug=project.slug)
else:
draft = ProjectDraft.objects.filter(user=request.user).first()
if draft:
initial = {k: draft.data.get(k, '') for k in DRAFT_KEYS}
project_form = ProjectForm(initial=initial)
version_form = VersionForm(
initial={
k: initial[k] for k in ('version_name', 'changelog') if k in initial
},
)
return render(request, 'library/project_create.html', {
'project_form': project_form,
'version_form': version_form,
'draft_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
'draft_data': _form_values(project_form, version_form),
'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')),
})
@login_required
def project_edit(request, slug):
project = get_object_or_404(Project, slug=slug)
if not project.can_edit(request.user):
return HttpResponseForbidden('You do not have permission to edit this project.')
if request.method == 'POST':
form = ProjectForm(request.POST)
if form.is_valid():
data = form.cleaned_data
project.title = data['title']
project.summary = data['summary']
project.description = data['description']
project.category = data['category']
thumb = _latest_pending(request.user, 'thumbnail')
if thumb is not None:
delete_file_index(project.thumbnail)
project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail')
thumb.status = 'used'
thumb.save(update_fields=['status'])
project.save()
_apply_tags(project, data['tags'], request.user)
project.sync_creator_tags(actor=request.user)
messages.success(request, 'Project updated.')
return redirect('library:project_detail', slug=project.slug)
else:
form = ProjectForm(initial={
'title': project.title,
'summary': project.summary,
'description': project.description,
'category': project.category,
'tags': _current_tags(project),
})
thumb = _latest_pending(request.user, 'thumbnail')
if thumb is not None:
pending_uploads = [_serialize_temp(thumb)]
elif project.thumbnail_id:
pending_uploads = [{
'uuid': str(project.thumbnail.uuid),
'kind': 'thumbnail',
'filename': project.thumbnail.original_filename,
'content_type': project.thumbnail.content_type,
'url': project.thumbnail_url,
'removable': False,
}]
else:
pending_uploads = []
established = _established_animation_id(project)
ns, _, pid = established.partition(':')
anim_edit = {
'current': established,
'namespace': ns or (slugify(project.owner.username) or 'site'),
'pack_id': pid or f'project_{project.pk}',
}
return render(request, 'library/project_edit.html', {
'project': project,
'project_form': form,
'pending_uploads': pending_uploads,
'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')),
'anim_edit': anim_edit,
})
@login_required
def project_delete(request, slug):
project = get_object_or_404(Project, slug=slug)
if request.user != project.owner:
return HttpResponseForbidden('Only the owner can delete this project.')
if request.method == 'POST':
indices = []
if project.thumbnail_id:
indices.append(project.thumbnail)
for version in project.versions.prefetch_related('files__file').all():
indices += [vf.file for vf in version.files.all() if vf.file_id]
indices += [a.file for a in project.assets.all() if a.file_id]
project.delete()
for index in indices:
delete_file_index(index)
messages.success(request, 'Project deleted.')
return redirect('library:browse')
return render(request, 'library/project_delete.html', {'project': project})
@login_required
def version_upload(request, slug):
project = get_object_or_404(Project, slug=slug)
if not project.can_edit(request.user):
return HttpResponseForbidden('You do not have permission to upload versions.')
form = VersionForm()
if request.method == 'POST':
form = VersionForm(request.POST)
if form.is_valid():
version_files = _pending_kind(request.user, 'version')
if not version_files:
form.add_error('version_name', 'Upload at least one version file before uploading.')
else:
errors, _injected = _inject_animation_metadata(
request, project, form.cleaned_data['version_name'], version_files,
)
for error in errors:
form.add_error(None, error)
if not errors:
_, missing_animation_id = _finalize_version(
project,
form.cleaned_data['version_name'],
form.cleaned_data['changelog'],
version_files,
request.user,
)
if missing_animation_id:
messages.warning(request, ANIMATION_API_WARNING)
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
return redirect('library:project_detail', slug=project.slug)
version_files = _pending_kind(request.user, 'version')
return render(request, 'library/version_upload.html', {
'project': project,
'version_form': form,
'pending_uploads': [_serialize_temp(u) for u in version_files],
'anim_meta': _animation_meta_for_upload(request, project),
})
def _bump_and_redirect(request, version, file_index):
Version.objects.filter(pk=version.pk).update(downloads=F('downloads') + 1)
return redirect(
reverse('library:file_request', args=[file_index.uuid]) + '?download=1'
)
@login_required
def regenerate_metadata(request, slug):
"""Rewrite the latest version's release zip so pack.mcmeta carries the
chosen animationframework.id, then re-parse the version's metadata."""
project = get_object_or_404(Project, slug=slug)
if not project.can_edit(request.user):
return HttpResponseForbidden('You do not have permission to edit this project.')
if request.method != 'POST':
return HttpResponse(status=405)
latest = project.versions.first()
if latest is None:
messages.error(request, 'No versions to regenerate.')
return redirect('library:project_edit', slug=slug)
release = latest.files.filter(kind='release').first()
if release is None:
messages.error(request, 'No release file to regenerate.')
return redirect('library:project_edit', slug=slug)
ns = request.POST.get('af_namespace', '').strip()
pid = request.POST.get('af_pack_id', '').strip()
if not _valid_id_component(ns) or not _valid_id_component(pid):
messages.error(request, 'Animation ID must only use letters, numbers, underscores, dots or dashes.')
return redirect('library:project_edit', slug=slug)
animation_id = f'{ns}:{pid}'
create_pack = {
'pack_format': 64,
'supported_formats': [64, 81],
'min_format': 64,
'max_format': 81,
'description': project.title,
}
path = _stored_path(release.file)
if not inject_animationframework(
path, _animation_fields(project, latest.version_name, animation_id), create_pack,
):
messages.error(request, 'Could not regenerate pack metadata.')
return redirect('library:project_edit', slug=slug)
refresh_file_index(release.file)
_reparse_version(latest)
messages.success(request, f'Pack metadata regenerated with id {animation_id}.')
return redirect('library:project_edit', slug=slug)
def version_download(request, slug, version_id):
"""Download the first release file of a version (kept for API/mods)."""
version = get_object_or_404(
Version.objects.select_related('project'),
pk=version_id, project__slug=slug,
)
first = version.files.select_related('file').first()
if first is None:
raise Http404
return _bump_and_redirect(request, version, first.file)
def version_file_download(request, slug, version_id, file_uuid):
"""Download a specific file of a version."""
version = get_object_or_404(
Version.objects.select_related('project'),
pk=version_id, project__slug=slug,
)
vf = get_object_or_404(
version.files.select_related('file'), file__uuid=file_uuid,
)
return _bump_and_redirect(request, version, vf.file)
@login_required
def asset_upload(request, slug):
project = get_object_or_404(Project, slug=slug)
if not project.can_edit(request.user):
return HttpResponseForbidden('You do not have permission to add media.')
if request.method == 'POST':
media = TempUpload.objects.filter(user=request.user, status='pending', kind='media')
if not media.exists():
messages.error(request, 'No media uploaded yet.')
else:
count = 0
for temp in media.select_related('file'):
index = _adopt_temp(temp, project.pk, 'gallery', 'asset')
ProjectAsset.objects.create(
project=project, file=index,
caption=request.POST.get(f'caption_{temp.uuid}', '')[:128],
uploaded_by=request.user,
)
temp.status = 'used'
temp.save(update_fields=['status'])
count += 1
messages.success(request, f'{count} media file{"s" if count != 1 else ""} added to the gallery.')
return redirect('library:project_detail', slug=project.slug)
return render(request, 'library/asset_upload.html', {
'project': project,
'pending_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
})
@login_required
def asset_delete(request, slug, asset_id):
project = get_object_or_404(Project, slug=slug)
asset = get_object_or_404(ProjectAsset, pk=asset_id, project=project)
if not project.can_edit(request.user):
return HttpResponseForbidden('You do not have permission to delete media.')
if request.method != 'POST':
return HttpResponse(status=405)
delete_file_index(asset.file)
asset.delete()
accept = request.META.get('HTTP_ACCEPT', '')
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or 'application/json' in accept:
return JsonResponse({'success': True})
messages.success(request, 'Media deleted.')
return redirect('library:project_detail', slug=project.slug)
def asset_thumbnail(request, slug, asset_id):
"""Cached ffmpeg thumbnail for a gallery video/gif (grid preview only;
the modal loads the real file)."""
from pathlib import Path as _Path
from django.http import FileResponse
from .thumbnails import generate_thumbnail
asset = get_object_or_404(
ProjectAsset.objects.select_related('file', 'project'),
pk=asset_id, project__slug=slug,
)
if not (asset.is_video or asset.is_gif):
raise Http404
stored = generate_thumbnail(asset.file)
if stored is None:
raise Http404
fh = open(_Path(settings.MEDIA_ROOT) / stored, 'rb')
response = FileResponse(fh, content_type='image/jpeg')
response['Cache-Control'] = 'public, max-age=86400'
return response
@login_required
def contributors(request, slug):
project = get_object_or_404(Project, slug=slug)
if request.user != project.owner:
return HttpResponseForbidden('Only the owner can manage contributors.')
if request.method == 'POST':
action = request.POST.get('action')
if action == 'add':
username = request.POST.get('username', '').strip()
user = get_user_model().objects.filter(username__iexact=username).first()
if user is None:
messages.error(request, f'No account named "{username}".')
elif user == project.owner:
messages.error(request, 'The owner is already a contributor.')
else:
_, created = ProjectContributor.objects.get_or_create(
project=project, user=user, defaults={'added_by': request.user},
)
project.sync_creator_tags(actor=request.user)
if created:
messages.success(request, f'{user.username} added as a contributor.')
else:
messages.info(request, f'{user.username} is already a contributor.')
elif action == 'remove':
contributor = ProjectContributor.objects.filter(
pk=request.POST.get('contributor_id'), project=project,
).first()
if contributor is not None:
user = contributor.user
contributor.delete()
project.detach_creator_tag(user)
project.sync_creator_tags(actor=request.user)
messages.success(request, f'{user.username} removed.')
return redirect('library:contributors', slug=project.slug)
return render(request, 'library/contributors.html', {
'project': project,
'contributors': project.contributors.select_related('user').all(),
'contributor_form': ContributorForm(),
})
# ---------- API (autocomplete) ----------
def api_tags_autocomplete(request):
q = request.GET.get('q', '').strip()
tags = Tag.objects.select_related('category').order_by('name')
if q:
tags = tags.filter(Q(name__icontains=q) | Q(category__name__icontains=q))
data = [
{'name': t.name, 'category': t.category.slug, 'category_name': t.category.name, 'color': t.category.color}
for t in tags[:20]
]
return JsonResponse(data, safe=False)
def api_users_autocomplete(request):
q = request.GET.get('q', '').strip()
users = get_user_model().objects.filter(username__icontains=q).order_by('username')[:20]
return JsonResponse([{'username': u.username} for u in users], safe=False)
# ---------- API (draft uploads + autosave) ----------
MEDIA_PREFIXES = ('image/', 'video/')
@login_required
def api_upload_temp(request):
"""Accept one file (J621-style: the JS uploads each file individually)."""
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
uploaded = request.FILES.get('file')
if uploaded is None:
return JsonResponse({'error': 'No file'}, status=400)
kind = request.POST.get('kind', 'media')
if kind not in dict(TempUpload.KIND_CHOICES):
kind = 'media'
content_type = getattr(uploaded, 'content_type', '') or ''
if kind in ('thumbnail', 'media'):
if not content_type.startswith(MEDIA_PREFIXES):
return JsonResponse({'error': 'Only images, videos and gifs are allowed here.'}, status=400)
if kind == 'thumbnail' and not content_type.startswith('image/'):
return JsonResponse({'error': 'The thumbnail must be an image.'}, status=400)
# Thumbnail is a single slot: discard any earlier pending one. Version and
# media accept multiple files (one Version can hold several files).
if kind == 'thumbnail':
for old in TempUpload.objects.filter(user=request.user, status='pending', kind='thumbnail'):
delete_file_index(old.file)
old.delete()
index = store_temp_file(request.user, kind, uploaded)
upload = TempUpload.objects.create(user=request.user, kind=kind, file=index)
return JsonResponse(_serialize_temp(upload))
@login_required
def api_delete_temp(request, upload_uuid):
upload = get_object_or_404(TempUpload, uuid=upload_uuid, user=request.user)
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
if upload.status == 'pending':
delete_file_index(upload.file)
upload.delete()
return JsonResponse({'success': True})
@login_required
def api_draft(request):
if request.method == 'GET':
draft = ProjectDraft.objects.filter(user=request.user).first()
return JsonResponse({
'data': draft.data if draft else {},
'uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
})
if request.method == 'POST':
try:
payload = json.loads(request.body or b'{}')
except json.JSONDecodeError:
payload = {}
data = payload.get('data') or {}
clean = {k: str(data.get(k, '')) for k in DRAFT_KEYS if k in data}
draft, _ = ProjectDraft.objects.get_or_create(user=request.user)
draft.data = clean
draft.save()
return JsonResponse({
'saved': True,
'updated_at': draft.updated_at.isoformat(),
})
if request.method == 'DELETE':
ProjectDraft.objects.filter(user=request.user).delete()
return JsonResponse({'success': True})
return JsonResponse({'error': 'Method not allowed'}, status=405)