import json import posixpath 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 .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, store_temp_file 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// → full file (200) Range: bytes=0-99 → partial content (206) Range: bytes=- → tail from Range: bytes=- → last 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) context = { 'project': project, 'versions': versions, 'assets': assets, 'tags': tags, 'can_edit': can_edit, } return render(request, 'library/project_detail.html', context) 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_/ 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 _finalize_version(project, version_name, changelog, temp_uploads): """Adopt pending 'version' temp uploads into a new Version as VersionFiles.""" version = Version.objects.create( project=project, version_name=version_name, changelog=changelog, ) 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']) return version 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']) _finalize_version( project, version_form.cleaned_data['version_name'], version_form.cleaned_data['changelog'], version_files, ) 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 = [] 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')), }) @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: _finalize_version( project, form.cleaned_data['version_name'], form.cleaned_data['changelog'], version_files, ) 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], }) 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' ) 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) @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)