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:
Jeff Emmett 2026-02-10 15:46:31 +00:00
commit cf9cc22c58
40 changed files with 4081 additions and 0 deletions

13
.env.example Normal file
View File

@ -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

35
.gitignore vendored Normal file
View File

@ -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/

28
Dockerfile Normal file
View File

@ -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"]

3
config/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)

13
config/celery.py Normal file
View File

@ -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()

54
config/middleware.py Normal file
View File

@ -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

151
config/settings.py Normal file
View File

@ -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',
]

45
config/urls.py Normal file
View File

@ -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")),
]

View File

@ -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'),
]

11
config/wsgi.py Normal file
View File

@ -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()

123
docker-compose.prod.yml Normal file
View File

@ -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

76
docker-compose.yml Normal file
View File

@ -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
files/__init__.py Normal file
View File

235
files/admin.py Normal file
View File

@ -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

7
files/apps.py Normal file
View File

@ -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'

383
files/models.py Normal file
View File

@ -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}"

235
files/serializers.py Normal file
View File

@ -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

78
files/tasks.py Normal file
View File

@ -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}"

31
files/urls.py Normal file
View File

@ -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'),
]

274
files/views.py Normal file
View File

@ -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)

22
manage.py Normal file
View File

@ -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
portal/__init__.py Normal file
View File

7
portal/apps.py Normal file
View File

@ -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

View File

@ -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"
}

181
portal/static/portal/sw.js Normal file
View File

@ -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);
}
}
}

View File

@ -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>

View File

@ -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 %}
&bull;
{{ share.download_count }} download{{ share.download_count|pluralize }}
{% if share.max_downloads %}/ {{ share.max_downloads }} max{% endif %}
{% if share.expires_at %}
&bull; Expires {{ share.expires_at|date:"M d, Y" }}
{% endif %}
{% if share.is_password_protected %}
&bull; 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>
&bull; 0 downloads
${result.share.expires_at ? '&bull; 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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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">&#9654;</span>
{% elif file.is_audio %}
<span class="file-icon">&#9835;</span>
{% elif file.is_pdf %}
<span class="file-icon">&#128196;</span>
{% else %}
<span class="file-icon">&#128462;</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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

32
portal/urls.py Normal file
View File

@ -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'),
]

293
portal/views.py Normal file
View File

@ -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

View File

@ -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,
})

12
requirements.txt Normal file
View File

@ -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