From cf9cc22c584e14f2dc16ab2666fd017784bb5dfa Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Feb 2026 15:46:31 +0000 Subject: [PATCH] Initial commit: rfiles.online standalone file sharing platform Extracted from PKMN (personal-knowledge-management-network) into its own repo with separate database, file storage, and Docker stack. - files app: SharedSpace, MediaFile, PublicShare, FileAccessLog models - portal app: Landing page, upload, file management, shared space views - Host-based URL routing for subdomain shared spaces (*.rfiles.online) - PWA with service worker and share target support - Celery tasks for expired share cleanup and file processing - Docker Compose for dev and production (Traefik + PostgreSQL + Redis) Co-Authored-By: Claude Opus 4.6 --- .env.example | 13 + .gitignore | 35 ++ Dockerfile | 28 ++ config/__init__.py | 3 + config/celery.py | 13 + config/middleware.py | 54 +++ config/settings.py | 151 +++++++ config/urls.py | 45 ++ config/urls_shared_space.py | 22 + config/wsgi.py | 11 + docker-compose.prod.yml | 123 ++++++ docker-compose.yml | 76 ++++ files/__init__.py | 0 files/admin.py | 235 +++++++++++ files/apps.py | 7 + files/models.py | 383 ++++++++++++++++++ files/serializers.py | 235 +++++++++++ files/tasks.py | 78 ++++ files/urls.py | 31 ++ files/views.py | 274 +++++++++++++ manage.py | 22 + portal/__init__.py | 0 portal/apps.py | 7 + portal/static/portal/icon-192.png | Bin 0 -> 1303 bytes portal/static/portal/icon-512.png | Bin 0 -> 3682 bytes portal/static/portal/manifest.json | 43 ++ portal/static/portal/sw.js | 181 +++++++++ portal/templates/portal/base.html | 209 ++++++++++ portal/templates/portal/file_detail.html | 196 +++++++++ portal/templates/portal/files.html | 57 +++ portal/templates/portal/landing.html | 91 +++++ .../templates/portal/shared_space/base.html | 166 ++++++++ .../templates/portal/shared_space/files.html | 193 +++++++++ .../templates/portal/shared_space/home.html | 339 ++++++++++++++++ .../templates/portal/shared_space/login.html | 185 +++++++++ portal/templates/portal/upload.html | 120 ++++++ portal/urls.py | 32 ++ portal/views.py | 293 ++++++++++++++ portal/views_shared_space.py | 118 ++++++ requirements.txt | 12 + 40 files changed, 4081 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 config/__init__.py create mode 100644 config/celery.py create mode 100644 config/middleware.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/urls_shared_space.py create mode 100644 config/wsgi.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 files/__init__.py create mode 100644 files/admin.py create mode 100644 files/apps.py create mode 100644 files/models.py create mode 100644 files/serializers.py create mode 100644 files/tasks.py create mode 100644 files/urls.py create mode 100644 files/views.py create mode 100644 manage.py create mode 100644 portal/__init__.py create mode 100644 portal/apps.py create mode 100644 portal/static/portal/icon-192.png create mode 100644 portal/static/portal/icon-512.png create mode 100644 portal/static/portal/manifest.json create mode 100644 portal/static/portal/sw.js create mode 100644 portal/templates/portal/base.html create mode 100644 portal/templates/portal/file_detail.html create mode 100644 portal/templates/portal/files.html create mode 100644 portal/templates/portal/landing.html create mode 100644 portal/templates/portal/shared_space/base.html create mode 100644 portal/templates/portal/shared_space/files.html create mode 100644 portal/templates/portal/shared_space/home.html create mode 100644 portal/templates/portal/shared_space/login.html create mode 100644 portal/templates/portal/upload.html create mode 100644 portal/urls.py create mode 100644 portal/views.py create mode 100644 portal/views_shared_space.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6fb3720 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Database +DB_PASSWORD=change_me_to_secure_password + +# Redis +REDIS_PASSWORD=change_me_to_secure_password + +# Django +SECRET_KEY=change_me_to_random_50_char_string +DEBUG=False +ALLOWED_HOSTS=rfiles.online,www.rfiles.online,.rfiles.online,localhost + +# File sharing +SHARE_BASE_URL=https://rfiles.online diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8b139d --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +*.egg + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +media/ +staticfiles/ + +# Environment +.env +.env.prod +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Docker +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1a59b7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /app/staticfiles /app/media + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8000/api/health/ || exit 1 + +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..fb989c4 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..a317eb1 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,13 @@ +""" +Celery configuration for rfiles.online. +""" + +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +app = Celery('rfiles') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/config/middleware.py b/config/middleware.py new file mode 100644 index 0000000..e3c3e98 --- /dev/null +++ b/config/middleware.py @@ -0,0 +1,54 @@ +""" +Custom middleware for host-based URL routing. + +Routes requests based on hostname: +- *.rfiles.online (subdomains) → shared space portal +- rfiles.online / www.rfiles.online → main upload portal +""" + +import re +from django.urls import set_urlconf +import logging + +logger = logging.getLogger(__name__) + + +class HostBasedURLConfMiddleware: + """ + Middleware that switches URL configuration based on the request host. + + - rfiles.online / www.rfiles.online -> config.urls (main portal) + - *.rfiles.online -> config.urls_shared_space (shared space portal) + """ + + RFILES_SUBDOMAIN_PATTERN = re.compile(r'^([a-z0-9-]+)\.rfiles\.online$') + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + host = request.get_host().split(':')[0].lower() + + # Reset any previous shared space context + request.shared_space_slug = None + + # Check for rfiles.online subdomain pattern + match = self.RFILES_SUBDOMAIN_PATTERN.match(host) + if match: + subdomain = match.group(1) + if subdomain not in ('www',): + request.shared_space_slug = subdomain + request.urlconf = 'config.urls_shared_space' + set_urlconf('config.urls_shared_space') + logger.info(f"Using shared space URLs for host: {host}, slug: {subdomain}") + response = self.get_response(request) + set_urlconf(None) + return response + + # All other hosts (rfiles.online, www.rfiles.online, localhost) → main portal + request.urlconf = 'config.urls' + set_urlconf('config.urls') + + response = self.get_response(request) + set_urlconf(None) + return response diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..44ca711 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,151 @@ +""" +Django settings for rfiles.online — standalone file sharing platform. +""" + +import os +from pathlib import Path +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent +load_dotenv(BASE_DIR / '.env') + +SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-rfiles-change-me-in-production') +DEBUG = os.environ.get('DEBUG', 'True') == 'True' +ALLOWED_HOSTS = [h.strip() for h in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1,.rfiles.online').split(',') if h.strip()] + +# Application definition +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework.authtoken', + 'corsheaders', + 'simple_history', + 'files', + 'portal', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'simple_history.middleware.HistoryRequestMiddleware', + 'config.middleware.HostBasedURLConfMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +# Database +import dj_database_url +DATABASES = { + 'default': dj_database_url.config( + default=f'sqlite:///{BASE_DIR / "db.sqlite3"}', + conn_max_age=600, + ) +} + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +# Internationalization +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +# Static files +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STORAGES = { + 'staticfiles': { + 'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage', + }, +} + +# Media files +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +# File upload limits +FILE_UPLOAD_MAX_MEMORY_SIZE = 104857600 # 100MB +DATA_UPLOAD_MAX_MEMORY_SIZE = 104857600 # 100MB + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# REST Framework +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 50, +} + +# CORS +CORS_ALLOWED_ORIGINS = [ + h.strip() for h in os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:3000,http://localhost:8000').split(',') if h.strip() +] +CORS_ALLOW_ALL_ORIGINS = DEBUG + +# Celery +CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0') +CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0') +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = 'UTC' +CELERY_BEAT_SCHEDULE = { + 'cleanup-expired-shares': { + 'task': 'files.tasks.cleanup_expired_shares', + 'schedule': 3600, # every hour + }, + 'cleanup-old-access-logs': { + 'task': 'files.tasks.cleanup_old_access_logs', + 'schedule': 86400, # daily + }, +} + +# rfiles-specific settings +SHARE_BASE_URL = os.environ.get('SHARE_BASE_URL', 'https://rfiles.online') + +# Security (production) +if not DEBUG: + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + CSRF_TRUSTED_ORIGINS = [ + 'https://rfiles.online', + 'https://*.rfiles.online', + ] diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..b14047a --- /dev/null +++ b/config/urls.py @@ -0,0 +1,45 @@ +""" +Root URL configuration for rfiles.online. + +Serves the main upload portal at the root path. +""" + +from django.contrib import admin +from django.urls import path, include +from django.http import JsonResponse +from django.db import connection + +from files.urls import api_urlpatterns as files_api_urls, public_urlpatterns as files_public_urls +from portal.views import ServiceWorkerView, ManifestView + + +def health_check(request): + """Health check endpoint for Docker.""" + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + db_status = "healthy" + except Exception as e: + db_status = f"unhealthy: {str(e)}" + + status = { + "status": "healthy" if db_status == "healthy" else "unhealthy", + "database": db_status, + "service": "rfiles-api", + } + + http_status = 200 if status["status"] == "healthy" else 503 + return JsonResponse(status, status=http_status) + + +urlpatterns = [ + # PWA files at root for proper scope + path("sw.js", ServiceWorkerView.as_view(), name="service_worker"), + path("manifest.json", ManifestView.as_view(), name="manifest"), + + path("api/health/", health_check, name="health_check"), + path("admin/", admin.site.urls), + path("api/v1/", include(files_api_urls)), + path("s/", include(files_public_urls)), + path("", include("portal.urls")), +] diff --git a/config/urls_shared_space.py b/config/urls_shared_space.py new file mode 100644 index 0000000..5503a14 --- /dev/null +++ b/config/urls_shared_space.py @@ -0,0 +1,22 @@ +""" +URL configuration for shared spaces (*.rfiles.online subdomains). +""" + +from django.urls import path + +from portal.views_shared_space import ( + SharedSpaceHomeView, + SharedSpaceLoginView, + SharedSpaceLogoutView, + SharedSpaceUploadAPIView, + SharedSpaceFileListView, +) + + +urlpatterns = [ + path('', SharedSpaceHomeView.as_view(), name='shared_space_home'), + path('login/', SharedSpaceLoginView.as_view(), name='shared_space_login'), + path('logout/', SharedSpaceLogoutView.as_view(), name='shared_space_logout'), + path('api/upload/', SharedSpaceUploadAPIView.as_view(), name='shared_space_upload'), + path('files/', SharedSpaceFileListView.as_view(), name='shared_space_files'), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..eb76d91 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,11 @@ +""" +WSGI config for rfiles.online. +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..8589555 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,123 @@ +services: + postgres: + image: postgres:15-alpine + container_name: rfiles-db + restart: unless-stopped + volumes: + - rfiles_postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=rfiles + - POSTGRES_USER=rfiles + - POSTGRES_PASSWORD=${DB_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U rfiles -d rfiles"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - rfiles-internal + + redis: + image: redis:7-alpine + container_name: rfiles-redis + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - rfiles_redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - rfiles-internal + + backend: + build: + context: . + dockerfile: Dockerfile + container_name: rfiles-api + restart: unless-stopped + volumes: + - rfiles_media:/app/media + - rfiles_static:/app/staticfiles + environment: + - DATABASE_URL=postgresql://rfiles:${DB_PASSWORD}@postgres:5432/rfiles + - CELERY_BROKER_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 + - CELERY_RESULT_BACKEND=redis://:${REDIS_PASSWORD}@redis:6379/0 + - DJANGO_SETTINGS_MODULE=config.settings + - DEBUG=False + - ALLOWED_HOSTS=rfiles.online,www.rfiles.online,.rfiles.online,localhost + - SHARE_BASE_URL=https://rfiles.online + - SECRET_KEY=${SECRET_KEY} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + labels: + - "traefik.enable=true" + - "traefik.http.routers.rfiles.rule=Host(`rfiles.online`) || Host(`www.rfiles.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rfiles.online`)" + - "traefik.http.routers.rfiles.entrypoints=web" + - "traefik.http.services.rfiles.loadbalancer.server.port=8000" + - "traefik.docker.network=traefik-public" + networks: + - rfiles-internal + - traefik-public + command: > + sh -c "python manage.py migrate --noinput && + python manage.py collectstatic --noinput && + gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --threads 2" + + celery-worker: + build: + context: . + dockerfile: Dockerfile + container_name: rfiles-celery-worker + restart: unless-stopped + volumes: + - rfiles_media:/app/media + environment: + - DATABASE_URL=postgresql://rfiles:${DB_PASSWORD}@postgres:5432/rfiles + - CELERY_BROKER_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 + - CELERY_RESULT_BACKEND=redis://:${REDIS_PASSWORD}@redis:6379/0 + - DJANGO_SETTINGS_MODULE=config.settings + - SECRET_KEY=${SECRET_KEY} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - rfiles-internal + command: celery -A config worker --loglevel=info --concurrency=2 + + celery-beat: + build: + context: . + dockerfile: Dockerfile + container_name: rfiles-celery-beat + restart: unless-stopped + environment: + - DATABASE_URL=postgresql://rfiles:${DB_PASSWORD}@postgres:5432/rfiles + - CELERY_BROKER_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 + - CELERY_RESULT_BACKEND=redis://:${REDIS_PASSWORD}@redis:6379/0 + - DJANGO_SETTINGS_MODULE=config.settings + - SECRET_KEY=${SECRET_KEY} + depends_on: + - celery-worker + networks: + - rfiles-internal + command: celery -A config beat --loglevel=info + +volumes: + rfiles_postgres_data: + rfiles_redis_data: + rfiles_media: + rfiles_static: + +networks: + rfiles-internal: + driver: bridge + traefik-public: + external: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d2a25aa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +services: + postgres: + image: postgres:15-alpine + container_name: rfiles-db + volumes: + - rfiles_postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=rfiles + - POSTGRES_USER=rfiles + - POSTGRES_PASSWORD=${DB_PASSWORD:-rfiles_dev_password} + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U rfiles -d rfiles"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: rfiles-redis + command: redis-server --requirepass ${REDIS_PASSWORD:-rfiles_redis_dev} + ports: + - "6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-rfiles_redis_dev}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: . + container_name: rfiles-api + volumes: + - .:/app + - rfiles_media:/app/media + environment: + - DATABASE_URL=postgresql://rfiles:${DB_PASSWORD:-rfiles_dev_password}@postgres:5432/rfiles + - CELERY_BROKER_URL=redis://:${REDIS_PASSWORD:-rfiles_redis_dev}@redis:6379/0 + - CELERY_RESULT_BACKEND=redis://:${REDIS_PASSWORD:-rfiles_redis_dev}@redis:6379/0 + - DJANGO_SETTINGS_MODULE=config.settings + - DEBUG=True + - ALLOWED_HOSTS=localhost,127.0.0.1,rfiles.online,.rfiles.online + - SHARE_BASE_URL=http://localhost:8000 + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: > + sh -c "python manage.py migrate --noinput && + python manage.py runserver 0.0.0.0:8000" + + celery-worker: + build: . + container_name: rfiles-celery-worker + volumes: + - .:/app + - rfiles_media:/app/media + environment: + - DATABASE_URL=postgresql://rfiles:${DB_PASSWORD:-rfiles_dev_password}@postgres:5432/rfiles + - CELERY_BROKER_URL=redis://:${REDIS_PASSWORD:-rfiles_redis_dev}@redis:6379/0 + - CELERY_RESULT_BACKEND=redis://:${REDIS_PASSWORD:-rfiles_redis_dev}@redis:6379/0 + - DJANGO_SETTINGS_MODULE=config.settings + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + command: celery -A config worker --loglevel=info --concurrency=2 + +volumes: + rfiles_postgres_data: + rfiles_media: diff --git a/files/__init__.py b/files/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/files/admin.py b/files/admin.py new file mode 100644 index 0000000..4be708c --- /dev/null +++ b/files/admin.py @@ -0,0 +1,235 @@ +""" +Admin configuration for rfiles.online file management. +""" + +from django import forms +from django.contrib import admin +from django.utils.html import format_html + +from .models import MediaFile, PublicShare, FileAccessLog, SharedSpace + + +class SharedSpaceAdminForm(forms.ModelForm): + password = forms.CharField( + widget=forms.PasswordInput, + required=False, + help_text="Leave blank to keep existing password. Set to change." + ) + + class Meta: + model = SharedSpace + fields = '__all__' + exclude = ['password_hash'] + + def save(self, commit=True): + instance = super().save(commit=False) + password = self.cleaned_data.get('password') + if password: + instance.set_password(password) + if commit: + instance.save() + return instance + + +@admin.register(SharedSpace) +class SharedSpaceAdmin(admin.ModelAdmin): + form = SharedSpaceAdminForm + list_display = [ + 'name', + 'slug', + 'url_display', + 'file_count_display', + 'total_size_display', + 'is_active', + 'created_at', + ] + list_filter = ['is_active', 'created_at'] + search_fields = ['name', 'slug', 'description'] + readonly_fields = ['id', 'created_at', 'updated_at', 'url_display', 'file_count_display', 'total_size_display'] + fieldsets = ( + (None, { + 'fields': ('id', 'name', 'slug', 'description', 'url_display') + }), + ('Access Control', { + 'fields': ('password', 'is_active', 'max_file_size_mb') + }), + ('Statistics', { + 'fields': ('file_count_display', 'total_size_display') + }), + ('Metadata', { + 'fields': ('created_by', 'created_at', 'updated_at') + }), + ) + + def url_display(self, obj): + url = obj.get_url() + return format_html('{}', url, url) + url_display.short_description = 'URL' + + def file_count_display(self, obj): + return obj.file_count + file_count_display.short_description = 'Files' + + def total_size_display(self, obj): + size = obj.total_size_bytes + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" + total_size_display.short_description = 'Total Size' + + +@admin.register(MediaFile) +class MediaFileAdmin(admin.ModelAdmin): + list_display = [ + 'title', + 'original_filename', + 'mime_type', + 'formatted_file_size', + 'share_count', + 'is_processed', + 'created_at', + ] + list_filter = ['mime_type', 'is_processed', 'created_at'] + search_fields = ['title', 'original_filename', 'description', 'tags'] + readonly_fields = ['id', 'file_hash', 'file_size', 'created_at', 'updated_at', 'share_links'] + fieldsets = ( + (None, { + 'fields': ('id', 'title', 'description', 'tags') + }), + ('File Info', { + 'fields': ('file', 'original_filename', 'mime_type', 'file_size', 'file_hash') + }), + ('Processing', { + 'fields': ('is_processed', 'processing_error', 'extracted_text'), + 'classes': ('collapse',) + }), + ('Links', { + 'fields': ('shared_space',), + 'classes': ('collapse',) + }), + ('Metadata', { + 'fields': ('uploaded_by', 'created_at', 'updated_at') + }), + ('Public Shares', { + 'fields': ('share_links',) + }), + ) + + def formatted_file_size(self, obj): + size = obj.file_size + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" + formatted_file_size.short_description = 'Size' + + def share_count(self, obj): + count = obj.get_public_shares().count() + if count > 0: + return format_html('{}', count) + return count + share_count.short_description = 'Active Shares' + + def share_links(self, obj): + shares = obj.get_public_shares() + if not shares: + return "No active shares" + + links = [] + for share in shares: + url = share.get_public_url() + status_parts = [] + if share.expires_at: + status_parts.append(f"expires {share.expires_at}") + if share.max_downloads: + status_parts.append(f"{share.download_count}/{share.max_downloads} downloads") + if share.is_password_protected: + status_parts.append("password protected") + + status_str = f" ({', '.join(status_parts)})" if status_parts else "" + links.append(f'{share.token[:12]}...{status_str}') + + return format_html('
'.join(links)) + share_links.short_description = 'Share Links' + + +@admin.register(PublicShare) +class PublicShareAdmin(admin.ModelAdmin): + list_display = [ + 'token_display', + 'media_file', + 'status_display', + 'download_count', + 'is_password_protected', + 'expires_at', + 'created_at', + ] + list_filter = ['is_active', 'is_password_protected', 'created_at', 'expires_at'] + search_fields = ['token', 'media_file__title', 'media_file__original_filename', 'note'] + readonly_fields = ['id', 'token', 'download_count', 'created_at', 'public_url_display'] + fieldsets = ( + (None, { + 'fields': ('id', 'token', 'media_file', 'public_url_display') + }), + ('Access Control', { + 'fields': ('is_active', 'expires_at', 'max_downloads', 'download_count') + }), + ('Password Protection', { + 'fields': ('is_password_protected', 'password_hash'), + 'classes': ('collapse',) + }), + ('Metadata', { + 'fields': ('note', 'created_by', 'created_at') + }), + ) + + def token_display(self, obj): + return f"{obj.token[:16]}..." + token_display.short_description = 'Token' + + def status_display(self, obj): + if not obj.is_active: + return format_html('Revoked') + if obj.is_expired: + return format_html('Expired') + if obj.is_download_limit_reached: + return format_html('Limit Reached') + return format_html('Active') + status_display.short_description = 'Status' + + def public_url_display(self, obj): + url = obj.get_public_url() + return format_html('{}', url, url) + public_url_display.short_description = 'Public URL' + + +@admin.register(FileAccessLog) +class FileAccessLogAdmin(admin.ModelAdmin): + list_display = [ + 'accessed_at', + 'media_file', + 'access_type', + 'ip_address', + 'share_token_display', + ] + list_filter = ['access_type', 'accessed_at'] + search_fields = ['media_file__title', 'ip_address'] + readonly_fields = [ + 'id', 'media_file', 'share', 'accessed_at', + 'ip_address', 'user_agent', 'access_type', 'user', + ] + + def share_token_display(self, obj): + if obj.share: + return f"{obj.share.token[:12]}..." + return "-" + share_token_display.short_description = 'Share' + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/files/apps.py b/files/apps.py new file mode 100644 index 0000000..bbbb3d2 --- /dev/null +++ b/files/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class FilesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'files' + verbose_name = 'File Storage' diff --git a/files/models.py b/files/models.py new file mode 100644 index 0000000..a8869bb --- /dev/null +++ b/files/models.py @@ -0,0 +1,383 @@ +""" +File storage and public sharing models for rfiles.online. + +Provides file upload capability with optional public sharing via +unique tokens with configurable expiration and access controls. +Also supports password-protected shared spaces for collaborative uploads. +""" + +import hashlib +import secrets +import uuid +from pathlib import Path + +from django.conf import settings +from django.contrib.auth.hashers import check_password, make_password +from django.db import models +from django.utils import timezone +from simple_history.models import HistoricalRecords + + +class SharedSpace(models.Model): + """ + Password-protected shared upload space accessible via subdomain. + + Users with the space password can view all files and upload new ones. + Example: cofi.rfiles.online would be a SharedSpace with slug='cofi'. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + slug = models.SlugField( + max_length=63, + unique=True, + help_text="Subdomain identifier (e.g., 'cofi' for cofi.rfiles.online)" + ) + name = models.CharField(max_length=255, help_text="Display name for the space") + description = models.TextField(blank=True, default='') + password_hash = models.CharField(max_length=128) + is_active = models.BooleanField(default=True) + max_file_size_mb = models.PositiveIntegerField( + default=100, + help_text="Maximum file size in MB for uploads" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_spaces' + ) + + class Meta: + ordering = ['name'] + verbose_name = 'Shared Space' + verbose_name_plural = 'Shared Spaces' + + def __str__(self): + return f"{self.name} ({self.slug}.rfiles.online)" + + def set_password(self, password): + self.password_hash = make_password(password) + + def check_password(self, password): + return check_password(password, self.password_hash) + + def get_url(self): + return f"https://{self.slug}.rfiles.online" + + @property + def file_count(self): + return self.files.count() + + @property + def total_size_bytes(self): + return self.files.aggregate(total=models.Sum('file_size'))['total'] or 0 + + +def media_upload_path(instance, filename): + """ + Generate upload path for media files. + Format: uploads/YYYY/MM/DD// + """ + now = timezone.now() + return f"uploads/{now.year}/{now.month:02d}/{now.day:02d}/{instance.id}/{filename}" + + +class MediaFile(models.Model): + """ + Uploaded media file with metadata. + + Supports any file type - PDFs, images, documents, audio, video, etc. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + # File data + file = models.FileField(upload_to=media_upload_path) + original_filename = models.CharField(max_length=255) + mime_type = models.CharField(max_length=127, blank=True, default='') + file_size = models.BigIntegerField(default=0, help_text="File size in bytes") + file_hash = models.CharField( + max_length=64, + db_index=True, + blank=True, + default='', + help_text="SHA256 hash for deduplication" + ) + + # Metadata + title = models.CharField(max_length=255, blank=True, default='') + description = models.TextField(blank=True, default='') + tags = models.JSONField(default=list, blank=True) + + # Optional extracted content (for OCR'd PDFs, etc.) + extracted_text = models.TextField(blank=True, default='') + + # Ownership and timestamps + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='uploaded_files' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # Link to shared space (for collaborative uploads) + shared_space = models.ForeignKey( + 'SharedSpace', + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='files', + help_text="Shared space this file belongs to" + ) + + # Processing status + is_processed = models.BooleanField( + default=False, + help_text="Whether OCR/transcription has been run" + ) + processing_error = models.TextField(blank=True, default='') + + history = HistoricalRecords() + + class Meta: + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['file_hash']), + models.Index(fields=['mime_type']), + models.Index(fields=['created_at']), + ] + + def __str__(self): + return self.title or self.original_filename + + def save(self, *args, **kwargs): + if not self.title and self.original_filename: + self.title = Path(self.original_filename).stem + + if self.file and not self.file_hash: + self.file_hash = self.compute_file_hash() + + if self.file and not self.file_size: + try: + self.file_size = self.file.size + except Exception: + pass + + super().save(*args, **kwargs) + + def compute_file_hash(self): + sha256 = hashlib.sha256() + self.file.seek(0) + for chunk in iter(lambda: self.file.read(8192), b''): + sha256.update(chunk) + self.file.seek(0) + return sha256.hexdigest() + + @property + def extension(self): + return Path(self.original_filename).suffix.lower() + + @property + def is_image(self): + return self.mime_type.startswith('image/') + + @property + def is_pdf(self): + return self.mime_type == 'application/pdf' + + @property + def is_audio(self): + return self.mime_type.startswith('audio/') + + @property + def is_video(self): + return self.mime_type.startswith('video/') + + def get_public_shares(self): + return self.public_shares.filter( + models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now()) + ) + + +def generate_share_token(): + return secrets.token_urlsafe(24) + + +class PublicShare(models.Model): + """ + Public sharing link for a media file. + + Allows creating time-limited or permanent public URLs for files + with optional password protection and download limits. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + token = models.CharField( + max_length=64, + unique=True, + default=generate_share_token, + db_index=True + ) + + media_file = models.ForeignKey( + MediaFile, + on_delete=models.CASCADE, + related_name='public_shares' + ) + + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_shares' + ) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField( + null=True, + blank=True, + help_text="Leave empty for permanent link" + ) + + max_downloads = models.PositiveIntegerField( + null=True, + blank=True, + help_text="Leave empty for unlimited downloads" + ) + download_count = models.PositiveIntegerField(default=0) + + is_password_protected = models.BooleanField(default=False) + password_hash = models.CharField(max_length=128, blank=True, default='') + + note = models.CharField( + max_length=255, + blank=True, + default='', + help_text="Optional note about this share" + ) + + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['token']), + models.Index(fields=['expires_at']), + models.Index(fields=['is_active']), + ] + + def __str__(self): + return f"Share: {self.media_file.title or self.media_file.original_filename}" + + @property + def is_expired(self): + if self.expires_at is None: + return False + return timezone.now() > self.expires_at + + @property + def is_download_limit_reached(self): + if self.max_downloads is None: + return False + return self.download_count >= self.max_downloads + + @property + def is_valid(self): + return ( + self.is_active + and not self.is_expired + and not self.is_download_limit_reached + ) + + def set_password(self, password): + from django.contrib.auth.hashers import make_password + self.is_password_protected = True + self.password_hash = make_password(password) + self.save(update_fields=['is_password_protected', 'password_hash']) + + def check_password(self, password): + from django.contrib.auth.hashers import check_password + if not self.is_password_protected: + return True + return check_password(password, self.password_hash) + + def record_download(self, request=None): + self.download_count += 1 + self.save(update_fields=['download_count']) + + if request: + FileAccessLog.objects.create( + media_file=self.media_file, + share=self, + ip_address=self._get_client_ip(request), + user_agent=request.META.get('HTTP_USER_AGENT', '')[:500], + access_type='download' + ) + + def _get_client_ip(self, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + return x_forwarded_for.split(',')[0].strip() + return request.META.get('REMOTE_ADDR', '') + + def get_public_url(self): + from django.conf import settings + base_url = getattr(settings, 'SHARE_BASE_URL', 'https://rfiles.online') + return f"{base_url}/s/{self.token}" + + +class FileAccessLog(models.Model): + """ + Audit log for file access events. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + media_file = models.ForeignKey( + MediaFile, + on_delete=models.CASCADE, + related_name='access_logs' + ) + share = models.ForeignKey( + PublicShare, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='access_logs' + ) + + accessed_at = models.DateTimeField(auto_now_add=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.CharField(max_length=500, blank=True, default='') + + ACCESS_TYPES = [ + ('download', 'Download'), + ('view', 'View'), + ('share_created', 'Share Created'), + ('share_revoked', 'Share Revoked'), + ] + access_type = models.CharField(max_length=20, choices=ACCESS_TYPES) + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True + ) + + class Meta: + ordering = ['-accessed_at'] + indexes = [ + models.Index(fields=['accessed_at']), + models.Index(fields=['access_type']), + ] + + def __str__(self): + return f"{self.access_type} - {self.media_file} at {self.accessed_at}" diff --git a/files/serializers.py b/files/serializers.py new file mode 100644 index 0000000..5df6440 --- /dev/null +++ b/files/serializers.py @@ -0,0 +1,235 @@ +""" +Serializers for file upload and sharing. +""" + +from rest_framework import serializers +from django.utils import timezone + +from .models import MediaFile, PublicShare, FileAccessLog + + +class MediaFileSerializer(serializers.ModelSerializer): + file_url = serializers.SerializerMethodField() + share_count = serializers.SerializerMethodField() + has_active_shares = serializers.SerializerMethodField() + + class Meta: + model = MediaFile + fields = [ + 'id', + 'file', + 'file_url', + 'original_filename', + 'title', + 'description', + 'mime_type', + 'file_size', + 'file_hash', + 'tags', + 'extracted_text', + 'is_processed', + 'processing_error', + 'shared_space', + 'created_at', + 'updated_at', + 'share_count', + 'has_active_shares', + ] + read_only_fields = [ + 'id', + 'file_url', + 'file_size', + 'file_hash', + 'is_processed', + 'processing_error', + 'created_at', + 'updated_at', + 'share_count', + 'has_active_shares', + ] + + def get_file_url(self, obj): + request = self.context.get('request') + if obj.file and request: + return request.build_absolute_uri(obj.file.url) + return None + + def get_share_count(self, obj): + return obj.get_public_shares().count() + + def get_has_active_shares(self, obj): + return obj.get_public_shares().exists() + + +class MediaFileUploadSerializer(serializers.ModelSerializer): + original_filename = serializers.CharField(required=False, allow_blank=True) + mime_type = serializers.CharField(required=False, allow_blank=True) + + class Meta: + model = MediaFile + fields = [ + 'file', + 'original_filename', + 'title', + 'description', + 'mime_type', + 'tags', + 'shared_space', + ] + + def create(self, validated_data): + file_obj = validated_data.get('file') + if file_obj and not validated_data.get('original_filename'): + validated_data['original_filename'] = file_obj.name + + if file_obj and not validated_data.get('mime_type'): + validated_data['mime_type'] = getattr(file_obj, 'content_type', 'application/octet-stream') + + request = self.context.get('request') + if request and request.user.is_authenticated: + validated_data['uploaded_by'] = request.user + + return super().create(validated_data) + + +class PublicShareSerializer(serializers.ModelSerializer): + public_url = serializers.SerializerMethodField() + is_valid = serializers.SerializerMethodField() + file_info = serializers.SerializerMethodField() + + class Meta: + model = PublicShare + fields = [ + 'id', + 'token', + 'media_file', + 'public_url', + 'expires_at', + 'max_downloads', + 'download_count', + 'is_password_protected', + 'note', + 'is_active', + 'is_valid', + 'created_at', + 'file_info', + ] + read_only_fields = [ + 'id', + 'token', + 'public_url', + 'download_count', + 'is_valid', + 'created_at', + 'file_info', + ] + extra_kwargs = { + 'media_file': {'required': True} + } + + def get_public_url(self, obj): + return obj.get_public_url() + + def get_is_valid(self, obj): + return obj.is_valid + + def get_file_info(self, obj): + return { + 'id': str(obj.media_file.id), + 'title': obj.media_file.title, + 'original_filename': obj.media_file.original_filename, + 'mime_type': obj.media_file.mime_type, + 'file_size': obj.media_file.file_size, + } + + +class PublicShareCreateSerializer(serializers.ModelSerializer): + password = serializers.CharField( + write_only=True, + required=False, + allow_blank=True, + ) + expires_in_hours = serializers.IntegerField( + write_only=True, + required=False, + min_value=1, + max_value=8760, + ) + + class Meta: + model = PublicShare + fields = [ + 'media_file', + 'expires_at', + 'expires_in_hours', + 'max_downloads', + 'password', + 'note', + ] + + def validate(self, data): + if 'expires_in_hours' in data: + hours = data.pop('expires_in_hours') + data['expires_at'] = timezone.now() + timezone.timedelta(hours=hours) + return data + + def create(self, validated_data): + password = validated_data.pop('password', None) + + request = self.context.get('request') + if request and request.user.is_authenticated: + validated_data['created_by'] = request.user + + share = super().create(validated_data) + + if password: + share.set_password(password) + + return share + + +class PublicShareInfoSerializer(serializers.ModelSerializer): + file_info = serializers.SerializerMethodField() + is_valid = serializers.SerializerMethodField() + downloads_remaining = serializers.SerializerMethodField() + + class Meta: + model = PublicShare + fields = [ + 'is_password_protected', + 'is_valid', + 'expires_at', + 'downloads_remaining', + 'file_info', + 'note', + ] + + def get_is_valid(self, obj): + return obj.is_valid + + def get_downloads_remaining(self, obj): + if obj.max_downloads is None: + return None + return max(0, obj.max_downloads - obj.download_count) + + def get_file_info(self, obj): + return { + 'title': obj.media_file.title, + 'original_filename': obj.media_file.original_filename, + 'file_size': obj.media_file.file_size, + 'mime_type': obj.media_file.mime_type, + } + + +class FileAccessLogSerializer(serializers.ModelSerializer): + class Meta: + model = FileAccessLog + fields = [ + 'id', + 'media_file', + 'share', + 'accessed_at', + 'ip_address', + 'access_type', + ] + read_only_fields = fields diff --git a/files/tasks.py b/files/tasks.py new file mode 100644 index 0000000..088e9b5 --- /dev/null +++ b/files/tasks.py @@ -0,0 +1,78 @@ +""" +Celery tasks for file management. +""" + +from celery import shared_task +from django.utils import timezone + + +@shared_task +def cleanup_expired_shares(): + """Deactivate expired share links.""" + from .models import PublicShare + + now = timezone.now() + expired_shares = PublicShare.objects.filter( + is_active=True, + expires_at__isnull=False, + expires_at__lt=now + ) + count = expired_shares.update(is_active=False) + return f"Deactivated {count} expired shares" + + +@shared_task +def cleanup_old_access_logs(days=90): + """Delete old access logs to save space.""" + from .models import FileAccessLog + + cutoff = timezone.now() - timezone.timedelta(days=days) + deleted, _ = FileAccessLog.objects.filter(accessed_at__lt=cutoff).delete() + return f"Deleted {deleted} access logs older than {days} days" + + +@shared_task +def process_media_file(media_file_id): + """Process an uploaded media file (OCR, metadata extraction).""" + import os + import requests + from .models import MediaFile + + try: + media_file = MediaFile.objects.get(id=media_file_id) + except MediaFile.DoesNotExist: + return f"MediaFile {media_file_id} not found" + + if media_file.is_processed: + return f"MediaFile {media_file_id} already processed" + + try: + if media_file.is_pdf: + ocr_url = os.environ.get('OCR_SERVICE_URL', 'http://pdf-ocr:8000') + + with media_file.file.open('rb') as f: + response = requests.post( + f'{ocr_url}/ocr/sync', + files={'file': (media_file.original_filename, f, 'application/pdf')}, + timeout=300 + ) + + if response.status_code == 200: + result = response.json() + media_file.extracted_text = result.get('text', '') + media_file.is_processed = True + media_file.save(update_fields=['extracted_text', 'is_processed']) + return f"OCR completed for {media_file_id}: {len(media_file.extracted_text)} chars" + else: + media_file.processing_error = f"OCR failed: {response.status_code}" + media_file.save(update_fields=['processing_error']) + return f"OCR failed for {media_file_id}: {response.status_code}" + + media_file.is_processed = True + media_file.save(update_fields=['is_processed']) + return f"Marked {media_file_id} as processed (no processing needed)" + + except Exception as e: + media_file.processing_error = str(e) + media_file.save(update_fields=['processing_error']) + return f"Error processing {media_file_id}: {e}" diff --git a/files/urls.py b/files/urls.py new file mode 100644 index 0000000..390a874 --- /dev/null +++ b/files/urls.py @@ -0,0 +1,31 @@ +""" +URL configuration for file storage API. +""" + +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import ( + MediaFileViewSet, + PublicShareViewSet, + PublicDownloadView, + PublicShareInfoView, + PublicShareVerifyPasswordView, +) + +router = DefaultRouter() +router.register(r'media', MediaFileViewSet, basename='media') +router.register(r'shares', PublicShareViewSet, basename='shares') + +# API URLs (under /api/v1/) +api_urlpatterns = [ + path('', include(router.urls)), +] + +# Public share URLs (under /s/) +public_urlpatterns = [ + path('/', PublicDownloadView.as_view(), name='public_download'), + path('/download/', PublicDownloadView.as_view(), name='public_download_explicit'), + path('/info/', PublicShareInfoView.as_view(), name='public_share_info'), + path('/verify/', PublicShareVerifyPasswordView.as_view(), name='public_share_verify'), +] diff --git a/files/views.py b/files/views.py new file mode 100644 index 0000000..5ed3ad0 --- /dev/null +++ b/files/views.py @@ -0,0 +1,274 @@ +""" +API views for file upload and public sharing. +""" + +from django.http import FileResponse, JsonResponse +from django.shortcuts import get_object_or_404 +from django.views import View +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt + +from rest_framework import viewsets, status, permissions +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser + +from .models import MediaFile, PublicShare, FileAccessLog +from .serializers import ( + MediaFileSerializer, + MediaFileUploadSerializer, + PublicShareSerializer, + PublicShareCreateSerializer, + PublicShareInfoSerializer, + FileAccessLogSerializer, +) + + +class MediaFileViewSet(viewsets.ModelViewSet): + """ViewSet for managing uploaded media files.""" + + queryset = MediaFile.objects.all() + serializer_class = MediaFileSerializer + parser_classes = [MultiPartParser, FormParser, JSONParser] + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + + def get_serializer_class(self): + if self.action == 'create': + return MediaFileUploadSerializer + return MediaFileSerializer + + def get_queryset(self): + queryset = MediaFile.objects.all() + + mime_type = self.request.query_params.get('mime_type') + if mime_type: + queryset = queryset.filter(mime_type__startswith=mime_type) + + tags = self.request.query_params.getlist('tag') + if tags: + for tag in tags: + queryset = queryset.filter(tags__contains=tag) + + space = self.request.query_params.get('space') + if space: + queryset = queryset.filter(shared_space__slug=space) + + return queryset + + def perform_create(self, serializer): + serializer.save() + + @action(detail=True, methods=['post']) + def share(self, request, pk=None): + """Create a public share link for this file.""" + media_file = self.get_object() + + data = request.data.copy() + data['media_file'] = media_file.id + + serializer = PublicShareCreateSerializer( + data=data, + context={'request': request} + ) + serializer.is_valid(raise_exception=True) + share = serializer.save() + + FileAccessLog.objects.create( + media_file=media_file, + share=share, + ip_address=self._get_client_ip(request), + user_agent=request.META.get('HTTP_USER_AGENT', '')[:500], + access_type='share_created', + user=request.user if request.user.is_authenticated else None + ) + + return Response( + PublicShareSerializer(share, context={'request': request}).data, + status=status.HTTP_201_CREATED + ) + + @action(detail=True, methods=['get']) + def shares(self, request, pk=None): + """List all shares for this file.""" + media_file = self.get_object() + shares = media_file.public_shares.all() + + if request.query_params.get('active') == 'true': + shares = media_file.get_public_shares() + + serializer = PublicShareSerializer( + shares, + many=True, + context={'request': request} + ) + return Response(serializer.data) + + @action(detail=True, methods=['get']) + def access_logs(self, request, pk=None): + """Get access logs for this file.""" + media_file = self.get_object() + logs = media_file.access_logs.all()[:100] + + serializer = FileAccessLogSerializer(logs, many=True) + return Response(serializer.data) + + def _get_client_ip(self, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + return x_forwarded_for.split(',')[0].strip() + return request.META.get('REMOTE_ADDR', '') + + +class PublicShareViewSet(viewsets.ModelViewSet): + """ViewSet for managing public shares.""" + + queryset = PublicShare.objects.all() + serializer_class = PublicShareSerializer + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + + def get_serializer_class(self): + if self.action == 'create': + return PublicShareCreateSerializer + return PublicShareSerializer + + def get_queryset(self): + queryset = PublicShare.objects.all() + + file_id = self.request.query_params.get('file') + if file_id: + queryset = queryset.filter(media_file_id=file_id) + + active = self.request.query_params.get('active') + if active == 'true': + from django.db.models import Q + from django.utils import timezone + queryset = queryset.filter( + is_active=True + ).filter( + Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()) + ) + + return queryset + + @action(detail=True, methods=['post']) + def revoke(self, request, pk=None): + """Revoke a share link.""" + share = self.get_object() + share.is_active = False + share.save(update_fields=['is_active']) + + FileAccessLog.objects.create( + media_file=share.media_file, + share=share, + ip_address=self._get_client_ip(request), + user_agent=request.META.get('HTTP_USER_AGENT', '')[:500], + access_type='share_revoked', + user=request.user if request.user.is_authenticated else None + ) + + return Response({'status': 'revoked'}) + + @action(detail=True, methods=['post']) + def set_password(self, request, pk=None): + """Set or update password for a share.""" + share = self.get_object() + password = request.data.get('password') + + if not password: + return Response( + {'error': 'Password is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + share.set_password(password) + return Response({'status': 'password_set'}) + + @action(detail=True, methods=['post']) + def remove_password(self, request, pk=None): + """Remove password protection from a share.""" + share = self.get_object() + share.is_password_protected = False + share.password_hash = '' + share.save(update_fields=['is_password_protected', 'password_hash']) + return Response({'status': 'password_removed'}) + + def _get_client_ip(self, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + return x_forwarded_for.split(',')[0].strip() + return request.META.get('REMOTE_ADDR', '') + + +@method_decorator(csrf_exempt, name='dispatch') +class PublicDownloadView(View): + """Public view for downloading shared files.""" + + def get(self, request, token): + share = get_object_or_404(PublicShare, token=token) + + if not share.is_valid: + if share.is_expired: + return JsonResponse({'error': 'This share link has expired'}, status=410) + if share.is_download_limit_reached: + return JsonResponse({'error': 'Download limit reached'}, status=410) + if not share.is_active: + return JsonResponse({'error': 'This share link has been revoked'}, status=410) + return JsonResponse({'error': 'Share link is not valid'}, status=403) + + if share.is_password_protected: + password = request.GET.get('password') or request.POST.get('password') + if not password: + return JsonResponse( + {'error': 'Password required', 'is_password_protected': True}, + status=401 + ) + if not share.check_password(password): + return JsonResponse({'error': 'Invalid password'}, status=401) + + share.record_download(request) + + media_file = share.media_file + try: + response = FileResponse( + media_file.file.open('rb'), + content_type=media_file.mime_type or 'application/octet-stream' + ) + response['Content-Disposition'] = f'attachment; filename="{media_file.original_filename}"' + response['Content-Length'] = media_file.file_size + return response + except FileNotFoundError: + return JsonResponse({'error': 'File not found'}, status=404) + + +@method_decorator(csrf_exempt, name='dispatch') +class PublicShareInfoView(View): + """Public view for getting share info without downloading.""" + + def get(self, request, token): + share = get_object_or_404(PublicShare, token=token) + serializer = PublicShareInfoSerializer(share) + return JsonResponse(serializer.data) + + +@method_decorator(csrf_exempt, name='dispatch') +class PublicShareVerifyPasswordView(View): + """Public view for verifying share password.""" + + def post(self, request, token): + import json + + share = get_object_or_404(PublicShare, token=token) + + if not share.is_password_protected: + return JsonResponse({'valid': True, 'message': 'No password required'}) + + try: + data = json.loads(request.body) + password = data.get('password', '') + except json.JSONDecodeError: + password = request.POST.get('password', '') + + if share.check_password(password): + return JsonResponse({'valid': True}) + else: + return JsonResponse({'valid': False, 'error': 'Invalid password'}, status=401) diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/portal/__init__.py b/portal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal/apps.py b/portal/apps.py new file mode 100644 index 0000000..7a3e11c --- /dev/null +++ b/portal/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PortalConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'portal' + verbose_name = 'Upload Portal' diff --git a/portal/static/portal/icon-192.png b/portal/static/portal/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..40f44e41dfb83305db441794dca86fbf1e8b0312 GIT binary patch literal 1303 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;V7ce%;uumf=k49SMIr72Z4W1Q zAL-gDSQIbOsjMC|qd2eSjv`-7wAiYtTk6x7RquO#^5E%`{pozsTA#oC`SYjPVa5Hh zdWHy31{Yz5i!2T%i~_zSGws&?W7z%s)7Rs7_aFbBKVSZXr^WM=|0P}<%58jcUFg_f zmV(8i-2Vj~WBeJ!SMB&9FL>lQ%ZI)`@BdE%osp!H!Vb72COWOn_8;;gTM{xu)M*KOwv_(L$;pqOm?-XekCS+bdXaWh&e)saMUa2g+^T zHNXBf!!G%MdRFHb<}%FXuXT@oe$l95UHjhMeZ`sL4#%DSF6G*-WqMJzW2M#Em1nOn zn9KKd&i?;@{=SO;d_4ZY%m>eBFF%N8c1th#{b~6tgY9CB2LeyAUwEi_==Q4f>q{7} z^7o$(nU{Q%K}%lh`f985n-~x9s4}nkwdH}G=<_JOhOYJ_k3(kXuVh#y%XfT*$@3t_ z139ToE53w0s0e))tHuzq(1js%9^0AF#CNJp4WZN610HG~uv-0mzYRk~W+ubx*UQ_c zRzF}94mE~FoLHG#|0nLyv+pQ9&A+Ym=jPI9Ko>t`toi@# z=ZpU=($-*8k&@nQp@#gX9Y=fi7BWLq;xg6;?-^r~uk8c+4`N9$^M~&YdWOaR5SK4w ze_+}H_2@K~2VxKAh*m!YhszzNJzKKmfvTdV8loEvuf1yoy7n}40e4EY?f<8Y2fS37 zBL0db^l}yJ3o@(;QfuJyXMVQQ=)Do+0j{M?D?Wuhh?x2;MytUvOO~%cJa^wyCW*!O z6hBG;GifW%)uOV{H& zx9}wN%xmAfdEMs>=>rnA{I%wDpI-nPUnT#K514pYuXnV6lzpjkm;OJ`-_j43#71@I>-%ft8sk2DeX-cB`|o6#`2p|Ue7W1^o-cgOaO_5Ph4GuZy6F8( zd!J2xQM4OqLI{JpQ$_TtNAq3S3Y1#@FV+3$S;C-xQgMmizfOiY4=%;pkNzwL$IdB6 z{E6p$AZy*4@bSFxfmW+y2`}x{8l*#c4jqd3XW-wY#KB!}&nWXoFfroGV~&Q5+y4z@ i_PCQ=qQDBG-{Qsdbnn@6>=FPLcnqGdelF{r5}E)ra5m`x literal 0 HcmV?d00001 diff --git a/portal/static/portal/icon-512.png b/portal/static/portal/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..531e01eabf8cbaafd885dfcd85d69491198f8f72 GIT binary patch literal 3682 zcmeHKYd}+15}tdLK*B2_s30N_k&0*mwJ0c<@Dxf*sBbNWAQh_650}HZ$igWB+9Ma{9IbMJE)%gB6nJ^J%m#&a|EYBDl9UYydLco5Gh63=20YF~} zV4n|wafw^d|4l)QsLc-Ec{Ik++Nu&K|4<}KwJ(eF-skan592cFo##KcOpEtJp0Xhv zzfm|**MQt~0=~E*N9!C7F?8r{*!eKm8B>9XV|d$Gf}Z-uJ^?9>}#YSl`1# zIf{*4G_22LLB%Bl_X6NsTXMWD(3|#PC2M3V7)a3Y8ZhY@7Bv?$gN9L@b;vY#$eNoN z3B9r&W|P;d(I+(QJrk=7T^^Y2C1}ZRwrfu+?Y&|Qug`VqZH|X;QxWmpLe8k7_@^V5 zU>lpyH#>)T*Bb*fXn!jbFdC{{Gr zvRv-fv-vXG`MLq^?&BrM*&!6)lzF*P;hWpnow;m9$?XsjEH}u%Rhbw)h0uE3-}u4~o{DU>*8J^;+pCsn%%LbLI4e@w97)B4Y{JB=)>JVO z2}8uTYJ8x)5`}hP(og&Q6?s*h`o7zRiB%l5KpmeIKY~g;3pwsNliP}5>!(t~o3Wz1 zPB5pZ1=-a4r3V9}c_nZ?_U-b~w=>pQo*;J7T$qDMSR&(cS=hJ!u(bYjbaV8%yY?`r z(;Yce`Q{&F*B{yN6MkB`>lSQHM+GCMjaGasS z9Do%JuYGXWT^$}%AX%bgfRy|3n~9QCl)8}J=^A)2QlZT5&aBkkwU{6^YX zZ>Rua>%H$uCrv9hV3(B%Ysj<(ZCs37slGQw0J5lD%I;bGrL`JQ{Xcz3%wZFnyJ%8) zR1ch;$Wsfx>zZ_qUI8!JxQJ`2Qm>r>)Pc?#hImeGdNHYPe}WNSAY2;+z_lk$kkUff zU3L2+nE{C*N=@7o@b|v_H~6j55Rb~#uGBb!yYDk?lOe!D zHt`_6QY90QD7&0qG-;lS-;>+LWl}l*5@)wRA4``!ef3OdQ!so_O!SG6sDY1P-1$>= zYBxkM8m~Yi9woh-i*jFbu(Ii zE9_y|Oy&FPD*wE^iA0bA@p5E$WpmR^kyaNdUc#)ELqak@W_Sstv~_7n1>3LIB2Oeg z=FRj-3l`^$RVoJv;?+UVvGO;tWy*Kwkr5u+xR@KY@B8_Bhqy6MXNj4b_D}TTiYX_I zT0VK}U7p^vmXEg6-jSB797qre{_Z)XtR9gX3+yxmr0dF+53`L0NAD|18~4MX(W;Nt z+4$>EaL+$YtCof3I)cH7)KpL^vC z{&t2sYojXBm2?tuu?mhEdtw2Bo+c!=>TVXZHkl}8vO5lNB*_zdtL@@;D%aPHA(>aZ zIgbp-`8yM3ed(QrV3hI>cBifK2P!woj45%^Y$_ssM_jD89=m^HiY?XgM8*)Tvc zD}JXmzaIol2Pba_B~^3d(iEa3D?hF$OY7LS)<&A zm>}8c#z)<8#6We5fyAZ^RkRBsP%M3jDO_BA y;t}q|+{=L0hHK%1O6fCQ-yit@*Uj^9xl!iSTFR^;?G1v90YU=90?zvJ5B>|su%0Xc literal 0 HcmV?d00001 diff --git a/portal/static/portal/manifest.json b/portal/static/portal/manifest.json new file mode 100644 index 0000000..d25878a --- /dev/null +++ b/portal/static/portal/manifest.json @@ -0,0 +1,43 @@ +{ + "name": "rfiles.online", + "short_name": "rfiles", + "description": "Share files by topic", + "start_url": "/", + "scope": "/", + "id": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#3b82f6", + "icons": [ + { + "src": "/static/portal/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/portal/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "share_target": { + "action": "/share-target/", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": ["*/*"] + } + ] + } + }, + "categories": ["productivity", "utilities"], + "orientation": "portrait-primary" +} diff --git a/portal/static/portal/sw.js b/portal/static/portal/sw.js new file mode 100644 index 0000000..c497fcf --- /dev/null +++ b/portal/static/portal/sw.js @@ -0,0 +1,181 @@ +// rfiles.online Service Worker +const CACHE_NAME = 'rfiles-upload-v1'; +const OFFLINE_QUEUE_NAME = 'rfiles-offline-queue'; + +// Assets to cache for offline use +const ASSETS_TO_CACHE = [ + '/', + '/static/portal/manifest.json', + '/static/portal/icon-192.png', + '/static/portal/icon-512.png', +]; + +// Install event - cache assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(ASSETS_TO_CACHE); + }) + ); + self.skipWaiting(); +}); + +// Activate event - clean old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => caches.delete(name)) + ); + }) + ); + self.clients.claim(); +}); + +// Fetch event - serve from cache, fallback to network +self.addEventListener('fetch', (event) => { + // Skip share-target requests (handle them separately) + if (event.request.url.includes('/share-target/')) { + return; + } + + // Skip API requests - always go to network + if (event.request.url.includes('/api/')) { + return; + } + + event.respondWith( + caches.match(event.request).then((response) => { + return response || fetch(event.request); + }) + ); +}); + +// Handle share target data +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Handle Web Share Target + if (url.pathname === '/share-target/' && event.request.method === 'POST') { + event.respondWith(handleShareTarget(event.request)); + } +}); + +async function handleShareTarget(request) { + const formData = await request.formData(); + + // Extract shared data + const title = formData.get('title') || ''; + const text = formData.get('text') || ''; + const url = formData.get('url') || ''; + const files = formData.getAll('files'); + + // Store in IndexedDB for the page to pick up + const shareData = { + title, + text, + url, + files: files.length, + timestamp: Date.now() + }; + + // If we have files, upload them directly + if (files.length > 0) { + try { + const uploadPromises = files.map(async (file) => { + const uploadFormData = new FormData(); + uploadFormData.append('file', file); + uploadFormData.append('title', title || file.name); + uploadFormData.append('description', text || url || ''); + + const response = await fetch('/api/upload/', { + method: 'POST', + body: uploadFormData, + }); + + return response.json(); + }); + + const results = await Promise.all(uploadPromises); + + // Redirect to upload page with success message + const successUrl = new URL('/', self.location.origin); + successUrl.searchParams.set('shared', 'files'); + successUrl.searchParams.set('count', files.length); + + return Response.redirect(successUrl.toString(), 303); + } catch (error) { + console.error('Share upload failed:', error); + // Queue for later if offline + await queueOfflineUpload({ title, text, url, files }); + + const offlineUrl = new URL('/', self.location.origin); + offlineUrl.searchParams.set('queued', 'true'); + return Response.redirect(offlineUrl.toString(), 303); + } + } + + // If we only have URL/text, redirect to upload page with params + const redirectUrl = new URL('/', self.location.origin); + if (url) redirectUrl.searchParams.set('url', url); + if (text) redirectUrl.searchParams.set('text', text); + if (title) redirectUrl.searchParams.set('title', title); + redirectUrl.searchParams.set('shared', 'true'); + + return Response.redirect(redirectUrl.toString(), 303); +} + +// Queue uploads for when back online +async function queueOfflineUpload(data) { + // Use IndexedDB to store offline queue + const db = await openDB(); + const tx = db.transaction('offline-queue', 'readwrite'); + await tx.store.add({ + ...data, + queuedAt: Date.now() + }); +} + +function openDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open('rfiles-upload', 1); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains('offline-queue')) { + db.createObjectStore('offline-queue', { keyPath: 'queuedAt' }); + } + }; + }); +} + +// Sync offline queue when back online +self.addEventListener('sync', (event) => { + if (event.tag === 'upload-queue') { + event.waitUntil(processOfflineQueue()); + } +}); + +async function processOfflineQueue() { + const db = await openDB(); + const tx = db.transaction('offline-queue', 'readonly'); + const items = await tx.store.getAll(); + + for (const item of items) { + try { + // Process queued item + console.log('Processing queued item:', item); + + // Remove from queue on success + const deleteTx = db.transaction('offline-queue', 'readwrite'); + await deleteTx.store.delete(item.queuedAt); + } catch (error) { + console.error('Failed to process queued item:', error); + } + } +} diff --git a/portal/templates/portal/base.html b/portal/templates/portal/base.html new file mode 100644 index 0000000..1efec25 --- /dev/null +++ b/portal/templates/portal/base.html @@ -0,0 +1,209 @@ + + + + + + + + + + {% load static %} + + {% block title %}Upload{% endblock %} - rfiles.online + + {% block extra_css %}{% endblock %} + + +
+
+

