Improve uploading/downloading files

This commit is contained in:
Joakim Hellsén 2024-03-17 19:39:22 +01:00
commit 7005490bf4
No known key found for this signature in database
GPG key ID: D196AE66FEBE1DC9
18 changed files with 4596 additions and 134 deletions

View file

@ -9,7 +9,7 @@ import feedparser
from django.utils import timezone
from feedparser import FeedParserDict
from feedvault.models import Author, Domain, Entry, Feed, Generator, Publisher
from feedvault.models import Author, Domain, Entry, Feed, FeedAddResult, Generator, Publisher
if TYPE_CHECKING:
import datetime
@ -171,59 +171,58 @@ def add_entry(feed: Feed, entry: FeedParserDict) -> Entry | None:
dateparser.parse(date_string=str(pre_created_parsed)) if pre_created_parsed else None
)
_entry = Entry(
entry_id = entry.get("id", "")
if not entry_id:
logger.error("Entry ID not found: %s", entry)
added_entry, created = Entry.objects.update_or_create(
feed=feed,
author=entry.get("author", ""),
author_detail=author,
comments=entry.get("comments", ""),
content=entry.get("content", {}),
contributors=entry.get("contributors", {}),
created=entry.get("created", ""),
created_parsed=created_parsed,
enclosures=entry.get("enclosures", []),
expired=entry.get("expired", ""),
expired_parsed=expired_parsed,
_id=entry.get("id", ""),
license=entry.get("license", ""),
link=entry.get("link", ""),
links=entry.get("links", []),
published=entry.get("published", ""),
published_parsed=published_parsed,
publisher=entry.get("publisher", ""),
publisher_detail=publisher,
source=entry.get("source", {}),
summary=entry.get("summary", ""),
summary_detail=entry.get("summary_detail", {}),
tags=entry.get("tags", []),
title=entry.get("title", ""),
title_detail=entry.get("title_detail", {}),
updated=entry.get("updated", ""),
updated_parsed=updated_parsed,
entry_id=entry_id,
defaults={
"author": entry.get("author", ""),
"author_detail": author,
"comments": entry.get("comments", ""),
"content": entry.get("content", {}),
"contributors": entry.get("contributors", {}),
"created": entry.get("created", ""),
"created_parsed": created_parsed,
"enclosures": entry.get("enclosures", []),
"expired": entry.get("expired", ""),
"expired_parsed": expired_parsed,
"license": entry.get("license", ""),
"link": entry.get("link", ""),
"links": entry.get("links", []),
"published": entry.get("published", ""),
"published_parsed": published_parsed,
"publisher": entry.get("publisher", ""),
"publisher_detail": publisher,
"source": entry.get("source", {}),
"summary": entry.get("summary", ""),
"summary_detail": entry.get("summary_detail", {}),
"tags": entry.get("tags", []),
"title": entry.get("title", ""),
"title_detail": entry.get("title_detail", {}),
"updated": entry.get("updated", ""),
"updated_parsed": updated_parsed,
},
)
if created:
logger.info("Created entry: %s", added_entry)
return added_entry
# Save the entry.
_entry.save()
logger.info("Created entry: %s", _entry)
return _entry
logger.info("Updated entry: %s", added_entry)
return added_entry
def add_feed(url: str | None, user: AbstractBaseUser | AnonymousUser) -> Feed | None: # noqa: PLR0914
"""Add a feed to the database.
def add_domain_to_db(url: str | None) -> Domain | None:
"""Add a domain to the database.
Args:
url: The URL of the feed.
user: The user adding the feed.
url: The URL of the domain.
Returns:
The feed that was added.
The domain that was added.
"""
# Parse the feed.
parsed_feed: dict | None = parse_feed(url=url)
if not parsed_feed:
return None
domain_url: None | str = get_domain(url=url)
if not domain_url:
return None
@ -235,6 +234,28 @@ def add_feed(url: str | None, user: AbstractBaseUser | AnonymousUser) -> Feed |
logger.info("Created domain: %s", domain.url)
domain.save()
return domain
def populate_feed(url: str | None, user: AbstractBaseUser | AnonymousUser) -> Feed | None:
"""Populate the feed with entries.
Args:
url: The URL of the feed.
user: The user adding the feed.
Returns:
The feed that was added.
"""
domain: Domain | None = add_domain_to_db(url=url)
if not domain:
return None
# Parse the feed.
parsed_feed: dict | None = parse_feed(url=url)
if not parsed_feed:
return None
author: Author = get_author(parsed_feed=parsed_feed)
generator: Generator = def_generator(parsed_feed=parsed_feed)
publisher: Publisher = get_publisher(parsed_feed=parsed_feed)
@ -252,58 +273,78 @@ def add_feed(url: str | None, user: AbstractBaseUser | AnonymousUser) -> Feed |
pre_modified: str = str(parsed_feed.get("modified", ""))
modified: timezone.datetime | None = dateparser.parse(date_string=pre_modified) if pre_modified else None
# Create the feed
feed = Feed(
# Create or update the feed
feed, created = Feed.objects.update_or_create(
feed_url=url,
user=user,
domain=domain,
last_checked=timezone.now(),
bozo=parsed_feed.get("bozo", 0),
bozo_exception=parsed_feed.get("bozo_exception", ""),
encoding=parsed_feed.get("encoding", ""),
etag=parsed_feed.get("etag", ""),
headers=parsed_feed.get("headers", {}),
href=parsed_feed.get("href", ""),
modified=modified,
namespaces=parsed_feed.get("namespaces", {}),
status=parsed_feed.get("status", 0),
version=parsed_feed.get("version", ""),
author=parsed_feed.get("author", ""),
author_detail=author,
cloud=parsed_feed.get("cloud", {}),
contributors=parsed_feed.get("contributors", {}),
docs=parsed_feed.get("docs", ""),
errorreportsto=parsed_feed.get("errorreportsto", ""),
generator=parsed_feed.get("generator", ""),
generator_detail=generator,
icon=parsed_feed.get("icon", ""),
_id=parsed_feed.get("id", ""),
image=parsed_feed.get("image", {}),
info=parsed_feed.get("info", ""),
language=parsed_feed.get("language", ""),
license=parsed_feed.get("license", ""),
link=parsed_feed.get("link", ""),
links=parsed_feed.get("links", []),
logo=parsed_feed.get("logo", ""),
published=parsed_feed.get("published", ""),
published_parsed=published_parsed,
publisher=parsed_feed.get("publisher", ""),
publisher_detail=publisher,
rights=parsed_feed.get("rights", ""),
rights_detail=parsed_feed.get("rights_detail", {}),
subtitle=parsed_feed.get("subtitle", ""),
subtitle_detail=parsed_feed.get("subtitle_detail", {}),
tags=parsed_feed.get("tags", []),
textinput=parsed_feed.get("textinput", {}),
title=parsed_feed.get("title", ""),
title_detail=parsed_feed.get("title_detail", {}),
ttl=parsed_feed.get("ttl", ""),
updated=parsed_feed.get("updated", ""),
updated_parsed=updated_parsed,
defaults={
"user": user,
"last_checked": timezone.now(),
"bozo": parsed_feed.get("bozo", 0),
"bozo_exception": parsed_feed.get("bozo_exception", ""),
"encoding": parsed_feed.get("encoding", ""),
"etag": parsed_feed.get("etag", ""),
"headers": parsed_feed.get("headers", {}),
"href": parsed_feed.get("href", ""),
"modified": modified,
"namespaces": parsed_feed.get("namespaces", {}),
"status": parsed_feed.get("status", 0),
"version": parsed_feed.get("version", ""),
"author": parsed_feed.get("author", ""),
"author_detail": author,
"cloud": parsed_feed.get("cloud", {}),
"contributors": parsed_feed.get("contributors", {}),
"docs": parsed_feed.get("docs", ""),
"errorreportsto": parsed_feed.get("errorreportsto", ""),
"generator": parsed_feed.get("generator", ""),
"generator_detail": generator,
"icon": parsed_feed.get("icon", ""),
"feed_id": parsed_feed.get("id", ""),
"image": parsed_feed.get("image", {}),
"info": parsed_feed.get("info", ""),
"language": parsed_feed.get("language", ""),
"license": parsed_feed.get("license", ""),
"link": parsed_feed.get("link", ""),
"links": parsed_feed.get("links", []),
"logo": parsed_feed.get("logo", ""),
"published": parsed_feed.get("published", ""),
"published_parsed": published_parsed,
"publisher": parsed_feed.get("publisher", ""),
"publisher_detail": publisher,
"rights": parsed_feed.get("rights", ""),
"rights_detail": parsed_feed.get("rights_detail", {}),
"subtitle": parsed_feed.get("subtitle", ""),
"subtitle_detail": parsed_feed.get("subtitle_detail", {}),
"tags": parsed_feed.get("tags", []),
"textinput": parsed_feed.get("textinput", {}),
"title": parsed_feed.get("title", ""),
"title_detail": parsed_feed.get("title_detail", {}),
"ttl": parsed_feed.get("ttl", ""),
"updated": parsed_feed.get("updated", ""),
"updated_parsed": updated_parsed,
},
)
# Save the feed.
feed.save()
grab_entries(feed=feed)
if created:
logger.info("Created feed: %s", feed)
return feed
logger.info("Updated feed: %s", feed)
return feed
def grab_entries(feed: Feed) -> None:
"""Grab the entries from a feed.
Args:
feed: The feed to grab the entries from.
"""
# Parse the feed.
parsed_feed: dict | None = parse_feed(url=feed.feed_url)
if not parsed_feed:
return
entries = parsed_feed.get("entries", [])
for entry in entries:
@ -311,5 +352,16 @@ def add_feed(url: str | None, user: AbstractBaseUser | AnonymousUser) -> Feed |
if not added_entry:
continue
logger.info("Created feed: %s", feed)
return feed
logger.info("Grabbed entries for feed: %s", feed)
return
def add_url(url: str, user: AbstractBaseUser | AnonymousUser) -> FeedAddResult:
"""Add a feed to the database so we can grab entries from it later."""
domain: Domain | None = add_domain_to_db(url=url)
if not domain:
return FeedAddResult(feed=None, created=False, error="Domain not found")
# Add the URL to the database.
_feed, _created = Feed.objects.get_or_create(feed_url=url, user=user, domain=domain)
return FeedAddResult(feed=_feed, created=_created, error=None)

View file

@ -0,0 +1,18 @@
# Generated by Django 5.0.3 on 2024-03-17 02:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feedvault', '0003_rename__id_entry_entry_id_rename__id_feed_feed_id'),
]
operations = [
migrations.AlterField(
model_name='feed',
name='bozo',
field=models.BooleanField(default=False),
),
]

View file

@ -0,0 +1,32 @@
# Generated by Django 5.0.3 on 2024-03-17 03:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feedvault', '0004_alter_feed_bozo'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserUploadedFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='uploads/')),
('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)),
('has_been_processed', models.BooleanField(default=False)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Uploaded file',
'verbose_name_plural': 'Uploaded files',
'ordering': ['-created_at'],
},
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 5.0.3 on 2024-03-17 03:29
import feedvault.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feedvault', '0005_useruploadedfile'),
]
operations = [
migrations.AddField(
model_name='useruploadedfile',
name='original_filename',
field=models.TextField(default='a', help_text='The original filename of the file.'),
preserve_default=False,
),
migrations.AlterField(
model_name='useruploadedfile',
name='file',
field=models.FileField(upload_to=feedvault.models.get_upload_path),
),
]

View file

@ -0,0 +1,52 @@
# Generated by Django 5.0.3 on 2024-03-17 16:00
import django.db.models.deletion
import feedvault.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feedvault', '0006_useruploadedfile_original_filename_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='useruploadedfile',
name='description',
field=models.TextField(blank=True, help_text='Description added by user.'),
),
migrations.AddField(
model_name='useruploadedfile',
name='notes',
field=models.TextField(blank=True, help_text='Notes from admin.'),
),
migrations.AlterField(
model_name='useruploadedfile',
name='created_at',
field=models.DateTimeField(auto_now_add=True, help_text='The time the file was uploaded.'),
),
migrations.AlterField(
model_name='useruploadedfile',
name='file',
field=models.FileField(help_text='The file that was uploaded.', upload_to=feedvault.models.get_upload_path),
),
migrations.AlterField(
model_name='useruploadedfile',
name='has_been_processed',
field=models.BooleanField(default=False, help_text='Has the file content been added to the archive?'),
),
migrations.AlterField(
model_name='useruploadedfile',
name='modified_at',
field=models.DateTimeField(auto_now=True, help_text='The last time the file was modified.'),
),
migrations.AlterField(
model_name='useruploadedfile',
name='user',
field=models.ForeignKey(blank=True, help_text='The user that uploaded the file.', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
]

View file

@ -2,6 +2,9 @@ from __future__ import annotations
import logging
import typing
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from django.db import models
@ -10,6 +13,15 @@ from django.db.models import JSONField
logger: logging.Logger = logging.getLogger(__name__)
@dataclass
class FeedAddResult:
"""The result of adding a feed to the database."""
feed: Feed | None
created: bool
error: str | None
class Domain(models.Model):
"""A domain that has one or more feeds."""
@ -142,7 +154,7 @@ class Feed(models.Model):
active = models.BooleanField(default=True)
# General data
bozo = models.BooleanField()
bozo = models.BooleanField(default=False)
bozo_exception = models.TextField(blank=True)
encoding = models.TextField(blank=True)
etag = models.TextField(blank=True)
@ -282,3 +294,46 @@ class Entry(models.Model):
def __str__(self) -> str:
"""Return string representation of the entry."""
return f"{self.feed} - {self.title}"
def get_upload_path(instance: UserUploadedFile, filename: str) -> str:
"""Don't save the file with the original filename."""
ext: str = Path(filename).suffix
filename = f"{uuid.uuid4()}{ext}" # For example: 51dc07a7-a299-473c-a737-1ef16bc71609.opml
return f"uploads/{instance.user.id}/{filename}" # type: ignore # noqa: PGH003
class UserUploadedFile(models.Model):
"""A file uploaded to the server by a user."""
file = models.FileField(upload_to=get_upload_path, help_text="The file that was uploaded.")
original_filename = models.TextField(help_text="The original filename of the file.")
created_at = models.DateTimeField(auto_now_add=True, help_text="The time the file was uploaded.")
modified_at = models.DateTimeField(auto_now=True, help_text="The last time the file was modified.")
user = models.ForeignKey(
"auth.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="The user that uploaded the file.",
)
has_been_processed = models.BooleanField(default=False, help_text="Has the file content been added to the archive?")
description = models.TextField(blank=True, help_text="Description added by user.")
notes = models.TextField(blank=True, help_text="Notes from admin.")
class Meta:
"""Meta information for the uploaded file model."""
ordering: typing.ClassVar[list[str]] = ["-created_at"]
verbose_name: str = "Uploaded file"
verbose_name_plural: str = "Uploaded files"
def __str__(self) -> str:
return f"{self.original_filename} - {self.created_at}"
def get_absolute_url(self) -> str:
"""Return the absolute URL of the uploaded file.
Note that you will need to be logged in to access the file.
"""
return f"/download/{self.pk}"

View file

@ -42,13 +42,14 @@ ROOT_URLCONF = "feedvault.urls"
WSGI_APPLICATION = "feedvault.wsgi.application"
NINJA_PAGINATION_PER_PAGE = 1000
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static"
STATIC_ROOT: Path = BASE_DIR / "static"
STATIC_ROOT.mkdir(parents=True, exist_ok=True)
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_ROOT: Path = BASE_DIR / "media"
MEDIA_ROOT.mkdir(parents=True, exist_ok=True)
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
# Is True when running tests, used for not spamming Discord when new users are created
TESTING: bool = len(sys.argv) > 1 and sys.argv[1] == "test"
@ -56,6 +57,7 @@ INSTALLED_APPS: list[str] = [
"feedvault.apps.FeedVaultConfig",
"debug_toolbar",
"django.contrib.auth",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
"django.contrib.contenttypes",
"django.contrib.sessions",
@ -66,6 +68,7 @@ INSTALLED_APPS: list[str] = [
MIDDLEWARE: list[str] = [
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@ -169,3 +172,12 @@ LOGGING = {
},
},
}
STORAGES: dict[str, dict[str, str]] = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}

View file

@ -24,8 +24,11 @@ urlpatterns: list = [
path("__debug__/", include("debug_toolbar.urls")),
path(route="feed/<int:feed_id>/", view=views.FeedView.as_view(), name="feed"),
path(route="feeds/", view=views.FeedsView.as_view(), name="feeds"),
path(route="add", view=views.AddView.as_view(), name="add"),
path(route="upload", view=views.UploadView.as_view(), name="upload"),
path(route="add/", view=views.AddView.as_view(), name="add"),
path(route="upload/", view=views.UploadView.as_view(), name="upload"),
path(route="download/", view=views.DownloadView.as_view(), name="download"),
path(route="delete_upload/", view=views.DeleteUploadView.as_view(), name="delete_upload"),
path(route="edit_description/", view=views.EditDescriptionView.as_view(), name="edit_description"),
path(route="robots.txt", view=cache_page(timeout=60 * 60 * 365)(views.RobotsView.as_view()), name="robots"),
path(
"sitemap.xml",

View file

@ -1,14 +1,20 @@
from __future__ import annotations
import logging
from mimetypes import guess_type
from pathlib import Path
from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse
from django.http import FileResponse, Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, render
from django.template import loader
from django.urls import reverse_lazy
@ -16,14 +22,16 @@ from django.views import View
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
from feedvault.add_feeds import add_feed
from feedvault.models import Domain, Entry, Feed
from feedvault.add_feeds import add_url
from feedvault.models import Domain, Entry, Feed, FeedAddResult, UserUploadedFile
if TYPE_CHECKING:
from django.contrib.auth.models import User
from django.core.files.uploadedfile import UploadedFile
from django.db.models.manager import BaseManager
logger: logging.Logger = logging.getLogger(__name__)
class IndexView(View):
"""Index path."""
@ -86,7 +94,7 @@ class FeedsView(ListView):
return context
class AddView(View):
class AddView(LoginRequiredMixin, View):
"""Add a feed."""
def get(self, request: HttpRequest) -> HttpResponse:
@ -114,22 +122,22 @@ class AddView(View):
# Split the urls by newline.
for url in urls.split("\n"):
feed: None | Feed = add_feed(url, request.user)
if not feed:
messages.error(request, f"{url} - Failed to add")
feed_result: FeedAddResult = add_url(url, request.user)
feed: Feed | None = feed_result.feed
if not feed_result or not feed:
messages.error(request, f"{url} - Failed to add, {feed_result.error}")
continue
# Check if bozo is true.
if feed.bozo:
messages.warning(request, f"{feed.feed_url} - Bozo: {feed.bozo_exception}")
messages.success(request, f"{feed.feed_url} added")
if feed_result.created:
messages.success(request, f"{feed.feed_url} added to queue")
else:
messages.warning(request, f"{feed.feed_url} already exists")
# Render the index page.
template = loader.get_template(template_name="index.html")
return HttpResponse(content=template.render(context={}, request=request))
class UploadView(View):
class UploadView(LoginRequiredMixin, View):
"""Upload a file."""
def get(self, request: HttpRequest) -> HttpResponse:
@ -155,23 +163,140 @@ class UploadView(View):
if not file:
return HttpResponse(content="No file", status=400)
# Split the urls by newline.
for url in file.read().decode("utf-8").split("\n"):
feed: None | Feed = add_feed(url, request.user)
if not feed:
messages.error(request, f"{url} - Failed to add")
continue
# Check if bozo is true.
if feed.bozo:
messages.warning(request, f"{feed.feed_url} - Bozo: {feed.bozo_exception}")
messages.success(request, f"{feed.feed_url} added")
# Save file to media folder
UserUploadedFile.objects.create(user=request.user, file=file, original_filename=file.name)
# Render the index page.
template = loader.get_template(template_name="index.html")
messages.success(request, f"{file.name} uploaded")
messages.info(
request,
"You can find your uploads on your profile page. Files will be parsed and added to the archive when possible. Thanks.", # noqa: E501
)
return HttpResponse(content=template.render(context={}, request=request))
class DeleteUploadView(LoginRequiredMixin, View):
"""Delete an uploaded file."""
def post(self, request: HttpRequest) -> HttpResponse:
"""Delete an uploaded file."""
file_id: str | None = request.POST.get("file_id", None)
if not file_id:
return HttpResponse("No file_id provided", status=400)
user_file: UserUploadedFile | None = UserUploadedFile.objects.filter(user=request.user, id=file_id).first()
if not user_file:
msg = "File not found"
raise Http404(msg)
user_upload_dir: Path = Path(settings.MEDIA_ROOT) / "uploads" / f"{request.user.id}" # type: ignore # noqa: PGH003
file_path: Path = user_upload_dir / Path(user_file.file.name).name
logger.debug("file_path: %s", file_path)
if not file_path.exists() or not file_path.is_file():
logger.error("User '%s' attempted to delete a file that does not exist: %s", request.user, file_path)
msg = "File not found"
raise Http404(msg)
if user_upload_dir not in file_path.parents:
logger.error(
"User '%s' attempted to delete a file that is not in their upload directory: %s",
request.user,
file_path,
)
msg = "Attempted unauthorized file access"
raise SuspiciousOperation(msg)
user_file.delete()
# Go back to the profile page
messages.success(request, f"{file_path.name} deleted")
return HttpResponse(status=204)
class EditDescriptionView(LoginRequiredMixin, View):
"""Edit the description of an uploaded file."""
def post(self, request: HttpRequest) -> HttpResponse:
"""Edit the description of an uploaded file."""
new_description: str | None = request.POST.get("description", None)
file_id: str | None = request.POST.get("file_id", None)
if not new_description:
return HttpResponse("No description provided", status=400)
if not file_id:
return HttpResponse("No file_id provided", status=400)
user_file: UserUploadedFile | None = UserUploadedFile.objects.filter(user=request.user, id=file_id).first()
if not user_file:
msg = "File not found"
raise Http404(msg)
user_upload_dir: Path = Path(settings.MEDIA_ROOT) / "uploads" / f"{request.user.id}" # type: ignore # noqa: PGH003
file_path: Path = user_upload_dir / Path(user_file.file.name).name
logger.debug("file_path: %s", file_path)
if not file_path.exists() or not file_path.is_file():
logger.error("User '%s' attempted to delete a file that does not exist: %s", request.user, file_path)
msg = "File not found"
raise Http404(msg)
if user_upload_dir not in file_path.parents:
logger.error(
"User '%s' attempted to delete a file that is not in their upload directory: %s",
request.user,
file_path,
)
msg = "Attempted unauthorized file access"
raise SuspiciousOperation(msg)
old_description: str = user_file.description
user_file.description = new_description
user_file.save()
logger.info(
"User '%s' updated the description of file '%s' from '%s' to '%s'",
request.user,
file_path,
old_description,
new_description,
)
return HttpResponse(content=new_description, status=200)
class DownloadView(LoginRequiredMixin, View):
"""Download a file."""
def get(self, request: HttpRequest) -> HttpResponse | FileResponse:
"""/download/?file_id=1."""
file_id: str | None = request.GET.get("file_id", None)
if not file_id:
return HttpResponse("No file_id provided", status=400)
user_file: UserUploadedFile | None = UserUploadedFile.objects.filter(user=request.user, id=file_id).first()
if not user_file:
msg = "File not found"
raise Http404(msg)
user_upload_dir: Path = Path(settings.MEDIA_ROOT) / "uploads" / f"{request.user.id}" # type: ignore # noqa: PGH003
file_path: Path = user_upload_dir / Path(user_file.file.name).name
if not file_path.exists() or not file_path.is_file():
msg = "File not found"
raise Http404(msg)
if user_upload_dir not in file_path.parents:
msg = "Attempted unauthorized file access"
raise SuspiciousOperation(msg)
content_type, _ = guess_type(file_path)
response = FileResponse(file_path.open("rb"), content_type=content_type or "application/octet-stream")
response["Content-Disposition"] = f'attachment; filename="{user_file.original_filename or file_path.name}"'
return response
class CustomLoginView(LoginView):
"""Custom login view."""
@ -228,7 +353,7 @@ class CustomPasswordChangeView(SuccessMessageMixin, PasswordChangeView):
return context
class ProfileView(View):
class ProfileView(LoginRequiredMixin, View):
"""Profile page."""
def get(self, request: HttpRequest) -> HttpResponse:
@ -236,6 +361,9 @@ class ProfileView(View):
template = loader.get_template(template_name="accounts/profile.html")
user_feeds: BaseManager[Feed] = Feed.objects.filter(user=request.user).order_by("-created_at")[:100]
user_uploads: BaseManager[UserUploadedFile] = UserUploadedFile.objects.filter(user=request.user).order_by(
"-created_at",
)[:100]
context: dict[str, str | Any] = {
"description": f"Profile page for {request.user.get_username()}",
@ -244,6 +372,7 @@ class ProfileView(View):
"canonical": "https://feedvault.se/accounts/profile/",
"title": f"{request.user.get_username()}",
"user_feeds": user_feeds,
"user_uploads": user_uploads,
}
return HttpResponse(content=template.render(context=context, request=request))