Files

1873 lines
70 KiB
Python

import json
import mimetypes
import posixpath
import re
import zipfile
from pathlib import Path
from uuid import UUID
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 (
Comment,
FileIndex,
Project,
ProjectAsset,
ProjectContributor,
ProjectDraft,
Rating,
Tag,
TagCategory,
TagList,
TempUpload,
Version,
VersionFile,
slugify_tag,
)
from .storage import delete_file_index, move_file_index, refresh_file_index, store_temp_file
from announcements.models import Notification
from announcements.notifications import notify_project_change
from .zips import (
inject_animationframework,
parse_mods_manifest,
read_animation_manifest,
read_logical_manifest,
read_models_manifest,
read_mod_version_manifest,
read_pack_meta,
read_pack_mcmeta,
read_plugins_manifest,
read_skins_manifest,
_mc_from_filename,
_png_size,
SKIN_SIZES,
)
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 == 'most_liked':
projects = projects.order_by('-rating_score')
elif sort == 'most_voted':
projects = projects.order_by('-rating_count')
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 = []
has_active = False
for tag in tags:
active = tag.name in tag_names
has_active = has_active or active
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, 'has_active': has_active})
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
plugins_manifest = None
skins_manifest = None
mod_manifest = None
anim_stats = None
latest = versions.first()
if project.category == 'guide' and latest is not None:
guide_docs = []
seen = set()
for vf in latest.files.all():
if vf.is_markdown:
guide_docs.append({
'filename': vf.filename,
'url': reverse('library:guide_doc', args=[project.slug, latest.pk, vf.file.uuid]),
})
seen.add((vf.filename or '').lower())
release = latest.files.filter(kind='release').first()
if release is not None and (release.filename or '').lower().endswith('.zip'):
try:
with zipfile.ZipFile(_stored_path(release.file)) as zf:
members = [
m for m in zf.namelist()
if not m.endswith('/') and m.lower().endswith('.md')
]
except (zipfile.BadZipFile, OSError, FileNotFoundError):
members = []
members.sort(key=lambda m: (
not _guide_norm_path(m).rsplit('/', 1)[-1].startswith(('readme', 'index')),
m,
))
for m in members:
bname = _guide_norm_path(m).rsplit('/', 1)[-1]
if bname in seen:
continue
seen.add(bname)
guide_docs.append({
'filename': bname,
'url': reverse('library:guide_zip_doc', args=[project.slug, latest.pk, m]),
})
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
plugins_manifest = latest.plugins_manifest or None
skins_manifest = latest.skins_manifest or None
if skins_manifest and latest.pk:
# Older manifests lack a per-skin url; fall back to the zip member.
for skin in skins_manifest.get('skins', []):
if not skin.get('url') and skin.get('member'):
skin['url'] = reverse(
'library:pack_asset',
args=[project.slug, latest.pk, skin['member']],
)
mod_manifest = latest.mod_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 [])}),
}
mod_loaders = []
mod_count = 0
if mod_manifest:
mods = mod_manifest.get('mods') or []
mod_count = len(mods)
for m in mods:
loader = m.get('modloader')
if loader and loader not in mod_loaders:
mod_loaders.append(loader)
# Ratings + forum.
rating_agg = project.ratings.aggregate(
positive=Count('pk', filter=Q(value=True)),
total=Count('pk'),
)
rating_positive = rating_agg['positive'] or 0
rating_total = rating_agg['total'] or 0
rating_negative = rating_total - rating_positive
rating_percent = round(rating_positive / rating_total * 100) if rating_total else 0
user_rating = None
if request.user.is_authenticated:
user_rating = project.ratings.filter(user=request.user).values_list('value', flat=True).first()
comments = project.comments.select_related('user').order_by('-created_at')[:200]
can_comment = request.user.is_authenticated
# Visiting the UGC page marks the user's personal notifications for it read.
if request.user.is_authenticated:
Notification.objects.filter(
user=request.user, project=project, read=False,
).update(read=True)
autoload_plugins_json = ''
if plugins_manifest:
members = [
p['member'] for p in plugins_manifest.get('plugins', [])
if p.get('autoload') and p.get('member')
]
autoload_plugins_json = json.dumps(members)
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,
'plugins_manifest': plugins_manifest,
'skins_manifest': skins_manifest,
'mod_manifest': mod_manifest,
'mod_loaders': mod_loaders,
'mod_count': mod_count,
'rating_stats': {
'positive': rating_positive,
'negative': rating_negative,
'total': rating_total,
'percent': rating_percent,
'negative_percent': 100 - rating_percent,
},
'user_rating': user_rating,
'comments': comments,
'can_comment': can_comment,
'autoload_plugins_json': autoload_plugins_json,
'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,
})
_GUIDE_LINK_RE = re.compile(r'(!?\[[^\]]*\]\()([^\s)]+)([^)]*)(\))')
_GUIDE_REF_RE = re.compile(r'^(\[[^\]]+\]:\s*)(\S+)\s*$', re.MULTILINE)
_GUIDE_LINK_SKIP = ('http://', 'https://', '//', 'data:', 'mailto:', '#', 'tel:', 'javascript:')
def _guide_norm_path(path):
"""Normalize a link target / stored path for matching (lowercase, no ./ or /)."""
p = (path or '').strip().replace('\\', '/')
while p.startswith('./'):
p = p[2:]
p = p.lstrip('/')
return p.lower()
def _guide_asset_maps(version, base_dir=None, source='zip'):
"""Build normalized path/basename -> served URL maps for a guide version.
``full_map`` keys are normalized full paths, ``base_map`` keys are
normalized basenames. First match wins; priority is: members inside the
current doc's own zip directory -> standalone uploaded files -> other zip
members. Markdown members map to the rendered-doc endpoints, everything
else to the raw file/asset endpoints."""
slug = version.project.slug
full = {}
base = {}
def _add(path, url):
norm = _guide_norm_path(path)
full.setdefault(norm, url)
bname = norm.rsplit('/', 1)[-1]
if bname and bname not in base:
base[bname] = url
release = version.files.filter(kind='release').first()
zip_members = []
if release is not None and (release.filename or '').lower().endswith('.zip'):
try:
with zipfile.ZipFile(_stored_path(release.file)) as zf:
zip_members = [m for m in zf.namelist() if not m.endswith('/')]
except (zipfile.BadZipFile, OSError, FileNotFoundError):
zip_members = []
def _zip_url(member):
if member.lower().endswith('.md'):
return reverse('library:guide_zip_doc', args=[slug, version.pk, member])
return reverse('library:pack_asset', args=[slug, version.pk, member])
def _under_base(member):
if not base_dir:
return False
norm = _guide_norm_path(member)
return norm == base_dir or norm.startswith(base_dir + '/')
if source == 'zip':
for m in zip_members:
if _under_base(m):
_add(m, _zip_url(m))
for vf in version.files.all():
name = (vf.filename or '').replace('\\', '/')
if not name or (vf.kind == 'release' and name.lower().endswith('.zip')):
continue
if vf.is_markdown:
url = reverse('library:guide_doc', args=[slug, version.pk, vf.file.uuid])
else:
url = reverse('library:file_request', args=[vf.file.uuid])
_add(name, url)
for m in zip_members:
if not (source == 'zip' and _under_base(m)):
_add(m, _zip_url(m))
return full, base
def _resolve_guide_src(src, full_map, base_map, base_dir=None):
"""Rewrite one markdown target to its served URL, or None when unknown."""
if not src or src.startswith(_GUIDE_LINK_SKIP):
return None
path = src.split('#')[0].split('?')[0]
if not path:
return None
norm = _guide_norm_path(path)
if base_dir:
cand = _guide_norm_path(f'{base_dir}/{path}')
if cand in full_map:
return full_map[cand]
if norm in full_map:
return full_map[norm]
bname = norm.rsplit('/', 1)[-1]
if bname in base_map:
return base_map[bname]
return None
def _rewrite_guide_links(md, version, base_dir=None, source='zip'):
"""Rewrite markdown link/image/reference targets to served URLs whenever a
file with the same name is present in the version's uploads (individual
files or the release zip). Guide category only."""
full_map, base_map = _guide_asset_maps(version, base_dir=base_dir, source=source)
def _sub_inline(m):
new = _resolve_guide_src(m.group(2), full_map, base_map, base_dir)
return f'{m.group(1)}{new or m.group(2)}{m.group(3)}{m.group(4)}'
def _sub_ref(m):
new = _resolve_guide_src(m.group(2), full_map, base_map, base_dir)
return f'{m.group(1)}{new or m.group(2)}'
md = _GUIDE_LINK_RE.sub(_sub_inline, md)
md = _GUIDE_REF_RE.sub(_sub_ref, md)
return md
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()
if version.project.category == 'guide':
text = _rewrite_guide_links(text, version, source='file')
return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8')
def guide_zip_doc(request, slug, version_id, asset_path):
"""Render one markdown document stored inside the version's release zip."""
version = get_object_or_404(
Version.objects.select_related('project'),
pk=version_id, project__slug=slug,
)
if version.project.category != 'guide':
raise Http404
if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'):
raise Http404
if not asset_path.lower().endswith('.md'):
raise Http404
vf = (
version.files.select_related('file').filter(kind='release').first()
or version.files.select_related('file').first()
)
if vf is None or not (vf.filename or '').lower().endswith('.zip'):
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)
text = data.decode('utf-8', 'replace')
text = _rewrite_guide_links(
text, version,
base_dir=posixpath.dirname(asset_path), source='zip',
)
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, draft_uuid=None):
qs = TempUpload.objects.filter(user=user, status='pending')
if draft_uuid is not None:
qs = qs.filter(draft_uuid=draft_uuid)
else:
qs = qs.filter(draft_uuid__isnull=True)
return list(qs.select_related('file').order_by('created_at'))
def _pending_kind(user, kind, draft_uuid=None):
qs = TempUpload.objects.filter(user=user, status='pending', kind=kind)
if draft_uuid is not None:
qs = qs.filter(draft_uuid=draft_uuid)
else:
qs = qs.filter(draft_uuid__isnull=True)
return list(qs.select_related('file').order_by('created_at'))
def _latest_pending(user, kind, draft_uuid=None):
qs = TempUpload.objects.filter(user=user, status='pending', kind=kind)
if draft_uuid is not None:
qs = qs.filter(draft_uuid=draft_uuid)
else:
qs = qs.filter(draft_uuid__isnull=True)
return qs.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 _clean_mc_version(value):
"""Extract the first X.Y from an MC version/range ('>=26.2 <26.3' -> '26.2')."""
m = re.search(r'(\d+\.\d+)', str(value or ''))
return m.group(1) if m else ''
def _vanilla_entities():
"""Set of real Minecraft entity names from the bundled vanilla index.
Excludes renderer aliases (slime_size_*, player_slim) that are model
variants rather than Minecraft entities.
"""
aliases = {'slime_size_0', 'slime_size_1', 'slime_size_3', 'player_slim'}
try:
with open(settings.BASE_DIR / 'static' / 'vanilla' / 'entity_index.json') as f:
return set(json.load(f).keys()) - aliases
except (OSError, ValueError):
return set()
def _species_entity(entity):
"""Map model-variant names to their real Minecraft entity."""
return {
'player_slim': 'player',
'slime_size_0': 'slime',
'slime_size_1': 'slime',
'slime_size_3': 'slime',
}.get(entity, entity)
def _auto_tag_specs(project, version):
"""Deduped list of (category_slug, tag_name) auto-tags for a version."""
specs = []
def add(cat, name):
name = slugify_tag(name)
if name and (cat, name) not in specs:
specs.append((cat, name))
if project.category == 'mod':
for m in (version.mod_manifest or {}).get('mods', []):
if m.get('modloader'):
add('loader', m['modloader'])
mc = _clean_mc_version(m.get('minecraft'))
if not mc:
mc = _mc_from_filename(m.get('file_name') or '')
if mc:
add('version', mc)
elif project.category == 'modpack':
for loader in (version.mods_manifest or {}).get('modloaders', []):
add('loader', loader)
mc = (version.mods_manifest or {}).get('minecraft')
if mc:
add('version', mc)
elif project.category == 'model':
vanilla = _vanilla_entities()
for m in (version.models_manifest or {}).get('models', []):
ext = (m.get('ext') or '').lstrip('.')
if ext == 'geo.json':
ext = 'geo'
if ext:
add('format', ext)
entity = _species_entity(m.get('entity') or '')
if entity and entity in vanilla:
add('species', entity)
elif project.category == 'skin':
add('general', 'skin')
elif project.category == 'guide':
add('general', 'guide')
elif project.category == 'non_pack':
# NoN packs may carry custom/modded species — no vanilla whitelist.
# Only skip placeholder model names (pregnancy-egg stubs, etc.).
placeholders = {'default', 'minecraft_spider'}
for tag in (version.animation_manifest or {}).get('content_tags', []):
add('content', tag)
for m in (version.models_manifest or {}).get('models', []):
entity = _species_entity(m.get('entity') or '')
if entity and entity not in placeholders:
add('species', entity)
return specs
def _sync_auto_tags(project, actor=None, version=None):
"""Apply auto tags (loader/version/format/species/general/content) from the
given version's manifests. Marks auto links, cleans stale ones, and never
touches manual links (arbitrary tags like creator:LiZo are preserved)."""
actor = actor or project.owner
version = version or project.versions.first()
if version is None:
return
TagCategory.objects.get_or_create(slug='format', defaults={'name': 'Format', 'color': '#0e7490'})
specs = _auto_tag_specs(project, version)
spec_set = set(specs)
cats = {}
for cat_slug, name in specs:
cat = cats.get(cat_slug) or TagCategory.objects.filter(slug=cat_slug).first()
if cat is None:
continue
cats[cat_slug] = cat
tag, _ = Tag.objects.get_or_create(
name=name, category=cat, defaults={'created_by': actor},
)
link, created = TagList.objects.get_or_create(
project=project, tag=tag, defaults={'added_by': actor, 'auto': True},
)
if not created and not link.auto:
link.auto = True
link.save(update_fields=['auto'])
for link in project.tag_links.select_related('tag__category').filter(auto=True).all():
if (link.tag.category.slug, link.tag.name) not in spec_set:
link.delete()
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 _build_skins_manifest(version, release_path=None):
"""Skins for a version: members of the release zip plus individually
uploaded PNG files. Each entry carries a ready-to-use ``url``."""
skins = []
if release_path is not None:
manifest = read_skins_manifest(release_path)
if manifest:
for skin in manifest.get('skins', []):
skin['url'] = reverse(
'library:pack_asset',
args=[version.project.slug, version.pk, skin['member']],
)
skins.append(skin)
for vf in version.files.filter(kind='png').select_related('file'):
try:
with open(_stored_path(vf.file), 'rb') as fh:
head = fh.read(24)
except OSError:
continue
size = _png_size(head)
if size and size in SKIN_SIZES:
skins.append({
'name': vf.filename,
'member': str(vf.file.uuid),
'width': size[0],
'height': size[1],
'url': reverse('library:file_request', args=[vf.file.uuid]),
})
if not skins:
return {}
return {'skins': skins}
def _reparse_version(version):
"""Re-derive a version's metadata (id + manifests) from its release zip."""
release = version.files.filter(kind='release').first()
path = _stored_path(release.file) if release is not None else None
if path is None and version.project.category != 'skin':
return
meta = read_pack_meta(path) if path else None
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 {} if path else {}
version.models_manifest = read_models_manifest(path, version.project.category) or {} if path else {}
version.logical_manifest = read_logical_manifest(path) or {} if path else {}
version.plugins_manifest = read_plugins_manifest(path) or {} if path and version.project.category == 'model' else {}
version.skins_manifest = _build_skins_manifest(version, path) if version.project.category == 'skin' else {}
if version.project.category == 'mod':
entries = [
(vf.filename, _stored_path(vf.file))
for vf in version.files.select_related('file').all()
if vf.filename.lower().endswith(('.jar', '.zip'))
]
version.mod_manifest = read_mod_version_manifest(entries)
else:
version.mod_manifest = {}
if release is not None:
manifest = parse_mods_manifest(path, release.file.original_filename)
version.mods_manifest = manifest if manifest.get('files') else {}
fmt, desc = read_pack_mcmeta(path)
if fmt is not None:
version.pack_format = fmt
version.pack_description = desc
else:
version.mods_manifest = {}
version.save()
_sync_auto_tags(version.project, version.project.owner, version.project.versions.first())
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 = {}
plugins_manifest = None
skins_manifest = None
mod_file_entries = []
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'])
if project.category == 'mod':
mod_name = (index.original_filename or '').lower()
if mod_name.endswith(('.jar', '.zip')):
mod_file_entries.append((index.original_filename, _stored_path(index)))
is_archive = (index.original_filename or '').lower().endswith(('.zip', '.mrpack', '.jar'))
if not is_archive:
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, project.category)
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
if plugins_manifest is None and project.category == 'model':
plugins_manifest = read_plugins_manifest(path)
# Skins come from the release zip members and/or individually uploaded PNGs.
if project.category == 'skin':
release = version.files.filter(kind='release').first()
release_path = None
if release is not None and (release.filename or '').lower().endswith('.zip'):
release_path = _stored_path(release.file)
skins_manifest = _build_skins_manifest(version, release_path)
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 plugins_manifest is not None:
version.plugins_manifest = plugins_manifest
update_fields.append('plugins_manifest')
if skins_manifest:
version.skins_manifest = skins_manifest
update_fields.append('skins_manifest')
if project.category == 'mod' and mod_file_entries:
version.mod_manifest = read_mod_version_manifest(mod_file_entries)
update_fields.append('mod_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)
_sync_auto_tags(project, actor, version)
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
@login_required
def my_projects(request):
"""The user's own projects and saved drafts (two-column page)."""
projects = Project.objects.filter(owner=request.user).order_by('-created_at')
drafts = ProjectDraft.objects.filter(user=request.user)
return render(request, 'library/my_projects.html', {
'projects': projects,
'drafts': drafts,
})
@login_required
def project_create_new(request):
"""Entry point for a new project: create a fresh draft UUID, then edit it."""
if request.method != 'GET':
return HttpResponse(status=405)
draft = ProjectDraft.objects.create(user=request.user)
return redirect('library:project_create', uuid=draft.uuid)
@login_required
def project_draft_delete(request, uuid):
"""Delete one draft (owner only) and purge its pending uploads."""
draft = ProjectDraft.objects.filter(uuid=uuid, user=request.user).first()
if draft is None:
return redirect('library:my_projects')
if request.method == 'POST':
for temp in TempUpload.objects.filter(draft_uuid=draft.uuid):
delete_file_index(temp.file)
TempUpload.objects.filter(draft_uuid=draft.uuid).delete()
draft.delete()
messages.success(request, 'Draft deleted.')
return redirect('library:my_projects')
@login_required
def project_create(request, uuid):
"""Create-page for one specific draft. Only its owner may access it."""
draft = ProjectDraft.objects.filter(uuid=uuid, user=request.user).first()
if draft is None:
return redirect('library:my_projects')
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', draft.uuid)
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', draft.uuid)
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 and project.category == 'non_pack':
messages.warning(request, ANIMATION_API_WARNING)
for temp in TempUpload.objects.filter(
user=request.user, status='pending', kind='media', draft_uuid=draft.uuid,
).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)
_sync_auto_tags(project, request.user)
draft.delete()
messages.success(request, 'Project created.')
return redirect('library:project_detail', slug=project.slug)
else:
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_uuid': draft.uuid,
'draft_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user, draft.uuid)],
'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)
_sync_auto_tags(project, request.user)
messages.success(request, 'Project updated.')
notify_project_change(
project, request.user, 'edit',
f'{request.user.username} edited {project.title}',
)
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 and project.category == 'non_pack':
messages.warning(request, ANIMATION_API_WARNING)
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
notify_project_change(
project, request.user, 'version',
f'{request.user.username} uploaded version {form.cleaned_data["version_name"]} on {project.title}',
)
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.')
notify_project_change(
project, request.user, 'asset',
f'{request.user.username} added {count} new media file{"s" if count != 1 else ""} to {project.title}',
)
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(),
})
# ---------- Ratings & Forum ----------
def refresh_rating_fields(project):
"""Recompute Project.rating_score (net) + rating_count from its votes."""
agg = project.ratings.aggregate(
up=Count('pk', filter=Q(value=True)),
total=Count('pk'),
)
up = agg['up'] or 0
total = agg['total'] or 0
project.rating_count = total
project.rating_score = 2 * up - total
project.save(update_fields=['rating_score', 'rating_count'])
def _require_rating_user(request, project):
"""Redirect guests to login; return None when allowed."""
if request.user.is_authenticated:
return None
return redirect('{}?next={}'.format(
reverse('profiles:login'),
request.path,
))
def rate_project(request, slug):
"""Set / change / clear the current user's vote on a project."""
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
if request.method != 'POST':
return redirect('library:project_detail', slug=slug)
value_raw = request.POST.get('value', '').strip()
if value_raw in ('1', 'up'):
value = True
elif value_raw in ('0', 'down'):
value = False
else:
value = None # clear the vote
rating, created = Rating.objects.get_or_create(
project=project, user=request.user, defaults={'value': value if value is not None else True},
)
if value is None:
rating.delete()
messages.success(request, 'Your rating was removed.')
elif created or rating.value != value:
rating.value = value
rating.save(update_fields=['value', 'updated_at'])
messages.success(request, 'Your rating was updated.')
else:
messages.info(request, 'Your rating is unchanged.')
refresh_rating_fields(project)
return redirect(reverse('library:project_detail', args=[slug]) + '#rating')
def add_comment(request, slug):
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
if request.method == 'POST':
body = request.POST.get('body', '').strip()
if body:
Comment.objects.create(project=project, user=request.user, body=body)
notify_project_change(
project, request.user, 'comment',
f'{request.user.username} commented on {project.title}',
)
messages.success(request, 'Comment added.')
else:
messages.error(request, 'Comment cannot be empty.')
return redirect(reverse('library:project_detail', args=[slug]) + '#forum')
def edit_comment(request, slug, comment_id):
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
comment = get_object_or_404(Comment, pk=comment_id, project=project)
if comment.user_id != request.user.pk and not request.user.is_staff:
return HttpResponseForbidden('You can only edit your own comments.')
if request.method == 'POST':
body = request.POST.get('body', '').strip()
if body:
comment.body = body
comment.save(update_fields=['body', 'updated_at'])
messages.success(request, 'Comment updated.')
else:
messages.error(request, 'Comment cannot be empty.')
return redirect(reverse('library:project_detail', args=[slug]) + '#forum')
def delete_comment(request, slug, comment_id):
project = get_object_or_404(Project, slug=slug)
if (login_redirect := _require_rating_user(request, project)) is not None:
return login_redirect
comment = get_object_or_404(Comment, pk=comment_id, project=project)
if comment.user_id != request.user.pk and not request.user.is_staff:
return HttpResponseForbidden('You can only delete your own comments.')
if request.method == 'POST':
comment.delete()
messages.success(request, 'Comment deleted.')
return redirect(reverse('library:project_detail', args=[slug]) + '#forum')
# ---------- 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)
draft_uuid_raw = request.POST.get('draft_uuid', '').strip()
draft_uuid = draft_uuid_raw or None
if draft_uuid is not None:
try:
draft_uuid = UUID(draft_uuid_raw)
except (ValueError, TypeError):
draft_uuid = None
# Thumbnail is a single slot: discard any earlier pending one for this
# draft (or non-draft flow). Version and media accept multiple files.
if kind == 'thumbnail':
old_qs = TempUpload.objects.filter(user=request.user, status='pending', kind='thumbnail')
if draft_uuid is not None:
old_qs = old_qs.filter(draft_uuid=draft_uuid)
else:
old_qs = old_qs.filter(draft_uuid__isnull=True)
for old in old_qs:
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, draft_uuid=draft_uuid,
)
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):
"""Autosave endpoint for a single draft, keyed by its UUID."""
def _draft_for(uuid_raw):
try:
return ProjectDraft.objects.filter(uuid=UUID(uuid_raw), user=request.user).first()
except (ValueError, TypeError):
return None
if request.method == 'GET':
draft = _draft_for(request.GET.get('draft', ''))
if draft is None:
return JsonResponse({'error': 'Draft not found'}, status=404)
return JsonResponse({
'data': draft.data,
'uploads': [_serialize_temp(u) for u in _pending_uploads(request.user, draft.uuid)],
})
if request.method == 'POST':
try:
payload = json.loads(request.body or b'{}')
except json.JSONDecodeError:
payload = {}
draft = _draft_for(payload.get('draft', ''))
if draft is None:
return JsonResponse({'error': 'Draft not found'}, status=404)
data = payload.get('data') or {}
clean = {k: str(data.get(k, '')) for k in DRAFT_KEYS if k in data}
draft.data = clean
draft.save()
return JsonResponse({
'saved': True,
'updated_at': draft.updated_at.isoformat(),
})
if request.method == 'DELETE':
draft = _draft_for(request.GET.get('draft', ''))
if draft is None:
return JsonResponse({'error': 'Draft not found'}, status=404)
for temp in TempUpload.objects.filter(draft_uuid=draft.uuid):
delete_file_index(temp.file)
TempUpload.objects.filter(draft_uuid=draft.uuid).delete()
draft.delete()
return JsonResponse({'success': True})
return JsonResponse({'error': 'Method not allowed'}, status=405)