rfiles.online

+ +
+ + {% block content %}{% endblock %} +
+ + {% block extra_js %}{% endblock %} + + + + + diff --git a/portal/templates/portal/file_detail.html b/portal/templates/portal/file_detail.html new file mode 100644 index 0000000..1660d89 --- /dev/null +++ b/portal/templates/portal/file_detail.html @@ -0,0 +1,196 @@ +{% extends "portal/base.html" %} + +{% block title %}{{ file.title|default:file.original_filename }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

{{ file.title|default:file.original_filename }}

+
+ {{ file.file_size|filesizeformat }} + {{ file.mime_type }} + Uploaded {{ file.created_at|date:"M d, Y H:i" }} +
+ {% if file.description %} +

{{ file.description }}

+ {% endif %} +
+
+ Download +
+
+ +
+

Create New Share Link

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Active Share Links ({{ shares.count }})

+ +
+ +
+

Danger Zone

+

Deleting this file will also remove all share links.

+ +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/portal/templates/portal/files.html b/portal/templates/portal/files.html new file mode 100644 index 0000000..bb3ed9a --- /dev/null +++ b/portal/templates/portal/files.html @@ -0,0 +1,57 @@ +{% extends "portal/base.html" %} + +{% block title %}All Files{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+

All Files

+ Upload New +
+ +{% if files %} +
+ {% for file in files %} +
+
+

{{ file.title|default:file.original_filename }}

+
+ {{ file.file_size|filesizeformat }} + {{ file.mime_type }} + {{ file.created_at|date:"M d, Y" }} + {% if file.get_public_shares %} + {{ file.get_public_shares.count }} share{{ file.get_public_shares.count|pluralize }} + {% endif %} +
+
+
+ View +
+
+ {% endfor %} +
+{% else %} +
+

No files yet

+

Upload your first file to get started

+ Upload File +
+{% endif %} +{% endblock %} diff --git a/portal/templates/portal/landing.html b/portal/templates/portal/landing.html new file mode 100644 index 0000000..f6db6d9 --- /dev/null +++ b/portal/templates/portal/landing.html @@ -0,0 +1,91 @@ +{% extends "portal/base.html" %} + +{% block title %}rfiles.online - Share Files by Topic{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

rfiles.online

+

Share files by topic

+
+ +
+

Enter a topic to start sharing

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ {% csrf_token %} +
+ +
+
+ +
+
+ + {% if active_topics %} +
+

Active topics

+
+ {% for topic in active_topics %} + + {{ topic.slug }} + ({{ topic.files.count }}) + + {% endfor %} +
+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/portal/templates/portal/shared_space/base.html b/portal/templates/portal/shared_space/base.html new file mode 100644 index 0000000..5faf136 --- /dev/null +++ b/portal/templates/portal/shared_space/base.html @@ -0,0 +1,166 @@ + + + + + + + {% block title %}{{ space.slug }}{% endblock %} - rfiles.online + + {% block extra_css %}{% endblock %} + + +
+
+
+

rfiles/{{ space.slug }}

+
+ +
+ + {% block content %}{% endblock %} +
+ + {% block extra_js %}{% endblock %} + + diff --git a/portal/templates/portal/shared_space/files.html b/portal/templates/portal/shared_space/files.html new file mode 100644 index 0000000..60f657c --- /dev/null +++ b/portal/templates/portal/shared_space/files.html @@ -0,0 +1,193 @@ +{% extends "portal/shared_space/base.html" %} + +{% block title %}All Files - {{ space.name }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
{{ files|length }}
+
Total Files
+
+
+
{{ space.total_size_bytes|filesizeformat }}
+
Total Size
+
+
+ +{% if files %} +
+ {% for file in files %} +
+
+ {% if file.is_image %} + {{ file.title }} + {% elif file.is_video %} + + {% elif file.is_audio %} + + {% elif file.is_pdf %} + 📄 + {% else %} + 🗎 + {% endif %} +
+
{{ file.title|default:file.original_filename|truncatechars:40 }}
+
+ {{ file.file_size|filesizeformat }} + + {{ file.extension|default:"file" }} + +
+
+ {{ file.created_at|date:"M d, Y" }} +
+
+ View + Download +
+
+ {% endfor %} +
+{% else %} +
+

No files yet

+

This space is empty. Upload some files to get started.

+
+{% endif %} +{% endblock %} diff --git a/portal/templates/portal/shared_space/home.html b/portal/templates/portal/shared_space/home.html new file mode 100644 index 0000000..a726f4c --- /dev/null +++ b/portal/templates/portal/shared_space/home.html @@ -0,0 +1,339 @@ +{% extends "portal/shared_space/base.html" %} + +{% block title %}Upload - {{ space.name }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +

Upload files to {{ space.slug }}

+ +
+
+
+

Drop files here or click to select

+

Max file size: {{ space.max_file_size_mb }}MB

+ +
+ +
+ +{% if recent_files %} +
+

Recent Uploads

+
+ {% for file in recent_files %} +
+ {{ file.title|default:file.original_filename }} + {{ file.file_size|filesizeformat }} +
+ {% endfor %} +
+
+{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/portal/templates/portal/shared_space/login.html b/portal/templates/portal/shared_space/login.html new file mode 100644 index 0000000..610dee5 --- /dev/null +++ b/portal/templates/portal/shared_space/login.html @@ -0,0 +1,185 @@ + + + + + + + Login - {{ space.name }} - rfiles.online + + + + + + diff --git a/portal/templates/portal/upload.html b/portal/templates/portal/upload.html new file mode 100644 index 0000000..dfb7e5e --- /dev/null +++ b/portal/templates/portal/upload.html @@ -0,0 +1,120 @@ +{% extends "portal/base.html" %} + +{% block title %}Upload Files{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+

Drop files here to upload

+

or click to select files

+

Max file size: 100MB

+ +
+ +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/portal/urls.py b/portal/urls.py new file mode 100644 index 0000000..822bb33 --- /dev/null +++ b/portal/urls.py @@ -0,0 +1,32 @@ +""" +URL configuration for upload portal. +""" + +from django.urls import path +from .views import ( + LandingPageView, + CreateTopicView, + UploadPageView, + UploadAPIView, + FileListView, + FileDetailView, + CreateShareView, + DeleteFileView, + RevokeShareView, + ShareTargetView, +) + +app_name = 'portal' + +urlpatterns = [ + path('', LandingPageView.as_view(), name='landing'), + path('go/', CreateTopicView.as_view(), name='create_topic'), + path('upload/', UploadPageView.as_view(), name='upload'), + path('api/upload/', UploadAPIView.as_view(), name='api_upload'), + path('share-target/', ShareTargetView.as_view(), name='share_target'), + path('files/', FileListView.as_view(), name='files'), + path('files//', FileDetailView.as_view(), name='file_detail'), + path('files//share/', CreateShareView.as_view(), name='create_share'), + path('files//delete/', DeleteFileView.as_view(), name='delete_file'), + path('shares//revoke/', RevokeShareView.as_view(), name='revoke_share'), +] diff --git a/portal/views.py b/portal/views.py new file mode 100644 index 0000000..4c96820 --- /dev/null +++ b/portal/views.py @@ -0,0 +1,293 @@ +""" +Upload portal views - clean interface for file uploads and sharing. +""" + +import json +import os +import re +from django.shortcuts import render, get_object_or_404, redirect +from django.http import JsonResponse, FileResponse, HttpResponse +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator +from django.conf import settings +from django.contrib.staticfiles import finders +from urllib.parse import urlencode + +from files.models import MediaFile, PublicShare, FileAccessLog, SharedSpace + + +class LandingPageView(View): + """Landing page - enter a topic to start sharing.""" + + def get(self, request): + active_topics = SharedSpace.objects.filter(is_active=True).order_by('-created_at')[:8] + return render(request, 'portal/landing.html', { + 'active_topics': active_topics, + }) + + +class CreateTopicView(View): + """Create or go to a topic (SharedSpace without password).""" + + def post(self, request): + topic = request.POST.get('topic', '').strip().lower() + topic = re.sub(r'[^a-z0-9-]', '', topic) + + if not topic: + return render(request, 'portal/landing.html', { + 'error': 'Please enter a topic name', + 'active_topics': SharedSpace.objects.filter(is_active=True)[:8], + }) + + if len(topic) < 2: + return render(request, 'portal/landing.html', { + 'error': 'Topic name must be at least 2 characters', + 'active_topics': SharedSpace.objects.filter(is_active=True)[:8], + }) + + if topic in ('www', 'api', 'admin', 'static', 'media', 'app'): + return render(request, 'portal/landing.html', { + 'error': 'This name is reserved', + 'active_topics': SharedSpace.objects.filter(is_active=True)[:8], + }) + + space, created = SharedSpace.objects.get_or_create( + slug=topic, + defaults={ + 'name': topic, + 'is_active': True, + } + ) + + return redirect(f'https://{topic}.rfiles.online') + + +class UploadPageView(View): + """Main upload page - now redirects to landing.""" + + def get(self, request): + return redirect('portal:landing') + + +@method_decorator(csrf_exempt, name='dispatch') +class UploadAPIView(View): + """Handle file uploads via AJAX.""" + + def post(self, request): + if not request.FILES.get('file'): + return JsonResponse({'error': 'No file provided'}, status=400) + + uploaded_file = request.FILES['file'] + title = request.POST.get('title', '') or uploaded_file.name + description = request.POST.get('description', '') + + media_file = MediaFile.objects.create( + file=uploaded_file, + original_filename=uploaded_file.name, + title=title, + description=description, + mime_type=uploaded_file.content_type or 'application/octet-stream', + uploaded_by=request.user if request.user.is_authenticated else None, + ) + + share = PublicShare.objects.create( + media_file=media_file, + created_by=request.user if request.user.is_authenticated else None, + note='Auto-created from upload portal', + ) + + return JsonResponse({ + 'success': True, + 'file': { + 'id': str(media_file.id), + 'title': media_file.title, + 'filename': media_file.original_filename, + 'size': media_file.file_size, + 'mime_type': media_file.mime_type, + }, + 'share': { + 'token': share.token, + 'url': share.get_public_url(), + } + }) + + +class FileListView(View): + """List all uploaded files.""" + + def get(self, request): + files = MediaFile.objects.all()[:50] + return render(request, 'portal/files.html', { + 'files': files, + }) + + +class FileDetailView(View): + """View details of a specific file.""" + + def get(self, request, file_id): + media_file = get_object_or_404(MediaFile, id=file_id) + shares = media_file.public_shares.all() + return render(request, 'portal/file_detail.html', { + 'file': media_file, + 'shares': shares, + }) + + +@method_decorator(csrf_exempt, name='dispatch') +class CreateShareView(View): + """Create a new share link for a file.""" + + def post(self, request, file_id): + media_file = get_object_or_404(MediaFile, id=file_id) + + try: + data = json.loads(request.body) if request.body else {} + except json.JSONDecodeError: + data = {} + + from django.utils import timezone + expires_hours = data.get('expires_in_hours') + expires_at = None + if expires_hours: + expires_at = timezone.now() + timezone.timedelta(hours=int(expires_hours)) + + share = PublicShare.objects.create( + media_file=media_file, + created_by=request.user if request.user.is_authenticated else None, + expires_at=expires_at, + max_downloads=data.get('max_downloads'), + note=data.get('note', ''), + ) + + if data.get('password'): + share.set_password(data['password']) + + return JsonResponse({ + 'success': True, + 'share': { + 'id': str(share.id), + 'token': share.token, + 'url': share.get_public_url(), + 'expires_at': share.expires_at.isoformat() if share.expires_at else None, + } + }) + + +@method_decorator(csrf_exempt, name='dispatch') +class DeleteFileView(View): + """Delete a file.""" + + def post(self, request, file_id): + media_file = get_object_or_404(MediaFile, id=file_id) + media_file.delete() + return JsonResponse({'success': True}) + + +@method_decorator(csrf_exempt, name='dispatch') +class RevokeShareView(View): + """Revoke a share link.""" + + def post(self, request, share_id): + share = get_object_or_404(PublicShare, id=share_id) + share.is_active = False + share.save() + return JsonResponse({'success': True}) + + +@method_decorator(csrf_exempt, name='dispatch') +class ShareTargetView(View): + """Handle Web Share Target API - receives shared content from Android/Chrome.""" + + def post(self, request): + title = request.POST.get('title', '') + text = request.POST.get('text', '') + url = request.POST.get('url', '') + files = request.FILES.getlist('files') + + uploaded_count = 0 + share_urls = [] + + if files: + for uploaded_file in files: + file_title = title or uploaded_file.name + description = f"{text}\n{url}".strip() if (text or url) else '' + + media_file = MediaFile.objects.create( + file=uploaded_file, + original_filename=uploaded_file.name, + title=file_title, + description=description, + mime_type=uploaded_file.content_type or 'application/octet-stream', + uploaded_by=request.user if request.user.is_authenticated else None, + ) + + share = PublicShare.objects.create( + media_file=media_file, + created_by=request.user if request.user.is_authenticated else None, + note='Shared from Android', + ) + share_urls.append(share.get_public_url()) + uploaded_count += 1 + + params = {'shared': 'files', 'count': uploaded_count} + return redirect(f"/?{urlencode(params)}") + + if url or text: + params = {'shared': 'true'} + if title: + params['title'] = title + if text: + params['text'] = text + if url: + params['url'] = url + return redirect(f"/?{urlencode(params)}") + + return redirect('/') + + def get(self, request): + return redirect('/') + + +class ServiceWorkerView(View): + """Serve the service worker from root path for proper scope.""" + + def get(self, request): + sw_path = finders.find('portal/sw.js') + if sw_path: + with open(sw_path, 'r') as f: + content = f.read() + else: + static_path = os.path.join(settings.STATIC_ROOT or '', 'portal', 'sw.js') + if os.path.exists(static_path): + with open(static_path, 'r') as f: + content = f.read() + else: + return HttpResponse('Service worker not found', status=404) + + response = HttpResponse(content, content_type='application/javascript') + response['Service-Worker-Allowed'] = '/' + response['Cache-Control'] = 'no-cache' + return response + + +class ManifestView(View): + """Serve the manifest from root path for proper PWA detection.""" + + def get(self, request): + manifest_path = finders.find('portal/manifest.json') + if manifest_path: + with open(manifest_path, 'r') as f: + content = f.read() + else: + static_path = os.path.join(settings.STATIC_ROOT or '', 'portal', 'manifest.json') + if os.path.exists(static_path): + with open(static_path, 'r') as f: + content = f.read() + else: + return HttpResponse('Manifest not found', status=404) + + response = HttpResponse(content, content_type='application/manifest+json') + response['Cache-Control'] = 'no-cache' + return response diff --git a/portal/views_shared_space.py b/portal/views_shared_space.py new file mode 100644 index 0000000..ebdba0c --- /dev/null +++ b/portal/views_shared_space.py @@ -0,0 +1,118 @@ +""" +Topic-based file sharing views. + +Each topic is accessible via a subdomain (e.g., cofi.rfiles.online). +Anyone can view files and upload new ones - no password required. +""" + +from django.shortcuts import render, redirect, get_object_or_404 +from django.http import JsonResponse, Http404 +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator + +from files.models import SharedSpace, MediaFile, PublicShare + + +def get_topic_or_404(request): + """Get the topic (shared space) from the request's subdomain slug.""" + slug = getattr(request, 'shared_space_slug', None) + if not slug: + raise Http404("Topic not found") + return get_object_or_404(SharedSpace, slug=slug, is_active=True) + + +class SharedSpaceLoginView(View): + """Redirect login to home - no login needed for topics.""" + + def get(self, request): + return redirect('shared_space_home') + + def post(self, request): + return redirect('shared_space_home') + + +class SharedSpaceLogoutView(View): + """Redirect to home - no logout needed.""" + + def get(self, request): + return redirect('shared_space_home') + + +class SharedSpaceHomeView(View): + """Main upload page for topic - upload zone + files.""" + + def get(self, request): + space = get_topic_or_404(request) + recent_files = space.files.all().order_by('-created_at')[:20] + + return render(request, 'portal/shared_space/home.html', { + 'space': space, + 'recent_files': recent_files, + }) + + +@method_decorator(csrf_exempt, name='dispatch') +class SharedSpaceUploadAPIView(View): + """Handle file uploads via AJAX for topic.""" + + def post(self, request): + space = get_topic_or_404(request) + + if not request.FILES.get('file'): + return JsonResponse({'error': 'No file provided'}, status=400) + + uploaded_file = request.FILES['file'] + + max_size_bytes = space.max_file_size_mb * 1024 * 1024 + if uploaded_file.size > max_size_bytes: + return JsonResponse({ + 'error': f'File too large. Maximum size is {space.max_file_size_mb}MB' + }, status=400) + + title = request.POST.get('title', '') or uploaded_file.name + description = request.POST.get('description', '') + + media_file = MediaFile.objects.create( + file=uploaded_file, + original_filename=uploaded_file.name, + title=title, + description=description, + mime_type=uploaded_file.content_type or 'application/octet-stream', + uploaded_by=request.user if request.user.is_authenticated else None, + shared_space=space, + ) + + share = PublicShare.objects.create( + media_file=media_file, + created_by=request.user if request.user.is_authenticated else None, + note=f'Uploaded to topic: {space.slug}', + ) + + return JsonResponse({ + 'success': True, + 'file': { + 'id': str(media_file.id), + 'title': media_file.title, + 'filename': media_file.original_filename, + 'size': media_file.file_size, + 'mime_type': media_file.mime_type, + }, + 'share': { + 'token': share.token, + 'url': share.get_public_url(), + } + }) + + +class SharedSpaceFileListView(View): + """List all files in the topic.""" + + def get(self, request): + space = get_topic_or_404(request) + files = space.files.all().order_by('-created_at') + + return render(request, 'portal/shared_space/files.html', { + 'space': space, + 'files': files, + }) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eac6a28 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +Django==5.2.8 +django-cors-headers==4.9.0 +django-simple-history==3.10.1 +djangorestframework==3.16.1 +python-dotenv==1.2.1 +celery==5.3.6 +redis==5.0.3 +dj-database-url +whitenoise==6.7.0 +psycopg2-binary +gunicorn +requests