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 <noreply@anthropic.com>
This commit is contained in:
commit
cf9cc22c58
|
|
@ -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
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ('celery_app',)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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',
|
||||||
|
]
|
||||||
|
|
@ -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")),
|
||||||
|
]
|
||||||
|
|
@ -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'),
|
||||||
|
]
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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('<a href="{}" target="_blank">{}</a>', 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('<span style="color: green;">{}</span>', 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'<a href="{url}" target="_blank">{share.token[:12]}...</a>{status_str}')
|
||||||
|
|
||||||
|
return format_html('<br>'.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('<span style="color: red;">Revoked</span>')
|
||||||
|
if obj.is_expired:
|
||||||
|
return format_html('<span style="color: orange;">Expired</span>')
|
||||||
|
if obj.is_download_limit_reached:
|
||||||
|
return format_html('<span style="color: orange;">Limit Reached</span>')
|
||||||
|
return format_html('<span style="color: green;">Active</span>')
|
||||||
|
status_display.short_description = 'Status'
|
||||||
|
|
||||||
|
def public_url_display(self, obj):
|
||||||
|
url = obj.get_public_url()
|
||||||
|
return format_html('<a href="{}" target="_blank">{}</a>', 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
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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/<uuid>/<original_filename>
|
||||||
|
"""
|
||||||
|
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}"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -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('<str:token>/', PublicDownloadView.as_view(), name='public_download'),
|
||||||
|
path('<str:token>/download/', PublicDownloadView.as_view(), name='public_download_explicit'),
|
||||||
|
path('<str:token>/info/', PublicShareInfoView.as_view(), name='public_share_info'),
|
||||||
|
path('<str:token>/verify/', PublicShareVerifyPasswordView.as_view(), name='public_share_verify'),
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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'
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#3b82f6">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
{% load static %}
|
||||||
|
<link rel="apple-touch-icon" href="{% static 'portal/icon-192.png' %}">
|
||||||
|
<title>{% block title %}Upload{% endblock %} - rfiles.online</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #141414;
|
||||||
|
--surface-hover: #1a1a1a;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-muted: #888;
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-hover: #2563eb;
|
||||||
|
--success: #22c55e;
|
||||||
|
--error: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a:hover, header nav a.active {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.text-success { color: var(--success); }
|
||||||
|
.text-error { color: var(--error); }
|
||||||
|
.mt-1 { margin-top: 0.5rem; }
|
||||||
|
.mt-2 { margin-top: 1rem; }
|
||||||
|
.mb-1 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-2 { margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>rfiles.online</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="{% url 'portal:upload' %}" class="{% if request.resolver_match.url_name == 'upload' %}active{% endif %}">Upload</a>
|
||||||
|
<a href="{% url 'portal:files' %}" class="{% if request.resolver_match.url_name == 'files' %}active{% endif %}">Files</a>
|
||||||
|
<a href="/admin/" target="_blank">Admin</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
||||||
|
.then((registration) => {
|
||||||
|
console.log('SW registered:', registration.scope);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log('SW registration failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('shared') === 'files') {
|
||||||
|
const count = params.get('count') || 1;
|
||||||
|
showNotification(`${count} file(s) uploaded successfully!`, 'success');
|
||||||
|
history.replaceState({}, '', '/');
|
||||||
|
} else if (params.get('shared') === 'true') {
|
||||||
|
const url = params.get('url');
|
||||||
|
const text = params.get('text');
|
||||||
|
const title = params.get('title');
|
||||||
|
if (url || text) {
|
||||||
|
showSharePreview(title, text, url);
|
||||||
|
}
|
||||||
|
history.replaceState({}, '', '/');
|
||||||
|
} else if (params.get('queued') === 'true') {
|
||||||
|
showNotification('Saved for upload when back online', 'info');
|
||||||
|
history.replaceState({}, '', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type) {
|
||||||
|
const notif = document.createElement('div');
|
||||||
|
notif.className = 'pwa-notification ' + type;
|
||||||
|
notif.textContent = message;
|
||||||
|
notif.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: ${type === 'success' ? '#22c55e' : type === 'info' ? '#3b82f6' : '#ef4444'};
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(notif);
|
||||||
|
setTimeout(() => notif.remove(), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSharePreview(title, text, url) {
|
||||||
|
const content = [title, text, url].filter(Boolean).join('\n\n');
|
||||||
|
if (content) {
|
||||||
|
showNotification('Shared content received! Create a note or save the URL.', 'info');
|
||||||
|
sessionStorage.setItem('sharedContent', JSON.stringify({ title, text, url }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
{% extends "portal/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ file.title|default:file.original_filename }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.file-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 2rem; }
|
||||||
|
.file-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
|
.file-header .meta { color: var(--text-muted); font-size: 0.875rem; }
|
||||||
|
.file-header .meta span { margin-right: 1rem; }
|
||||||
|
.section { margin-bottom: 2rem; }
|
||||||
|
.section h2 { font-size: 1rem; margin-bottom: 1rem; color: var(--text-muted); }
|
||||||
|
.share-list { display: grid; gap: 0.75rem; }
|
||||||
|
.share-item { display: flex; justify-content: space-between; align-items: center; padding: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
|
||||||
|
.share-item.inactive { opacity: 0.5; }
|
||||||
|
.share-info { flex: 1; }
|
||||||
|
.share-url { font-family: monospace; font-size: 0.875rem; color: var(--primary); word-break: break-all; }
|
||||||
|
.share-meta { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem; }
|
||||||
|
.share-actions { display: flex; gap: 0.5rem; }
|
||||||
|
.new-share-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; padding: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 1rem; }
|
||||||
|
.form-group label { display: block; font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.25rem; }
|
||||||
|
.form-group input, .form-group select { width: 100%; padding: 0.5rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 0.875rem; }
|
||||||
|
.form-group input:focus, .form-group select:focus { outline: none; border-color: var(--primary); }
|
||||||
|
.danger-zone { border-color: var(--error); }
|
||||||
|
.danger-zone h2 { color: var(--error); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="file-header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ file.title|default:file.original_filename }}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span>{{ file.file_size|filesizeformat }}</span>
|
||||||
|
<span>{{ file.mime_type }}</span>
|
||||||
|
<span>Uploaded {{ file.created_at|date:"M d, Y H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
{% if file.description %}
|
||||||
|
<p class="mt-1">{{ file.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ file.file.url }}" class="btn btn-primary" download>Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Create New Share Link</h2>
|
||||||
|
<form class="new-share-form" id="newShareForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Expires In</label>
|
||||||
|
<select name="expires_in_hours" id="expiresIn">
|
||||||
|
<option value="">Never</option>
|
||||||
|
<option value="1">1 hour</option>
|
||||||
|
<option value="24">1 day</option>
|
||||||
|
<option value="168" selected>1 week</option>
|
||||||
|
<option value="720">30 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Max Downloads</label>
|
||||||
|
<input type="number" name="max_downloads" id="maxDownloads" placeholder="Unlimited" min="1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password (optional)</label>
|
||||||
|
<input type="password" name="password" id="password" placeholder="Leave empty for no password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display: flex; align-items: flex-end;">
|
||||||
|
<button type="submit" class="btn btn-primary">Create Share Link</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Active Share Links ({{ shares.count }})</h2>
|
||||||
|
<div class="share-list" id="shareList">
|
||||||
|
{% for share in shares %}
|
||||||
|
<div class="share-item {% if not share.is_valid %}inactive{% endif %}" data-share-id="{{ share.id }}">
|
||||||
|
<div class="share-info">
|
||||||
|
<div class="share-url">{{ share.get_public_url }}</div>
|
||||||
|
<div class="share-meta">
|
||||||
|
{% if share.is_valid %}
|
||||||
|
<span class="text-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-error">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
•
|
||||||
|
{{ share.download_count }} download{{ share.download_count|pluralize }}
|
||||||
|
{% if share.max_downloads %}/ {{ share.max_downloads }} max{% endif %}
|
||||||
|
{% if share.expires_at %}
|
||||||
|
• Expires {{ share.expires_at|date:"M d, Y" }}
|
||||||
|
{% endif %}
|
||||||
|
{% if share.is_password_protected %}
|
||||||
|
• Password protected
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="share-actions">
|
||||||
|
<button class="btn btn-ghost copy-btn" data-url="{{ share.get_public_url }}">Copy</button>
|
||||||
|
{% if share.is_valid %}
|
||||||
|
<button class="btn btn-ghost revoke-btn" data-share-id="{{ share.id }}">Revoke</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted">No share links yet. Create one above.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section card danger-zone">
|
||||||
|
<h2>Danger Zone</h2>
|
||||||
|
<p class="text-muted mb-1">Deleting this file will also remove all share links.</p>
|
||||||
|
<button class="btn btn-danger" id="deleteBtn">Delete File</button>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.getElementById('newShareForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = {
|
||||||
|
expires_in_hours: document.getElementById('expiresIn').value || null,
|
||||||
|
max_downloads: document.getElementById('maxDownloads').value || null,
|
||||||
|
password: document.getElementById('password').value || null,
|
||||||
|
};
|
||||||
|
const response = await fetch('{% url "portal:create_share" file.id %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
const shareList = document.getElementById('shareList');
|
||||||
|
const newItem = document.createElement('div');
|
||||||
|
newItem.className = 'share-item';
|
||||||
|
newItem.innerHTML = `
|
||||||
|
<div class="share-info">
|
||||||
|
<div class="share-url">${result.share.url}</div>
|
||||||
|
<div class="share-meta">
|
||||||
|
<span class="text-success">Active</span>
|
||||||
|
• 0 downloads
|
||||||
|
${result.share.expires_at ? '• Expires ' + new Date(result.share.expires_at).toLocaleDateString() : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="share-actions">
|
||||||
|
<button class="btn btn-ghost copy-btn" data-url="${result.share.url}">Copy</button>
|
||||||
|
<button class="btn btn-ghost revoke-btn" data-share-id="${result.share.id}">Revoke</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
shareList.insertBefore(newItem, shareList.firstChild);
|
||||||
|
navigator.clipboard.writeText(result.share.url);
|
||||||
|
alert('Share link created and copied to clipboard!');
|
||||||
|
e.target.reset();
|
||||||
|
} else {
|
||||||
|
alert('Failed to create share link');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('copy-btn')) {
|
||||||
|
const url = e.target.dataset.url;
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
const originalText = e.target.textContent;
|
||||||
|
e.target.textContent = 'Copied!';
|
||||||
|
setTimeout(() => e.target.textContent = originalText, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
if (e.target.classList.contains('revoke-btn')) {
|
||||||
|
if (!confirm('Are you sure you want to revoke this share link?')) return;
|
||||||
|
const shareId = e.target.dataset.shareId;
|
||||||
|
const response = await fetch(`/shares/${shareId}/revoke/`, { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
const item = e.target.closest('.share-item');
|
||||||
|
item.classList.add('inactive');
|
||||||
|
item.querySelector('.text-success')?.classList.replace('text-success', 'text-error');
|
||||||
|
item.querySelector('.text-success, .text-error').textContent = 'Inactive';
|
||||||
|
e.target.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('deleteBtn').addEventListener('click', async () => {
|
||||||
|
if (!confirm('Are you sure you want to delete this file? This cannot be undone.')) return;
|
||||||
|
const response = await fetch('{% url "portal:delete_file" file.id %}', { method: 'POST' });
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = '{% url "portal:files" %}';
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete file');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
{% extends "portal/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}All Files{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.files-grid { display: grid; gap: 1rem; }
|
||||||
|
.file-card { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.25rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; transition: all 0.2s; }
|
||||||
|
.file-card:hover { border-color: var(--primary); }
|
||||||
|
.file-info { flex: 1; min-width: 0; }
|
||||||
|
.file-info h3 { font-size: 1rem; margin-bottom: 0.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.file-info h3 a { color: var(--text); text-decoration: none; }
|
||||||
|
.file-info h3 a:hover { color: var(--primary); }
|
||||||
|
.file-meta { display: flex; gap: 1rem; font-size: 0.75rem; color: var(--text-muted); }
|
||||||
|
.file-actions { display: flex; gap: 0.5rem; }
|
||||||
|
.badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; background: var(--border); color: var(--text-muted); }
|
||||||
|
.badge-success { background: rgba(34, 197, 94, 0.2); color: var(--success); }
|
||||||
|
.empty-state { text-align: center; padding: 4rem 2rem; color: var(--text-muted); }
|
||||||
|
.empty-state h2 { margin-bottom: 0.5rem; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-2" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h2>All Files</h2>
|
||||||
|
<a href="{% url 'portal:upload' %}" class="btn btn-primary">Upload New</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if files %}
|
||||||
|
<div class="files-grid">
|
||||||
|
{% for file in files %}
|
||||||
|
<div class="file-card">
|
||||||
|
<div class="file-info">
|
||||||
|
<h3><a href="{% url 'portal:file_detail' file.id %}">{{ file.title|default:file.original_filename }}</a></h3>
|
||||||
|
<div class="file-meta">
|
||||||
|
<span>{{ file.file_size|filesizeformat }}</span>
|
||||||
|
<span>{{ file.mime_type }}</span>
|
||||||
|
<span>{{ file.created_at|date:"M d, Y" }}</span>
|
||||||
|
{% if file.get_public_shares %}
|
||||||
|
<span class="badge badge-success">{{ file.get_public_shares.count }} share{{ file.get_public_shares.count|pluralize }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-actions">
|
||||||
|
<a href="{% url 'portal:file_detail' file.id %}" class="btn btn-ghost">View</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state card">
|
||||||
|
<h2>No files yet</h2>
|
||||||
|
<p>Upload your first file to get started</p>
|
||||||
|
<a href="{% url 'portal:upload' %}" class="btn btn-primary mt-2">Upload File</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
{% extends "portal/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}rfiles.online - Share Files by Topic{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.landing-container { max-width: 500px; margin: 0 auto; padding: 2rem; min-height: 80vh; display: flex; flex-direction: column; justify-content: center; }
|
||||||
|
.hero { text-align: center; margin-bottom: 2.5rem; }
|
||||||
|
.hero h1 { font-size: 2.5rem; margin-bottom: 0.5rem; background: linear-gradient(135deg, var(--primary), #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||||
|
.hero .tagline { color: var(--text-muted); font-size: 1.1rem; }
|
||||||
|
.create-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 2rem; }
|
||||||
|
.create-card h2 { font-size: 1.1rem; margin-bottom: 1.5rem; text-align: center; color: var(--text-muted); }
|
||||||
|
.form-group { margin-bottom: 1.25rem; }
|
||||||
|
.form-group label { display: block; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.25rem; }
|
||||||
|
.form-group input { width: 100%; padding: 0.875rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 1.1rem; transition: border-color 0.2s; }
|
||||||
|
.form-group input:focus { outline: none; border-color: var(--primary); }
|
||||||
|
.form-group input::placeholder { color: var(--text-muted); }
|
||||||
|
.form-group .slug-preview { font-size: 0.85rem; color: var(--primary); margin-top: 0.5rem; font-family: monospace; text-align: center; }
|
||||||
|
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.875rem 1.5rem; border-radius: 8px; font-size: 1.1rem; font-weight: 500; cursor: pointer; transition: all 0.2s; border: none; width: 100%; }
|
||||||
|
.btn-primary { background: var(--primary); color: white; }
|
||||||
|
.btn-primary:hover { background: var(--primary-hover); }
|
||||||
|
.error-message { background: rgba(239, 68, 68, 0.1); border: 1px solid var(--error); color: var(--error); padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; text-align: center; }
|
||||||
|
.active-topics { margin-top: 2.5rem; text-align: center; }
|
||||||
|
.active-topics h3 { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 1rem; }
|
||||||
|
.topic-list { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }
|
||||||
|
.topic-chip { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.4rem 0.75rem; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; color: var(--text); text-decoration: none; font-size: 0.85rem; transition: all 0.2s; }
|
||||||
|
.topic-chip:hover { border-color: var(--primary); background: var(--surface-hover); }
|
||||||
|
.topic-chip .count { font-size: 0.7rem; color: var(--text-muted); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="landing-container">
|
||||||
|
<div class="hero">
|
||||||
|
<h1>rfiles.online</h1>
|
||||||
|
<p class="tagline">Share files by topic</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="create-card">
|
||||||
|
<h2>Enter a topic to start sharing</h2>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error-message">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'portal:create_topic' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="topic" name="topic" placeholder="e.g., birthday-photos" pattern="[a-z0-9-]+" required autofocus>
|
||||||
|
<div class="slug-preview" id="slugPreview"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Go</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if active_topics %}
|
||||||
|
<div class="active-topics">
|
||||||
|
<h3>Active topics</h3>
|
||||||
|
<div class="topic-list">
|
||||||
|
{% for topic in active_topics %}
|
||||||
|
<a href="https://{{ topic.slug }}.rfiles.online" class="topic-chip">
|
||||||
|
{{ topic.slug }}
|
||||||
|
<span class="count">({{ topic.files.count }})</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const topicInput = document.getElementById('topic');
|
||||||
|
const slugPreview = document.getElementById('slugPreview');
|
||||||
|
|
||||||
|
function updateSlugPreview() {
|
||||||
|
const slug = topicInput.value.toLowerCase().replace(/[^a-z0-9-]/g, '').replace(/\s+/g, '-');
|
||||||
|
if (slug) {
|
||||||
|
slugPreview.textContent = slug + '.rfiles.online';
|
||||||
|
} else {
|
||||||
|
slugPreview.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topicInput.addEventListener('input', (e) => {
|
||||||
|
e.target.value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||||
|
updateSlugPreview();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#8b5cf6">
|
||||||
|
<title>{% block title %}{{ space.slug }}{% endblock %} - rfiles.online</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #141414;
|
||||||
|
--surface-hover: #1a1a1a;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-muted: #888;
|
||||||
|
--primary: #8b5cf6;
|
||||||
|
--primary-hover: #7c3aed;
|
||||||
|
--success: #22c55e;
|
||||||
|
--error: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .topic-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav a:hover, header nav a.active {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-1 { margin-top: 0.5rem; }
|
||||||
|
.mt-2 { margin-top: 1rem; }
|
||||||
|
.mb-1 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-2 { margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1><a href="https://rfiles.online">rfiles</a>/<span style="color: var(--text)">{{ space.slug }}</span></h1>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<a href="{% url 'shared_space_home' %}" class="{% if request.resolver_match.url_name == 'shared_space_home' %}active{% endif %}">Upload</a>
|
||||||
|
<a href="{% url 'shared_space_files' %}" class="{% if request.resolver_match.url_name == 'shared_space_files' %}active{% endif %}">All Files</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
{% extends "portal/shared_space/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}All Files - {{ space.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card .filename {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card .meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card .actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card .btn {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview .file-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge.image { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
|
||||||
|
.type-badge.video { background: rgba(168, 85, 247, 0.2); color: #c084fc; }
|
||||||
|
.type-badge.audio { background: rgba(34, 197, 94, 0.2); color: #4ade80; }
|
||||||
|
.type-badge.pdf { background: rgba(239, 68, 68, 0.2); color: #f87171; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value">{{ files|length }}</div>
|
||||||
|
<div class="stat-label">Total Files</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value">{{ space.total_size_bytes|filesizeformat }}</div>
|
||||||
|
<div class="stat-label">Total Size</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if files %}
|
||||||
|
<div class="file-grid">
|
||||||
|
{% for file in files %}
|
||||||
|
<div class="file-card">
|
||||||
|
<div class="file-preview">
|
||||||
|
{% if file.is_image %}
|
||||||
|
<img src="{{ file.file.url }}" alt="{{ file.title }}" loading="lazy">
|
||||||
|
{% elif file.is_video %}
|
||||||
|
<span class="file-icon">▶</span>
|
||||||
|
{% elif file.is_audio %}
|
||||||
|
<span class="file-icon">♫</span>
|
||||||
|
{% elif file.is_pdf %}
|
||||||
|
<span class="file-icon">📄</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="file-icon">🗎</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="filename">{{ file.title|default:file.original_filename|truncatechars:40 }}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span>{{ file.file_size|filesizeformat }}</span>
|
||||||
|
<span class="type-badge {% if file.is_image %}image{% elif file.is_video %}video{% elif file.is_audio %}audio{% elif file.is_pdf %}pdf{% endif %}">
|
||||||
|
{{ file.extension|default:"file" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span>{{ file.created_at|date:"M d, Y" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="{{ file.file.url }}" class="btn btn-ghost" target="_blank">View</a>
|
||||||
|
<a href="{{ file.file.url }}" class="btn btn-primary" download="{{ file.original_filename }}">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h2>No files yet</h2>
|
||||||
|
<p>This space is empty. <a href="{% url 'shared_space_home' %}">Upload some files</a> to get started.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
{% extends "portal/shared_space/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Upload - {{ space.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone:hover, .upload-zone.dragover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(139, 92, 246, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone.uploading {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone input[type="file"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.success {
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.error {
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info .meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
width: 300px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar .progress {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-section h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-files {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-file {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-file:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-file .name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-file .size {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.share-link input {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 style="text-align: center; margin-bottom: 1.5rem; font-weight: 500;">Upload files to <span style="color: var(--primary);">{{ space.slug }}</span></h2>
|
||||||
|
|
||||||
|
<div class="upload-zone" id="uploadZone">
|
||||||
|
<div class="upload-icon">+</div>
|
||||||
|
<p>Drop files here or click to select</p>
|
||||||
|
<p class="mt-1 text-muted">Max file size: {{ space.max_file_size_mb }}MB</p>
|
||||||
|
<input type="file" id="fileInput" multiple>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results" id="results"></div>
|
||||||
|
|
||||||
|
{% if recent_files %}
|
||||||
|
<div class="recent-section mt-2">
|
||||||
|
<h2>Recent Uploads</h2>
|
||||||
|
<div class="recent-files">
|
||||||
|
{% for file in recent_files %}
|
||||||
|
<div class="recent-file">
|
||||||
|
<span class="name">{{ file.title|default:file.original_filename }}</span>
|
||||||
|
<span class="size">{{ file.file_size|filesizeformat }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const uploadZone = document.getElementById('uploadZone');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const results = document.getElementById('results');
|
||||||
|
const maxSize = {{ space.max_file_size_mb }} * 1024 * 1024;
|
||||||
|
|
||||||
|
// Click to select
|
||||||
|
uploadZone.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
uploadZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadZone.addEventListener('dragleave', () => {
|
||||||
|
uploadZone.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.remove('dragover');
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
// File input change
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
handleFiles(fileInput.files);
|
||||||
|
fileInput.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFiles(files) {
|
||||||
|
Array.from(files).forEach(uploadFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(file) {
|
||||||
|
const itemId = 'upload-' + Date.now() + Math.random().toString(36).substr(2, 9);
|
||||||
|
|
||||||
|
// Check file size before upload
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'result-item error';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="result-info">
|
||||||
|
<h3>${file.name}</h3>
|
||||||
|
<div class="meta">File too large (${formatBytes(file.size)}). Max: {{ space.max_file_size_mb }}MB</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
results.insertBefore(item, results.firstChild);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create result item
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'result-item';
|
||||||
|
item.id = itemId;
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="result-info">
|
||||||
|
<h3>${file.name}</h3>
|
||||||
|
<div class="meta">${formatBytes(file.size)} - Uploading...</div>
|
||||||
|
<div class="progress-bar"><div class="progress" style="width: 0%"></div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
results.insertBefore(item, results.firstChild);
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const percent = (e.loaded / e.total) * 100;
|
||||||
|
item.querySelector('.progress').style.width = percent + '%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
item.classList.add('success');
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="result-info">
|
||||||
|
<h3>${data.file.title}</h3>
|
||||||
|
<div class="meta">${formatBytes(data.file.size)} - Uploaded successfully</div>
|
||||||
|
</div>
|
||||||
|
<div class="share-link">
|
||||||
|
<input type="text" value="${data.share.url}" readonly onclick="this.select()">
|
||||||
|
<button class="copy-btn" onclick="copyLink(this, '${data.share.url}')">Copy</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
let errorMsg = 'Upload failed';
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
errorMsg = data.error || errorMsg;
|
||||||
|
} catch(e) {}
|
||||||
|
item.classList.add('error');
|
||||||
|
item.querySelector('.meta').textContent = errorMsg;
|
||||||
|
item.querySelector('.progress-bar').remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
item.classList.add('error');
|
||||||
|
item.querySelector('.meta').textContent = 'Upload failed - network error';
|
||||||
|
item.querySelector('.progress-bar').remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', '{% url "shared_space_upload" %}');
|
||||||
|
xhr.send(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLink(btn, url) {
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => btn.textContent = originalText, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#8b5cf6">
|
||||||
|
<title>Login - {{ space.name }} - rfiles.online</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--surface: #141414;
|
||||||
|
--surface-hover: #1a1a1a;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--text: #e0e0e0;
|
||||||
|
--text-muted: #888;
|
||||||
|
--primary: #8b5cf6;
|
||||||
|
--primary-hover: #7c3aed;
|
||||||
|
--error: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .space-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid var(--error);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>{{ space.name }}</h1>
|
||||||
|
<span class="space-badge">ZOME</span>
|
||||||
|
|
||||||
|
{% if space.description %}
|
||||||
|
<p>{{ space.description }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Enter the password to access this ZOME.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error-message">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Enter ZOME password" required autofocus>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Enter ZOME</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="footer-text">
|
||||||
|
Want your own ZOME? Visit <a href="https://rfiles.online">rfiles.online</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
{% extends "portal/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Upload Files{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.upload-zone { border: 2px dashed var(--border); border-radius: 12px; padding: 4rem 2rem; text-align: center; transition: all 0.3s; cursor: pointer; margin-bottom: 2rem; }
|
||||||
|
.upload-zone:hover, .upload-zone.dragover { border-color: var(--primary); background: rgba(59, 130, 246, 0.05); }
|
||||||
|
.upload-zone.uploading { pointer-events: none; opacity: 0.7; }
|
||||||
|
.upload-icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||||
|
.upload-zone h2 { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
||||||
|
.upload-zone p { color: var(--text-muted); font-size: 0.875rem; }
|
||||||
|
.upload-zone input[type="file"] { display: none; }
|
||||||
|
.results { display: grid; gap: 1rem; }
|
||||||
|
.result-item { display: flex; justify-content: space-between; align-items: center; padding: 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
|
||||||
|
.result-item.success { border-color: var(--success); }
|
||||||
|
.result-item.error { border-color: var(--error); }
|
||||||
|
.result-info { flex: 1; }
|
||||||
|
.result-info h3 { font-size: 1rem; margin-bottom: 0.25rem; }
|
||||||
|
.result-info .meta { font-size: 0.75rem; color: var(--text-muted); }
|
||||||
|
.share-link { display: flex; align-items: center; gap: 0.5rem; background: var(--bg); padding: 0.5rem 1rem; border-radius: 6px; font-family: monospace; font-size: 0.875rem; }
|
||||||
|
.share-link input { background: transparent; border: none; color: var(--text); width: 300px; outline: none; }
|
||||||
|
.copy-btn { background: var(--primary); color: white; border: none; padding: 0.25rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }
|
||||||
|
.copy-btn:hover { background: var(--primary-hover); }
|
||||||
|
.progress-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: 0.5rem; }
|
||||||
|
.progress-bar .progress { height: 100%; background: var(--primary); transition: width 0.3s; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="upload-zone" id="uploadZone">
|
||||||
|
<div class="upload-icon">+</div>
|
||||||
|
<h2>Drop files here to upload</h2>
|
||||||
|
<p>or click to select files</p>
|
||||||
|
<p class="mt-1 text-muted">Max file size: 100MB</p>
|
||||||
|
<input type="file" id="fileInput" multiple>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results" id="results"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
const uploadZone = document.getElementById('uploadZone');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const results = document.getElementById('results');
|
||||||
|
|
||||||
|
uploadZone.addEventListener('click', () => fileInput.click());
|
||||||
|
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
||||||
|
uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('dragover'); });
|
||||||
|
uploadZone.addEventListener('drop', (e) => { e.preventDefault(); uploadZone.classList.remove('dragover'); handleFiles(e.dataTransfer.files); });
|
||||||
|
fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
|
||||||
|
|
||||||
|
function handleFiles(files) { Array.from(files).forEach(uploadFile); }
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFile(file) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'result-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="result-info">
|
||||||
|
<h3>${file.name}</h3>
|
||||||
|
<div class="meta">${formatBytes(file.size)} - Uploading...</div>
|
||||||
|
<div class="progress-bar"><div class="progress" style="width: 0%"></div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
results.insertBefore(item, results.firstChild);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
item.querySelector('.progress').style.width = (e.loaded / e.total * 100) + '%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
item.classList.add('success');
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="result-info">
|
||||||
|
<h3>${data.file.title}</h3>
|
||||||
|
<div class="meta">${formatBytes(data.file.size)} - Uploaded successfully</div>
|
||||||
|
</div>
|
||||||
|
<div class="share-link">
|
||||||
|
<input type="text" value="${data.share.url}" readonly onclick="this.select()">
|
||||||
|
<button class="copy-btn" onclick="copyLink(this, '${data.share.url}')">Copy</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
item.classList.add('error');
|
||||||
|
item.querySelector('.meta').textContent = 'Upload failed';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
item.classList.add('error');
|
||||||
|
item.querySelector('.meta').textContent = 'Upload failed';
|
||||||
|
});
|
||||||
|
xhr.open('POST', '{% url "portal:api_upload" %}');
|
||||||
|
xhr.send(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLink(btn, url) {
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => btn.textContent = originalText, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -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/<uuid:file_id>/', FileDetailView.as_view(), name='file_detail'),
|
||||||
|
path('files/<uuid:file_id>/share/', CreateShareView.as_view(), name='create_share'),
|
||||||
|
path('files/<uuid:file_id>/delete/', DeleteFileView.as_view(), name='delete_file'),
|
||||||
|
path('shares/<uuid:share_id>/revoke/', RevokeShareView.as_view(), name='revoke_share'),
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue