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