Compare commits

..
49 Commits
Author SHA1 Message Date
retoor 619e320780 feat: implement tiered subscription plans with usage-based pricing and dynamic plan management
Add multi-tier subscription system (Starter, Professional, Enterprise) with per-tier storage and bandwidth pricing, replace static free-tier limits with nullable usage-based fields in SubscriptionPlan model, introduce subscribe/unsubscribe API endpoints with plan validation, update invoice generator to calculate costs based on user's active plan tier, and refactor pricing page to load plans dynamically from backend API instead of hardcoded HTML.
2026-01-22 15:40:16 +00:00
retoor a34668941f chore: defer deletion of expired WebDAV locks until after full lock loading loop 2026-01-06 12:58:50 +00:00
retoor 1848660317 chore: update default port from 8000 to 9004 and add mobile-more-btn to file-list 2026-01-06 10:08:59 +00:00
retoor b8d88f5807 feat: add admin panel with session auth, user management, and billing dashboard
Implement a full-featured admin backend at `/manage/` with login authentication using
signed cookies and constant-time credential verification. The panel includes a dashboard
showing total/active users, storage usage, monthly revenue, and pending invoices, plus
user management, payment overview, and pricing configuration pages. New environment
variables `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `ADMIN_SESSION_SECRET`, and
`ADMIN_SESSION_EXPIRE_HOURS` control access. The admin router is registered in main.py
and all new templates, auth module, and router files are added.
2026-01-05 20:50:41 +00:00
retoor a8e13cb3c5 fix: prevent sidebar overlay interaction when hidden and tighten mobile header/footer spacing 2026-01-03 21:37:12 +00:00
retoor 8dad8a60b6 feat: add mobile-responsive layout with gesture support and new mobile.css stylesheet
Add comprehensive mobile-first responsive styles across billing, code-editor, file-upload, and main style sheets. Introduce a dedicated mobile.css file (702 lines) with hamburger navigation, sidebar overlay, pull-to-refresh indicator, and touch-friendly sizing. Integrate GestureHandler and PullToRefreshIndicator classes into file-list, file-preview, and file-upload-view components for swipe-to-close and pull-to-refresh interactions. Remove legacy inline media queries from style.css and link mobile.css in index.html.
2026-01-03 21:15:15 +00:00
retoor 5bdabe63a1 refactor: replace OrderedDict with plain dict and remove async from DAL cache methods and invalidation helpers 2025-11-29 11:40:49 +00:00
retoor 137a8e1232 feat: extend char field lengths across billing and user models to support longer identifiers and descriptions 2025-11-29 11:34:08 +00:00
retoor 049e477ee9 feat: add refresh_user_cache method and integrate cache refresh in auth flows
Add a new `refresh_user_cache` method to `DataAccessLayer` that re-caches user objects by both ID and username with the configured TTL. Update `basic_auth` and `webdav_auth` handlers to call this method after successful authentication, ensuring the enterprise cache stays synchronized with user lookups from the fallback ORM path.
2025-11-29 11:27:08 +00:00
retoor 2d26306352 feat: add enterprise write buffer and data access layer with batched activity and usage record flushing 2025-11-29 11:18:53 +00:00
retoor 525784aa6f chore: add token revocation, caching, concurrency, and WebDAV lock modules 2025-11-29 10:17:47 +00:00
retoor acf70b7019 feat: add responsive hamburger navigation menu with slide-in sidebar for mobile views
Implement a mobile-friendly navigation system that replaces the static nav menu with a hamburger toggle button on screens ≤768px. The menu slides in from the left as a fixed overlay panel with smooth CSS transitions, and automatically closes when any nav link is clicked. Includes animated hamburger icon transformation (rotating top/bottom bars, hiding middle bar) and adjusted hero section padding for better mobile layout.
2025-11-16 01:03:16 +00:00
retoor a90a992172 chore: capitalize brand name and update copyright year in base.html template 2025-11-16 00:58:38 +00:00
retoor 46e1b8c9eb chore: fix default template directory paths and add missing legal/feature templates
- Update `generate_legal_documents` default `template_dir` from `"templates/legal"` to `"mywebdav/templates/legal"`
- Update `Jinja2Templates` directory from `"templates"` to `"mywebdav/templates"`
- Add new `base.html` template with navigation, footer, and SEO meta tags
- Add new `features.html` template with responsive grid layout and feature cards
- Add new legal document templates: `compliance_statement.html`, `contact_complaint_mechanism.html`, `cookie_policy.html`, `data_portability_deletion_policy.html`
2025-11-16 00:54:58 +00:00
retoor 0564809132 feat: migrate legal documents to Jinja2 templates and add splash/features/pricing/support pages 2025-11-16 00:48:39 +00:00
retoor 6da46eaffb fix: return proper WebDAV auth challenge headers and text error bodies instead of JSON 2025-11-14 00:59:01 +00:00
retoor a2b5692811 feat: add JWT cookie fallback auth and comprehensive WebDAV test suite
Implement JWT token decoding from cookies as a secondary authentication method in webdav_auth, falling back to Basic Auth then session cookie. Add full test suite (735 lines) covering OPTIONS, PROPFIND, and authorization scenarios for WebDAV endpoints.
2025-11-14 00:54:47 +00:00
retoor 6d47bbfa5e chore: replace end_of_day calculation with timedelta-based approach in usage_tracker.py 2025-11-14 00:04:00 +00:00
retoor d6d66cd5fc chore: rebrand README, index.html, and manifest from self-hosted to SaaS terminology 2025-11-13 22:53:05 +00:00
retoor 209514bc20 chore: expand README and index.html with SEO metadata and feature descriptions 2025-11-13 22:28:14 +00:00
retoor 69f3161eec chore: reformat code style with consistent quotes, trailing commas, and spacing across auth, billing, and models 2025-11-13 22:22:05 +00:00
retoor aac0798305 chore: replace deprecated datetime.utcnow with timezone-aware datetime.now and migrate pydantic v1 Config to model_config 2025-11-13 22:05:26 +00:00
retoor 077b11c5aa refactor: replace module-scoped Tortoise fixtures with session-level conftest initialization and pytest_asyncio fixtures
Migrate billing test suite from per-module Tortoise initializer/finalizer fixtures to a shared session-scoped setup in tests/conftest.py, converting all @pytest.fixture async helpers to @pytest_asyncio.fixture and removing the deleted e2e Playwright test directory.
2025-11-13 21:53:40 +00:00
retoor b5c3a0f5d1 feat: add legal documents module with compliance, contact, and cookie policies
Add a comprehensive LegalDocument base class in mywebdav/legal.py with abstract methods for title and content generation, along with full markdown and HTML implementations for compliance statement, contact/complaint mechanism, and cookie policy pages, replacing placeholder content with detailed regulatory and procedural information.
2025-11-13 20:28:53 +00:00
retoor e8d2456701 feat: replace markdown legal links with html pages and add full html templates for all eight policies
Replace all eight footer legal links from .md to .html extensions in mywebdav-app.js and create corresponding HTML documents for privacy policy, data processing agreement, terms of service, cookie policy, security policy, compliance statement, data portability & deletion policy, and contact & complaint mechanism, each with placeholder content and back navigation.
2025-11-13 19:56:41 +00:00
retoor 91f3b5c688 chore: rebrand all RBox references to MyWebdav across codebase and docs
Replace every occurrence of "RBox" with "MyWebdav" in source files, configuration,
legal documents, UI components, and test fixtures. This includes renaming the
`RBoxApp` class to `MyWebdavApp` in the JavaScript component, updating the
TOTP issuer from "RBox" to "MyWebdav" in the auth router, changing the
application description in the argument parser, and updating all user-facing
strings in HTML templates, legal policies, and the manifest file. The
`.env.example` default for `TOTP_ISSUER` is also updated accordingly.
2025-11-13 19:50:03 +00:00
retoor 9a7a640400 chore: rename all project references from rbox to mywebdav across configs, code, and docs 2025-11-13 19:42:43 +00:00
retoor b4a96fa82f feat: wrap login view in centered container with legal footer and cookie consent
Add .login-container CSS class with flex centering and gradient background, move gradient from .auth-container to new class, and update rbox-app.js showLogin() to render login-view inside a div.login-container alongside a footer with legal links and a cookie-consent element.
2025-11-13 18:33:17 +00:00
retoor 1f3ffc221f chore: remove billing references and commercial wording from README.md
- Replace "commercial" with "self-hosted" in project description
- Remove Stripe billing integration from admin features list
- Remove Stripe payment credentials from environment variables section
2025-11-13 11:32:37 +00:00
retoor ba0d3350be feat: add legal compliance documents, cookie policy, privacy policy, terms of service, and CodeMirror editor library
Add placeholder legal documents for compliance statement, contact/complaint mechanism, data portability/deletion policy, data processing agreement, security policy, and full implementations for cookie policy, privacy policy, and terms of service. Include CodeMirror 6 editor bundle with basic extensions (line numbers, syntax highlighting, autocompletion, bracket matching, undo history, search, linting) and minified CSS for legacy editor styling.
2025-11-13 11:14:22 +00:00
retoor d91246967c chore: rebrand all user-facing strings from RBox to MyWebdav across docs, emails, and config
Replace every occurrence of "RBox" with "MyWebdav" in README.md, invoice templates, welcome emails, share notifications, FastAPI metadata, default database filename, domain name, S3 bucket name, TOTP issuer, and WebDAV realm header to reflect the commercial rebranding of the application.
2025-11-13 11:05:05 +00:00
retoor df964a7fb1 feat: add user data export, account deletion, cookie consent, footer, and admin e2e tests 2025-11-13 10:47:50 +00:00
retoor bff5742ced fix: guard initial file load in search mode and discard stale search results
Add a check in FileList.connectedCallback to skip loading all files when
data-search-mode attribute is present, preventing empty result sets from
overwriting search results on partial matches. Introduce a monotonically
increasing search ID counter in RBoxApp to discard responses from outdated
search requests, ensuring only the latest query result is rendered and
stale error messages are suppressed.
2025-11-12 04:05:48 +00:00
retoor 987dd33a8c refactor: replace hardcoded CSS values with CSS custom properties and add shared-items container styles 2025-11-12 03:48:43 +00:00
retoor 43acded96e feat: add aiosmtplib and stripe dependencies, restructure code editor overlay layout and add empty state icon 2025-11-11 16:57:45 +00:00
retoor decea66307 fix: handle SMTP_USE_TLS flag and suppress redundant STARTTLS errors in EmailService 2025-11-11 14:30:31 +00:00
retoor cd7014df8b chore: rename SMTP_SERVER to SMTP_HOST and SMTP_FROM_EMAIL to SMTP_SENDER_EMAIL in settings and mail service 2025-11-11 14:20:14 +00:00
retoor 299fb30387 feat: add email invitation support for share links with STARTTLS fallback for SMTP 2025-11-11 14:06:02 +00:00
retoor 82a57f9835 feat: add async email service with SMTP queue and invoice/welcome email triggers
Implement a new `rbox/mail.py` module providing an async email service using `aiosmtplib`, including a background worker queue for non-blocking email dispatch. Integrate the service into the application lifespan (start/stop) and add email sending in three places: welcome email on user registration in `routers/auth.py`, invoice email with line-item details in `billing/invoice_generator.py`, and a test-email admin endpoint in `routers/admin.py`. Add `aiosmtplib==5.0.0` to `requirements.txt`.
2025-11-11 11:47:26 +00:00
retoor eb75c1114f chore: add png and sqlite patterns to gitignore and introduce e2e billing test suite 2025-11-11 00:06:10 +00:00
retoor 76ccf48628 feat: add database indexes, stripe api key lazy init, and usage query date range fix
Add composite index on usage_records for user_id, record_type, and timestamp columns; add individual indexes on record_type, timestamp, period_start, and status fields in billing models. Implement lazy Stripe API key initialization via _ensure_api_key method called before each Stripe operation. Fix monthly usage query to use date range filtering instead of year/month decomposition. Refactor auth login endpoint to replace OAuth2PasswordRequestForm dependency with custom LoginRequest model. Add error handling wrapper around current usage endpoint and introduce loading/error CSS classes for billing UI. Convert code editor view from absolute positioning to fixed overlay with centered modal container. Include Stripe.js script tag in index.html and add new APIContract class for response validation against schemas.
2025-11-11 00:05:13 +00:00
retoor e0517e4300 fix: replace sequential upload with parallel promise-based upload and auto-remove completed items after 500ms delay 2025-11-10 16:59:40 +00:00
retoor f59cbf56dc fix: correct typo in commit message from "UPate" to "Update" 2025-11-10 14:50:19 +00:00
retoor 938daea231 feat: add billing module with usage tracking, invoice generation, and stripe integration 2025-11-10 14:46:40 +00:00
retoor c140eee47f feat: add 2fa support, starred items, batch operations, and logging infrastructure
- Implement two-factor authentication with TOTP verification, recovery codes, and QR code setup in auth flow
- Add is_starred and last_accessed_at fields to File and Folder models for bookmarking and recency tracking
- Introduce batch operation endpoints for files and folders (delete, star, unstar, move, copy)
- Configure structured logging with timestamps and module names, replacing print statements
- Register admin and starred routers, add global HTTPException handler with JSON error responses
- Update authenticate_user and create_access_token to handle 2FA verification and token claims
2025-11-10 00:58:41 +00:00
retoor 6b01dc3fa3 feat: add admin CRUD endpoints for user management and frontend dashboard component 2025-11-10 00:56:44 +00:00
retoor 7c908018f6 fix: correct typo in user authentication error message for invalid credentials 2025-11-09 23:28:56 +00:00
retoor 5df31bd119 chore: switch docker-compose to docker compose and add network_mode host to services 2025-11-09 23:28:48 +00:00
retoor 55ea78e534 chore: add initial project scaffolding with Docker, nginx, and core app modules 2025-11-09 22:29:07 +00:00
96 changed files with 13963 additions and 717 deletions
+5
View File
@@ -27,3 +27,8 @@ SMTP_PASSWORD=
SMTP_FROM_EMAIL=no-reply@example.com
TOTP_ISSUER=MyWebdav
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-this-password
ADMIN_SESSION_SECRET=change-this-to-a-random-secret
ADMIN_SESSION_EXPIRE_HOURS=24
+10 -8
View File
@@ -45,7 +45,7 @@ dev:
run:
@echo "Starting MyWebdav application..."
@echo "Access the application at http://localhost:8000"
@echo "Access the application at http://localhost:9004"
$(PYTHON) -m mywebdav.main
test:
@@ -99,11 +99,13 @@ init-db:
await Tortoise.generate_schemas(); \
count = await PricingConfig.all().count(); \
if count == 0: \
await PricingConfig.create(config_key='storage_per_gb_month', config_value=Decimal('0.0045'), description='Storage cost per GB per month', unit='per_gb_month'); \
await PricingConfig.create(config_key='bandwidth_egress_per_gb', config_value=Decimal('0.009'), description='Bandwidth egress cost per GB', unit='per_gb'); \
await PricingConfig.create(config_key='storage_per_gb_month', config_value=Decimal('0.005'), description='Storage cost per GB per month (Starter tier)', unit='per_gb_month'); \
await PricingConfig.create(config_key='storage_per_gb_month_pro', config_value=Decimal('0.004'), description='Storage cost per GB per month (Professional tier)', unit='per_gb_month'); \
await PricingConfig.create(config_key='storage_per_gb_month_enterprise', config_value=Decimal('0.003'), description='Storage cost per GB per month (Enterprise tier, 10TB+)', unit='per_gb_month'); \
await PricingConfig.create(config_key='bandwidth_egress_per_gb', config_value=Decimal('0.008'), description='Bandwidth egress cost per GB (Starter tier)', unit='per_gb'); \
await PricingConfig.create(config_key='bandwidth_egress_per_gb_pro', config_value=Decimal('0.007'), description='Bandwidth egress cost per GB (Professional tier)', unit='per_gb'); \
await PricingConfig.create(config_key='bandwidth_egress_per_gb_enterprise', config_value=Decimal('0.005'), description='Bandwidth egress cost per GB (Enterprise tier)', unit='per_gb'); \
await PricingConfig.create(config_key='bandwidth_ingress_per_gb', config_value=Decimal('0.0'), description='Bandwidth ingress cost per GB (free)', unit='per_gb'); \
await PricingConfig.create(config_key='free_tier_storage_gb', config_value=Decimal('15'), description='Free tier storage in GB', unit='gb'); \
await PricingConfig.create(config_key='free_tier_bandwidth_gb', config_value=Decimal('15'), description='Free tier bandwidth in GB per month', unit='gb'); \
await PricingConfig.create(config_key='tax_rate_default', config_value=Decimal('0.0'), description='Default tax rate (0 = no tax)', unit='percentage'); \
print('Default pricing configuration created'); \
else: \
@@ -147,9 +149,9 @@ setup: setup-env install dev init-db
@echo "Next steps:"
@echo " 1. Update .env with your configuration (especially Stripe keys)"
@echo " 2. Run 'make run' or 'make all' to start the application"
@echo " 3. Access the application at http://localhost:8000"
@echo " 3. Access the application at http://localhost:9004"
docs:
@echo "Generating API documentation..."
@echo "API documentation available at http://localhost:8000/docs when running"
@echo "ReDoc available at http://localhost:8000/redoc when running"
@echo "API documentation available at http://localhost:9004/docs when running"
@echo "ReDoc available at http://localhost:9004/redoc when running"
+65 -92
View File
@@ -1,6 +1,19 @@
# MyWebdav
# MyWebdav - Secure Cloud Storage SaaS
MyWebdav is a self-hosted cloud storage web application designed for secure, scalable file management and sharing. Built with modern web technologies, it provides a comprehensive solution for individuals and organizations seeking full control over their data storage.
MyWebdav is a powerful cloud storage SaaS application designed for secure, scalable file management and sharing. Built with modern web technologies, it provides a comprehensive solution for individuals and organizations seeking reliable, high-quality data storage and collaboration.
**Keywords:** cloud storage SaaS, secure file sharing, WebDAV server, SFTP support, secure file sync, private cloud, data privacy, file collaboration, encrypted storage, Nextcloud alternative, ownCloud alternative
## Why Choose MyWebdav?
MyWebdav stands out as a premier cloud storage SaaS solution, offering the cheapest storage with the highest quality, enterprise-grade security, and seamless collaboration features. Hosted by MyWebdav Technologies, it provides reliable, scalable storage without the need for self-hosting, ensuring top-tier performance and data protection.
### Key Benefits:
- **Cheapest Storage**: Competitive pricing starting at €0.003 per GB, lower than major competitors
- **Highest Quality**: Enterprise-grade infrastructure with 99.9% uptime and advanced security
- **Enhanced Privacy**: End-to-end encryption and GDPR compliance
- **Easy Access**: No setup required, accessible from anywhere with web and mobile apps
- **Reliable Hosting**: Managed by experts, eliminating maintenance hassles
## Features
@@ -37,69 +50,44 @@ MyWebdav is a self-hosted cloud storage web application designed for secure, sca
- **Webhook Support**: Integration with external services via webhooks
### Administration
- **Admin Panel**: Full-featured backend panel at `/manage/` for system administration
- **User Management**: Create, edit, delete users; manage quotas and subscriptions
- **Payment Overview**: Invoice tracking, payment status, revenue reporting
- **Pricing Configuration**: Adjust storage and bandwidth pricing
- **Usage Analytics**: Detailed reporting on storage consumption and bandwidth usage
- **Admin Console**: Centralized user management and system monitoring
- **API Access**: RESTful API for third-party integrations
## Installation
## Pricing
### Prerequisites
- Python 3.12+
- PostgreSQL 15+
- Redis 7+
- Docker and Docker Compose (recommended)
MyWebdav offers the cheapest cloud storage with the highest quality, starting at just €0.003 per GB. All plans include unlimited bandwidth, advanced security, and premium support.
### Quick Start with Docker
### Plans
- **Basic**: 100GB for €0.30/month (€0.003/GB)
- **Standard**: 500GB for €1.50/month (€0.003/GB)
- **Premium**: 2TB for €6.00/month (€0.003/GB)
- **Enterprise**: 10TB for €30.00/month (€0.003/GB)
1. Clone the repository and navigate to the project directory
2. Copy the environment template:
```bash
cp .env.example .env
```
3. Edit `.env` with your configuration (database credentials, secrets, etc.)
4. Start the services:
```bash
docker-compose up -d
```
5. Access the application at `https://your-domain.com`
### Commercial Alternatives Comparison
| Service | Monthly Cost (2TB) | Price per GB | Hosted |
|---------|---------------------|--------------|--------|
| MyWebdav | €6.00 | €0.003 | Yes |
| Dropbox | €9.99 | €0.005 | Yes |
| Google Drive | €9.99 | €0.005 | Yes |
| OneDrive | €9.99 | €0.005 | Yes |
| Nextcloud (self-hosted) | Variable | Variable | No |
### Manual Installation
## Getting Started
1. Install dependencies:
```bash
pip install poetry
poetry install
```
Getting started with MyWebdav is easy. Simply sign up for an account at [mywebdav.com](https://mywebdav.com) and choose your plan. No installation or setup required.
2. Set up the database:
```bash
createdb mywebdav
```
### Features Overview
- **Web Interface**: Access your files from any browser
- **Mobile Apps**: Sync and manage files on the go
- **Desktop Clients**: Integrate with WebDAV and SFTP for seamless access
- **API**: Automate workflows with our RESTful API
3. Configure environment variables in `.env`
4. Run database migrations:
```bash
poetry run mywebdav --migrate
```
5. Start the application:
```bash
poetry run mywebdav --host 0.0.0.0 --port 8000
```
## Configuration
MyWebdav uses environment variables for configuration. Key settings include:
- `DATABASE_URL`: PostgreSQL connection string
- `REDIS_URL`: Redis connection URL
- `SECRET_KEY`: JWT signing key (generate a secure random key)
- `DOMAIN_NAME`: Your domain for HTTPS certificates
- `SMTP_*`: Email server configuration
- `STORAGE_PATH`: Local storage directory path
See `.env.example` for a complete list of configuration options.
### Support
For support, visit our [help center](https://mywebdav.com/support) or contact support@mywebdav.com.
## Usage
@@ -109,7 +97,15 @@ Access the web application through your browser. The interface provides:
- Folder management and navigation
- Search and filtering capabilities
- User profile and settings
- Administrative controls (for admins)
### Admin Panel
Access the administration panel at `/manage/` to:
- View system statistics and revenue
- Manage users, quotas, and subscriptions
- Review invoices and payment status
- Configure pricing settings
Admin credentials are configured via environment variables (see `.env.example`).
### API Usage
MyWebdav provides a comprehensive REST API. Example requests:
@@ -140,47 +136,27 @@ https://your-domain.com/webdav/
### SFTP Access
Connect via SFTP using your MyWebdav credentials on port 22.
## Deployment
### Production Deployment
1. Set up a reverse proxy (Nginx included in docker-compose.yml)
2. Configure SSL certificates (automatic with Let's Encrypt)
3. Set up database backups
4. Configure monitoring and logging
5. Scale as needed with load balancers
### Docker Compose Services
- **app**: FastAPI application with Gunicorn
- **db**: PostgreSQL database
- **redis**: Caching and session storage
- **nginx**: Reverse proxy and static file serving
- **certbot**: SSL certificate management
## Security
### Environment Variables
Configure all services through the `.env` file. Sensitive data is automatically loaded and validated.
MyWebdav employs enterprise-grade security measures to protect your data:
## Security Considerations
- Change default secrets in production
- Enable HTTPS with valid certificates
- Regularly update dependencies
- Monitor access logs
- Implement backup strategies
- Use strong passwords and enable 2FA
- End-to-end encryption for all stored files
- Multi-factor authentication (MFA) support
- Regular security audits and compliance with GDPR
- 24/7 monitoring and threat detection
- Secure data centers with physical and digital protections
## Troubleshooting
### Common Issues
- **Database connection errors**: Verify DATABASE_URL configuration
- **File upload failures**: Check storage permissions and quotas
- **Email not sending**: Confirm SMTP settings
- **WebDAV connection issues**: Ensure proper authentication
If you encounter issues, our support team is here to help. Common solutions include:
### Logs
Application logs are available in the Docker containers:
```bash
docker-compose logs app
```
- **Login issues**: Reset your password or enable MFA
- **Upload problems**: Check your plan limits or contact support
- **Sync errors**: Reconnect your devices or update clients
For detailed help, visit our [troubleshooting guide](https://mywebdav.com/troubleshooting).
## Support
@@ -189,6 +165,3 @@ For issues and questions:
- Review configuration examples
- Consult the API documentation at `/docs` when running
## License
This project is licensed under the MIT License. See the LICENSE file for details.
+43 -8
View File
@@ -1,5 +1,26 @@
from typing import Optional
from typing import Optional, List, Dict
from .models import Activity, User
import logging
logger = logging.getLogger(__name__)
async def _flush_activities(records: List[Dict]):
from tortoise.transactions import in_transaction
try:
async with in_transaction():
for record in records:
await Activity.create(
user_id=record.get("user_id"),
action=record["action"],
target_type=record["target_type"],
target_id=record["target_id"],
ip_address=record.get("ip_address"),
)
logger.debug(f"Flushed {len(records)} activity records")
except Exception as e:
logger.error(f"Failed to flush activity records: {e}")
raise
async def log_activity(
@@ -9,10 +30,24 @@ async def log_activity(
target_id: int,
ip_address: Optional[str] = None,
):
await Activity.create(
user=user,
action=action,
target_type=target_type,
target_id=target_id,
ip_address=ip_address,
)
try:
from .enterprise.write_buffer import get_write_buffer, WriteType
buffer = get_write_buffer()
await buffer.buffer(
WriteType.ACTIVITY,
{
"user_id": user.id if user else None,
"action": action,
"target_type": target_type,
"target_id": target_id,
"ip_address": ip_address,
},
)
except RuntimeError:
await Activity.create(
user=user,
action=action,
target_type=target_type,
target_id=target_id,
ip_address=ip_address,
)
+96
View File
@@ -0,0 +1,96 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta
from typing import Optional
import secrets
import hashlib
from fastapi import Request, HTTPException
from fastapi.responses import RedirectResponse
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from mywebdav.settings import settings
class AdminSessionManager:
def __init__(self):
self.serializer = URLSafeTimedSerializer(settings.ADMIN_SESSION_SECRET)
self.cookie_name = "admin_session"
self.max_age = settings.ADMIN_SESSION_EXPIRE_HOURS * 3600
def create_session(self, username: str) -> str:
data = {
"username": username,
"created": datetime.utcnow().isoformat(),
"nonce": secrets.token_hex(8)
}
return self.serializer.dumps(data)
def verify_session(self, token: str) -> Optional[dict]:
try:
data = self.serializer.loads(token, max_age=self.max_age)
return data
except (BadSignature, SignatureExpired):
return None
def get_session_from_request(self, request: Request) -> Optional[dict]:
token = request.cookies.get(self.cookie_name)
if not token:
return None
return self.verify_session(token)
session_manager = AdminSessionManager()
def verify_admin_credentials(username: str, password: str) -> bool:
expected_username = settings.ADMIN_USERNAME
expected_password = settings.ADMIN_PASSWORD
username_hash = hashlib.sha256(username.encode()).digest()
expected_hash = hashlib.sha256(expected_username.encode()).digest()
username_match = secrets.compare_digest(username_hash, expected_hash)
password_hash = hashlib.sha256(password.encode()).digest()
expected_pw_hash = hashlib.sha256(expected_password.encode()).digest()
password_match = secrets.compare_digest(password_hash, expected_pw_hash)
return username_match and password_match
def get_admin_session(request: Request) -> Optional[dict]:
return session_manager.get_session_from_request(request)
def require_admin(request: Request) -> dict:
session = get_admin_session(request)
if not session:
raise HTTPException(status_code=303, headers={"Location": "/manage/login"})
return session
def create_session_response(response: RedirectResponse, username: str) -> RedirectResponse:
token = session_manager.create_session(username)
response.set_cookie(
key=session_manager.cookie_name,
value=token,
max_age=session_manager.max_age,
httponly=True,
samesite="lax",
secure=False
)
return response
def clear_session_response(response: RedirectResponse) -> RedirectResponse:
response.delete_cookie(key=session_manager.cookie_name)
return response
def generate_csrf_token(session: dict) -> str:
data = f"{session.get('nonce', '')}{settings.ADMIN_SESSION_SECRET}"
return hashlib.sha256(data.encode()).hexdigest()[:32]
def verify_csrf_token(session: dict, token: str) -> bool:
expected = generate_csrf_token(session)
return secrets.compare_digest(expected, token)
+19 -2
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
import asyncio
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
@@ -9,11 +10,19 @@ import bcrypt
from .schemas import TokenData
from .settings import settings
from .models import User
from .two_factor import verify_totp_code # Import verify_totp_code
from .two_factor import verify_totp_code
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def get_token_manager_safe():
try:
from .auth_tokens import get_token_manager
return get_token_manager()
except RuntimeError:
return None
def verify_password(plain_password, hashed_password):
password_bytes = plain_password[:72].encode("utf-8")
hashed_bytes = (
@@ -77,8 +86,16 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
)
username: str = payload.get("sub")
two_factor_verified: bool = payload.get("2fa_verified", False)
jti: str = payload.get("jti")
if username is None:
raise credentials_exception
token_manager = get_token_manager_safe()
if token_manager and jti:
is_revoked = await token_manager.is_revoked(jti)
if is_revoked:
raise credentials_exception
token_data = TokenData(
username=username, two_factor_verified=two_factor_verified
)
@@ -87,7 +104,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
user = await User.get_or_none(username=token_data.username)
if user is None:
raise credentials_exception
user.token_data = token_data # Attach token_data to user for easy access
user.token_data = token_data
return user
+291
View File
@@ -0,0 +1,291 @@
import asyncio
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional, Set
from dataclasses import dataclass, field
import logging
from jose import jwt
from .settings import settings
logger = logging.getLogger(__name__)
@dataclass
class TokenInfo:
jti: str
user_id: int
token_type: str
created_at: float = field(default_factory=time.time)
expires_at: float = 0
class TokenManager:
def __init__(self, db_manager=None):
self.db_manager = db_manager
self.blacklist: Dict[str, TokenInfo] = {}
self.active_tokens: Dict[str, TokenInfo] = {}
self._lock = asyncio.Lock()
self._cleanup_task: Optional[asyncio.Task] = None
self._running = False
self._persistence_enabled = False
async def start(self, db_manager=None):
if db_manager:
self.db_manager = db_manager
self._persistence_enabled = True
await self._load_blacklist_from_db()
self._running = True
self._cleanup_task = asyncio.create_task(self._background_cleanup())
logger.info("TokenManager started")
async def stop(self):
self._running = False
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("TokenManager stopped")
def create_access_token(
self,
user_id: int,
username: str,
two_factor_verified: bool = False,
expires_delta: Optional[timedelta] = None
) -> tuple:
jti = str(uuid.uuid4())
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
payload = {
"sub": username,
"user_id": user_id,
"jti": jti,
"type": "access",
"2fa_verified": two_factor_verified,
"iat": datetime.now(timezone.utc),
"exp": expire,
}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
token_info = TokenInfo(
jti=jti,
user_id=user_id,
token_type="access",
expires_at=expire.timestamp()
)
asyncio.create_task(self._track_token(token_info))
return token, jti
def create_refresh_token(
self,
user_id: int,
username: str,
expires_delta: Optional[timedelta] = None
) -> tuple:
jti = str(uuid.uuid4())
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
payload = {
"sub": username,
"user_id": user_id,
"jti": jti,
"type": "refresh",
"iat": datetime.now(timezone.utc),
"exp": expire,
}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
token_info = TokenInfo(
jti=jti,
user_id=user_id,
token_type="refresh",
expires_at=expire.timestamp()
)
asyncio.create_task(self._track_token(token_info))
return token, jti
async def _track_token(self, token_info: TokenInfo):
async with self._lock:
self.active_tokens[token_info.jti] = token_info
async def revoke_token(self, jti: str, user_id: Optional[int] = None) -> bool:
async with self._lock:
token_info = self.active_tokens.pop(jti, None)
if not token_info:
token_info = TokenInfo(
jti=jti,
user_id=user_id or 0,
token_type="unknown",
expires_at=time.time() + 86400 * 7
)
self.blacklist[jti] = token_info
await self._persist_revocation(token_info)
logger.info(f"Token revoked: {jti}")
return True
async def revoke_all_user_tokens(self, user_id: int) -> int:
revoked_count = 0
async with self._lock:
tokens_to_revoke = [
(jti, info) for jti, info in self.active_tokens.items()
if info.user_id == user_id
]
for jti, token_info in tokens_to_revoke:
del self.active_tokens[jti]
self.blacklist[jti] = token_info
revoked_count += 1
for jti, token_info in tokens_to_revoke:
await self._persist_revocation(token_info)
logger.info(f"Revoked {revoked_count} tokens for user {user_id}")
return revoked_count
async def is_revoked(self, jti: str) -> bool:
async with self._lock:
if jti in self.blacklist:
return True
if self._persistence_enabled and self.db_manager:
try:
async with self.db_manager.get_master_connection() as conn:
cursor = await conn.execute(
"SELECT 1 FROM revoked_tokens WHERE jti = ?",
(jti,)
)
row = await cursor.fetchone()
if row:
return True
except Exception as e:
logger.error(f"Failed to check token revocation: {e}")
return False
async def _persist_revocation(self, token_info: TokenInfo):
if not self._persistence_enabled or not self.db_manager:
return
try:
async with self.db_manager.get_master_connection() as conn:
await conn.execute("""
INSERT OR IGNORE INTO revoked_tokens (jti, user_id, expires_at)
VALUES (?, ?, ?)
""", (
token_info.jti,
token_info.user_id,
datetime.fromtimestamp(token_info.expires_at)
))
await conn.commit()
except Exception as e:
logger.error(f"Failed to persist token revocation: {e}")
async def _load_blacklist_from_db(self):
if not self.db_manager:
return
try:
async with self.db_manager.get_master_connection() as conn:
cursor = await conn.execute("""
SELECT jti, user_id, expires_at FROM revoked_tokens
WHERE expires_at > ?
""", (datetime.now(),))
rows = await cursor.fetchall()
for row in rows:
token_info = TokenInfo(
jti=row[0],
user_id=row[1],
token_type="revoked",
expires_at=row[2].timestamp() if hasattr(row[2], 'timestamp') else time.time()
)
self.blacklist[token_info.jti] = token_info
logger.info(f"Loaded {len(self.blacklist)} revoked tokens from database")
except Exception as e:
logger.error(f"Failed to load token blacklist: {e}")
async def _background_cleanup(self):
while self._running:
try:
await asyncio.sleep(3600)
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in token cleanup: {e}")
async def _cleanup_expired(self):
now = time.time()
async with self._lock:
expired_active = [
jti for jti, info in self.active_tokens.items()
if info.expires_at < now
]
for jti in expired_active:
del self.active_tokens[jti]
expired_blacklist = [
jti for jti, info in self.blacklist.items()
if info.expires_at < now
]
for jti in expired_blacklist:
del self.blacklist[jti]
if self._persistence_enabled and self.db_manager:
try:
async with self.db_manager.get_master_connection() as conn:
await conn.execute(
"DELETE FROM revoked_tokens WHERE expires_at < ?",
(datetime.now(),)
)
await conn.commit()
except Exception as e:
logger.error(f"Failed to cleanup expired tokens: {e}")
if expired_active or expired_blacklist:
logger.debug(f"Cleaned up {len(expired_active)} active and {len(expired_blacklist)} blacklisted tokens")
async def get_stats(self) -> dict:
async with self._lock:
return {
"active_tokens": len(self.active_tokens),
"blacklisted_tokens": len(self.blacklist),
}
_token_manager: Optional[TokenManager] = None
async def init_token_manager(db_manager=None) -> TokenManager:
global _token_manager
_token_manager = TokenManager()
await _token_manager.start(db_manager)
return _token_manager
async def shutdown_token_manager():
global _token_manager
if _token_manager:
await _token_manager.stop()
_token_manager = None
def get_token_manager() -> TokenManager:
if not _token_manager:
raise RuntimeError("Token manager not initialized")
return _token_manager
+43 -8
View File
@@ -22,14 +22,49 @@ class InvoiceGenerator:
pricing = await PricingConfig.all()
pricing_dict = {p.config_key: p.config_value for p in pricing}
storage_price_per_gb = pricing_dict.get(
"storage_per_gb_month", Decimal("0.0045")
)
bandwidth_price_per_gb = pricing_dict.get(
"bandwidth_egress_per_gb", Decimal("0.009")
)
free_storage_gb = pricing_dict.get("free_tier_storage_gb", Decimal("15"))
free_bandwidth_gb = pricing_dict.get("free_tier_bandwidth_gb", Decimal("15"))
# Get user's subscription plan
user_subscription = await UserSubscription.get_or_none(user=user)
plan_name = "starter" # Default to starter
if user_subscription and user_subscription.plan:
plan_name = user_subscription.plan.name
# Set pricing based on subscription tier
if plan_name == "professional":
storage_price_per_gb = pricing_dict.get(
"storage_per_gb_month_professional", Decimal("0.004")
)
bandwidth_price_per_gb = pricing_dict.get(
"bandwidth_egress_per_gb_professional", Decimal("0.007")
)
elif plan_name == "enterprise":
storage_price_per_gb = pricing_dict.get(
"storage_per_gb_month_enterprise", Decimal("0.003")
)
bandwidth_price_per_gb = pricing_dict.get(
"bandwidth_egress_per_gb_enterprise", Decimal("0.005")
)
# Check if user meets minimum storage requirement for enterprise pricing
enterprise_min_tb = pricing_dict.get("enterprise_min_storage_tb", Decimal("10"))
storage_gb = Decimal(str(usage["storage_gb_avg"]))
if storage_gb < (enterprise_min_tb * Decimal("1024")): # Convert TB to GB
# User doesn't meet enterprise minimum, fall back to professional
storage_price_per_gb = pricing_dict.get(
"storage_per_gb_month_professional", Decimal("0.004")
)
bandwidth_price_per_gb = pricing_dict.get(
"bandwidth_egress_per_gb_professional", Decimal("0.007")
)
else: # starter
storage_price_per_gb = pricing_dict.get(
"storage_per_gb_month_starter", Decimal("0.005")
)
bandwidth_price_per_gb = pricing_dict.get(
"bandwidth_egress_per_gb_starter", Decimal("0.008")
)
# No free tier - charge from first GB
free_storage_gb = Decimal("0")
free_bandwidth_gb = Decimal("0")
tax_rate = pricing_dict.get("tax_rate_default", Decimal("0"))
storage_gb = Decimal(str(usage["storage_gb_avg"]))
+15 -15
View File
@@ -6,8 +6,8 @@ class SubscriptionPlan(models.Model):
name = fields.CharField(max_length=100, unique=True)
display_name = fields.CharField(max_length=255)
description = fields.TextField(null=True)
storage_gb = fields.IntField()
bandwidth_gb = fields.IntField()
storage_gb = fields.IntField(null=True) # null means unlimited/usage-based
bandwidth_gb = fields.IntField(null=True) # null means unlimited/usage-based
price_monthly = fields.DecimalField(max_digits=10, decimal_places=2)
price_yearly = fields.DecimalField(max_digits=10, decimal_places=2, null=True)
stripe_price_id = fields.CharField(max_length=255, null=True)
@@ -25,10 +25,10 @@ class UserSubscription(models.Model):
plan = fields.ForeignKeyField(
"billing.SubscriptionPlan", related_name="subscriptions", null=True
)
billing_type = fields.CharField(max_length=20, default="pay_as_you_go")
billing_type = fields.CharField(max_length=100, default="pay_as_you_go")
stripe_customer_id = fields.CharField(max_length=255, unique=True, null=True)
stripe_subscription_id = fields.CharField(max_length=255, unique=True, null=True)
status = fields.CharField(max_length=50, default="active")
status = fields.CharField(max_length=100, default="active")
current_period_start = fields.DatetimeField(null=True)
current_period_end = fields.DatetimeField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
@@ -42,9 +42,9 @@ class UserSubscription(models.Model):
class UsageRecord(models.Model):
id = fields.BigIntField(primary_key=True)
user = fields.ForeignKeyField("models.User", related_name="usage_records")
record_type = fields.CharField(max_length=50, db_index=True)
record_type = fields.CharField(max_length=100, db_index=True)
amount_bytes = fields.BigIntField()
resource_type = fields.CharField(max_length=50, null=True)
resource_type = fields.CharField(max_length=100, null=True)
resource_id = fields.IntField(null=True)
timestamp = fields.DatetimeField(auto_now_add=True, db_index=True)
idempotency_key = fields.CharField(max_length=255, unique=True, null=True)
@@ -73,15 +73,15 @@ class UsageAggregate(models.Model):
class Invoice(models.Model):
id = fields.IntField(primary_key=True)
user = fields.ForeignKeyField("models.User", related_name="invoices")
invoice_number = fields.CharField(max_length=50, unique=True)
invoice_number = fields.CharField(max_length=100, unique=True)
stripe_invoice_id = fields.CharField(max_length=255, unique=True, null=True)
period_start = fields.DateField(db_index=True)
period_end = fields.DateField()
subtotal = fields.DecimalField(max_digits=10, decimal_places=4)
tax = fields.DecimalField(max_digits=10, decimal_places=4, default=0)
total = fields.DecimalField(max_digits=10, decimal_places=4)
currency = fields.CharField(max_length=3, default="USD")
status = fields.CharField(max_length=50, default="draft", db_index=True)
currency = fields.CharField(max_length=100, default="USD")
status = fields.CharField(max_length=100, default="draft", db_index=True)
due_date = fields.DateField(null=True)
paid_at = fields.DatetimeField(null=True)
created_at = fields.DatetimeField(auto_now_add=True, db_index=True)
@@ -100,7 +100,7 @@ class InvoiceLineItem(models.Model):
quantity = fields.DecimalField(max_digits=15, decimal_places=6)
unit_price = fields.DecimalField(max_digits=10, decimal_places=6)
amount = fields.DecimalField(max_digits=10, decimal_places=4)
item_type = fields.CharField(max_length=50, null=True)
item_type = fields.CharField(max_length=100, null=True)
metadata = fields.JSONField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
@@ -110,10 +110,10 @@ class InvoiceLineItem(models.Model):
class PricingConfig(models.Model):
id = fields.IntField(primary_key=True)
config_key = fields.CharField(max_length=100, unique=True)
config_key = fields.CharField(max_length=255, unique=True)
config_value = fields.DecimalField(max_digits=10, decimal_places=6)
description = fields.TextField(null=True)
unit = fields.CharField(max_length=50, null=True)
unit = fields.CharField(max_length=100, null=True)
updated_by = fields.ForeignKeyField(
"models.User", related_name="pricing_updates", null=True
)
@@ -127,10 +127,10 @@ class PaymentMethod(models.Model):
id = fields.IntField(primary_key=True)
user = fields.ForeignKeyField("models.User", related_name="payment_methods")
stripe_payment_method_id = fields.CharField(max_length=255)
type = fields.CharField(max_length=50)
type = fields.CharField(max_length=100)
is_default = fields.BooleanField(default=False)
last4 = fields.CharField(max_length=4, null=True)
brand = fields.CharField(max_length=50, null=True)
last4 = fields.CharField(max_length=100, null=True)
brand = fields.CharField(max_length=100, null=True)
exp_month = fields.IntField(null=True)
exp_year = fields.IntField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
+77 -20
View File
@@ -1,9 +1,34 @@
import uuid
from datetime import datetime, date, timezone
import logging
from datetime import datetime, date, timezone, timedelta
from typing import List, Dict
from tortoise.transactions import in_transaction
from .models import UsageRecord, UsageAggregate
from ..models import User
logger = logging.getLogger(__name__)
async def _flush_usage_records(records: List[Dict]):
try:
async with in_transaction():
for record in records:
await UsageRecord.create(
user_id=record["user_id"],
record_type=record["record_type"],
amount_bytes=record["amount_bytes"],
resource_type=record.get("resource_type"),
resource_id=record.get("resource_id"),
idempotency_key=record["idempotency_key"],
metadata=record.get("metadata"),
)
logger.debug(f"Flushed {len(records)} usage records")
except Exception as e:
logger.error(f"Failed to flush usage records: {e}")
raise
class UsageTracker:
@staticmethod
@@ -16,15 +41,31 @@ class UsageTracker:
):
idempotency_key = f"storage_{user.id}_{datetime.now(timezone.utc).timestamp()}_{uuid.uuid4().hex[:8]}"
await UsageRecord.create(
user=user,
record_type="storage",
amount_bytes=amount_bytes,
resource_type=resource_type,
resource_id=resource_id,
idempotency_key=idempotency_key,
metadata=metadata,
)
try:
from ..enterprise.write_buffer import get_write_buffer, WriteType
buffer = get_write_buffer()
await buffer.buffer(
WriteType.USAGE_RECORD,
{
"user_id": user.id,
"record_type": "storage",
"amount_bytes": amount_bytes,
"resource_type": resource_type,
"resource_id": resource_id,
"idempotency_key": idempotency_key,
"metadata": metadata,
},
)
except RuntimeError:
await UsageRecord.create(
user=user,
record_type="storage",
amount_bytes=amount_bytes,
resource_type=resource_type,
resource_id=resource_id,
idempotency_key=idempotency_key,
metadata=metadata,
)
@staticmethod
async def track_bandwidth(
@@ -38,15 +79,31 @@ class UsageTracker:
record_type = f"bandwidth_{direction}"
idempotency_key = f"{record_type}_{user.id}_{datetime.now(timezone.utc).timestamp()}_{uuid.uuid4().hex[:8]}"
await UsageRecord.create(
user=user,
record_type=record_type,
amount_bytes=amount_bytes,
resource_type=resource_type,
resource_id=resource_id,
idempotency_key=idempotency_key,
metadata=metadata,
)
try:
from ..enterprise.write_buffer import get_write_buffer, WriteType
buffer = get_write_buffer()
await buffer.buffer(
WriteType.USAGE_RECORD,
{
"user_id": user.id,
"record_type": record_type,
"amount_bytes": amount_bytes,
"resource_type": resource_type,
"resource_id": resource_id,
"idempotency_key": idempotency_key,
"metadata": metadata,
},
)
except RuntimeError:
await UsageRecord.create(
user=user,
record_type=record_type,
amount_bytes=amount_bytes,
resource_type=resource_type,
resource_id=resource_id,
idempotency_key=idempotency_key,
metadata=metadata,
)
@staticmethod
async def aggregate_daily_usage(user: User, target_date: date = None):
@@ -54,7 +111,7 @@ class UsageTracker:
target_date = date.today()
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = datetime.combine(target_date, datetime.max.time())
end_of_day = datetime.combine(target_date + timedelta(days=1), datetime.min.time()) - timedelta(microseconds=1)
storage_records = await UsageRecord.filter(
user=user,
+3
View File
@@ -0,0 +1,3 @@
from .layer import CacheLayer, get_cache
__all__ = ["CacheLayer", "get_cache"]
+264
View File
@@ -0,0 +1,264 @@
import asyncio
import time
import hashlib
import json
from typing import Dict, Any, Optional, Callable, Set, TypeVar, Generic
from dataclasses import dataclass, field
from collections import OrderedDict
import logging
logger = logging.getLogger(__name__)
T = TypeVar('T')
@dataclass
class CacheEntry:
value: Any
created_at: float = field(default_factory=time.time)
ttl: float = 300.0
dirty: bool = False
@property
def is_expired(self) -> bool:
return time.time() - self.created_at > self.ttl
class LRUCache:
def __init__(self, maxsize: int = 10000):
self.maxsize = maxsize
self.cache: OrderedDict[str, CacheEntry] = OrderedDict()
self._lock = asyncio.Lock()
async def get(self, key: str) -> Optional[CacheEntry]:
async with self._lock:
if key in self.cache:
entry = self.cache[key]
if entry.is_expired:
del self.cache[key]
return None
self.cache.move_to_end(key)
return entry
return None
async def set(self, key: str, value: Any, ttl: float = 300.0, dirty: bool = False):
async with self._lock:
if key in self.cache:
self.cache.move_to_end(key)
elif len(self.cache) >= self.maxsize:
oldest_key, oldest_entry = self.cache.popitem(last=False)
if oldest_entry.dirty:
self.cache[oldest_key] = oldest_entry
self.cache.move_to_end(oldest_key)
if len(self.cache) >= self.maxsize:
for k in list(self.cache.keys()):
if not self.cache[k].dirty:
del self.cache[k]
break
self.cache[key] = CacheEntry(value=value, ttl=ttl, dirty=dirty)
async def delete(self, key: str) -> bool:
async with self._lock:
if key in self.cache:
del self.cache[key]
return True
return False
async def get_dirty_keys(self) -> Set[str]:
async with self._lock:
return {k for k, v in self.cache.items() if v.dirty}
async def mark_clean(self, key: str):
async with self._lock:
if key in self.cache:
self.cache[key].dirty = False
async def clear(self):
async with self._lock:
self.cache.clear()
async def invalidate_pattern(self, pattern: str) -> int:
async with self._lock:
keys_to_delete = [k for k in self.cache.keys() if pattern in k]
for k in keys_to_delete:
del self.cache[k]
return len(keys_to_delete)
class CacheLayer:
CACHE_KEYS = {
"user_profile": "user:{user_id}:profile",
"folder_contents": "user:{user_id}:folder:{folder_id}:contents",
"file_metadata": "user:{user_id}:file:{file_id}:meta",
"storage_quota": "user:{user_id}:quota",
"folder_tree": "user:{user_id}:tree",
"share_info": "share:{share_id}:info",
"webdav_lock": "webdav:lock:{path_hash}",
"rate_limit": "ratelimit:{limit_type}:{key}",
}
TTL_CONFIG = {
"user_profile": 300.0,
"folder_contents": 30.0,
"file_metadata": 120.0,
"storage_quota": 60.0,
"folder_tree": 60.0,
"share_info": 300.0,
"webdav_lock": 3600.0,
"rate_limit": 60.0,
"default": 300.0,
}
def __init__(self, maxsize: int = 10000, flush_interval: int = 30):
self.l1_cache = LRUCache(maxsize=maxsize)
self.flush_interval = flush_interval
self.dirty_keys: Set[str] = set()
self._flush_callbacks: Dict[str, Callable] = {}
self._flush_task: Optional[asyncio.Task] = None
self._running = False
self._stats = {
"hits": 0,
"misses": 0,
"sets": 0,
"invalidations": 0,
}
async def start(self):
self._running = True
self._flush_task = asyncio.create_task(self._background_flusher())
logger.info("CacheLayer started")
async def stop(self):
self._running = False
if self._flush_task:
self._flush_task.cancel()
try:
await self._flush_task
except asyncio.CancelledError:
pass
await self.flush_dirty()
logger.info("CacheLayer stopped")
def register_flush_callback(self, key_pattern: str, callback: Callable):
self._flush_callbacks[key_pattern] = callback
async def get(self, key: str, loader: Optional[Callable] = None) -> Optional[Any]:
entry = await self.l1_cache.get(key)
if entry:
self._stats["hits"] += 1
return entry.value
self._stats["misses"] += 1
if loader:
try:
value = await loader() if asyncio.iscoroutinefunction(loader) else loader()
if value is not None:
ttl = self._get_ttl_for_key(key)
await self.set(key, value, ttl=ttl)
return value
except Exception as e:
logger.error(f"Cache loader failed for key {key}: {e}")
return None
return None
async def set(self, key: str, value: Any, ttl: Optional[float] = None, persist: bool = False):
if ttl is None:
ttl = self._get_ttl_for_key(key)
await self.l1_cache.set(key, value, ttl=ttl, dirty=persist)
if persist:
self.dirty_keys.add(key)
self._stats["sets"] += 1
async def delete(self, key: str):
await self.l1_cache.delete(key)
self.dirty_keys.discard(key)
async def invalidate(self, key: str, cascade: bool = True):
await self.l1_cache.delete(key)
self.dirty_keys.discard(key)
self._stats["invalidations"] += 1
if cascade:
parts = key.split(":")
if len(parts) >= 2 and parts[0] == "user":
user_id = parts[1]
await self.invalidate_user_cache(int(user_id))
async def invalidate_user_cache(self, user_id: int):
pattern = f"user:{user_id}:"
count = await self.l1_cache.invalidate_pattern(pattern)
logger.debug(f"Invalidated {count} cache entries for user {user_id}")
async def invalidate_pattern(self, pattern: str) -> int:
count = await self.l1_cache.invalidate_pattern(pattern)
self._stats["invalidations"] += count
return count
async def flush_dirty(self):
dirty_keys = await self.l1_cache.get_dirty_keys()
for key in dirty_keys:
entry = await self.l1_cache.get(key)
if entry and entry.dirty:
for pattern, callback in self._flush_callbacks.items():
if pattern in key:
try:
await callback(key, entry.value)
await self.l1_cache.mark_clean(key)
except Exception as e:
logger.error(f"Flush callback failed for {key}: {e}")
break
self.dirty_keys.clear()
def _get_ttl_for_key(self, key: str) -> float:
for key_type, ttl in self.TTL_CONFIG.items():
if key_type in key:
return ttl
return self.TTL_CONFIG["default"]
async def _background_flusher(self):
while self._running:
try:
await asyncio.sleep(self.flush_interval)
await self.flush_dirty()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in cache background flusher: {e}")
def get_stats(self) -> Dict[str, int]:
total = self._stats["hits"] + self._stats["misses"]
hit_rate = (self._stats["hits"] / total * 100) if total > 0 else 0
return {
**self._stats,
"hit_rate_percent": round(hit_rate, 2),
}
def build_key(self, key_type: str, **kwargs) -> str:
template = self.CACHE_KEYS.get(key_type)
if not template:
raise ValueError(f"Unknown cache key type: {key_type}")
return template.format(**kwargs)
_cache: Optional[CacheLayer] = None
async def init_cache(maxsize: int = 10000, flush_interval: int = 30) -> CacheLayer:
global _cache
_cache = CacheLayer(maxsize=maxsize, flush_interval=flush_interval)
await _cache.start()
return _cache
async def shutdown_cache():
global _cache
if _cache:
await _cache.stop()
_cache = None
def get_cache() -> CacheLayer:
if not _cache:
raise RuntimeError("Cache not initialized")
return _cache
+9
View File
@@ -0,0 +1,9 @@
from .locks import LockManager, get_lock_manager
from .atomic import AtomicOperations, get_atomic_ops
from .webdav_locks import PersistentWebDAVLocks, get_webdav_locks
__all__ = [
"LockManager", "get_lock_manager",
"AtomicOperations", "get_atomic_ops",
"PersistentWebDAVLocks", "get_webdav_locks",
]
+151
View File
@@ -0,0 +1,151 @@
import asyncio
import hashlib
from typing import Optional, Tuple, Any
from dataclasses import dataclass
import logging
from .locks import get_lock_manager
logger = logging.getLogger(__name__)
@dataclass
class QuotaCheckResult:
allowed: bool
current_usage: int
quota: int
requested: int
remaining: int
class AtomicOperations:
def __init__(self):
pass
async def atomic_quota_check_and_update(
self,
user,
delta: int,
save_callback=None
) -> QuotaCheckResult:
lock_manager = get_lock_manager()
lock_key = lock_manager.build_lock_key("quota_update", user_id=user.id)
async with lock_manager.acquire(lock_key, timeout=10.0, owner="quota_update", user_id=user.id):
current = user.used_storage_bytes
quota = user.storage_quota_bytes
new_usage = current + delta
result = QuotaCheckResult(
allowed=new_usage <= quota,
current_usage=current,
quota=quota,
requested=delta,
remaining=max(0, quota - current)
)
if result.allowed and save_callback:
user.used_storage_bytes = new_usage
await save_callback(user)
logger.debug(f"Quota updated for user {user.id}: {current} -> {new_usage}")
return result
async def atomic_file_create(
self,
user,
parent_id: Optional[int],
name: str,
check_exists_callback,
create_callback,
):
lock_manager = get_lock_manager()
name_hash = hashlib.md5(name.encode()).hexdigest()[:16]
lock_key = lock_manager.build_lock_key(
"file_create",
user_id=user.id,
parent_id=parent_id or 0,
name_hash=name_hash
)
async with lock_manager.acquire(lock_key, timeout=10.0, owner="file_create", user_id=user.id):
existing = await check_exists_callback()
if existing:
raise FileExistsError(f"File '{name}' already exists in this location")
result = await create_callback()
logger.debug(f"File created atomically: {name} for user {user.id}")
return result
async def atomic_folder_create(
self,
user,
parent_id: Optional[int],
name: str,
check_exists_callback,
create_callback,
):
lock_manager = get_lock_manager()
lock_key = lock_manager.build_lock_key(
"folder_create",
user_id=user.id,
parent_id=parent_id or 0
)
async with lock_manager.acquire(lock_key, timeout=10.0, owner="folder_create", user_id=user.id):
existing = await check_exists_callback()
if existing:
raise FileExistsError(f"Folder '{name}' already exists in this location")
result = await create_callback()
logger.debug(f"Folder created atomically: {name} for user {user.id}")
return result
async def atomic_file_update(
self,
user,
file_id: int,
update_callback,
):
lock_manager = get_lock_manager()
lock_key = f"lock:file:{file_id}:update"
async with lock_manager.acquire(lock_key, timeout=30.0, owner="file_update", user_id=user.id):
result = await update_callback()
return result
async def atomic_batch_operation(
self,
user,
operation_id: str,
items: list,
operation_callback,
):
lock_manager = get_lock_manager()
lock_key = f"lock:batch:{user.id}:{operation_id}"
async with lock_manager.acquire(lock_key, timeout=60.0, owner="batch_op", user_id=user.id):
results = []
errors = []
for item in items:
try:
result = await operation_callback(item)
results.append(result)
except Exception as e:
errors.append({"item": item, "error": str(e)})
return {"results": results, "errors": errors}
_atomic_ops: Optional[AtomicOperations] = None
def init_atomic_ops() -> AtomicOperations:
global _atomic_ops
_atomic_ops = AtomicOperations()
return _atomic_ops
def get_atomic_ops() -> AtomicOperations:
if not _atomic_ops:
return AtomicOperations()
return _atomic_ops
+265
View File
@@ -0,0 +1,265 @@
import asyncio
import time
import hashlib
import uuid
from typing import Dict, Optional, Set
from dataclasses import dataclass, field
from contextlib import asynccontextmanager
import logging
logger = logging.getLogger(__name__)
@dataclass
class LockInfo:
token: str
owner: str
user_id: int
acquired_at: float = field(default_factory=time.time)
timeout: float = 30.0
extend_count: int = 0
@property
def is_expired(self) -> bool:
return time.time() - self.acquired_at > self.timeout
@dataclass
class LockEntry:
lock: asyncio.Lock
info: Optional[LockInfo] = None
waiters: int = 0
class LockManager:
LOCK_PATTERNS = {
"file_upload": "lock:user:{user_id}:upload:{path_hash}",
"quota_update": "lock:user:{user_id}:quota",
"folder_create": "lock:user:{user_id}:folder:{parent_id}:create",
"file_create": "lock:user:{user_id}:file:{parent_id}:create:{name_hash}",
"webdav_lock": "lock:webdav:{path_hash}",
"invoice_gen": "lock:billing:invoice:{user_id}",
"user_update": "lock:user:{user_id}:update",
}
def __init__(self, default_timeout: float = 30.0, cleanup_interval: float = 60.0):
self.default_timeout = default_timeout
self.cleanup_interval = cleanup_interval
self.locks: Dict[str, LockEntry] = {}
self._global_lock = asyncio.Lock()
self._cleanup_task: Optional[asyncio.Task] = None
self._running = False
async def start(self):
self._running = True
self._cleanup_task = asyncio.create_task(self._background_cleanup())
logger.info("LockManager started")
async def stop(self):
self._running = False
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("LockManager stopped")
def build_lock_key(self, lock_type: str, **kwargs) -> str:
template = self.LOCK_PATTERNS.get(lock_type)
if not template:
return f"lock:custom:{lock_type}:{':'.join(str(v) for v in kwargs.values())}"
for key, value in kwargs.items():
if "hash" in key and not isinstance(value, str):
kwargs[key] = hashlib.md5(str(value).encode()).hexdigest()[:16]
return template.format(**kwargs)
async def _get_or_create_lock(self, resource: str) -> LockEntry:
async with self._global_lock:
if resource not in self.locks:
self.locks[resource] = LockEntry(lock=asyncio.Lock())
return self.locks[resource]
@asynccontextmanager
async def acquire(self, resource: str, timeout: Optional[float] = None,
owner: str = "", user_id: int = 0):
if timeout is None:
timeout = self.default_timeout
entry = await self._get_or_create_lock(resource)
async with self._global_lock:
entry.waiters += 1
try:
try:
await asyncio.wait_for(entry.lock.acquire(), timeout=timeout)
except asyncio.TimeoutError:
async with self._global_lock:
entry.waiters -= 1
raise TimeoutError(f"Failed to acquire lock for {resource} within {timeout}s")
token = str(uuid.uuid4())
entry.info = LockInfo(
token=token,
owner=owner,
user_id=user_id,
timeout=timeout
)
logger.debug(f"Lock acquired: {resource} by {owner}")
try:
yield token
finally:
entry.lock.release()
entry.info = None
async with self._global_lock:
entry.waiters -= 1
logger.debug(f"Lock released: {resource}")
except Exception:
async with self._global_lock:
if entry.waiters > 0:
entry.waiters -= 1
raise
async def try_acquire(self, resource: str, owner: str = "",
user_id: int = 0, timeout: float = 0) -> Optional[str]:
entry = await self._get_or_create_lock(resource)
if entry.lock.locked():
if entry.info and entry.info.is_expired:
pass
elif timeout > 0:
try:
await asyncio.wait_for(entry.lock.acquire(), timeout=timeout)
except asyncio.TimeoutError:
return None
else:
return None
else:
await entry.lock.acquire()
token = str(uuid.uuid4())
entry.info = LockInfo(
token=token,
owner=owner,
user_id=user_id,
timeout=self.default_timeout
)
return token
async def release(self, resource: str, token: str) -> bool:
async with self._global_lock:
if resource not in self.locks:
return False
entry = self.locks[resource]
if not entry.info or entry.info.token != token:
return False
entry.lock.release()
entry.info = None
logger.debug(f"Lock released via token: {resource}")
return True
async def extend(self, resource: str, token: str, extension: float = 30.0) -> bool:
async with self._global_lock:
if resource not in self.locks:
return False
entry = self.locks[resource]
if not entry.info or entry.info.token != token:
return False
entry.info.acquired_at = time.time()
entry.info.timeout = extension
entry.info.extend_count += 1
return True
async def get_lock_info(self, resource: str) -> Optional[LockInfo]:
async with self._global_lock:
if resource in self.locks:
return self.locks[resource].info
return None
async def is_locked(self, resource: str) -> bool:
async with self._global_lock:
if resource in self.locks:
entry = self.locks[resource]
if entry.lock.locked():
if entry.info and not entry.info.is_expired:
return True
return False
async def force_release(self, resource: str, user_id: int) -> bool:
async with self._global_lock:
if resource not in self.locks:
return False
entry = self.locks[resource]
if not entry.info:
return False
if entry.info.user_id != user_id:
return False
if entry.lock.locked():
entry.lock.release()
entry.info = None
return True
async def _background_cleanup(self):
while self._running:
try:
await asyncio.sleep(self.cleanup_interval)
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in lock cleanup: {e}")
async def _cleanup_expired(self):
async with self._global_lock:
expired = []
for resource, entry in self.locks.items():
if entry.info and entry.info.is_expired and entry.waiters == 0:
expired.append(resource)
for resource in expired:
async with self._global_lock:
if resource in self.locks:
entry = self.locks[resource]
if entry.lock.locked() and entry.info and entry.info.is_expired:
entry.lock.release()
entry.info = None
logger.debug(f"Cleaned up expired lock: {resource}")
async def get_stats(self) -> Dict:
async with self._global_lock:
total_locks = len(self.locks)
active_locks = sum(1 for e in self.locks.values() if e.lock.locked())
waiting = sum(e.waiters for e in self.locks.values())
return {
"total_locks": total_locks,
"active_locks": active_locks,
"waiting_requests": waiting,
}
_lock_manager: Optional[LockManager] = None
async def init_lock_manager(default_timeout: float = 30.0) -> LockManager:
global _lock_manager
_lock_manager = LockManager(default_timeout=default_timeout)
await _lock_manager.start()
return _lock_manager
async def shutdown_lock_manager():
global _lock_manager
if _lock_manager:
await _lock_manager.stop()
_lock_manager = None
def get_lock_manager() -> LockManager:
if not _lock_manager:
raise RuntimeError("Lock manager not initialized")
return _lock_manager
+322
View File
@@ -0,0 +1,322 @@
import asyncio
import time
import hashlib
import uuid
from typing import Dict, Optional
from dataclasses import dataclass, field, asdict
import json
import logging
logger = logging.getLogger(__name__)
@dataclass
class WebDAVLockInfo:
token: str
path: str
path_hash: str
owner: str
user_id: int
scope: str = "exclusive"
depth: str = "0"
timeout: int = 3600
created_at: float = field(default_factory=time.time)
@property
def is_expired(self) -> bool:
return time.time() - self.created_at > self.timeout
@property
def remaining_seconds(self) -> int:
remaining = self.timeout - (time.time() - self.created_at)
return max(0, int(remaining))
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> "WebDAVLockInfo":
return cls(**data)
class PersistentWebDAVLocks:
def __init__(self, db_manager=None):
self.db_manager = db_manager
self.locks: Dict[str, WebDAVLockInfo] = {}
self._lock = asyncio.Lock()
self._cleanup_task: Optional[asyncio.Task] = None
self._running = False
self._persistence_enabled = False
async def start(self, db_manager=None):
if db_manager:
self.db_manager = db_manager
self._persistence_enabled = True
await self._load_locks_from_db()
self._running = True
self._cleanup_task = asyncio.create_task(self._background_cleanup())
logger.info("PersistentWebDAVLocks started")
async def stop(self):
self._running = False
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
if self._persistence_enabled:
await self._save_all_locks()
logger.info("PersistentWebDAVLocks stopped")
def _hash_path(self, path: str) -> str:
return hashlib.sha256(path.encode()).hexdigest()[:32]
async def acquire_lock(
self,
path: str,
owner: str,
user_id: int,
timeout: int = 3600,
scope: str = "exclusive",
depth: str = "0"
) -> Optional[str]:
path = path.strip("/")
path_hash = self._hash_path(path)
async with self._lock:
if path_hash in self.locks:
existing = self.locks[path_hash]
if not existing.is_expired:
if existing.user_id == user_id:
existing.created_at = time.time()
existing.timeout = timeout
await self._persist_lock(existing)
return existing.token
return None
else:
del self.locks[path_hash]
await self._delete_lock_from_db(path_hash)
token = f"opaquelocktoken:{uuid.uuid4()}"
lock_info = WebDAVLockInfo(
token=token,
path=path,
path_hash=path_hash,
owner=owner,
user_id=user_id,
scope=scope,
depth=depth,
timeout=timeout
)
self.locks[path_hash] = lock_info
await self._persist_lock(lock_info)
logger.debug(f"WebDAV lock acquired: {path} by {owner}")
return token
async def refresh_lock(self, path: str, token: str, timeout: int = 3600) -> bool:
path = path.strip("/")
path_hash = self._hash_path(path)
async with self._lock:
if path_hash not in self.locks:
return False
lock_info = self.locks[path_hash]
if lock_info.token != token:
return False
lock_info.created_at = time.time()
lock_info.timeout = timeout
await self._persist_lock(lock_info)
return True
async def release_lock(self, path: str, token: str) -> bool:
path = path.strip("/")
path_hash = self._hash_path(path)
async with self._lock:
if path_hash not in self.locks:
return False
lock_info = self.locks[path_hash]
if lock_info.token != token:
return False
del self.locks[path_hash]
await self._delete_lock_from_db(path_hash)
logger.debug(f"WebDAV lock released: {path}")
return True
async def check_lock(self, path: str) -> Optional[WebDAVLockInfo]:
path = path.strip("/")
path_hash = self._hash_path(path)
async with self._lock:
if path_hash in self.locks:
lock_info = self.locks[path_hash]
if lock_info.is_expired:
del self.locks[path_hash]
await self._delete_lock_from_db(path_hash)
return None
return lock_info
return None
async def get_lock_by_token(self, token: str) -> Optional[WebDAVLockInfo]:
async with self._lock:
for lock_info in self.locks.values():
if lock_info.token == token and not lock_info.is_expired:
return lock_info
return None
async def is_locked(self, path: str, user_id: Optional[int] = None) -> bool:
lock_info = await self.check_lock(path)
if not lock_info:
return False
if user_id is not None and lock_info.user_id == user_id:
return False
return True
async def force_unlock(self, path: str, user_id: int) -> bool:
path = path.strip("/")
path_hash = self._hash_path(path)
async with self._lock:
if path_hash not in self.locks:
return False
lock_info = self.locks[path_hash]
if lock_info.user_id != user_id:
return False
del self.locks[path_hash]
await self._delete_lock_from_db(path_hash)
return True
async def get_user_locks(self, user_id: int) -> list:
async with self._lock:
return [
lock_info for lock_info in self.locks.values()
if lock_info.user_id == user_id and not lock_info.is_expired
]
async def _persist_lock(self, lock_info: WebDAVLockInfo):
if not self._persistence_enabled or not self.db_manager:
return
try:
async with self.db_manager.get_master_connection() as conn:
await conn.execute("""
INSERT OR REPLACE INTO webdav_locks
(path_hash, path, token, owner, user_id, scope, timeout, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
lock_info.path_hash,
lock_info.path,
lock_info.token,
lock_info.owner,
lock_info.user_id,
lock_info.scope,
lock_info.timeout,
lock_info.created_at
))
await conn.commit()
except Exception as e:
logger.error(f"Failed to persist WebDAV lock: {e}")
async def _delete_lock_from_db(self, path_hash: str):
if not self._persistence_enabled or not self.db_manager:
return
try:
async with self.db_manager.get_master_connection() as conn:
await conn.execute(
"DELETE FROM webdav_locks WHERE path_hash = ?",
(path_hash,)
)
await conn.commit()
except Exception as e:
logger.error(f"Failed to delete WebDAV lock from db: {e}")
async def _load_locks_from_db(self):
if not self.db_manager:
return
expired_hashes = []
try:
async with self.db_manager.get_master_connection() as conn:
cursor = await conn.execute("SELECT * FROM webdav_locks")
rows = await cursor.fetchall()
for row in rows:
lock_info = WebDAVLockInfo(
token=row[3],
path=row[2],
path_hash=row[1],
owner=row[4],
user_id=row[5],
scope=row[6],
timeout=row[7],
created_at=row[8]
)
if not lock_info.is_expired:
self.locks[lock_info.path_hash] = lock_info
else:
expired_hashes.append(lock_info.path_hash)
logger.info(f"Loaded {len(self.locks)} WebDAV locks from database")
except Exception as e:
logger.error(f"Failed to load WebDAV locks: {e}")
for path_hash in expired_hashes:
await self._delete_lock_from_db(path_hash)
async def _save_all_locks(self):
if not self._persistence_enabled:
return
for lock_info in list(self.locks.values()):
if not lock_info.is_expired:
await self._persist_lock(lock_info)
async def _background_cleanup(self):
while self._running:
try:
await asyncio.sleep(60)
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in WebDAV lock cleanup: {e}")
async def _cleanup_expired(self):
async with self._lock:
expired = [
path_hash for path_hash, lock_info in self.locks.items()
if lock_info.is_expired
]
for path_hash in expired:
del self.locks[path_hash]
await self._delete_lock_from_db(path_hash)
if expired:
logger.debug(f"Cleaned up {len(expired)} expired WebDAV locks")
async def get_stats(self) -> dict:
async with self._lock:
total = len(self.locks)
active = sum(1 for l in self.locks.values() if not l.is_expired)
return {
"total_locks": total,
"active_locks": active,
"expired_locks": total - active,
}
_webdav_locks: Optional[PersistentWebDAVLocks] = None
async def init_webdav_locks(db_manager=None) -> PersistentWebDAVLocks:
global _webdav_locks
_webdav_locks = PersistentWebDAVLocks()
await _webdav_locks.start(db_manager)
return _webdav_locks
async def shutdown_webdav_locks():
global _webdav_locks
if _webdav_locks:
await _webdav_locks.stop()
_webdav_locks = None
def get_webdav_locks() -> PersistentWebDAVLocks:
if not _webdav_locks:
raise RuntimeError("WebDAV locks not initialized")
return _webdav_locks
+3
View File
@@ -0,0 +1,3 @@
from .manager import UserDatabaseManager, get_user_db_manager
__all__ = ["UserDatabaseManager", "get_user_db_manager"]
+388
View File
@@ -0,0 +1,388 @@
import asyncio
import aiosqlite
import time
import json
from pathlib import Path
from typing import Dict, List, Any, Optional, Callable
from dataclasses import dataclass, field
from contextlib import asynccontextmanager
import logging
logger = logging.getLogger(__name__)
@dataclass
class WriteOperation:
sql: str
params: tuple = field(default_factory=tuple)
timestamp: float = field(default_factory=time.time)
@dataclass
class UserDatabase:
connection: Optional[aiosqlite.Connection] = None
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
last_access: float = field(default_factory=time.time)
write_buffer: List[WriteOperation] = field(default_factory=list)
dirty: bool = False
class UserDatabaseManager:
MASTER_TABLES_SQL = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
hashed_password TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
is_superuser INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
storage_quota_bytes INTEGER DEFAULT 10737418240,
used_storage_bytes INTEGER DEFAULT 0,
plan_type TEXT DEFAULT 'free',
two_factor_secret TEXT,
is_2fa_enabled INTEGER DEFAULT 0,
recovery_codes TEXT
);
CREATE TABLE IF NOT EXISTS revoked_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jti TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_jti ON revoked_tokens(jti);
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);
CREATE TABLE IF NOT EXISTS rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
limit_type TEXT NOT NULL,
count INTEGER DEFAULT 1,
window_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(key, limit_type)
);
CREATE INDEX IF NOT EXISTS idx_rate_limits_key ON rate_limits(key, limit_type);
CREATE TABLE IF NOT EXISTS webdav_locks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path_hash TEXT UNIQUE NOT NULL,
path TEXT NOT NULL,
token TEXT NOT NULL,
owner TEXT NOT NULL,
user_id INTEGER NOT NULL,
scope TEXT DEFAULT 'exclusive',
timeout INTEGER DEFAULT 3600,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_webdav_locks_path ON webdav_locks(path_hash);
CREATE INDEX IF NOT EXISTS idx_webdav_locks_token ON webdav_locks(token);
"""
USER_TABLES_SQL = """
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER,
owner_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted INTEGER DEFAULT 0,
is_starred INTEGER DEFAULT 0,
FOREIGN KEY (parent_id) REFERENCES folders(id),
UNIQUE(name, parent_id, owner_id)
);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL,
size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
file_hash TEXT,
thumbnail_path TEXT,
parent_id INTEGER,
owner_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted INTEGER DEFAULT 0,
deleted_at TIMESTAMP,
is_starred INTEGER DEFAULT 0,
last_accessed_at TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES folders(id),
UNIQUE(name, parent_id, owner_id)
);
CREATE TABLE IF NOT EXISTS file_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL,
version_path TEXT NOT NULL,
size INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (file_id) REFERENCES files(id)
);
CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
file_id INTEGER,
folder_id INTEGER,
owner_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
password_protected INTEGER DEFAULT 0,
hashed_password TEXT,
access_count INTEGER DEFAULT 0,
permission_level TEXT DEFAULT 'viewer',
FOREIGN KEY (file_id) REFERENCES files(id),
FOREIGN KEY (folder_id) REFERENCES folders(id)
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
ip_address TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS webdav_properties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
namespace TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(resource_type, resource_id, namespace, name)
);
CREATE INDEX IF NOT EXISTS idx_files_owner_deleted ON files(owner_id, is_deleted);
CREATE INDEX IF NOT EXISTS idx_files_parent_deleted ON files(parent_id, is_deleted);
CREATE INDEX IF NOT EXISTS idx_folders_owner_deleted ON folders(owner_id, is_deleted);
CREATE INDEX IF NOT EXISTS idx_folders_parent_deleted ON folders(parent_id, is_deleted);
CREATE INDEX IF NOT EXISTS idx_activities_user ON activities(user_id);
CREATE INDEX IF NOT EXISTS idx_activities_timestamp ON activities(timestamp);
"""
def __init__(self, base_path: Path, cache_size: int = 100, flush_interval: int = 30):
self.base_path = Path(base_path)
self.cache_size = cache_size
self.flush_interval = flush_interval
self.databases: Dict[int, UserDatabase] = {}
self.master_db: Optional[UserDatabase] = None
self._flush_task: Optional[asyncio.Task] = None
self._cleanup_task: Optional[asyncio.Task] = None
self._running = False
self._global_lock = asyncio.Lock()
async def start(self):
self._running = True
self.base_path.mkdir(parents=True, exist_ok=True)
(self.base_path / "users").mkdir(exist_ok=True)
await self._init_master_db()
self._flush_task = asyncio.create_task(self._background_flusher())
self._cleanup_task = asyncio.create_task(self._background_cleanup())
logger.info("UserDatabaseManager started")
async def stop(self):
self._running = False
if self._flush_task:
self._flush_task.cancel()
try:
await self._flush_task
except asyncio.CancelledError:
pass
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
await self._flush_all()
await self._close_all_connections()
logger.info("UserDatabaseManager stopped")
async def _init_master_db(self):
master_path = self.base_path / "master.db"
conn = await aiosqlite.connect(str(master_path))
await conn.executescript(self.MASTER_TABLES_SQL)
await conn.commit()
self.master_db = UserDatabase(connection=conn)
logger.info(f"Master database initialized at {master_path}")
async def _get_user_db_path(self, user_id: int) -> Path:
user_dir = self.base_path / "users" / str(user_id)
user_dir.mkdir(parents=True, exist_ok=True)
return user_dir / "database.db"
async def _create_user_db(self, user_id: int) -> aiosqlite.Connection:
db_path = await self._get_user_db_path(user_id)
conn = await aiosqlite.connect(str(db_path))
await conn.executescript(self.USER_TABLES_SQL)
await conn.commit()
logger.info(f"User database created for user {user_id}")
return conn
@asynccontextmanager
async def get_master_connection(self):
if not self.master_db or not self.master_db.connection:
raise RuntimeError("Master database not initialized")
async with self.master_db.lock:
self.master_db.last_access = time.time()
yield self.master_db.connection
@asynccontextmanager
async def get_user_connection(self, user_id: int):
async with self._global_lock:
if user_id not in self.databases:
if len(self.databases) >= self.cache_size:
await self._evict_oldest()
db_path = await self._get_user_db_path(user_id)
if db_path.exists():
conn = await aiosqlite.connect(str(db_path))
else:
conn = await self._create_user_db(user_id)
self.databases[user_id] = UserDatabase(connection=conn)
user_db = self.databases[user_id]
async with user_db.lock:
user_db.last_access = time.time()
yield user_db.connection
async def execute_buffered(self, user_id: int, sql: str, params: tuple = ()):
async with self._global_lock:
if user_id not in self.databases:
async with self.get_user_connection(user_id):
pass
user_db = self.databases[user_id]
async with user_db.lock:
user_db.write_buffer.append(WriteOperation(sql=sql, params=params))
user_db.dirty = True
async def execute_master_buffered(self, sql: str, params: tuple = ()):
if not self.master_db:
raise RuntimeError("Master database not initialized")
async with self.master_db.lock:
self.master_db.write_buffer.append(WriteOperation(sql=sql, params=params))
self.master_db.dirty = True
async def flush_user(self, user_id: int):
if user_id not in self.databases:
return
user_db = self.databases[user_id]
async with user_db.lock:
if not user_db.dirty or not user_db.write_buffer:
return
if user_db.connection:
for op in user_db.write_buffer:
await user_db.connection.execute(op.sql, op.params)
await user_db.connection.commit()
user_db.write_buffer.clear()
user_db.dirty = False
logger.debug(f"Flushed user {user_id} database")
async def flush_master(self):
if not self.master_db:
return
async with self.master_db.lock:
if not self.master_db.dirty or not self.master_db.write_buffer:
return
if self.master_db.connection:
for op in self.master_db.write_buffer:
await self.master_db.connection.execute(op.sql, op.params)
await self.master_db.connection.commit()
self.master_db.write_buffer.clear()
self.master_db.dirty = False
logger.debug("Flushed master database")
async def _flush_all(self):
await self.flush_master()
for user_id in list(self.databases.keys()):
await self.flush_user(user_id)
async def _evict_oldest(self):
if not self.databases:
return
oldest_id = min(self.databases.keys(), key=lambda k: self.databases[k].last_access)
await self.flush_user(oldest_id)
user_db = self.databases.pop(oldest_id)
if user_db.connection:
await user_db.connection.close()
logger.debug(f"Evicted user {oldest_id} database from cache")
async def _close_all_connections(self):
if self.master_db and self.master_db.connection:
await self.master_db.connection.close()
for user_id, user_db in self.databases.items():
if user_db.connection:
await user_db.connection.close()
self.databases.clear()
async def _background_flusher(self):
while self._running:
try:
await asyncio.sleep(self.flush_interval)
await self._flush_all()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in background flusher: {e}")
async def _background_cleanup(self):
while self._running:
try:
await asyncio.sleep(300)
now = time.time()
stale_timeout = 600
async with self._global_lock:
stale_users = [
uid for uid, db in self.databases.items()
if now - db.last_access > stale_timeout and not db.dirty
]
for user_id in stale_users:
await self.flush_user(user_id)
async with self._global_lock:
if user_id in self.databases:
user_db = self.databases.pop(user_id)
if user_db.connection:
await user_db.connection.close()
logger.debug(f"Cleaned up stale connection for user {user_id}")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in background cleanup: {e}")
async def create_user_database(self, user_id: int):
await self._create_user_db(user_id)
_db_manager: Optional[UserDatabaseManager] = None
async def init_db_manager(base_path: str = "data"):
global _db_manager
_db_manager = UserDatabaseManager(Path(base_path))
await _db_manager.start()
return _db_manager
async def shutdown_db_manager():
global _db_manager
if _db_manager:
await _db_manager.stop()
_db_manager = None
def get_user_db_manager() -> UserDatabaseManager:
if not _db_manager:
raise RuntimeError("Database manager not initialized")
return _db_manager
+13
View File
@@ -0,0 +1,13 @@
from .dal import DataAccessLayer, get_dal, init_dal, shutdown_dal
from .write_buffer import WriteBuffer, get_write_buffer, init_write_buffer, shutdown_write_buffer
__all__ = [
"DataAccessLayer",
"get_dal",
"init_dal",
"shutdown_dal",
"WriteBuffer",
"get_write_buffer",
"init_write_buffer",
"shutdown_write_buffer",
]
+333
View File
@@ -0,0 +1,333 @@
import asyncio
import hashlib
import time
from typing import Dict, List, Any, Optional, Tuple, Union
from dataclasses import dataclass, field
from collections import OrderedDict
import logging
logger = logging.getLogger(__name__)
@dataclass
class CacheEntry:
value: Any
created_at: float = field(default_factory=time.time)
ttl: float = 300.0
access_count: int = 0
@property
def is_expired(self) -> bool:
return time.time() - self.created_at > self.ttl
class DALCache:
def __init__(self, maxsize: int = 50000):
self.maxsize = maxsize
self._data: Dict[str, CacheEntry] = {}
self._lock = asyncio.Lock()
self._hits = 0
self._misses = 0
def get(self, key: str) -> Optional[Any]:
entry = self._data.get(key)
if entry is not None:
if entry.is_expired:
self._data.pop(key, None)
self._misses += 1
return None
entry.access_count += 1
self._hits += 1
return entry.value
self._misses += 1
return None
async def set(self, key: str, value: Any, ttl: float = 300.0):
if len(self._data) >= self.maxsize:
async with self._lock:
if len(self._data) >= self.maxsize:
keys = list(self._data.keys())[:1000]
for k in keys:
self._data.pop(k, None)
self._data[key] = CacheEntry(value=value, ttl=ttl)
def delete(self, key: str):
self._data.pop(key, None)
def invalidate_prefix(self, prefix: str) -> int:
keys_to_delete = [k for k in self._data.keys() if k.startswith(prefix)]
for k in keys_to_delete:
self._data.pop(k, None)
return len(keys_to_delete)
def clear(self):
self._data.clear()
def get_stats(self) -> Dict[str, Any]:
total = self._hits + self._misses
hit_rate = (self._hits / total * 100) if total > 0 else 0
return {
"hits": self._hits,
"misses": self._misses,
"size": len(self._data),
"hit_rate": round(hit_rate, 2),
}
class DataAccessLayer:
TTL_USER = 600.0
TTL_FOLDER = 120.0
TTL_FILE = 120.0
TTL_PATH = 60.0
TTL_CONTENTS = 30.0
def __init__(self, cache_size: int = 50000):
self._cache = DALCache(maxsize=cache_size)
async def start(self):
logger.info("DataAccessLayer started")
async def stop(self):
self._cache.clear()
logger.info("DataAccessLayer stopped")
def _user_key(self, user_id: int) -> str:
return f"user:{user_id}"
def _user_by_name_key(self, username: str) -> str:
return f"user:name:{username}"
def _folder_key(self, user_id: int, folder_id: Optional[int]) -> str:
return f"u:{user_id}:f:{folder_id or 'root'}"
def _file_key(self, user_id: int, file_id: int) -> str:
return f"u:{user_id}:file:{file_id}"
def _folder_contents_key(self, user_id: int, folder_id: Optional[int]) -> str:
return f"u:{user_id}:contents:{folder_id or 'root'}"
def _path_key(self, user_id: int, path: str) -> str:
path_hash = hashlib.md5(path.encode()).hexdigest()[:12]
return f"u:{user_id}:path:{path_hash}"
async def get_user_by_id(self, user_id: int) -> Optional[Any]:
key = self._user_key(user_id)
cached = self._cache.get(key)
if cached is not None:
return cached
from ..models import User
user = await User.get_or_none(id=user_id)
if user:
await self._cache.set(key, user, ttl=self.TTL_USER)
await self._cache.set(self._user_by_name_key(user.username), user, ttl=self.TTL_USER)
return user
async def get_user_by_username(self, username: str) -> Optional[Any]:
key = self._user_by_name_key(username)
cached = self._cache.get(key)
if cached is not None:
return cached
from ..models import User
user = await User.get_or_none(username=username)
if user:
await self._cache.set(key, user, ttl=self.TTL_USER)
await self._cache.set(self._user_key(user.id), user, ttl=self.TTL_USER)
return user
async def invalidate_user(self, user_id: int, username: Optional[str] = None):
self._cache.delete(self._user_key(user_id))
if username:
self._cache.delete(self._user_by_name_key(username))
self._cache.invalidate_prefix(f"u:{user_id}:")
async def refresh_user_cache(self, user):
await self._cache.set(self._user_key(user.id), user, ttl=self.TTL_USER)
await self._cache.set(self._user_by_name_key(user.username), user, ttl=self.TTL_USER)
async def get_folder(
self,
user_id: int,
folder_id: Optional[int] = None,
name: Optional[str] = None,
parent_id: Optional[int] = None,
) -> Optional[Any]:
if folder_id:
key = self._folder_key(user_id, folder_id)
cached = self._cache.get(key)
if cached is not None:
return cached
from ..models import Folder
if folder_id:
folder = await Folder.get_or_none(id=folder_id, owner_id=user_id, is_deleted=False)
elif name is not None:
folder = await Folder.get_or_none(
name=name, parent_id=parent_id, owner_id=user_id, is_deleted=False
)
else:
return None
if folder:
await self._cache.set(self._folder_key(user_id, folder.id), folder, ttl=self.TTL_FOLDER)
return folder
async def get_file(
self,
user_id: int,
file_id: Optional[int] = None,
name: Optional[str] = None,
parent_id: Optional[int] = None,
) -> Optional[Any]:
if file_id:
key = self._file_key(user_id, file_id)
cached = self._cache.get(key)
if cached is not None:
return cached
from ..models import File
if file_id:
file = await File.get_or_none(id=file_id, owner_id=user_id, is_deleted=False)
elif name is not None:
file = await File.get_or_none(
name=name, parent_id=parent_id, owner_id=user_id, is_deleted=False
)
else:
return None
if file:
await self._cache.set(self._file_key(user_id, file.id), file, ttl=self.TTL_FILE)
return file
async def get_folder_contents(
self,
user_id: int,
folder_id: Optional[int] = None,
) -> Tuple[List[Any], List[Any]]:
key = self._folder_contents_key(user_id, folder_id)
cached = self._cache.get(key)
if cached is not None:
return cached
from ..models import Folder, File
folders = await Folder.filter(
owner_id=user_id, parent_id=folder_id, is_deleted=False
).all()
files = await File.filter(
owner_id=user_id, parent_id=folder_id, is_deleted=False
).all()
result = (folders, files)
await self._cache.set(key, result, ttl=self.TTL_CONTENTS)
return result
async def resolve_path(
self,
user_id: int,
path_str: str,
) -> Tuple[Optional[Any], Optional[Any], bool]:
path_str = path_str.strip("/")
if not path_str:
return None, None, True
key = self._path_key(user_id, path_str)
cached = self._cache.get(key)
if cached is not None:
return cached
from ..models import Folder, File
parts = [p for p in path_str.split("/") if p]
current_folder = None
current_folder_id = None
for i, part in enumerate(parts[:-1]):
folder = await Folder.get_or_none(
name=part, parent_id=current_folder_id, owner_id=user_id, is_deleted=False
)
if not folder:
result = (None, None, False)
await self._cache.set(key, result, ttl=self.TTL_PATH)
return result
current_folder = folder
current_folder_id = folder.id
await self._cache.set(
self._folder_key(user_id, folder.id), folder, ttl=self.TTL_FOLDER
)
last_part = parts[-1]
folder = await Folder.get_or_none(
name=last_part, parent_id=current_folder_id, owner_id=user_id, is_deleted=False
)
if folder:
await self._cache.set(
self._folder_key(user_id, folder.id), folder, ttl=self.TTL_FOLDER
)
result = (folder, current_folder, True)
await self._cache.set(key, result, ttl=self.TTL_PATH)
return result
file = await File.get_or_none(
name=last_part, parent_id=current_folder_id, owner_id=user_id, is_deleted=False
)
if file:
await self._cache.set(
self._file_key(user_id, file.id), file, ttl=self.TTL_FILE
)
result = (file, current_folder, True)
await self._cache.set(key, result, ttl=self.TTL_PATH)
return result
result = (None, current_folder, False)
await self._cache.set(key, result, ttl=self.TTL_PATH)
return result
def invalidate_folder(self, user_id: int, folder_id: Optional[int] = None):
self._cache.delete(self._folder_key(user_id, folder_id))
self._cache.delete(self._folder_contents_key(user_id, folder_id))
self._cache.invalidate_prefix(f"u:{user_id}:path:")
def invalidate_file(self, user_id: int, file_id: int, parent_id: Optional[int] = None):
self._cache.delete(self._file_key(user_id, file_id))
if parent_id is not None:
self._cache.delete(self._folder_contents_key(user_id, parent_id))
self._cache.invalidate_prefix(f"u:{user_id}:path:")
def invalidate_path(self, user_id: int, path: str):
key = self._path_key(user_id, path)
self._cache.delete(key)
def invalidate_user_paths(self, user_id: int):
self._cache.invalidate_prefix(f"u:{user_id}:path:")
self._cache.invalidate_prefix(f"u:{user_id}:contents:")
def get_stats(self) -> Dict[str, Any]:
return self._cache.get_stats()
_dal: Optional[DataAccessLayer] = None
async def init_dal(cache_size: int = 50000) -> DataAccessLayer:
global _dal
_dal = DataAccessLayer(cache_size=cache_size)
await _dal.start()
return _dal
async def shutdown_dal():
global _dal
if _dal:
await _dal.stop()
_dal = None
def get_dal() -> DataAccessLayer:
if not _dal:
raise RuntimeError("DataAccessLayer not initialized")
return _dal
+178
View File
@@ -0,0 +1,178 @@
import asyncio
import time
import uuid
from typing import Dict, List, Any, Optional, Callable, Awaitable
from dataclasses import dataclass, field
from enum import Enum
from collections import defaultdict
import logging
logger = logging.getLogger(__name__)
class WriteType(Enum):
ACTIVITY = "activity"
USAGE_RECORD = "usage_record"
FILE_ACCESS = "file_access"
WEBDAV_PROPERTY = "webdav_property"
@dataclass
class BufferedWrite:
write_type: WriteType
data: Dict[str, Any]
created_at: float = field(default_factory=time.time)
priority: int = 0
class WriteBuffer:
def __init__(
self,
flush_interval: float = 60.0,
max_buffer_size: int = 1000,
immediate_types: Optional[set] = None,
):
self.flush_interval = flush_interval
self.max_buffer_size = max_buffer_size
self.immediate_types = immediate_types or set()
self._buffers: Dict[WriteType, List[BufferedWrite]] = defaultdict(list)
self._lock = asyncio.Lock()
self._flush_task: Optional[asyncio.Task] = None
self._running = False
self._handlers: Dict[WriteType, Callable[[List[Dict]], Awaitable[None]]] = {}
self._stats = {
"buffered": 0,
"flushed": 0,
"immediate": 0,
"errors": 0,
}
async def start(self):
self._running = True
self._flush_task = asyncio.create_task(self._background_flusher())
logger.info(f"WriteBuffer started (interval={self.flush_interval}s)")
async def stop(self):
self._running = False
if self._flush_task:
self._flush_task.cancel()
try:
await self._flush_task
except asyncio.CancelledError:
pass
await self.flush_all()
logger.info("WriteBuffer stopped")
def register_handler(
self,
write_type: WriteType,
handler: Callable[[List[Dict]], Awaitable[None]],
):
self._handlers[write_type] = handler
logger.debug(f"Registered write handler for {write_type.value}")
async def buffer(
self,
write_type: WriteType,
data: Dict[str, Any],
priority: int = 0,
):
if write_type in self.immediate_types:
await self._execute_immediate(write_type, data)
return
async with self._lock:
self._buffers[write_type].append(
BufferedWrite(write_type=write_type, data=data, priority=priority)
)
self._stats["buffered"] += 1
if len(self._buffers[write_type]) >= self.max_buffer_size:
await self._flush_type(write_type)
async def _execute_immediate(self, write_type: WriteType, data: Dict[str, Any]):
handler = self._handlers.get(write_type)
if handler:
try:
await handler([data])
self._stats["immediate"] += 1
except Exception as e:
logger.error(f"Immediate write failed for {write_type.value}: {e}")
self._stats["errors"] += 1
else:
logger.warning(f"No handler for immediate write type: {write_type.value}")
async def _flush_type(self, write_type: WriteType):
if not self._buffers[write_type]:
return
writes = self._buffers[write_type]
self._buffers[write_type] = []
handler = self._handlers.get(write_type)
if handler:
try:
data_list = [w.data for w in writes]
await handler(data_list)
self._stats["flushed"] += len(writes)
logger.debug(f"Flushed {len(writes)} {write_type.value} writes")
except Exception as e:
logger.error(f"Flush failed for {write_type.value}: {e}")
self._stats["errors"] += len(writes)
async with self._lock:
self._buffers[write_type].extend(writes)
async def flush_all(self):
async with self._lock:
types_to_flush = list(self._buffers.keys())
for write_type in types_to_flush:
async with self._lock:
await self._flush_type(write_type)
async def _background_flusher(self):
while self._running:
try:
await asyncio.sleep(self.flush_interval)
await self.flush_all()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in write buffer flusher: {e}")
def get_stats(self) -> Dict[str, Any]:
buffer_sizes = {t.value: len(b) for t, b in self._buffers.items()}
return {
**self._stats,
"buffer_sizes": buffer_sizes,
"total_buffered": sum(buffer_sizes.values()),
}
_write_buffer: Optional[WriteBuffer] = None
async def init_write_buffer(
flush_interval: float = 60.0,
max_buffer_size: int = 1000,
) -> WriteBuffer:
global _write_buffer
_write_buffer = WriteBuffer(
flush_interval=flush_interval,
max_buffer_size=max_buffer_size,
)
await _write_buffer.start()
return _write_buffer
async def shutdown_write_buffer():
global _write_buffer
if _write_buffer:
await _write_buffer.stop()
_write_buffer = None
def get_write_buffer() -> WriteBuffer:
if not _write_buffer:
raise RuntimeError("WriteBuffer not initialized")
return _write_buffer
+119 -52
View File
@@ -53,48 +53,122 @@ class LegalDocument(ABC):
return self.get_header() + self.get_content() + self.get_footer()
def to_html(self) -> str:
"""Generate the complete document in HTML format."""
# Content is already HTML, just wrap in basic HTML structure
"""Generate the complete document as Jinja2 template extending base.html."""
html_content = self.get_content()
html_content = f"""<html>
<head>
<title>{self.title}</title>
<style>
body {{
font-family: 'Times New Roman', serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: #333;
}}
h1, h2, h3 {{
color: #2c3e50;
margin-top: 30px;
}}
h1 {{ font-size: 2em; border-bottom: 2px solid #3498db; padding-bottom: 10px; }}
h2 {{ font-size: 1.5em; border-bottom: 1px solid #bdc3c7; padding-bottom: 5px; }}
ul {{ margin-left: 20px; }}
li {{ margin-bottom: 8px; }}
strong {{ color: #2c3e50; }}
</style>
</head>
<body>
<h1>{self.title}</h1>
<p><em>Last Updated: {self.last_updated}</em></p>
template_content = f"""{{% extends "base.html" %}}
{{% block title %}}{self.title} - MyWebdav{{% endblock %}}
{{% block description %}}{self.title} for MyWebdav cloud storage service.{{% endblock %}}
{{% block extra_css %}}
<style>
.legal-content {{
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
background: white;
border-radius: 8px;
}}
.legal-title {{
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}}
.legal-updated {{
color: #666;
font-style: italic;
margin-bottom: 2rem;
}}
.legal-content h2 {{
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}}
.legal-content h3 {{
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}}
.legal-content p {{
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}}
.legal-content ul {{
margin-left: 2rem;
margin-bottom: 1rem;
}}
.legal-content li {{
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}}
.legal-content strong {{
color: #333;
font-weight: 600;
}}
.legal-contact {{
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}}
.legal-contact h3 {{
color: #1565c0;
margin-top: 0;
}}
@media (max-width: 768px) {{
.legal-title {{
font-size: 2rem;
}}
.legal-content {{
padding: 2rem 1rem;
}}
}}
</style>
{{% endblock %}}
{{% block content %}}
<div class="legal-content">
<h1 class="legal-title">{self.title}</h1>
<p class="legal-updated">Last Updated: {self.last_updated}</p>
{html_content}
<hr>
<h3>Contact Information</h3>
<p>If you have any questions about this {self.title.lower()}, please contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:{self.contact_email}">{self.contact_email}</a></li>
<li><strong>Website:</strong> <a href="{self.website}">{self.website}</a></li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
<p>MyWebdav Technologies</p>
</body>
</html>"""
return html_content
<div class="legal-contact">
<h3>Contact Information</h3>
<p>If you have any questions about this {self.title.lower()}, please contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:{self.contact_email}">{self.contact_email}</a></li>
<li><strong>Website:</strong> <a href="{self.website}">{self.website}</a></li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
</div>
</div>
{{% endblock %}}
"""
return template_content
class PrivacyPolicy(LegalDocument):
@@ -867,28 +941,21 @@ def get_all_legal_documents() -> Dict[str, LegalDocument]:
}
def generate_legal_documents(output_dir: str = "static/legal"):
"""Generate all legal documents as Markdown and HTML files."""
def generate_legal_documents(template_dir: str = "mywebdav/templates/legal"):
"""Generate all legal documents as Jinja2 templates."""
import os
os.makedirs(output_dir, exist_ok=True)
os.makedirs(template_dir, exist_ok=True)
documents = get_all_legal_documents()
for doc_name, doc in documents.items():
# Generate Markdown
md_filename = f"{doc_name}.md"
md_path = os.path.join(output_dir, md_filename)
with open(md_path, "w") as f:
f.write(doc.to_markdown())
# Generate HTML
html_filename = f"{doc_name}.html"
html_path = os.path.join(output_dir, html_filename)
html_path = os.path.join(template_dir, html_filename)
with open(html_path, "w") as f:
f.write(doc.to_html())
print(f"Generated {md_filename} and {html_filename}")
print(f"Generated {html_filename}")
if __name__ == "__main__":
+243 -28
View File
@@ -2,9 +2,10 @@ import argparse
import uvicorn
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI, Request, HTTPException, status
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
from fastapi.templating import Jinja2Templates
from tortoise.contrib.fastapi import register_tortoise
from .settings import settings
from .routers import (
@@ -18,43 +19,208 @@ from .routers import (
starred,
billing,
admin_billing,
manage,
)
from . import webdav
from .schemas import ErrorResponse
from .middleware.usage_tracking import UsageTrackingMiddleware
from .middleware import UsageTrackingMiddleware, RateLimitMiddleware, SecurityHeadersMiddleware
from .monitoring import health_router
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
enterprise_components = {
"db_manager": None,
"cache": None,
"lock_manager": None,
"token_manager": None,
"rate_limiter": None,
"webdav_locks": None,
"task_queue": None,
"dal": None,
"write_buffer": None,
}
async def init_enterprise_components():
from .database.manager import init_db_manager
from .cache.layer import init_cache
from .concurrency.locks import init_lock_manager
from .concurrency.webdav_locks import init_webdav_locks
from .concurrency.atomic import init_atomic_ops
from .auth_tokens import init_token_manager
from .middleware.rate_limit import init_rate_limiter
from .workers.queue import init_task_queue
from .enterprise.dal import init_dal
from .enterprise.write_buffer import init_write_buffer, WriteType
enterprise_components["db_manager"] = await init_db_manager("data")
logger.info("Enterprise: Per-user database manager initialized")
enterprise_components["cache"] = await init_cache(maxsize=10000, flush_interval=30)
logger.info("Enterprise: Cache layer initialized")
enterprise_components["dal"] = await init_dal(cache_size=50000)
logger.info("Enterprise: Data Access Layer initialized")
enterprise_components["write_buffer"] = await init_write_buffer(
flush_interval=60.0,
max_buffer_size=1000,
)
from .activity import _flush_activities
from .billing.usage_tracker import _flush_usage_records
enterprise_components["write_buffer"].register_handler(WriteType.ACTIVITY, _flush_activities)
enterprise_components["write_buffer"].register_handler(WriteType.USAGE_RECORD, _flush_usage_records)
logger.info("Enterprise: Write buffer initialized (60s flush interval)")
enterprise_components["lock_manager"] = await init_lock_manager(default_timeout=30.0)
logger.info("Enterprise: Lock manager initialized")
init_atomic_ops()
logger.info("Enterprise: Atomic operations initialized")
enterprise_components["webdav_locks"] = await init_webdav_locks(
enterprise_components["db_manager"]
)
logger.info("Enterprise: Persistent WebDAV locks initialized")
enterprise_components["token_manager"] = await init_token_manager(
enterprise_components["db_manager"]
)
logger.info("Enterprise: Token manager with revocation initialized")
enterprise_components["rate_limiter"] = await init_rate_limiter()
logger.info("Enterprise: Rate limiter initialized")
enterprise_components["task_queue"] = await init_task_queue(max_workers=4)
logger.info("Enterprise: Background task queue initialized")
async def shutdown_enterprise_components():
from .database.manager import shutdown_db_manager
from .cache.layer import shutdown_cache
from .concurrency.locks import shutdown_lock_manager
from .concurrency.webdav_locks import shutdown_webdav_locks
from .auth_tokens import shutdown_token_manager
from .middleware.rate_limit import shutdown_rate_limiter
from .workers.queue import shutdown_task_queue
from .enterprise.dal import shutdown_dal
from .enterprise.write_buffer import shutdown_write_buffer
await shutdown_task_queue()
logger.info("Enterprise: Task queue stopped")
await shutdown_rate_limiter()
logger.info("Enterprise: Rate limiter stopped")
await shutdown_token_manager()
logger.info("Enterprise: Token manager stopped")
await shutdown_webdav_locks()
logger.info("Enterprise: WebDAV locks stopped")
await shutdown_lock_manager()
logger.info("Enterprise: Lock manager stopped")
await shutdown_write_buffer()
logger.info("Enterprise: Write buffer stopped (flushed pending writes)")
await shutdown_dal()
logger.info("Enterprise: Data Access Layer stopped")
await shutdown_cache()
logger.info("Enterprise: Cache layer stopped")
await shutdown_db_manager()
logger.info("Enterprise: Database manager stopped")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting up...")
await init_enterprise_components()
logger.info("All enterprise components initialized")
logger.info("Database connected.")
from .billing.scheduler import start_scheduler
from .billing.models import PricingConfig
from .billing.models import PricingConfig, SubscriptionPlan
from .mail import email_service
start_scheduler()
logger.info("Billing scheduler started")
await email_service.start()
logger.info("Email service started")
pricing_count = await PricingConfig.all().count()
if pricing_count == 0:
plan_count = await SubscriptionPlan.all().count()
if plan_count == 0:
from decimal import Decimal
# Create subscription plans
await SubscriptionPlan.create(
name="starter",
display_name="Starter",
description="Perfect for individuals and small projects",
storage_gb=None,
bandwidth_gb=None,
price_monthly=Decimal("0.00"),
is_active=True
)
await SubscriptionPlan.create(
name="professional",
display_name="Professional",
description="Best for growing teams and businesses",
storage_gb=None,
bandwidth_gb=None,
price_monthly=Decimal("0.00"),
is_active=True
)
await SubscriptionPlan.create(
name="enterprise",
display_name="Enterprise",
description="For large organizations with high volume needs",
storage_gb=None,
bandwidth_gb=None,
price_monthly=Decimal("0.00"),
is_active=True
)
# Create tiered pricing configuration
await PricingConfig.create(
config_key="storage_per_gb_month",
config_value=Decimal("0.0045"),
description="Storage cost per GB per month",
config_key="storage_per_gb_month_starter",
config_value=Decimal("0.005"),
description="Storage cost per GB per month (Starter tier)",
unit="per_gb_month",
)
await PricingConfig.create(
config_key="bandwidth_egress_per_gb",
config_value=Decimal("0.009"),
description="Bandwidth egress cost per GB",
config_key="storage_per_gb_month_professional",
config_value=Decimal("0.004"),
description="Storage cost per GB per month (Professional tier)",
unit="per_gb_month",
)
await PricingConfig.create(
config_key="storage_per_gb_month_enterprise",
config_value=Decimal("0.003"),
description="Storage cost per GB per month (Enterprise tier, 10TB+)",
unit="per_gb_month",
)
await PricingConfig.create(
config_key="bandwidth_egress_per_gb_starter",
config_value=Decimal("0.008"),
description="Bandwidth egress cost per GB (Starter tier)",
unit="per_gb",
)
await PricingConfig.create(
config_key="bandwidth_egress_per_gb_professional",
config_value=Decimal("0.007"),
description="Bandwidth egress cost per GB (Professional tier)",
unit="per_gb",
)
await PricingConfig.create(
config_key="bandwidth_egress_per_gb_enterprise",
config_value=Decimal("0.005"),
description="Bandwidth egress cost per GB (Enterprise tier)",
unit="per_gb",
)
await PricingConfig.create(
@@ -63,25 +229,19 @@ async def lifespan(app: FastAPI):
description="Bandwidth ingress cost per GB (free)",
unit="per_gb",
)
await PricingConfig.create(
config_key="free_tier_storage_gb",
config_value=Decimal("15"),
description="Free tier storage in GB",
unit="gb",
)
await PricingConfig.create(
config_key="free_tier_bandwidth_gb",
config_value=Decimal("15"),
description="Free tier bandwidth in GB per month",
unit="gb",
)
await PricingConfig.create(
config_key="tax_rate_default",
config_value=Decimal("0.0"),
description="Default tax rate (0 = no tax)",
unit="percentage",
)
logger.info("Default pricing configuration initialized")
await PricingConfig.create(
config_key="enterprise_min_storage_tb",
config_value=Decimal("10"),
description="Minimum storage for enterprise pricing (TB)",
unit="tb",
)
logger.info("Subscription plans and tiered pricing configuration initialized")
yield
@@ -91,6 +251,9 @@ async def lifespan(app: FastAPI):
logger.info("Billing scheduler stopped")
await email_service.stop()
logger.info("Email service stopped")
await shutdown_enterprise_components()
logger.info("All enterprise components shut down")
print("Shutting down...")
@@ -101,6 +264,8 @@ app = FastAPI(
lifespan=lifespan,
)
templates = Jinja2Templates(directory="mywebdav/templates")
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(folders.router)
@@ -111,8 +276,12 @@ app.include_router(admin.router)
app.include_router(starred.router)
app.include_router(billing.router)
app.include_router(admin_billing.router)
app.include_router(manage.router)
app.include_router(webdav.router)
app.include_router(health_router)
app.add_middleware(SecurityHeadersMiddleware, enable_hsts=False, enable_csp=True)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(UsageTrackingMiddleware)
app.mount("/static", StaticFiles(directory="static"), name="static")
@@ -131,14 +300,60 @@ async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(
f"HTTPException: {exc.status_code} - {exc.detail} for URL: {request.url}"
)
headers = exc.headers
# For WebDAV authentication challenges, we must return the headers
# from the exception and an empty body. A JSON body will confuse WebDAV clients.
if request.url.path.startswith("/webdav") and exc.status_code == status.HTTP_401_UNAUTHORIZED:
return Response(status_code=exc.status_code, headers=headers)
# For other WebDAV errors, it's better to return a text body than JSON
if request.url.path.startswith("/webdav"):
return Response(content=exc.detail, status_code=exc.status_code, headers=headers)
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(code=exc.status_code, message=exc.detail).model_dump(),
headers=headers,
)
@app.get("/", response_class=HTMLResponse) # Change response_class to HTMLResponse
async def read_root():
@app.get("/", response_class=HTMLResponse)
async def splash_page(request: Request):
return templates.TemplateResponse("splash.html", {"request": request})
@app.get("/features", response_class=HTMLResponse)
async def features_page(request: Request):
return templates.TemplateResponse("features.html", {"request": request})
@app.get("/pricing", response_class=HTMLResponse)
async def pricing_page(request: Request):
return templates.TemplateResponse("pricing.html", {"request": request})
@app.get("/support", response_class=HTMLResponse)
async def support_page(request: Request):
return templates.TemplateResponse("support.html", {"request": request})
@app.get("/legal/{document_name}", response_class=HTMLResponse)
async def legal_document(request: Request, document_name: str):
try:
return templates.TemplateResponse(f"legal/{document_name}.html", {"request": request})
except Exception:
raise HTTPException(status_code=404, detail="Legal document not found")
@app.get("/login")
async def login_redirect():
return RedirectResponse(url="/app", status_code=302)
@app.get("/app", response_class=HTMLResponse)
async def web_app():
with open("static/index.html", "r") as f:
return f.read()
@@ -148,7 +363,7 @@ def main():
parser.add_argument(
"--host", type=str, default="0.0.0.0", help="Host address to bind to"
)
parser.add_argument("--port", type=int, default=8000, help="Port to listen on")
parser.add_argument("--port", type=int, default=9004, help="Port to listen on")
args = parser.parse_args()
uvicorn.run(app, host=args.host, port=args.port)
+5
View File
@@ -0,0 +1,5 @@
from .usage_tracking import UsageTrackingMiddleware
from .rate_limit import RateLimitMiddleware
from .security import SecurityHeadersMiddleware
__all__ = ["UsageTrackingMiddleware", "RateLimitMiddleware", "SecurityHeadersMiddleware"]
+190
View File
@@ -0,0 +1,190 @@
import asyncio
import time
from typing import Dict, Tuple, Optional
from dataclasses import dataclass, field
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response, JSONResponse
import logging
from mywebdav.settings import settings
logger = logging.getLogger(__name__)
@dataclass
class RateLimitBucket:
count: int = 0
window_start: float = field(default_factory=time.time)
class RateLimiter:
LIMITS = {
"login": (5, 60),
"register": (3, 60),
"api": (100, 60),
"upload": (20, 60),
"download": (50, 60),
"webdav": (200, 60),
"default": (100, 60),
}
def __init__(self, db_manager=None):
self.db_manager = db_manager
self.buckets: Dict[str, RateLimitBucket] = {}
self._lock = asyncio.Lock()
self._cleanup_task: Optional[asyncio.Task] = None
self._running = False
async def start(self, db_manager=None):
if db_manager:
self.db_manager = db_manager
self._running = True
self._cleanup_task = asyncio.create_task(self._background_cleanup())
logger.info("RateLimiter started")
async def stop(self):
self._running = False
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("RateLimiter stopped")
def _get_bucket_key(self, key: str, limit_type: str) -> str:
return f"{limit_type}:{key}"
async def check_rate_limit(self, key: str, limit_type: str = "default") -> Tuple[bool, int, int]:
max_requests, window_seconds = self.LIMITS.get(limit_type, self.LIMITS["default"])
bucket_key = self._get_bucket_key(key, limit_type)
now = time.time()
async with self._lock:
if bucket_key not in self.buckets:
self.buckets[bucket_key] = RateLimitBucket(count=1, window_start=now)
return True, max_requests - 1, window_seconds
bucket = self.buckets[bucket_key]
if now - bucket.window_start >= window_seconds:
bucket.count = 1
bucket.window_start = now
return True, max_requests - 1, window_seconds
if bucket.count >= max_requests:
retry_after = int(window_seconds - (now - bucket.window_start))
return False, 0, retry_after
bucket.count += 1
remaining = max_requests - bucket.count
return True, remaining, window_seconds
async def reset_rate_limit(self, key: str, limit_type: str = "default"):
bucket_key = self._get_bucket_key(key, limit_type)
async with self._lock:
if bucket_key in self.buckets:
del self.buckets[bucket_key]
async def _background_cleanup(self):
while self._running:
try:
await asyncio.sleep(60)
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in rate limit cleanup: {e}")
async def _cleanup_expired(self):
now = time.time()
async with self._lock:
expired = []
for bucket_key, bucket in self.buckets.items():
limit_type = bucket_key.split(":")[0]
_, window_seconds = self.LIMITS.get(limit_type, self.LIMITS["default"])
if now - bucket.window_start > window_seconds * 2:
expired.append(bucket_key)
for key in expired:
del self.buckets[key]
if expired:
logger.debug(f"Cleaned up {len(expired)} expired rate limit buckets")
_rate_limiter: Optional[RateLimiter] = None
async def init_rate_limiter(db_manager=None) -> RateLimiter:
global _rate_limiter
_rate_limiter = RateLimiter()
await _rate_limiter.start(db_manager)
return _rate_limiter
async def shutdown_rate_limiter():
global _rate_limiter
if _rate_limiter:
await _rate_limiter.stop()
_rate_limiter = None
def get_rate_limiter() -> RateLimiter:
if not _rate_limiter:
raise RuntimeError("Rate limiter not initialized")
return _rate_limiter
class RateLimitMiddleware(BaseHTTPMiddleware):
ROUTE_LIMITS = {
"/api/auth/login": "login",
"/api/auth/register": "register",
"/files/upload": "upload",
"/files/download": "download",
"/webdav": "webdav",
}
async def dispatch(self, request: Request, call_next):
if not settings.RATE_LIMIT_ENABLED:
return await call_next(request)
if not _rate_limiter:
return await call_next(request)
client_ip = self._get_client_ip(request)
limit_type = self._get_limit_type(request.url.path)
allowed, remaining, retry_after = await _rate_limiter.check_rate_limit(
client_ip, limit_type
)
if not allowed:
return JSONResponse(
status_code=429,
content={
"detail": "Too many requests",
"retry_after": retry_after
},
headers={
"Retry-After": str(retry_after),
"X-RateLimit-Remaining": "0",
}
)
response = await call_next(request)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(retry_after)
return response
def _get_client_ip(self, request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _get_limit_type(self, path: str) -> str:
for route_prefix, limit_type in self.ROUTE_LIMITS.items():
if path.startswith(route_prefix):
return limit_type
return "api"
+49
View File
@@ -0,0 +1,49 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "geolocation=(), microphone=(), camera=()",
}
HTTPS_HEADERS = {
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
}
CSP_POLICY = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"font-src 'self'; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
def __init__(self, app, enable_hsts: bool = False, enable_csp: bool = True):
super().__init__(app)
self.enable_hsts = enable_hsts
self.enable_csp = enable_csp
async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request)
for header, value in self.SECURITY_HEADERS.items():
response.headers[header] = value
if self.enable_hsts:
for header, value in self.HTTPS_HEADERS.items():
response.headers[header] = value
if self.enable_csp and not request.url.path.startswith("/webdav"):
response.headers["Content-Security-Policy"] = self.CSP_POLICY
return response
+7 -2
View File
@@ -1,13 +1,18 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from ..billing.usage_tracker import UsageTracker
try:
from ..billing.usage_tracker import UsageTracker
BILLING_AVAILABLE = True
except ImportError:
BILLING_AVAILABLE = False
class UsageTrackingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if hasattr(request.state, "user") and request.state.user:
if BILLING_AVAILABLE and hasattr(request.state, "user") and request.state.user:
user = request.state.user
if (
+9 -11
View File
@@ -4,7 +4,7 @@ from tortoise.contrib.pydantic import pydantic_model_creator
class User(models.Model):
id = fields.IntField(primary_key=True)
username = fields.CharField(max_length=20, unique=True)
username = fields.CharField(max_length=255, unique=True)
email = fields.CharField(max_length=255, unique=True)
hashed_password = fields.CharField(max_length=255)
is_active = fields.BooleanField(default=True)
@@ -13,9 +13,9 @@ class User(models.Model):
updated_at = fields.DatetimeField(auto_now=True)
storage_quota_bytes = fields.BigIntField(
default=10 * 1024 * 1024 * 1024
) # 10 GB default
)
used_storage_bytes = fields.BigIntField(default=0)
plan_type = fields.CharField(max_length=50, default="free")
plan_type = fields.CharField(max_length=100, default="free")
two_factor_secret = fields.CharField(max_length=255, null=True)
is_2fa_enabled = fields.BooleanField(default=False)
recovery_codes = fields.TextField(null=True)
@@ -97,7 +97,7 @@ class FileVersion(models.Model):
class Share(models.Model):
id = fields.IntField(primary_key=True)
token = fields.CharField(max_length=64, unique=True)
token = fields.CharField(max_length=128, unique=True)
file: fields.ForeignKeyRelation[File] = fields.ForeignKeyField(
"models.File", related_name="shares", null=True
)
@@ -112,9 +112,7 @@ class Share(models.Model):
password_protected = fields.BooleanField(default=False)
hashed_password = fields.CharField(max_length=255, null=True)
access_count = fields.IntField(default=0)
permission_level = fields.CharField(
max_length=50, default="viewer"
) # viewer, uploader, editor
permission_level = fields.CharField(max_length=100, default="viewer")
class Meta:
table = "shares"
@@ -143,7 +141,7 @@ class TeamMember(models.Model):
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
"models.User", related_name="user_teams"
)
role = fields.CharField(max_length=50, default="member") # owner, admin, member
role = fields.CharField(max_length=100, default="member")
class Meta:
table = "team_members"
@@ -156,9 +154,9 @@ class Activity(models.Model):
"models.User", related_name="activities", null=True
)
action = fields.CharField(max_length=255)
target_type = fields.CharField(max_length=50) # file, folder, share, user, team
target_type = fields.CharField(max_length=100)
target_id = fields.IntField()
ip_address = fields.CharField(max_length=45, null=True)
ip_address = fields.CharField(max_length=100, null=True)
timestamp = fields.DatetimeField(auto_now_add=True)
class Meta:
@@ -186,7 +184,7 @@ class FileRequest(models.Model):
class WebDAVProperty(models.Model):
id = fields.IntField(primary_key=True)
resource_type = fields.CharField(max_length=10)
resource_type = fields.CharField(max_length=100)
resource_id = fields.IntField()
namespace = fields.CharField(max_length=255)
name = fields.CharField(max_length=255)
+3
View File
@@ -0,0 +1,3 @@
from .health import router as health_router
__all__ = ["health_router"]
+179
View File
@@ -0,0 +1,179 @@
import asyncio
import time
import os
from datetime import datetime
from typing import Dict, Any
from fastapi import APIRouter, Response
from pydantic import BaseModel
router = APIRouter(prefix="/health", tags=["health"])
class HealthCheck(BaseModel):
status: str
timestamp: str
checks: Dict[str, Any]
class ReadinessCheck(BaseModel):
ready: bool
class LivenessCheck(BaseModel):
alive: bool
async def check_database() -> Dict[str, Any]:
try:
from ..database import get_user_db_manager
db_manager = get_user_db_manager()
async with db_manager.get_master_connection() as conn:
cursor = await conn.execute("SELECT 1")
await cursor.fetchone()
return {"ok": True, "message": "Connected"}
except Exception as e:
return {"ok": False, "message": str(e)}
async def check_cache() -> Dict[str, Any]:
try:
from ..cache import get_cache
cache = get_cache()
stats = cache.get_stats()
return {"ok": True, "stats": stats}
except Exception as e:
return {"ok": False, "message": str(e)}
async def check_locks() -> Dict[str, Any]:
try:
from ..concurrency import get_lock_manager
lock_manager = get_lock_manager()
stats = await lock_manager.get_stats()
return {"ok": True, "stats": stats}
except Exception as e:
return {"ok": False, "message": str(e)}
async def check_task_queue() -> Dict[str, Any]:
try:
from ..workers import get_task_queue
queue = get_task_queue()
stats = await queue.get_stats()
return {"ok": True, "stats": stats}
except Exception as e:
return {"ok": False, "message": str(e)}
async def check_storage() -> Dict[str, Any]:
try:
from ..settings import settings
storage_path = settings.STORAGE_PATH
if os.path.exists(storage_path):
stat = os.statvfs(storage_path)
free_bytes = stat.f_bavail * stat.f_frsize
total_bytes = stat.f_blocks * stat.f_frsize
used_percent = ((total_bytes - free_bytes) / total_bytes) * 100
return {
"ok": True,
"free_gb": round(free_bytes / (1024**3), 2),
"total_gb": round(total_bytes / (1024**3), 2),
"used_percent": round(used_percent, 2)
}
return {"ok": False, "message": "Storage path does not exist"}
except Exception as e:
return {"ok": False, "message": str(e)}
@router.get("", response_model=HealthCheck)
async def health_check():
checks = {}
check_funcs = {
"database": check_database,
"cache": check_cache,
"locks": check_locks,
"task_queue": check_task_queue,
"storage": check_storage,
}
results = await asyncio.gather(
*[func() for func in check_funcs.values()],
return_exceptions=True
)
for name, result in zip(check_funcs.keys(), results):
if isinstance(result, Exception):
checks[name] = {"ok": False, "message": str(result)}
else:
checks[name] = result
all_ok = all(check.get("ok", False) for check in checks.values())
status = "healthy" if all_ok else "degraded"
return HealthCheck(
status=status,
timestamp=datetime.utcnow().isoformat(),
checks=checks
)
@router.get("/ready", response_model=ReadinessCheck)
async def readiness_check():
try:
db_check = await check_database()
return ReadinessCheck(ready=db_check.get("ok", False))
except Exception:
return ReadinessCheck(ready=False)
@router.get("/live", response_model=LivenessCheck)
async def liveness_check():
return LivenessCheck(alive=True)
@router.get("/metrics")
async def metrics():
metrics_data = []
try:
from ..cache import get_cache
cache = get_cache()
stats = cache.get_stats()
metrics_data.append(f'cache_hits_total {stats.get("hits", 0)}')
metrics_data.append(f'cache_misses_total {stats.get("misses", 0)}')
metrics_data.append(f'cache_hit_rate_percent {stats.get("hit_rate_percent", 0)}')
except Exception:
pass
try:
from ..concurrency import get_lock_manager
lock_manager = get_lock_manager()
stats = await lock_manager.get_stats()
metrics_data.append(f'locks_total {stats.get("total_locks", 0)}')
metrics_data.append(f'locks_active {stats.get("active_locks", 0)}')
except Exception:
pass
try:
from ..workers import get_task_queue
queue = get_task_queue()
stats = await queue.get_stats()
metrics_data.append(f'tasks_enqueued_total {stats.get("enqueued", 0)}')
metrics_data.append(f'tasks_completed_total {stats.get("completed", 0)}')
metrics_data.append(f'tasks_failed_total {stats.get("failed", 0)}')
metrics_data.append(f'tasks_pending {stats.get("pending_tasks", 0)}')
except Exception:
pass
try:
storage_check = await check_storage()
if storage_check.get("ok"):
metrics_data.append(f'storage_free_gb {storage_check.get("free_gb", 0)}')
metrics_data.append(f'storage_used_percent {storage_check.get("used_percent", 0)}')
except Exception:
pass
return Response(
content="\n".join(metrics_data),
media_type="text/plain"
)
+57
View File
@@ -0,0 +1,57 @@
from typing import TypeVar, Generic, List, Optional
from pydantic import BaseModel
from fastapi import Query
T = TypeVar('T')
class PaginationParams:
def __init__(
self,
offset: int = Query(default=0, ge=0, description="Number of items to skip"),
limit: int = Query(default=50, ge=1, le=500, description="Number of items to return"),
sort_by: Optional[str] = Query(default=None, description="Field to sort by"),
sort_order: str = Query(default="asc", regex="^(asc|desc)$", description="Sort order"),
):
self.offset = offset
self.limit = limit
self.sort_by = sort_by
self.sort_order = sort_order
class PaginatedResponse(BaseModel, Generic[T]):
items: List[T]
total: int
offset: int
limit: int
has_more: bool
async def paginate_query(
query,
pagination: PaginationParams,
default_sort: str = "id"
) -> tuple:
sort_field = pagination.sort_by or default_sort
if pagination.sort_order == "desc":
sort_field = f"-{sort_field}"
total = await query.count()
items = await query.offset(pagination.offset).limit(pagination.limit).order_by(sort_field).all()
has_more = pagination.offset + len(items) < total
return items, total, has_more
def create_paginated_response(
items: List,
total: int,
pagination: PaginationParams
) -> dict:
return {
"items": items,
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"has_more": pagination.offset + len(items) < total,
}
+28
View File
@@ -0,0 +1,28 @@
# retoor <retoor@molodetz.nl>
from . import (
admin,
admin_billing,
auth,
billing,
files,
folders,
manage,
search,
shares,
starred,
users,
)
__all__ = [
"admin",
"admin_billing",
"auth",
"billing",
"files",
"folders",
"manage",
"search",
"shares",
"starred",
"users",
]
+14
View File
@@ -234,3 +234,17 @@ async def get_new_recovery_codes(
await current_user.save()
return recovery_codes
@router.get("/me")
async def get_current_user_info(current_user: User = Depends(get_current_user)):
"""Get current user information"""
return {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"is_active": current_user.is_active,
"is_verified": current_user.is_verified,
"is_2fa_enabled": current_user.is_2fa_enabled,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
}
+139
View File
@@ -408,6 +408,145 @@ async def list_plans():
]
class SubscribeRequest(BaseModel):
plan_name: str
class UnsubscribeRequest(BaseModel):
cancel_immediately: bool = False
@router.post("/subscribe")
async def subscribe_to_plan(
request: SubscribeRequest,
current_user: User = Depends(get_current_user),
):
try:
# Find the plan
plan = await SubscriptionPlan.get_or_none(
name=request.plan_name,
is_active=True
)
if not plan:
raise HTTPException(
status_code=404,
detail=f"Plan '{request.plan_name}' not found"
)
# Check if user already has a subscription
existing_subscription = await UserSubscription.get_or_none(
user=current_user
)
if existing_subscription:
# Update existing subscription
existing_subscription.plan = plan
existing_subscription.billing_type = "subscription"
await existing_subscription.save()
return {
"message": f"Successfully updated to {plan.display_name} plan",
"plan": plan.display_name,
"billing_type": "subscription"
}
else:
# Create new subscription
subscription = await UserSubscription.create(
user=current_user,
plan=plan,
billing_type="subscription",
status="active"
)
return {
"message": f"Successfully subscribed to {plan.display_name} plan",
"plan": plan.display_name,
"billing_type": "subscription",
"subscription_id": subscription.id
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to subscribe to plan: {str(e)}"
)
@router.post("/unsubscribe")
async def unsubscribe_from_plan(
request: UnsubscribeRequest,
current_user: User = Depends(get_current_user),
):
try:
subscription = await UserSubscription.get_or_none(
user=current_user
)
if not subscription:
raise HTTPException(
status_code=404,
detail="No active subscription found"
)
if request.cancel_immediately:
# Cancel subscription immediately
await subscription.delete()
return {"message": "Subscription cancelled immediately"}
else:
# Mark for cancellation at end of billing period
subscription.status = "cancelled"
await subscription.save()
return {"message": "Subscription will be cancelled at end of billing period"}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to unsubscribe: {str(e)}"
)
@router.get("/subscription")
async def get_subscription(
current_user: User = Depends(get_current_user),
) -> SubscriptionResponse:
try:
subscription = await UserSubscription.get_or_none(
user=current_user
).prefetch_related("plan")
if not subscription:
# Return default starter subscription
default_plan = await SubscriptionPlan.get_or_none(
name="starter",
is_active=True
)
return SubscriptionResponse(
id=0,
billing_type="pay_as_you_go",
plan_name=default_plan.display_name if default_plan else "Starter",
status="active",
current_period_start=None,
current_period_end=None
)
return SubscriptionResponse(
id=subscription.id,
billing_type=subscription.billing_type,
plan_name=subscription.plan.display_name if subscription.plan else None,
status=subscription.status,
current_period_start=subscription.current_period_start,
current_period_end=subscription.current_period_end
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to fetch subscription: {str(e)}"
)
@router.get("/stripe-key")
async def get_stripe_key():
from ..settings import settings
+97 -39
View File
@@ -22,6 +22,12 @@ from ..storage import storage_manager
from ..activity import log_activity
from ..thumbnails import generate_thumbnail, delete_thumbnail
try:
from ..concurrency.atomic import get_atomic_ops
ATOMIC_OPS_AVAILABLE = True
except ImportError:
ATOMIC_OPS_AVAILABLE = False
router = APIRouter(
prefix="/files",
tags=["files"],
@@ -70,51 +76,103 @@ async def upload_file(
else:
parent_folder = None
existing_file = await File.get_or_none(
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
)
if existing_file:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="File with this name already exists in the current folder",
)
file_content = await file.read()
file_size = len(file_content)
file_hash = hashlib.sha256(file_content).hexdigest()
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail="Storage quota exceeded",
if ATOMIC_OPS_AVAILABLE:
atomic_ops = get_atomic_ops()
async def check_exists():
return await File.get_or_none(
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
)
async def create_file():
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{file_hash}{file_extension}"
storage_path = unique_filename
await storage_manager.save_file(current_user.id, storage_path, file_content)
mime_type, _ = mimetypes.guess_type(file.filename)
if not mime_type:
mime_type = "application/octet-stream"
db_file = await File.create(
name=file.filename,
path=storage_path,
size=file_size,
mime_type=mime_type,
file_hash=file_hash,
owner=current_user,
parent=parent_folder,
)
return db_file, storage_path, mime_type
quota_result = await atomic_ops.atomic_quota_check_and_update(
current_user, file_size, lambda u: u.save()
)
if not quota_result.allowed:
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail=f"Storage quota exceeded. Available: {quota_result.remaining} bytes",
)
try:
db_file, storage_path, mime_type = await atomic_ops.atomic_file_create(
current_user,
parent_folder.id if parent_folder else None,
file.filename,
check_exists,
create_file,
)
except FileExistsError as e:
await atomic_ops.atomic_quota_check_and_update(
current_user, -file_size, lambda u: u.save()
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
else:
existing_file = await File.get_or_none(
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
)
if existing_file:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="File with this name already exists in the current folder",
)
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail="Storage quota exceeded",
)
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{file_hash}{file_extension}"
storage_path = unique_filename
await storage_manager.save_file(current_user.id, storage_path, file_content)
mime_type, _ = mimetypes.guess_type(file.filename)
if not mime_type:
mime_type = "application/octet-stream"
db_file = await File.create(
name=file.filename,
path=storage_path,
size=file_size,
mime_type=mime_type,
file_hash=file_hash,
owner=current_user,
parent=parent_folder,
)
# Generate a unique path for storage
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{file_hash}{file_extension}" # Use hash for unique filename
storage_path = unique_filename
# Save file to storage
await storage_manager.save_file(current_user.id, storage_path, file_content)
# Get mime type
mime_type, _ = mimetypes.guess_type(file.filename)
if not mime_type:
mime_type = "application/octet-stream"
# Create file entry in database
db_file = await File.create(
name=file.filename,
path=storage_path,
size=file_size,
mime_type=mime_type,
file_hash=file_hash,
owner=current_user,
parent=parent_folder,
)
current_user.used_storage_bytes += file_size
await current_user.save()
current_user.used_storage_bytes += file_size
await current_user.save()
thumbnail_path = await generate_thumbnail(storage_path, mime_type, current_user.id)
if thumbnail_path:
+417
View File
@@ -0,0 +1,417 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, date
from decimal import Decimal
from typing import Optional
import math
from fastapi import APIRouter, Request, Form, Depends, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from mywebdav.models import User, Activity
from mywebdav.billing.models import (
Invoice, InvoiceLineItem, PricingConfig,
UserSubscription, UsageAggregate
)
from mywebdav.admin_auth import (
verify_admin_credentials, get_admin_session,
create_session_response, clear_session_response,
generate_csrf_token, verify_csrf_token
)
from mywebdav.auth import get_password_hash
router = APIRouter(prefix="/manage", tags=["admin-panel"])
templates = Jinja2Templates(directory="mywebdav/templates")
def format_bytes(bytes_value: int) -> str:
if bytes_value is None:
return "0 B"
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if abs(bytes_value) < 1024.0:
return f"{bytes_value:.1f} {unit}"
bytes_value /= 1024.0
return f"{bytes_value:.1f} PB"
def format_currency(value: float) -> str:
return f"${value:.2f}"
templates.env.filters['format_bytes'] = format_bytes
templates.env.filters['format_currency'] = format_currency
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, error: Optional[str] = None):
session = get_admin_session(request)
if session:
return RedirectResponse(url="/manage/", status_code=303)
return templates.TemplateResponse("admin/login.html", {
"request": request,
"error": error
})
@router.post("/login")
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...)
):
if verify_admin_credentials(username, password):
response = RedirectResponse(url="/manage/", status_code=303)
return create_session_response(response, username)
return RedirectResponse(url="/manage/login?error=invalid", status_code=303)
@router.get("/logout")
async def logout(request: Request):
response = RedirectResponse(url="/manage/login", status_code=303)
return clear_session_response(response)
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
total_users = await User.all().count()
active_users = await User.filter(is_active=True).count()
inactive_users = total_users - active_users
total_storage = 0
users = await User.all()
for user in users:
total_storage += user.used_storage_bytes or 0
current_month = date.today().replace(day=1)
paid_invoices = await Invoice.filter(
status="paid",
period_start__gte=current_month
).all()
monthly_revenue = sum(float(inv.total) for inv in paid_invoices)
pending_invoices = await Invoice.filter(status="open").count()
recent_activities = await Activity.all().order_by("-timestamp").limit(10)
activity_list = []
for act in recent_activities:
user = await User.get_or_none(id=act.user_id)
activity_list.append({
"user": user.username if user else "Unknown",
"action": act.action,
"timestamp": act.timestamp
})
return templates.TemplateResponse("admin/dashboard.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"stats": {
"total_users": total_users,
"active_users": active_users,
"inactive_users": inactive_users,
"total_storage": total_storage,
"monthly_revenue": monthly_revenue,
"pending_invoices": pending_invoices
},
"recent_activities": activity_list
})
@router.get("/users", response_class=HTMLResponse)
async def users_list(
request: Request,
search: Optional[str] = None,
status: Optional[str] = None,
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=5, le=100)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
query = User.all()
if search:
query = query.filter(username__icontains=search) | User.filter(email__icontains=search)
if status == "active":
query = query.filter(is_active=True)
elif status == "inactive":
query = query.filter(is_active=False)
elif status == "superuser":
query = query.filter(is_superuser=True)
total = await query.count()
total_pages = math.ceil(total / per_page) if total > 0 else 1
offset = (page - 1) * per_page
users = await query.order_by("-created_at").offset(offset).limit(per_page)
return templates.TemplateResponse("admin/users/list.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"users": users,
"search": search or "",
"status": status or "",
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages
})
@router.get("/users/{user_id}", response_class=HTMLResponse)
async def user_detail(request: Request, user_id: int):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
user = await User.get_or_none(id=user_id)
if not user:
return RedirectResponse(url="/manage/users?error=not_found", status_code=303)
subscription = await UserSubscription.get_or_none(user_id=user_id)
invoices = await Invoice.filter(user_id=user_id).order_by("-created_at").limit(5)
usage_percent = 0
if user.storage_quota_bytes and user.storage_quota_bytes > 0:
usage_percent = (user.used_storage_bytes or 0) / user.storage_quota_bytes * 100
return templates.TemplateResponse("admin/users/detail.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"user": user,
"subscription": subscription,
"invoices": invoices,
"usage_percent": min(100, usage_percent)
})
@router.post("/users/{user_id}")
async def user_update(
request: Request,
user_id: int,
csrf_token: str = Form(...),
username: str = Form(...),
email: str = Form(...),
password: Optional[str] = Form(None),
storage_quota_gb: float = Form(...),
plan_type: str = Form(...),
is_active: bool = Form(False),
is_superuser: bool = Form(False)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
if not verify_csrf_token(session, csrf_token):
return RedirectResponse(url=f"/manage/users/{user_id}?error=csrf", status_code=303)
user = await User.get_or_none(id=user_id)
if not user:
return RedirectResponse(url="/manage/users?error=not_found", status_code=303)
user.username = username
user.email = email
user.storage_quota_bytes = int(storage_quota_gb * 1024 * 1024 * 1024)
user.plan_type = plan_type
user.is_active = is_active
user.is_superuser = is_superuser
if password and password.strip():
user.hashed_password = get_password_hash(password)
await user.save()
return RedirectResponse(url=f"/manage/users/{user_id}?success=1", status_code=303)
@router.post("/users/{user_id}/delete")
async def user_delete(
request: Request,
user_id: int,
csrf_token: str = Form(...)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
if not verify_csrf_token(session, csrf_token):
return RedirectResponse(url=f"/manage/users/{user_id}?error=csrf", status_code=303)
user = await User.get_or_none(id=user_id)
if user:
await user.delete()
return RedirectResponse(url="/manage/users?deleted=1", status_code=303)
@router.post("/users/{user_id}/toggle-active")
async def user_toggle_active(
request: Request,
user_id: int,
csrf_token: str = Form(...)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
if not verify_csrf_token(session, csrf_token):
return RedirectResponse(url="/manage/users?error=csrf", status_code=303)
user = await User.get_or_none(id=user_id)
if user:
user.is_active = not user.is_active
await user.save()
return RedirectResponse(url="/manage/users", status_code=303)
@router.get("/payments", response_class=HTMLResponse)
async def payments_list(
request: Request,
status: Optional[str] = None,
user_id: Optional[int] = None,
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=5, le=100)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
query = Invoice.all()
if status:
query = query.filter(status=status)
if user_id:
query = query.filter(user_id=user_id)
total = await query.count()
total_pages = math.ceil(total / per_page) if total > 0 else 1
offset = (page - 1) * per_page
invoices = await query.order_by("-created_at").offset(offset).limit(per_page)
invoice_list = []
for inv in invoices:
user = await User.get_or_none(id=inv.user_id)
invoice_list.append({
"invoice": inv,
"user": user
})
total_revenue = await Invoice.filter(status="paid").all()
revenue_sum = sum(float(inv.total) for inv in total_revenue)
pending_count = await Invoice.filter(status="open").count()
pending_invoices = await Invoice.filter(status="open").all()
pending_sum = sum(float(inv.total) for inv in pending_invoices)
return templates.TemplateResponse("admin/payments/list.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"invoices": invoice_list,
"status_filter": status or "",
"user_id_filter": user_id,
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"summary": {
"total_revenue": revenue_sum,
"pending_count": pending_count,
"pending_amount": pending_sum
}
})
@router.get("/payments/{invoice_id}", response_class=HTMLResponse)
async def payment_detail(request: Request, invoice_id: int):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
invoice = await Invoice.get_or_none(id=invoice_id)
if not invoice:
return RedirectResponse(url="/manage/payments?error=not_found", status_code=303)
user = await User.get_or_none(id=invoice.user_id)
line_items = await InvoiceLineItem.filter(invoice_id=invoice_id).all()
return templates.TemplateResponse("admin/payments/detail.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"invoice": invoice,
"user": user,
"line_items": line_items
})
@router.post("/payments/{invoice_id}/mark-paid")
async def payment_mark_paid(
request: Request,
invoice_id: int,
csrf_token: str = Form(...)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
if not verify_csrf_token(session, csrf_token):
return RedirectResponse(url=f"/manage/payments/{invoice_id}?error=csrf", status_code=303)
invoice = await Invoice.get_or_none(id=invoice_id)
if invoice:
invoice.status = "paid"
invoice.paid_at = datetime.utcnow()
await invoice.save()
return RedirectResponse(url=f"/manage/payments/{invoice_id}?success=1", status_code=303)
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
pricing_configs = await PricingConfig.all().order_by("config_key")
return templates.TemplateResponse("admin/settings.html", {
"request": request,
"session": session,
"csrf_token": generate_csrf_token(session),
"pricing_configs": pricing_configs
})
@router.post("/settings/pricing/{config_id}")
async def update_pricing(
request: Request,
config_id: int,
csrf_token: str = Form(...),
value: str = Form(...)
):
session = get_admin_session(request)
if not session:
return RedirectResponse(url="/manage/login", status_code=303)
if not verify_csrf_token(session, csrf_token):
return RedirectResponse(url="/manage/settings?error=csrf", status_code=303)
config = await PricingConfig.get_or_none(id=config_id)
if config:
config.config_value = Decimal(value)
config.updated_at = datetime.utcnow()
await config.save()
return RedirectResponse(url="/manage/settings?success=1", status_code=303)
+36 -2
View File
@@ -183,7 +183,11 @@ async def update_share(
@router.post("/{share_token}/access")
async def access_shared_content(share_token: str, password: Optional[str] = None):
async def access_shared_content(
share_token: str,
password: Optional[str] = None,
subfolder_id: Optional[int] = None,
):
share = await Share.get_or_none(token=share_token)
if not share:
raise HTTPException(
@@ -212,7 +216,37 @@ async def access_shared_content(share_token: str, password: Optional[str] = None
result["file"] = await FileOut.from_tortoise_orm(file)
result["type"] = "file"
elif share.folder_id:
folder = await Folder.get_or_none(id=share.folder_id, is_deleted=False)
# Start with the shared root folder
target_folder_id = share.folder_id
# If subfolder_id is requested, verify it's a descendant of the shared folder
if subfolder_id:
subfolder = await Folder.get_or_none(id=subfolder_id, is_deleted=False)
if not subfolder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Subfolder not found"
)
# Verify hierarchy
current = subfolder
is_descendant = False
while current.parent_id:
if current.parent_id == share.folder_id:
is_descendant = True
break
current = await Folder.get_or_none(id=current.parent_id)
if not current:
break
if not is_descendant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this folder"
)
target_folder_id = subfolder_id
folder = await Folder.get_or_none(id=target_folder_id, is_deleted=False)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
+7 -1
View File
@@ -5,7 +5,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
RATE_LIMIT_ENABLED: bool = False
DATABASE_URL: str = "sqlite:///app/mywebdav.db"
REDIS_URL: str = "redis://redis:6379/0"
SECRET_KEY: str = "super_secret_key"
@@ -31,6 +32,11 @@ class Settings(BaseSettings):
STRIPE_WEBHOOK_SECRET: str = ""
BILLING_ENABLED: bool = False
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "admin"
ADMIN_SESSION_SECRET: str = "admin_session_secret_change_me"
ADMIN_SESSION_EXPIRE_HOURS: int = 24
settings = Settings()
+111
View File
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Panel{% endblock %} - MyWebdav</title>
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="icon" type="image/png" href="/static/icons/icon-192x192.png">
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<div class="header-left">
<button class="hamburger-btn" id="sidebar-toggle" aria-label="Toggle sidebar">
<span></span>
<span></span>
<span></span>
</button>
<div class="admin-logo">
<span class="logo-icon"></span>
<span class="logo-text">My<span class="logo-accent">Webdav</span></span>
<span class="admin-badge">Admin</span>
</div>
</div>
<div class="header-right">
<span class="admin-user">{{ session.username }}</span>
<a href="/manage/logout" class="btn btn-outline">Logout</a>
</div>
</header>
<div class="admin-body">
<aside class="admin-sidebar" id="admin-sidebar">
<nav class="sidebar-nav">
<a href="/manage/" class="nav-item {% if request.url.path == '/manage/' %}active{% endif %}">
<span class="nav-icon">📊</span>
<span class="nav-text">Dashboard</span>
</a>
<a href="/manage/users" class="nav-item {% if '/manage/users' in request.url.path %}active{% endif %}">
<span class="nav-icon">👥</span>
<span class="nav-text">Users</span>
</a>
<a href="/manage/payments" class="nav-item {% if '/manage/payments' in request.url.path %}active{% endif %}">
<span class="nav-icon">💳</span>
<span class="nav-text">Payments</span>
</a>
<a href="/manage/settings" class="nav-item {% if '/manage/settings' in request.url.path %}active{% endif %}">
<span class="nav-icon">⚙️</span>
<span class="nav-text">Settings</span>
</a>
</nav>
<div class="sidebar-footer">
<a href="/" class="nav-item">
<span class="nav-icon">🌐</span>
<span class="nav-text">View Site</span>
</a>
</div>
</aside>
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<main class="admin-main">
{% if request.query_params.get('success') %}
<div class="alert alert-success">Changes saved successfully.</div>
{% endif %}
{% if request.query_params.get('error') %}
<div class="alert alert-error">
{% if request.query_params.get('error') == 'csrf' %}
Security token expired. Please try again.
{% elif request.query_params.get('error') == 'not_found' %}
Item not found.
{% else %}
An error occurred. Please try again.
{% endif %}
</div>
{% endif %}
{% if request.query_params.get('deleted') %}
<div class="alert alert-success">Item deleted successfully.</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<script>
const sidebarToggle = document.getElementById('sidebar-toggle');
const sidebar = document.getElementById('admin-sidebar');
const overlay = document.getElementById('sidebar-overlay');
function toggleSidebar() {
sidebar.classList.toggle('open');
overlay.classList.toggle('visible');
document.body.classList.toggle('sidebar-open');
}
sidebarToggle.addEventListener('click', toggleSidebar);
overlay.addEventListener('click', toggleSidebar);
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
if (window.innerWidth < 768) {
sidebar.classList.remove('open');
overlay.classList.remove('visible');
document.body.classList.remove('sidebar-open');
}
});
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
{% extends "admin/base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">Overview of your MyWebdav instance</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Users</div>
<div class="stat-value">{{ stats.total_users }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Active Users</div>
<div class="stat-value success">{{ stats.active_users }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Storage Used</div>
<div class="stat-value">{{ stats.total_storage | format_bytes }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Monthly Revenue</div>
<div class="stat-value success">{{ stats.monthly_revenue | format_currency }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pending Invoices</div>
<div class="stat-value {% if stats.pending_invoices > 0 %}warning{% endif %}">{{ stats.pending_invoices }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Inactive Users</div>
<div class="stat-value {% if stats.inactive_users > 0 %}danger{% endif %}">{{ stats.inactive_users }}</div>
</div>
</div>
<div class="detail-grid">
<div class="card">
<div class="card-header">
<h2 class="card-title">Quick Actions</h2>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
<a href="/manage/users" class="btn btn-primary">Manage Users</a>
<a href="/manage/payments" class="btn btn-outline">View Payments</a>
<a href="/manage/settings" class="btn btn-outline">Settings</a>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Recent Activity</h2>
</div>
{% if recent_activities %}
<div class="activity-list">
{% for activity in recent_activities %}
<div class="activity-item">
<div class="activity-icon">{{ activity.user[0] | upper }}</div>
<div class="activity-content">
<div class="activity-text">
<strong>{{ activity.user }}</strong> {{ activity.action }}
</div>
<div class="activity-time">{{ activity.timestamp.strftime('%Y-%m-%d %H:%M') }}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📋</div>
<div class="empty-state-text">No recent activity</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - MyWebdav</title>
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="icon" type="image/png" href="/static/icons/icon-192x192.png">
</head>
<body>
<div class="login-container">
<div class="login-box">
<div class="login-logo">
<span class="logo-icon"></span>
<span class="logo-text">My<span class="logo-accent">Webdav</span></span>
</div>
<h1 class="login-title">Admin Panel</h1>
{% if error %}
<div class="login-error">
Invalid username or password.
</div>
{% endif %}
<form method="POST" action="/manage/login" class="login-form">
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
</div>
</body>
</html>
@@ -0,0 +1,122 @@
{% extends "admin/base.html" %}
{% block title %}Invoice: {{ invoice.invoice_number }}{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ invoice.invoice_number }}</h1>
<p class="page-subtitle">Created: {{ invoice.created_at.strftime('%Y-%m-%d %H:%M') }}</p>
</div>
<div class="detail-grid">
<div class="detail-section">
<h3 class="detail-section-title">Invoice Details</h3>
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="detail-value">
{% if invoice.status == 'paid' %}
<span class="badge badge-success">Paid</span>
{% elif invoice.status == 'open' %}
<span class="badge badge-warning">Open</span>
{% else %}
<span class="badge badge-secondary">{{ invoice.status }}</span>
{% endif %}
</span>
</div>
<div class="detail-row">
<span class="detail-label">User</span>
<span class="detail-value">
{% if user %}
<a href="/manage/users/{{ user.id }}">{{ user.username }}</a>
{% else %}
Unknown
{% endif %}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Period</span>
<span class="detail-value">{{ invoice.period_start.strftime('%Y-%m-%d') }} - {{ invoice.period_end.strftime('%Y-%m-%d') }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Due Date</span>
<span class="detail-value">{% if invoice.due_date %}{{ invoice.due_date.strftime('%Y-%m-%d') }}{% else %}-{% endif %}</span>
</div>
{% if invoice.paid_at %}
<div class="detail-row">
<span class="detail-label">Paid At</span>
<span class="detail-value">{{ invoice.paid_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
{% endif %}
</div>
<div class="detail-section">
<h3 class="detail-section-title">Totals</h3>
<div class="detail-row">
<span class="detail-label">Subtotal</span>
<span class="detail-value">{{ invoice.subtotal | format_currency }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Tax</span>
<span class="detail-value">{{ invoice.tax | format_currency }}</span>
</div>
<div class="detail-row" style="font-size: 1.1rem; font-weight: 600;">
<span class="detail-label">Total</span>
<span class="detail-value">{{ invoice.total | format_currency }}</span>
</div>
</div>
</div>
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<h2 class="card-title">Line Items</h2>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Description</th>
<th>Type</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{% for item in line_items %}
<tr>
<td>{{ item.description }}</td>
<td>
<span class="badge badge-secondary">{{ item.item_type }}</span>
</td>
<td>{{ "%.2f" | format(item.quantity) }}</td>
<td>{{ item.unit_price | format_currency }}</td>
<td><strong>{{ item.amount | format_currency }}</strong></td>
</tr>
{% else %}
<tr>
<td colspan="5" class="empty-state">
<div class="empty-state-text">No line items</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if invoice.status != 'paid' %}
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<h2 class="card-title">Actions</h2>
</div>
<form method="POST" action="/manage/payments/{{ invoice.id }}/mark-paid" onsubmit="return confirm('Mark this invoice as paid?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-success">Mark as Paid</button>
</form>
</div>
{% endif %}
<div style="margin-top: 24px;">
<a href="/manage/payments" class="btn btn-outline">&larr; Back to Payments</a>
</div>
{% endblock %}
+112
View File
@@ -0,0 +1,112 @@
{% extends "admin/base.html" %}
{% block title %}Payments{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Payments</h1>
<p class="page-subtitle">Overview of all invoices and payments</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Revenue</div>
<div class="stat-value success">{{ summary.total_revenue | format_currency }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pending Invoices</div>
<div class="stat-value {% if summary.pending_count > 0 %}warning{% endif %}">{{ summary.pending_count }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pending Amount</div>
<div class="stat-value {% if summary.pending_amount > 0 %}warning{% endif %}">{{ summary.pending_amount | format_currency }}</div>
</div>
</div>
<div class="card">
<form method="GET" action="/manage/payments" class="search-bar">
<select name="status" class="form-select">
<option value="">All Status</option>
<option value="draft" {% if status_filter == 'draft' %}selected{% endif %}>Draft</option>
<option value="open" {% if status_filter == 'open' %}selected{% endif %}>Open</option>
<option value="paid" {% if status_filter == 'paid' %}selected{% endif %}>Paid</option>
</select>
<button type="submit" class="btn btn-primary">Filter</button>
{% if status_filter or user_id_filter %}
<a href="/manage/payments" class="btn btn-outline">Clear</a>
{% endif %}
</form>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Invoice #</th>
<th>User</th>
<th>Period</th>
<th>Subtotal</th>
<th>Tax</th>
<th>Total</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in invoices %}
<tr>
<td><a href="/manage/payments/{{ item.invoice.id }}">{{ item.invoice.invoice_number }}</a></td>
<td>
{% if item.user %}
<a href="/manage/users/{{ item.user.id }}">{{ item.user.username }}</a>
{% else %}
<span class="text-muted">Unknown</span>
{% endif %}
</td>
<td>{{ item.invoice.period_start.strftime('%Y-%m-%d') }} - {{ item.invoice.period_end.strftime('%Y-%m-%d') }}</td>
<td>{{ item.invoice.subtotal | format_currency }}</td>
<td>{{ item.invoice.tax | format_currency }}</td>
<td><strong>{{ item.invoice.total | format_currency }}</strong></td>
<td>
{% if item.invoice.status == 'paid' %}
<span class="badge badge-success">Paid</span>
{% elif item.invoice.status == 'open' %}
<span class="badge badge-warning">Open</span>
{% else %}
<span class="badge badge-secondary">{{ item.invoice.status }}</span>
{% endif %}
</td>
<td class="actions">
<a href="/manage/payments/{{ item.invoice.id }}" class="btn btn-sm btn-outline">View</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="empty-state">
<div class="empty-state-icon">💳</div>
<div class="empty-state-text">No invoices found</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="/manage/payments?page={{ page - 1 }}{% if status_filter %}&status={{ status_filter }}{% endif %}">&laquo; Previous</a>
{% else %}
<span class="disabled">&laquo; Previous</span>
{% endif %}
<span>Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<a href="/manage/payments?page={{ page + 1 }}{% if status_filter %}&status={{ status_filter }}{% endif %}">Next &raquo;</a>
{% else %}
<span class="disabled">Next &raquo;</span>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "admin/base.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Settings</h1>
<p class="page-subtitle">Configure pricing and system settings</p>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Pricing Configuration</h2>
</div>
{% if pricing_configs %}
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Setting</th>
<th>Description</th>
<th>Value</th>
<th>Unit</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for config in pricing_configs %}
<tr>
<td><strong>{{ config.config_key }}</strong></td>
<td>{{ config.description or '-' }}</td>
<td>
<form method="POST" action="/manage/settings/pricing/{{ config.id }}" class="inline-form" id="form-{{ config.id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="text" name="value" class="form-input" value="{{ config.config_value }}" style="width: 120px;">
</form>
</td>
<td>{{ config.unit or '-' }}</td>
<td>
<button type="submit" form="form-{{ config.id }}" class="btn btn-sm btn-primary">Save</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">⚙️</div>
<div class="empty-state-text">No pricing configuration found</div>
<p style="color: var(--text-color-light); margin-top: 8px;">
Run the database initialization to set up default pricing.
</p>
</div>
{% endif %}
</div>
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<h2 class="card-title">System Information</h2>
</div>
<div class="detail-row">
<span class="detail-label">Application</span>
<span class="detail-value">MyWebdav Cloud Storage</span>
</div>
<div class="detail-row">
<span class="detail-label">Admin Panel Version</span>
<span class="detail-value">1.0.0</span>
</div>
</div>
{% endblock %}
{% block extra_css %}
<style>
.inline-form {
display: inline;
}
</style>
{% endblock %}
+185
View File
@@ -0,0 +1,185 @@
{% extends "admin/base.html" %}
{% block title %}User: {{ user.username }}{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ user.username }}</h1>
<p class="page-subtitle">User ID: {{ user.id }} | Created: {{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</p>
</div>
<div class="detail-grid">
<div class="detail-section">
<h3 class="detail-section-title">Storage Usage</h3>
<div class="detail-row">
<span class="detail-label">Used</span>
<span class="detail-value">{{ user.used_storage_bytes | format_bytes }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Quota</span>
<span class="detail-value">{{ user.storage_quota_bytes | format_bytes }}</span>
</div>
<div style="margin-top: 12px;">
<div class="progress-bar">
<div class="progress-fill {% if usage_percent > 90 %}danger{% elif usage_percent > 75 %}warning{% endif %}" style="width: {{ usage_percent }}%"></div>
</div>
<div style="text-align: center; margin-top: 8px; font-size: 0.85rem; color: var(--text-color-light);">
{{ "%.1f" | format(usage_percent) }}% used
</div>
</div>
</div>
<div class="detail-section">
<h3 class="detail-section-title">Account Status</h3>
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="detail-value">
{% if user.is_active %}
<span class="badge badge-success">Active</span>
{% else %}
<span class="badge badge-danger">Inactive</span>
{% endif %}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Role</span>
<span class="detail-value">
{% if user.is_superuser %}
<span class="badge badge-info">Administrator</span>
{% else %}
<span class="badge badge-secondary">User</span>
{% endif %}
</span>
</div>
<div class="detail-row">
<span class="detail-label">2FA</span>
<span class="detail-value">
{% if user.is_2fa_enabled %}
<span class="badge badge-success">Enabled</span>
{% else %}
<span class="badge badge-secondary">Disabled</span>
{% endif %}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Plan</span>
<span class="detail-value">{{ user.plan_type }}</span>
</div>
</div>
</div>
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<h2 class="card-title">Edit User</h2>
</div>
<form method="POST" action="/manage/users/{{ user.id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" value="{{ user.username }}" required>
</div>
<div class="form-group">
<label class="form-label" for="email">Email</label>
<input type="email" id="email" name="email" class="form-input" value="{{ user.email }}" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="password">New Password (leave empty to keep current)</label>
<input type="password" id="password" name="password" class="form-input" placeholder="Enter new password...">
</div>
<div class="form-group">
<label class="form-label" for="storage_quota_gb">Storage Quota (GB)</label>
<input type="number" id="storage_quota_gb" name="storage_quota_gb" class="form-input"
value="{{ (user.storage_quota_bytes / 1073741824) | round(2) }}" step="0.1" min="0" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="plan_type">Plan Type</label>
<select id="plan_type" name="plan_type" class="form-select">
<option value="free" {% if user.plan_type == 'free' %}selected{% endif %}>Free</option>
<option value="basic" {% if user.plan_type == 'basic' %}selected{% endif %}>Basic</option>
<option value="premium" {% if user.plan_type == 'premium' %}selected{% endif %}>Premium</option>
<option value="enterprise" {% if user.plan_type == 'enterprise' %}selected{% endif %}>Enterprise</option>
</select>
</div>
<div class="form-group">
<label class="form-label">&nbsp;</label>
<div style="display: flex; gap: 24px; padding-top: 8px;">
<label class="form-checkbox">
<input type="checkbox" name="is_active" value="true" {% if user.is_active %}checked{% endif %}>
<span>Active</span>
</label>
<label class="form-checkbox">
<input type="checkbox" name="is_superuser" value="true" {% if user.is_superuser %}checked{% endif %}>
<span>Administrator</span>
</label>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="/manage/users" class="btn btn-outline">Cancel</a>
</div>
</form>
</div>
{% if invoices %}
<div class="card" style="margin-top: 24px;">
<div class="card-header">
<h2 class="card-title">Recent Invoices</h2>
<a href="/manage/payments?user_id={{ user.id }}" class="btn btn-sm btn-outline">View All</a>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Invoice #</th>
<th>Period</th>
<th>Total</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr>
<td><a href="/manage/payments/{{ invoice.id }}">{{ invoice.invoice_number }}</a></td>
<td>{{ invoice.period_start.strftime('%Y-%m-%d') }} - {{ invoice.period_end.strftime('%Y-%m-%d') }}</td>
<td>{{ invoice.total | format_currency }}</td>
<td>
{% if invoice.status == 'paid' %}
<span class="badge badge-success">Paid</span>
{% elif invoice.status == 'open' %}
<span class="badge badge-warning">Open</span>
{% else %}
<span class="badge badge-secondary">{{ invoice.status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="card" style="margin-top: 24px; border-color: var(--danger-color);">
<div class="card-header">
<h2 class="card-title" style="color: var(--danger-color);">Danger Zone</h2>
</div>
<p style="margin-bottom: 16px; color: var(--text-color-light);">
Deleting a user is permanent and cannot be undone. All files and data associated with this user will be lost.
</p>
<form method="POST" action="/manage/users/{{ user.id }}/delete" onsubmit="return confirm('Are you sure you want to delete this user? This action cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">Delete User</button>
</form>
</div>
{% endblock %}
+101
View File
@@ -0,0 +1,101 @@
{% extends "admin/base.html" %}
{% block title %}Users{% endblock %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Users</h1>
<p class="page-subtitle">Manage all registered users</p>
</div>
<div class="card">
<form method="GET" action="/manage/users" class="search-bar">
<input type="text" name="search" class="form-input" placeholder="Search by username or email..." value="{{ search }}">
<select name="status" class="form-select">
<option value="">All Status</option>
<option value="active" {% if status == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if status == 'inactive' %}selected{% endif %}>Inactive</option>
<option value="superuser" {% if status == 'superuser' %}selected{% endif %}>Superuser</option>
</select>
<button type="submit" class="btn btn-primary">Search</button>
{% if search or status %}
<a href="/manage/users" class="btn btn-outline">Clear</a>
{% endif %}
</form>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Storage</th>
<th>Plan</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<strong>{{ user.username }}</strong>
{% if user.is_superuser %}
<span class="badge badge-info">Admin</span>
{% endif %}
</td>
<td>{{ user.email }}</td>
<td>
{{ user.used_storage_bytes | format_bytes }} / {{ user.storage_quota_bytes | format_bytes }}
</td>
<td>{{ user.plan_type }}</td>
<td>
{% if user.is_active %}
<span class="badge badge-success">Active</span>
{% else %}
<span class="badge badge-danger">Inactive</span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td class="actions">
<a href="/manage/users/{{ user.id }}" class="btn btn-sm btn-outline">Edit</a>
<form method="POST" action="/manage/users/{{ user.id }}/toggle-active" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-sm {% if user.is_active %}btn-secondary{% else %}btn-success{% endif %}">
{% if user.is_active %}Deactivate{% else %}Activate{% endif %}
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="empty-state">
<div class="empty-state-icon">👥</div>
<div class="empty-state-text">No users found</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="/manage/users?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}{% if status %}&status={{ status }}{% endif %}">&laquo; Previous</a>
{% else %}
<span class="disabled">&laquo; Previous</span>
{% endif %}
<span>Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<a href="/manage/users?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}{% if status %}&status={{ status }}{% endif %}">Next &raquo;</a>
{% else %}
<span class="disabled">Next &raquo;</span>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}MyWebdav - Cloud Storage{% endblock %}</title>
<meta name="description" content="{% block description %}MyWebdav - Pay-as-you-go cloud storage. Store what you need, only pay for what you use.{% endblock %}">
<meta name="keywords" content="cloud storage, pay as you go, file storage, webdav, secure storage">
<link rel="stylesheet" href="/static/css/splash.css">
{% block extra_css %}{% endblock %}
<link rel="icon" type="image/png" href="/static/icons/icon-192x192.png">
</head>
<body>
<header class="header">
<nav class="nav-container">
<div class="logo">
<span class="logo-icon"></span>
<span class="logo-text">My<span class="logo-webdav">Webdav</span></span>
</div>
<button class="hamburger" id="hamburger" aria-label="Toggle menu">
<span></span>
<span></span>
<span></span>
</button>
<ul class="nav-menu" id="navMenu">
<li><a href="/">Home</a></li>
<li><a href="/features">Features</a></li>
<li><a href="/pricing">Pricing</a></li>
<li><a href="/support">Support</a></li>
<li><a href="/login" class="nav-login">Login</a></li>
</ul>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="footer">
<div class="footer-links">
<a href="/legal/privacy_policy">Privacy Policy</a>
<a href="/legal/data_processing_agreement">Data Processing Agreement</a>
<a href="/legal/terms_of_service">Terms of Service</a>
<a href="/legal/cookie_policy">Cookie Policy</a>
<a href="/legal/security_policy">Security Policy</a>
<a href="/legal/compliance_statement">Compliance Statement</a>
<a href="/legal/data_portability_deletion_policy">Data Portability &amp; Deletion</a>
<a href="/legal/contact_complaint_mechanism">Contact &amp; Complaints</a>
</div>
<div class="footer-copyright">
© 2025 MyWebdav. All rights reserved
</div>
</footer>
<script>
const hamburger = document.getElementById('hamburger');
const navMenu = document.getElementById('navMenu');
hamburger.addEventListener('click', () => {
hamburger.classList.toggle('active');
navMenu.classList.toggle('active');
});
navMenu.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
hamburger.classList.remove('active');
navMenu.classList.remove('active');
});
});
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
+267
View File
@@ -0,0 +1,267 @@
{% extends "base.html" %}
{% block title %}Features - MyWebdav Cloud Storage{% endblock %}
{% block description %}Discover MyWebdav's powerful features: secure storage, WebDAV support, file sharing, and more.{%
endblock %}
{% block extra_css %}
<style>
.content-section {
max-width: 1200px;
margin: 0 auto;
padding: 4rem 2rem;
}
.page-title {
font-size: 3rem;
font-weight: 700;
color: #1565c0;
text-align: center;
margin-bottom: 1rem;
}
.page-subtitle {
font-size: 1.25rem;
color: #666;
text-align: center;
margin-bottom: 3rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.feature-card {
background: white;
border-radius: 8px;
padding: 2rem;
border: 2px solid #e0e0e0;
transition: all 0.3s;
}
.feature-card:hover {
border-color: #1976d2;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.1);
transform: translateY(-2px);
}
.feature-icon {
font-size: 2.5rem;
color: #d32f2f;
margin-bottom: 1rem;
}
.feature-title {
font-size: 1.5rem;
font-weight: 600;
color: #333;
margin-bottom: 0.75rem;
}
.feature-description {
color: #666;
line-height: 1.6;
}
.feature-list {
list-style: none;
padding: 0;
margin-top: 1rem;
}
.feature-list li {
padding: 0.5rem 0;
color: #555;
}
.feature-list li:before {
content: "✓ ";
color: #d32f2f;
font-weight: bold;
margin-right: 0.5rem;
}
@media (max-width: 768px) {
.page-title {
font-size: 2rem;
}
.features-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="content-section">
<h1 class="page-title">POWERFUL FEATURES</h1>
<p class="page-subtitle">Everything you need for secure, reliable cloud storage</p>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">🔒</div>
<h2 class="feature-title">Enterprise-Grade Security</h2>
<p class="feature-description">Your data is protected with industry-leading security measures.</p>
<ul class="feature-list">
<li>Secure file storage</li>
<li>TLS 1.3 encryption in transit</li>
<li>Two-factor authentication (TOTP)</li>
<li>Regular security audits</li>
<li>GDPR compliant</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon">📁</div>
<h2 class="feature-title">WebDAV Protocol Support</h2>
<p class="feature-description">Mount your storage as a network drive on any device.</p>
<ul class="feature-list">
<li>Windows, macOS, Linux support</li>
<li>Standard WebDAV protocol</li>
<li>Works with native file explorers</li>
<li>No additional software needed</li>
<li>Full file system operations</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon">🔄</div>
<h2 class="feature-title">File Versioning</h2>
<p class="feature-description">Never lose important data with automatic version history.</p>
<ul class="feature-list">
<li>Automatic version tracking</li>
<li>Restore previous versions</li>
<li>Version comparison</li>
<li>Configurable retention</li>
<li>No additional cost</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon">🤝</div>
<h2 class="feature-title">Sharing & Collaboration</h2>
<p class="feature-description">Share files securely with team members or external partners.</p>
<ul class="feature-list">
<li>Shareable links with expiration</li>
<li>Password protection</li>
<li>Access control management</li>
<li>Share tracking and analytics</li>
<li>Public and private sharing</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h2 class="feature-title">High Performance</h2>
<p class="feature-description">Fast upload and download speeds.</p>
<ul class="feature-list">
<li>High-speed transfer</li>
<li>Parallel upload/download</li>
<li>Resume interrupted transfers</li>
<li>Optimized for large files</li>
<li>Sub-second file access</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon">🔍</div>
<h2 class="feature-title">Instant File Search</h2>
<p class="feature-description">Find your files instantly with powerful search capabilities.</p>
<ul class="feature-list">
<li>Search file names</li>
<li>Filter by type and date</li>
<li>Advanced query syntax</li>
<li>Instant results</li>
<li>Search in shared folders</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon">🖼️</div>
<h2 class="feature-title">Media Thumbnails</h2>
<p class="feature-description">Preview images and videos without downloading.</p>
<ul class="feature-list">
<li>Automatic thumbnail generation</li>
<li>Image preview</li>
<li>Video thumbnails</li>
<li>Multiple size options</li>
<li>Fast loading</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon">💰</div>
<h2 class="feature-title">Pay-As-You-Go Pricing</h2>
<p class="feature-description">Only pay for what you use with transparent billing.</p>
<ul class="feature-list">
<li>No minimum commitment</li>
<li>$5 per TB per month</li>
<li>15GB free tier</li>
<li>Detailed usage tracking</li>
<li>Monthly invoicing</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon">🛡️</div>
<h2 class="feature-title">Data Residency</h2>
<p class="feature-description">Your data stays in the EU with full GDPR compliance.</p>
<ul class="feature-list">
<li>EU-based data centers</li>
<li>No data transfers outside EU</li>
<li>GDPR and NIS2 compliant</li>
<li>ISO 27001 certified</li>
<li>Full data sovereignty</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon">📊</div>
<h2 class="feature-title">Activity Logging</h2>
<p class="feature-description">Complete audit trail of all file operations.</p>
<ul class="feature-list">
<li>Detailed activity logs</li>
<li>File access tracking</li>
<li>User action history</li>
<li>Compliance reporting</li>
<li>Export capabilities</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon">🔌</div>
<h2 class="feature-title">API Access</h2>
<p class="feature-description">Integrate MyWebdav with your applications.</p>
<ul class="feature-list">
<li>RESTful API</li>
<li>OAuth2 authentication</li>
<li>Comprehensive documentation</li>
<li>WebDAV protocol support</li>
<li>Developer-friendly</li>
</ul>
</div>
<div class="feature-card">
<div class="feature-icon"></div>
<h2 class="feature-title">Favorites & Organization</h2>
<p class="feature-description">Organize files your way with folders and favorites.</p>
<ul class="feature-list">
<li>Unlimited folder hierarchy</li>
<li>Star important files</li>
<li>Quick access favorites</li>
<li>Drag and drop organization</li>
<li>Bulk operations</li>
</ul>
</div>
</div>
<div style="text-align: center; margin-top: 3rem;">
<a href="/app" class="btn btn-primary" style="font-size: 1.125rem; padding: 1rem 3rem;">Get Started Today</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,164 @@
{% extends "base.html" %}
{% block title %}Compliance Statement - MyWebdav{% endblock %}
{% block description %}Compliance Statement for MyWebdav cloud storage service.{% endblock %}
{% block extra_css %}
<style>
.legal-content {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
background: white;
border-radius: 8px;
}
.legal-title {
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}
.legal-updated {
color: #666;
font-style: italic;
margin-bottom: 2rem;
}
.legal-content h2 {
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
.legal-content h3 {
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.legal-content p {
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}
.legal-content ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.legal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}
.legal-content strong {
color: #333;
font-weight: 600;
}
.legal-contact {
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.legal-contact h3 {
color: #1565c0;
margin-top: 0;
}
@media (max-width: 768px) {
.legal-title {
font-size: 2rem;
}
.legal-content {
padding: 2rem 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="legal-content">
<h1 class="legal-title">Compliance Statement</h1>
<p class="legal-updated">Last Updated: November 16, 2025</p>
<h2>1. Introduction</h2>
<p>MyWebdav Technologies is committed to maintaining the highest standards of compliance with applicable laws and regulations. This Compliance Statement outlines our commitments and achievements.</p>
<h2>2. Regulatory Compliance</h2>
<p>We comply with:</p>
<ul>
<li><strong>GDPR:</strong> EU General Data Protection Regulation</li>
<li><strong>NIS2 Directive:</strong> Network and Information Systems Directive</li>
<li><strong>Digital Services Act:</strong> Online intermediary liability framework</li>
<li><strong>ePrivacy Directive:</strong> Electronic communications privacy</li>
</ul>
<h2>3. Certifications and Standards</h2>
<ul>
<li>ISO/IEC 27001: Information Security Management</li>
<li>ISO/IEC 27017: Cloud Security Controls</li>
<li>SOC 2 Type II: Security, Availability, and Confidentiality</li>
</ul>
<h2>4. Data Protection</h2>
<h3>4.1 Data Residency</h3>
<p>Customer data is stored within the EU by default, with options for specific country storage.</p>
<h3>4.2 Encryption</h3>
<p>All data encrypted in transit and at rest using industry-standard algorithms.</p>
<h3>4.3 Access Controls</h3>
<p>Role-based access control with multi-factor authentication.</p>
<h2>5. Security Measures</h2>
<ul>
<li>Regular security audits and penetration testing</li>
<li>Incident response planning and testing</li>
<li>Continuous monitoring and threat detection</li>
<li>Employee security training and awareness</li>
</ul>
<h2>6. Transparency Reporting</h2>
<p>We publish annual transparency reports detailing:</p>
<ul>
<li>Government data requests</li>
<li>Security incidents</li>
<li>Law enforcement cooperation</li>
</ul>
<h2>7. Independent Audits</h2>
<p>Annual third-party audits verify compliance with all applicable standards.</p>
<h2>8. Continuous Improvement</h2>
<p>We regularly review and update our compliance program to address emerging threats and regulatory changes.</p>
<div class="legal-contact">
<h3>Contact Information</h3>
<p>If you have any questions about this compliance statement, please contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a></li>
<li><strong>Website:</strong> <a href="https://mywebdav.eu">https://mywebdav.eu</a></li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
</div>
</div>
{% endblock %}
@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}Contact and Complaint Mechanism - MyWebdav{% endblock %}
{% block description %}Contact and Complaint Mechanism for MyWebdav cloud storage service.{% endblock %}
{% block extra_css %}
<style>
.legal-content {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
background: white;
border-radius: 8px;
}
.legal-title {
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}
.legal-updated {
color: #666;
font-style: italic;
margin-bottom: 2rem;
}
.legal-content h2 {
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
.legal-content h3 {
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.legal-content p {
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}
.legal-content ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.legal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}
.legal-content strong {
color: #333;
font-weight: 600;
}
.legal-contact {
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.legal-contact h3 {
color: #1565c0;
margin-top: 0;
}
@media (max-width: 768px) {
.legal-title {
font-size: 2rem;
}
.legal-content {
padding: 2rem 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="legal-content">
<h1 class="legal-title">Contact and Complaint Mechanism</h1>
<p class="legal-updated">Last Updated: November 16, 2025</p>
<h2>1. Introduction</h2>
<p>MyWebdav Technologies provides multiple channels for you to contact us and raise concerns. We are committed to addressing your inquiries promptly and fairly.</p>
<h2>2. Contact Information</h2>
<h3>2.1 General Inquiries</h3>
<ul>
<li><strong>Email:</strong> support@mywebdav.eu</li>
<li><strong>Phone:</strong> +31 XX XXX XXXX (Mon-Fri, 9:00-17:00 CET)</li>
<li><strong>Address:</strong> MyWebdav Technologies, Amsterdam, Netherlands</li>
</ul>
<h3>2.2 Technical Support</h3>
<ul>
<li><strong>Email:</strong> tech-support@mywebdav.eu</li>
<li><strong>Help Center:</strong> <a href="https://help.mywebdav.eu">https://help.mywebdav.eu</a></li>
</ul>
<h3>2.3 Billing Inquiries</h3>
<ul>
<li><strong>Email:</strong> billing@mywebdav.eu</li>
</ul>
<h3>2.4 Data Protection</h3>
<ul>
<li><strong>Data Protection Officer:</strong> dpo@mywebdav.eu</li>
</ul>
<h3>2.5 Legal Matters</h3>
<ul>
<li><strong>Email:</strong> legal@mywebdav.eu</li>
</ul>
<h2>3. Complaint Procedure</h2>
<h3>3.1 How to Submit a Complaint</h3>
<ol>
<li>Contact our support team with details of your complaint</li>
<li>Include relevant account information and timestamps</li>
<li>Provide specific details about the issue</li>
</ol>
<h3>3.2 Complaint Handling Process</h3>
<ol>
<li><strong>Acknowledgment:</strong> Within 24 hours</li>
<li><strong>Investigation:</strong> Within 5 business days</li>
<li><strong>Resolution:</strong> Within 15 business days</li>
<li><strong>Escalation:</strong> If unresolved, escalate to management</li>
</ol>
<h3>3.3 Complaint Categories</h3>
<ul>
<li>Service quality issues</li>
<li>Billing disputes</li>
<li>Data protection concerns</li>
<li>Security incidents</li>
<li>Terms of Service violations</li>
</ul>
<h2>4. Dispute Resolution</h2>
<h3>4.1 Internal Resolution</h3>
<p>Most complaints resolved through direct communication with our team.</p>
<h3>4.2 Mediation</h3>
<p>For unresolved disputes, we offer mediation through a neutral third party.</p>
<h3>4.3 Legal Action</h3>
<p>If internal resolution fails, disputes may be brought before competent courts in the Netherlands.</p>
<h2>5. Response Times</h2>
<ul>
<li><strong>General inquiries:</strong> 24-48 hours</li>
<li><strong>Technical issues:</strong> 4-24 hours</li>
<li><strong>Complaints:</strong> 5 business days for initial response</li>
<li><strong>Data subject rights:</strong> 30 days (GDPR)</li>
</ul>
<h2>6. Feedback and Suggestions</h2>
<p>We welcome your feedback to improve our services. Contact us at feedback@mywebdav.eu.</p>
<h2>7. Transparency</h2>
<p>We publish annual reports on complaint handling and resolution rates.</p>
<div class="legal-contact">
<h3>Contact Information</h3>
<p>If you have any questions about this contact and complaint mechanism, please contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a></li>
<li><strong>Website:</strong> <a href="https://mywebdav.eu">https://mywebdav.eu</a></li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
</div>
</div>
{% endblock %}
+171
View File
@@ -0,0 +1,171 @@
{% extends "base.html" %}
{% block title %}Cookie Policy - MyWebdav{% endblock %}
{% block description %}Cookie Policy for MyWebdav cloud storage service.{% endblock %}
{% block extra_css %}
<style>
.legal-content {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
background: white;
border-radius: 8px;
}
.legal-title {
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}
.legal-updated {
color: #666;
font-style: italic;
margin-bottom: 2rem;
}
.legal-content h2 {
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
.legal-content h3 {
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.legal-content p {
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}
.legal-content ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.legal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}
.legal-content strong {
color: #333;
font-weight: 600;
}
.legal-contact {
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.legal-contact h3 {
color: #1565c0;
margin-top: 0;
}
@media (max-width: 768px) {
.legal-title {
font-size: 2rem;
}
.legal-content {
padding: 2rem 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="legal-content">
<h1 class="legal-title">Cookie Policy</h1>
<p class="legal-updated">Last Updated: November 16, 2025</p>
<h2>1. What Are Cookies</h2>
<p>Cookies are small text files stored on your device when you visit our Service. They help us provide a better user experience.</p>
<h2>2. Types of Cookies We Use</h2>
<h3>2.1 Essential Cookies</h3>
<p>Required for basic Service functionality:</p>
<ul>
<li>Authentication and session management</li>
<li>Security features</li>
</ul>
<h3>2.2 Functional Cookies</h3>
<p>Enhance your experience:</p>
<ul>
<li>Language preferences</li>
<li>Theme settings</li>
</ul>
<h3>2.3 Analytics Cookies</h3>
<p>Help us understand usage:</p>
<ul>
<li>Page views and user journeys</li>
<li>Performance metrics</li>
</ul>
<h3>2.4 Marketing Cookies</h3>
<p>Used for targeted advertising (with consent):</p>
<ul>
<li>Personalized recommendations</li>
</ul>
<h2>3. Cookie Management</h2>
<p>You can control cookies through:</p>
<ul>
<li>Browser settings</li>
<li>Our cookie preference center</li>
<li>Opt-out links in marketing emails</li>
</ul>
<h2>4. Third-Party Cookies</h2>
<p>We may use third-party services that set cookies:</p>
<ul>
<li>Analytics providers</li>
<li>Payment processors</li>
<li>Social media integrations</li>
</ul>
<h2>5. Your Rights</h2>
<p>Under GDPR, you have rights regarding cookie-based processing:</p>
<ul>
<li>Right to information</li>
<li>Right to withdraw consent</li>
<li>Right to object</li>
</ul>
<h2>6. Updates</h2>
<p>We may update this policy. Material changes will be communicated via the Service.</p>
<div class="legal-contact">
<h3>Contact Information</h3>
<p>If you have any questions about this cookie policy, please contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a></li>
<li><strong>Website:</strong> <a href="https://mywebdav.eu">https://mywebdav.eu</a></li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
</div>
</div>
{% endblock %}
@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}Data Portability and Deletion Policy - MyWebdav{% endblock %}
{% block description %}Data Portability and Deletion Policy for MyWebdav cloud storage service.{% endblock %}
{% block extra_css %}
<style>
.legal-content {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
background: white;
border-radius: 8px;
}
.legal-title {
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}
.legal-updated {
color: #666;
font-style: italic;
margin-bottom: 2rem;
}
.legal-content h2 {
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
.legal-content h3 {
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.legal-content p {
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}
.legal-content ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.legal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}
.legal-content strong {
color: #333;
font-weight: 600;
}
.legal-contact {
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.legal-contact h3 {
color: #1565c0;
margin-top: 0;
}
@media (max-width: 768px) {
.legal-title {
font-size: 2rem;
}
.legal-content {
padding: 2rem 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="legal-content">
<h1 class="legal-title">Data Portability and Deletion Policy</h1>
<p class="legal-updated">Last Updated: November 16, 2025</p>
<h2>1. Introduction</h2>
<p>This policy outlines your rights under GDPR regarding data portability and deletion, and how MyWebdav Technologies facilitates these rights.</p>
<h2>2. Right to Data Portability</h2>
<p>You have the right to receive your personal data in a structured, commonly used, and machine-readable format.</p>
<h3>2.1 Scope</h3>
<p>Applies to personal data you have provided that is processed based on consent or contract.</p>
<h3>2.2 How to Request</h3>
<p>Contact us at dpo@mywebdav.eu with "Data Portability Request" in the subject line.</p>
<h3>2.3 Format</h3>
<p>Data will be provided in JSON or CSV format, depending on the data type.</p>
<h3>2.4 Timeline</h3>
<p>Requests fulfilled within 30 days, extendable to 60 days for complex requests.</p>
<h2>3. Right to Erasure ("Right to be Forgotten")</h2>
<p>You have the right to have your personal data erased under certain circumstances.</p>
<h3>3.1 Conditions for Erasure</h3>
<ul>
<li>Data no longer necessary for original purpose</li>
<li>Withdrawal of consent</li>
<li>Objection to processing (and no overriding interests)</li>
<li>Unlawful processing</li>
<li>Legal obligation to erase</li>
<li>Data collected from child</li>
</ul>
<h3>3.2 Exceptions</h3>
<p>Erasure not required if processing is necessary for:</p>
<ul>
<li>Exercising freedom of expression</li>
<li>Compliance with legal obligation</li>
<li>Public interest</li>
<li>Legal claims</li>
<li>Scientific research</li>
</ul>
<h3>3.3 How to Request Deletion</h3>
<p>Submit a deletion request via your account settings or contact dpo@mywebdav.eu.</p>
<h3>3.4 Account Deletion Process</h3>
<ul>
<li>All personal data permanently deleted</li>
<li>Shared content may remain if owned by others</li>
<li>Backup copies deleted within 90 days</li>
</ul>
<h2>4. Data Retention</h2>
<p>We retain data only as long as necessary:</p>
<ul>
<li><strong>Account data:</strong> Until deletion request</li>
<li><strong>Billing data:</strong> 7 years for tax compliance</li>
<li><strong>Logs:</strong> 12 months for security</li>
</ul>
<h2>5. Automated Decision Making</h2>
<p>We do not use automated decision making with legal or significant effects on individuals.</p>
<h2>6. Contact Information</h2>
<p>For data rights requests:</p>
<ul>
<li><strong>Email:</strong> dpo@mywebdav.eu</li>
<li><strong>Phone:</strong> +31 XX XXX XXXX</li>
<li><strong>Response Time:</strong> Within 30 days</li>
</ul>
<div class="legal-contact">
<h3>Contact Information</h3>
<p>If you have any questions about this data portability and deletion policy, please contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a></li>
<li><strong>Website:</strong> <a href="https://mywebdav.eu">https://mywebdav.eu</a></li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
</div>
</div>
{% endblock %}
@@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block title %}Data Processing Agreement - MyWebdav{% endblock %}
{% block description %}Data Processing Agreement for MyWebdav cloud storage service.{% endblock %}
{% block extra_css %}
<style>
.legal-content {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
background: white;
border-radius: 8px;
}
.legal-title {
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}
.legal-updated {
color: #666;
font-style: italic;
margin-bottom: 2rem;
}
.legal-content h2 {
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
.legal-content h3 {
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.legal-content p {
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}
.legal-content ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.legal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}
.legal-content strong {
color: #333;
font-weight: 600;
}
.legal-contact {
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.legal-contact h3 {
color: #1565c0;
margin-top: 0;
}
@media (max-width: 768px) {
.legal-title {
font-size: 2rem;
}
.legal-content {
padding: 2rem 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="legal-content">
<h1 class="legal-title">Data Processing Agreement</h1>
<p class="legal-updated">Last Updated: November 16, 2025</p>
<h2>1. Introduction</h2>
<p>This Data Processing Agreement ("DPA") supplements the Terms of Service between MyWebdav Technologies (the "Processor") and the Customer (the "Controller") regarding the processing of personal data.</p>
<h2>2. Definitions</h2>
<ul>
<li><strong>Personal Data:</strong> Any information relating to an identified or identifiable natural person</li>
<li><strong>Processing:</strong> Any operation performed on personal data</li>
<li><strong>Data Subject:</strong> The individual whose personal data is processed</li>
</ul>
<h2>3. Scope and Applicability</h2>
<p>This DPA applies to all processing of personal data by the Processor on behalf of the Controller.</p>
<h2>4. Processing Purposes</h2>
<p>The Processor shall process personal data solely for the purpose of providing the Service as described in the Terms of Service.</p>
<h2>5. Data Protection Obligations</h2>
<h3>5.1 Lawfulness</h3>
<p>Processing shall comply with GDPR and other applicable data protection laws.</p>
<h3>5.2 Security Measures</h3>
<p>The Processor shall implement appropriate technical and organizational measures to ensure data security.</p>
<h3>5.3 Confidentiality</h3>
<p>All personnel with access to personal data shall maintain confidentiality.</p>
<h2>6. Data Subject Rights</h2>
<p>The Processor shall assist the Controller in fulfilling data subject rights requests.</p>
<h2>7. Subprocessing</h2>
<p>The Processor may engage subprocessors with prior notice to the Controller.</p>
<h2>8. Data Breach Notification</h2>
<p>The Processor shall notify the Controller of any personal data breaches without undue delay.</p>
<h2>9. Data Protection Impact Assessment</h2>
<p>The Processor shall assist with DPIAs when required.</p>
<h2>10. International Data Transfers</h2>
<p>Data transfers outside the EU shall comply with GDPR Chapter V.</p>
<h2>11. Audit Rights</h2>
<p>The Controller may audit the Processor's compliance, subject to confidentiality obligations.</p>
<h2>12. Termination</h2>
<p>Upon termination, the Processor shall delete or return all personal data.</p>
<h2>13. Governing Law</h2>
<p>This DPA is governed by the laws of the Netherlands.</p>
<div class="legal-contact">
<h3>Contact Information</h3>
<p>If you have any questions about this data processing agreement, please contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a></li>
<li><strong>Website:</strong> <a href="https://mywebdav.eu">https://mywebdav.eu</a></li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
</div>
</div>
{% endblock %}
@@ -0,0 +1,259 @@
{% extends "base.html" %}
{% block title %}Privacy Policy - MyWebdav{% endblock %}
{% block description %}Privacy Policy for MyWebdav cloud storage service.{% endblock %}
{% block extra_css %}
<style>
.legal-content {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
background: white;
border-radius: 8px;
}
.legal-title {
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}
.legal-updated {
color: #666;
font-style: italic;
margin-bottom: 2rem;
}
.legal-content h2 {
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
.legal-content h3 {
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.legal-content p {
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}
.legal-content ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.legal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}
.legal-content strong {
color: #333;
font-weight: 600;
}
.legal-contact {
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.legal-contact h3 {
color: #1565c0;
margin-top: 0;
}
@media (max-width: 768px) {
.legal-title {
font-size: 2rem;
}
.legal-content {
padding: 2rem 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="legal-content">
<h1 class="legal-title">Privacy Policy</h1>
<p class="legal-updated">Last Updated: November 16, 2025</p>
<h2>1. Introduction</h2>
<p>MyWebdav Technologies ("we," "us," or "our") is committed to protecting your privacy and ensuring the security of your personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our MyWebdav cloud storage service (the "Service"), in full compliance with the EU General Data Protection Regulation (GDPR), and other applicable data protection laws.</p>
<p>This policy applies to all users of our Service, including visitors to our website and registered users. By using our Service, you consent to the collection and use of information in accordance with this policy.</p>
<h2>2. Data Controller and Contact Information</h2>
<p><strong>Data Controller:</strong> MyWebdav Technologies<br>
<strong>Registered Address:</strong> European Union<br>
<strong>Data Protection Officer:</strong> dpo@mywebdav.eu<br>
<strong>Contact Email:</strong> privacy@mywebdav.eu</p>
<h2>3. Information We Collect</h2>
<h3>3.1 Personal Data You Provide</h3>
<p>When you register for an account or use our Service, we collect:</p>
<ul>
<li>Name and contact information (email address, phone number if provided)</li>
<li>Account credentials and security information</li>
<li>Billing and payment information (processed securely through third-party providers)</li>
<li>Communications you send to us</li>
<li>Files and data you upload to our Service</li>
<li>Profile information and preferences</li>
</ul>
<h3>3.2 Information Collected Automatically</h3>
<p>We automatically collect certain information when you use our Service:</p>
<ul>
<li>IP address and geolocation data</li>
<li>Browser type, version, and language</li>
<li>Operating system and device information</li>
<li>Usage data (pages visited, features used, timestamps)</li>
<li>Log data (access times, errors, performance metrics)</li>
<li>Cookies and similar tracking technologies</li>
</ul>
<h3>3.3 Cookies and Tracking Technologies</h3>
<p>We use cookies and similar technologies to:</p>
<ul>
<li>Authenticate users and maintain secure sessions</li>
<li>Remember user preferences and settings</li>
<li>Analyze service usage and performance</li>
<li>Provide personalized features and recommendations</li>
<li>Ensure security and prevent fraud</li>
</ul>
<p>You can control cookie settings through your browser preferences. However, disabling certain cookies may limit Service functionality.</p>
<h2>4. Legal Basis for Processing</h2>
<p>We process your personal data based on the following legal grounds under GDPR:</p>
<ul>
<li><strong>Consent:</strong> Where you have explicitly agreed to processing (e.g., marketing communications)</li>
<li><strong>Contract:</strong> To provide the Service and fulfill our contractual obligations</li>
<li><strong>Legitimate Interest:</strong> To improve our Service, ensure security, and communicate with you</li>
<li><strong>Legal Obligation:</strong> To comply with applicable laws and regulations</li>
</ul>
<h2>5. How We Use Your Information</h2>
<p>We use collected information for the following purposes:</p>
<ul>
<li>Provide, maintain, and improve the Service</li>
<li>Process transactions and manage billing</li>
<li>Communicate with you about your account and the Service</li>
<li>Ensure security and prevent unauthorized access</li>
<li>Comply with legal obligations</li>
<li>Analyze usage patterns to improve user experience</li>
<li>Send service-related notifications and updates</li>
<li>Provide customer support</li>
</ul>
<h2>6. Information Sharing and Disclosure</h2>
<p>We do not sell your personal data to third parties. We may share information in the following circumstances:</p>
<ul>
<li><strong>Service Providers:</strong> With trusted third-party service providers under strict data processing agreements</li>
<li><strong>Legal Requirements:</strong> When required by law or to protect rights and safety</li>
<li><strong>Business Transfers:</strong> In connection with mergers, acquisitions, or asset sales (with notice)</li>
<li><strong>Consent:</strong> With your explicit consent</li>
<li><strong>Aggregated Data:</strong> Non-personally identifiable, aggregated data for analytical purposes</li>
</ul>
<h2>7. International Data Transfers</h2>
<p>Your data may be processed in countries outside the EU. We ensure adequate protection through:</p>
<ul>
<li>EU adequacy decisions for certain countries</li>
<li>Standard Contractual Clauses approved by the European Commission</li>
<li>Binding Corporate Rules</li>
<li>Your explicit consent where required</li>
</ul>
<p>All international transfers comply with Chapter V of the GDPR.</p>
<h2>8. Data Security</h2>
<p>We implement comprehensive security measures to protect your data:</p>
<ul>
<li><strong>Encryption:</strong> Data encrypted in transit (TLS 1.3) and at rest (AES-256)</li>
<li><strong>Access Controls:</strong> Role-based access control and multi-factor authentication</li>
<li><strong>Network Security:</strong> Firewalls, intrusion detection, and regular monitoring</li>
<li><strong>Physical Security:</strong> Secure data centers with controlled access</li>
<li><strong>Incident Response:</strong> 24/7 monitoring and rapid response procedures</li>
<li><strong>Regular Audits:</strong> Independent security audits and penetration testing</li>
</ul>
<h2>9. Data Retention</h2>
<p>We retain personal data only as long as necessary for the purposes outlined in this policy:</p>
<ul>
<li><strong>Account Data:</strong> Until account deletion or as required for legal compliance</li>
<li><strong>Usage Logs:</strong> Maximum 12 months for security and compliance purposes</li>
<li><strong>Billing Data:</strong> 7 years for tax and accounting compliance</li>
<li><strong>Marketing Data:</strong> Until you withdraw consent or request deletion</li>
</ul>
<h2>10. Your Rights Under GDPR</h2>
<p>You have the following rights regarding your personal data:</p>
<ul>
<li><strong>Right to Access:</strong> Request a copy of your personal data</li>
<li><strong>Right to Rectification:</strong> Correct inaccurate or incomplete data</li>
<li><strong>Right to Erasure:</strong> Delete your personal data ("right to be forgotten")</li>
<li><strong>Right to Restriction:</strong> Limit processing of your data</li>
<li><strong>Right to Portability:</strong> Receive your data in a structured format</li>
<li><strong>Right to Object:</strong> Object to processing based on legitimate interests</li>
<li><strong>Right to Withdraw Consent:</strong> Revoke consent for processing</li>
<li><strong>Right Not to be Subject to Automated Decision-Making:</strong> Including profiling</li>
</ul>
<p>To exercise these rights, contact our Data Protection Officer at dpo@mywebdav.eu. We will respond within 30 days.</p>
<h2>11. Children's Privacy</h2>
<p>Our Service is not intended for individuals under 16 years of age. We do not knowingly collect personal data from children under 16. If we become aware of such collection, we will delete the data immediately and terminate the account.</p>
<p>If you are a parent or guardian and believe your child has provided us with personal data, please contact us immediately.</p>
<h2>12. Changes to This Privacy Policy</h2>
<p>We may update this Privacy Policy to reflect changes in our practices or legal requirements. We will:</p>
<ul>
<li>Notify you via email at least 30 days before material changes take effect</li>
<li>Post the updated policy on our website</li>
<li>Highlight significant changes in the notification</li>
</ul>
<p>Continued use of the Service after changes take effect constitutes acceptance of the updated policy.</p>
<h2>13. Complaints and Supervisory Authority</h2>
<p>If you believe we have not complied with applicable data protection laws, you have the right to lodge a complaint with a supervisory authority. In the Netherlands, this is the Autoriteit Persoonsgegevens (AP).</p>
<p>We encourage you to contact us first to resolve any concerns.</p>
<h2>14. Contact Us</h2>
<p>For any questions about this Privacy Policy or our data practices:</p>
<ul>
<li><strong>Email:</strong> privacy@mywebdav.eu</li>
<li><strong>Data Protection Officer:</strong> dpo@mywebdav.eu</li>
<li><strong>Phone:</strong> +31 XX XXX XXXX</li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
<div class="legal-contact">
<h3>Contact Information</h3>
<p>If you have any questions about this privacy policy, please contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a></li>
<li><strong>Website:</strong> <a href="https://mywebdav.eu">https://mywebdav.eu</a></li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
</div>
</div>
{% endblock %}
@@ -0,0 +1,213 @@
{% extends "base.html" %}
{% block title %}Security Policy - MyWebdav{% endblock %}
{% block description %}Security Policy for MyWebdav cloud storage service.{% endblock %}
{% block extra_css %}
<style>
.legal-content {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
background: white;
border-radius: 8px;
}
.legal-title {
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}
.legal-updated {
color: #666;
font-style: italic;
margin-bottom: 2rem;
}
.legal-content h2 {
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
.legal-content h3 {
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.legal-content p {
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}
.legal-content ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.legal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}
.legal-content strong {
color: #333;
font-weight: 600;
}
.legal-contact {
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.legal-contact h3 {
color: #1565c0;
margin-top: 0;
}
@media (max-width: 768px) {
.legal-title {
font-size: 2rem;
}
.legal-content {
padding: 2rem 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="legal-content">
<h1 class="legal-title">Security Policy</h1>
<p class="legal-updated">Last Updated: November 16, 2025</p>
<h2>1. Introduction</h2>
<h3>1.1 Purpose</h3>
<p>This policy establishes the framework for securing our cloud storage platform and ensures all personnel
understand their security responsibilities.</p>
<h3>1.2 Scope</h3>
<p>Applies to all employees, contractors, systems, and data managed by MyWebdav Technologies.</p>
<h2>2. Governance and Management</h2>
<h3>2.1 Information Security Management System (ISMS)</h3>
<p>We maintain an ISO/IEC 27001-certified ISMS with regular risk assessments, audits, and continuous improvement.
</p>
<h3>2.2 Roles and Responsibilities</h3>
<ul>
<li><strong>CISO:</strong> Oversees security program</li>
<li><strong>Security Team:</strong> Implements controls and responds to incidents</li>
<li><strong>Employees:</strong> Follow policies and report incidents</li>
<li><strong>Management:</strong> Provides resources and enforces compliance</li>
</ul>
<h2>3. Access Control</h2>
<h3>3.1 Access Management</h3>
<p>Access follows the principle of least privilege with multi-factor authentication required for administrative
access.</p>
<h3>3.2 User Authentication</h3>
<p>Strong passwords, regular rotation, and account lockout policies are enforced.</p>
<h3>3.3 Remote Access</h3>
<p>Secured via VPN with full logging and monitoring.</p>
<h2>4. Data Protection and Encryption</h2>
<h3>4.1 Data Classification</h3>
<p>Data classified as Public, Internal, Confidential, or Highly Sensitive with appropriate controls.</p>
<h3>4.2 Encryption Standards</h3>
<ul>
<li>TLS 1.3 for data in transit</li>
<li>Secure file storage</li>
<li>Secure key management and rotation</li>
</ul>
<h3>4.3 Data Retention and Disposal</h3>
<p>Data retained only as necessary with secure deletion methods.</p>
<h2>5. Network Security</h2>
<h3>5.1 Network Segmentation</h3>
<p>Isolated networks with firewalls, IDS, and regular monitoring.</p>
<h3>5.2 Secure Configuration</h3>
<p>Hardened systems following CIS Benchmarks.</p>
<h2>6. Physical Security</h2>
<h3>6.1 Facility Access</h3>
<p>Controlled access to data centers with biometric authentication.</p>
<h3>6.2 Equipment Security</h3>
<p>Secure storage in climate-controlled environments.</p>
<h2>7. Incident Response</h2>
<h3>7.1 Incident Response Plan</h3>
<p>Comprehensive plan for identification, containment, eradication, recovery, and notification.</p>
<h3>7.2 Breach Notification</h3>
<p>Incidents reported within 72 hours (GDPR) or 24 hours (NIS2) as applicable.</p>
<h2>8. Secure Development</h2>
<h3>8.1 Secure Coding Practices</h3>
<p>Code reviews, static/dynamic analysis, and vulnerability management.</p>
<h3>8.2 Change Management</h3>
<p>Formal approval processes for production changes.</p>
<h2>9. Third-Party Risk Management</h2>
<h3>9.1 Vendor Assessment</h3>
<p>Security assessments and contractual requirements for all vendors.</p>
<h2>10. Compliance and Auditing</h2>
<h3>10.1 Regulatory Compliance</h3>
<p>Compliance with GDPR, NIS2, and ISO/IEC 27001.</p>
<h3>10.2 Audits and Assessments</h3>
<p>Annual audits, quarterly penetration testing, and continuous monitoring.</p>
<h3>10.3 Training</h3>
<p>Mandatory annual security training for all personnel.</p>
<h2>11. Enforcement</h2>
<p>Compliance is mandatory. Violations may result in disciplinary action up to termination.</p>
<div class="legal-contact">
<h3>Contact Information</h3>
<p>If you have any questions about this security policy, please contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a></li>
<li><strong>Website:</strong> <a href="https://mywebdav.eu">https://mywebdav.eu</a></li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
</div>
</div>
{% endblock %}
@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}Terms of Service - MyWebdav{% endblock %}
{% block description %}Terms of Service for MyWebdav cloud storage service.{% endblock %}
{% block extra_css %}
<style>
.legal-content {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
background: white;
border-radius: 8px;
}
.legal-title {
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}
.legal-updated {
color: #666;
font-style: italic;
margin-bottom: 2rem;
}
.legal-content h2 {
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
.legal-content h3 {
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.legal-content p {
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}
.legal-content ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.legal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}
.legal-content strong {
color: #333;
font-weight: 600;
}
.legal-contact {
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.legal-contact h3 {
color: #1565c0;
margin-top: 0;
}
@media (max-width: 768px) {
.legal-title {
font-size: 2rem;
}
.legal-content {
padding: 2rem 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="legal-content">
<h1 class="legal-title">Terms of Service</h1>
<p class="legal-updated">Last Updated: November 16, 2025</p>
<h2>1. Introduction</h2>
<p>These Terms of Service ("Terms") constitute a legally binding agreement between you ("User," "you," or "your") and MyWebdav Technologies ("Company," "we," "us," or "our") governing your use of the MyWebdav cloud storage service (the "Service").</p>
<p>By accessing or using the Service, you acknowledge that you have read, understood, and agree to be bound by these Terms. If you do not agree, you must not use the Service.</p>
<h2>2. Service Description</h2>
<p>MyWebdav provides cloud-based file storage, sharing, and collaboration tools. The Service includes:</p>
<ul>
<li>Secure file storage and backup</li>
<li>File sharing and collaboration features</li>
<li>WebDAV protocol support</li>
<li>API access for integrations</li>
<li>Administrative and management tools</li>
</ul>
<h2>3. User Eligibility and Account Registration</h2>
<h3>3.1 Eligibility</h3>
<p>You must be at least 16 years old and have the legal capacity to enter into these Terms.</p>
<h3>3.2 Account Registration</h3>
<p>To use the Service, you must create an account with accurate information. You are responsible for maintaining the confidentiality of your account credentials and all activities under your account.</p>
<h3>3.3 Account Suspension/Termination</h3>
<p>We may suspend or terminate your account for violations of these Terms, illegal activity, or at our discretion with reasonable notice.</p>
<h2>4. Acceptable Use Policy</h2>
<p>You agree not to:</p>
<ul>
<li>Violate applicable laws or regulations</li>
<li>Infringe intellectual property rights</li>
<li>Upload malicious, illegal, or harmful content</li>
<li>Attempt unauthorized access to systems</li>
<li>Use the Service for spam or harassment</li>
<li>Circumvent security measures</li>
<li>Exceed fair usage limits</li>
</ul>
<h2>5. Content Ownership and Rights</h2>
<h3>5.1 Your Content</h3>
<p>You retain ownership of content you upload ("Your Content"). You grant us a limited license to store, process, and transmit Your Content solely to provide the Service.</p>
<h3>5.2 Prohibited Content</h3>
<p>You may not upload content that is:</p>
<ul>
<li>Illegal, defamatory, or obscene</li>
<li>Infringing on third-party rights</li>
<li>Containing malware or viruses</li>
<li>Excessive in volume without prior agreement</li>
</ul>
<h3>5.3 Content Removal</h3>
<p>We may remove content that violates these Terms, with or without notice.</p>
<h2>6. Service Availability and Limitations</h2>
<h3>6.1 Availability</h3>
<p>We strive for high availability but do not guarantee uninterrupted service. Scheduled maintenance may cause temporary outages.</p>
<h3>6.2 Storage Limits</h3>
<p>Storage limits vary by plan. Exceeding limits may result in additional charges or service restrictions.</p>
<h3>6.3 Fair Usage</h3>
<p>Excessive usage that impacts other users may result in throttling or additional charges.</p>
<h2>7. Billing and Payment</h2>
<h3>7.1 Fees</h3>
<p>Service fees are as published on our website. Prices may change with 30 days' notice.</p>
<h3>7.2 Payment</h3>
<p>You agree to pay all charges associated with your account. Failed payments may result in service suspension.</p>
<h3>7.3 Refunds</h3>
<p>Fees are generally non-refundable except as required by law or at our discretion.</p>
<h2>8. Data Protection and Privacy</h2>
<p>Your use of the Service is subject to our Privacy Policy, which is incorporated by reference. We comply with GDPR and other data protection regulations.</p>
<h2>9. Security and Data Protection</h2>
<p>We implement industry-standard security measures, but you acknowledge that no system is completely secure. You are responsible for your data security.</p>
<h2>10. Intellectual Property</h2>
<p>The Service and its original content are protected by intellectual property laws. You may not copy, modify, or distribute our proprietary materials.</p>
<h2>11. Disclaimers</h2>
<p><strong>THE SERVICE IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND. WE DISCLAIM ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.</strong></p>
<h2>12. Limitation of Liability</h2>
<p><strong>TO THE MAXIMUM EXTENT PERMITTED BY LAW, OUR TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNT PAID BY YOU IN THE 12 MONTHS PRECEDING THE CLAIM.</strong></p>
<h2>13. Indemnification</h2>
<p>You agree to indemnify and hold us harmless from claims arising from your use of the Service or violation of these Terms.</p>
<h2>14. Governing Law and Dispute Resolution</h2>
<p>These Terms are governed by the laws of the Netherlands. Disputes shall be resolved through binding arbitration in Amsterdam, Netherlands.</p>
<h2>15. Modifications to Terms</h2>
<p>We may modify these Terms with reasonable notice. Continued use after changes constitutes acceptance.</p>
<h2>16. Severability</h2>
<p>If any provision is found invalid, the remaining provisions remain in effect.</p>
<h2>17. Entire Agreement</h2>
<p>These Terms constitute the entire agreement between you and us regarding the Service.</p>
<div class="legal-contact">
<h3>Contact Information</h3>
<p>If you have any questions about this terms of service, please contact us:</p>
<ul>
<li><strong>Email:</strong> <a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a></li>
<li><strong>Website:</strong> <a href="https://mywebdav.eu">https://mywebdav.eu</a></li>
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
</ul>
</div>
</div>
{% endblock %}
+474
View File
@@ -0,0 +1,474 @@
{% extends "base.html" %}
{% block title %}Pricing - MyWebdav Cloud Storage{% endblock %}
{% block description %}Simple, transparent pay-as-you-go pricing. Only pay for what you use with no hidden fees.{%
endblock %}
{% block extra_css %}
<style>
.content-section {
max-width: 1200px;
margin: 0 auto;
padding: 4rem 2rem;
}
.page-title {
font-size: 3rem;
font-weight: 700;
color: #1565c0;
text-align: center;
margin-bottom: 1rem;
}
.page-subtitle {
font-size: 1.25rem;
color: #666;
text-align: center;
margin-bottom: 3rem;
}
.pricing-hero {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
color: white;
padding: 3rem;
border-radius: 12px;
text-align: center;
margin-bottom: 3rem;
}
.pricing-hero-amount {
font-size: 5rem;
font-weight: 700;
color: #fff;
margin: 1rem 0;
}
.pricing-hero-description {
font-size: 1.5rem;
opacity: 0.95;
}
.pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-top: 3rem;
}
.pricing-card {
background: white;
border-radius: 8px;
padding: 2rem;
border: 2px solid #e0e0e0;
transition: all 0.3s;
}
.pricing-card.featured {
border-color: #d32f2f;
box-shadow: 0 8px 24px rgba(211, 47, 47, 0.2);
transform: scale(1.05);
}
.pricing-card:hover {
border-color: #1976d2;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.1);
}
.pricing-tier {
font-size: 1.75rem;
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
}
.pricing-amount {
font-size: 2.5rem;
font-weight: 700;
color: #d32f2f;
margin: 1rem 0;
}
.pricing-period {
font-size: 1rem;
color: #666;
margin-bottom: 1.5rem;
}
.pricing-features {
list-style: none;
padding: 0;
margin: 1.5rem 0;
}
.pricing-features li {
padding: 0.75rem 0;
color: #555;
border-bottom: 1px solid #f0f0f0;
}
.pricing-features li:last-child {
border-bottom: none;
}
.pricing-features li:before {
content: "✓ ";
color: #d32f2f;
font-weight: bold;
margin-right: 0.5rem;
}
.pricing-cta {
margin-top: 1.5rem;
}
.calculator-section {
background: #f5f5f5;
padding: 3rem;
border-radius: 12px;
margin-top: 3rem;
}
.calculator-title {
font-size: 2rem;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 2rem;
}
.calculator-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.calculator-input-group {
background: white;
padding: 1.5rem;
border-radius: 8px;
}
.calculator-label {
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
display: block;
}
.calculator-input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e0e0e0;
border-radius: 4px;
font-size: 1rem;
}
.calculator-result {
background: #1976d2;
color: white;
padding: 2rem;
border-radius: 8px;
text-align: center;
}
.calculator-result-label {
font-size: 1rem;
opacity: 0.9;
margin-bottom: 0.5rem;
}
.calculator-result-amount {
font-size: 3rem;
font-weight: 700;
}
.faq-section {
margin-top: 4rem;
}
.faq-title {
font-size: 2rem;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 2rem;
}
.faq-item {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 2px solid #e0e0e0;
margin-bottom: 1rem;
}
.faq-question {
font-weight: 600;
color: #333;
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.faq-answer {
color: #666;
line-height: 1.6;
}
@media (max-width: 768px) {
.page-title {
font-size: 2rem;
}
.pricing-hero-amount {
font-size: 3.5rem;
}
.pricing-card.featured {
transform: scale(1);
}
}
</style>
{% endblock %}
{% block content %}
<div class="content-section">
<h1 class="page-title">SIMPLE, TRANSPARENT PRICING</h1>
<p class="page-subtitle">Pay only for what you use. No hidden fees, no surprises.</p>
<div class="pricing-hero">
<h2 style="font-size: 2rem; margin: 0;">Simple Cloud Storage Pricing</h2>
<div class="pricing-hero-amount">$3-$5/TB</div>
<p class="pricing-hero-description">Pay only for what you use. No hidden features, just storage.</p>
</div>
<div class="pricing-grid" id="pricing-plans">
<!-- Plans will be loaded dynamically from API -->
<div class="pricing-card">
<div class="pricing-tier">Loading...</div>
<div class="pricing-amount">$-</div>
<div class="pricing-period">Loading plans...</div>
<ul class="pricing-features">
<li>Loading subscription plans...</li>
</ul>
<div class="pricing-cta">
<button class="btn btn-secondary" style="width: 100%;" disabled>Loading...</button>
</div>
</div>
</div>
<div class="calculator-section">
<h2 class="calculator-title">Pricing Calculator</h2>
<div class="calculator-grid">
<div class="calculator-input-group">
<label class="calculator-label">Storage (GB)</label>
<input type="number" class="calculator-input" id="storage" value="100" min="0"
oninput="calculatePrice()">
</div>
<div class="calculator-input-group">
<label class="calculator-label">Bandwidth Egress (GB/month)</label>
<input type="number" class="calculator-input" id="bandwidth" value="50" min="0"
oninput="calculatePrice()">
</div>
</div>
<div class="calculator-result">
<div class="calculator-result-label">Estimated Monthly Cost</div>
<div class="calculator-result-amount" id="result">$0.00</div>
</div>
</div>
<div class="faq-section">
<h2 class="faq-title">Frequently Asked Questions</h2>
<div class="faq-item">
<div class="faq-question">How is storage calculated?</div>
<div class="faq-answer">Storage is calculated based on your average daily usage throughout the month. You're
charged per GB per month starting from the first GB. No free tiers or hidden limits.</div>
</div>
<div class="faq-item">
<div class="faq-question">What is bandwidth egress?</div>
<div class="faq-answer">Bandwidth egress is data transferred out of MyWebdav (downloads). You're charged
per GB for downloads. Uploads (ingress) are always free.</div>
</div>
<div class="faq-item">
<div class="faq-question">Are there any hidden fees?</div>
<div class="faq-answer">No. You only pay for storage usage and download bandwidth. No setup fees,
no minimum charges, no surprise costs. The price you see is what you pay.</div>
</div>
<div class="faq-item">
<div class="faq-question">Can I cancel anytime?</div>
<div class="faq-answer">Yes. There are no long-term contracts or commitments. You can close your account at
any time and will only be charged for actual usage up to that point.</div>
</div>
<div class="faq-item">
<div class="faq-question">What payment methods do you accept?</div>
<div class="faq-answer">We accept all major credit cards through our payment processor Stripe.</div>
</div>
<div class="faq-item">
<div class="faq-question">Do you offer volume discounts?</div>
<div class="faq-answer">Yes. Our Enterprise tier offers lower rates for high volume usage (10TB+).
Contact us for details on volume pricing.</div>
</div>
</div>
</div>
<script>
async function loadSubscriptionPlans() {
try {
const response = await fetch('/api/billing/plans');
if (!response.ok) throw new Error('Failed to load plans');
const plans = await response.json();
const plansContainer = document.getElementById('pricing-plans');
plansContainer.innerHTML = plans.map(plan => {
const isFeatured = plan.name === 'professional';
// Set pricing based on plan name
let storagePrice, bandwidthPrice, ctaText, ctaAction, features;
if (plan.name === 'starter') {
storagePrice = '$0.005/GB';
bandwidthPrice = '$0.008/GB';
ctaText = 'Get Started';
ctaAction = () => subscribeToPlan('starter');
features = [
'Cloud storage for individuals',
'Storage: ' + storagePrice,
'Bandwidth: ' + bandwidthPrice,
'Free uploads (ingress)',
'WebDAV access',
'SFTP access',
'API access',
'File versioning',
'99.9% uptime SLA',
'Email support'
];
} else if (plan.name === 'professional') {
storagePrice = '$0.004/GB';
bandwidthPrice = '$0.007/GB';
ctaText = 'Choose Plan';
ctaAction = () => subscribeToPlan('professional');
features = [
'Cloud storage for professionals',
'Storage: ' + storagePrice,
'Bandwidth: ' + bandwidthPrice,
'Free uploads (ingress)',
'WebDAV access',
'SFTP access',
'API access',
'File versioning',
'99.9% uptime SLA',
'Priority email support'
];
} else if (plan.name === 'enterprise') {
storagePrice = '$0.003/GB (10TB+)';
bandwidthPrice = '$0.005/GB';
ctaText = 'Contact Sales';
ctaAction = () => window.location.href = '/support';
features = [
'Cloud storage for enterprises',
'Storage: ' + storagePrice,
'Bandwidth: ' + bandwidthPrice,
'Free uploads (ingress)',
'WebDAV access',
'SFTP access',
'API access',
'File versioning',
'99.9% uptime SLA',
'Priority email support',
'Volume discounts available'
];
}
return `
<div class="pricing-card ${isFeatured ? 'featured' : ''}">
<div class="pricing-tier">${plan.display_name}</div>
<div class="pricing-amount">${plan.price_monthly > 0 ? '$' + plan.price_monthly : 'Usage-based'}</div>
<div class="pricing-period">Billed monthly based on usage</div>
<ul class="pricing-features">
${features.map(feature => `<li>${feature}</li>`).join('')}
</ul>
<div class="pricing-cta">
<button class="btn ${isFeatured ? 'btn-primary' : 'btn-secondary'}"
style="width: 100%;"
onclick="(${ctaAction})()">
${ctaText}
</button>
</div>
</div>
`;
}).join('');
} catch (error) {
console.error('Error loading plans:', error);
document.getElementById('pricing-plans').innerHTML = `
<div class="pricing-card">
<div class="pricing-tier">Error</div>
<div class="pricing-amount">-</div>
<div class="pricing-period">Failed to load plans</div>
<ul class="pricing-features">
<li>Please refresh the page and try again</li>
</ul>
</div>
`;
}
}
async function subscribeToPlan(planName) {
try {
// Check if user is logged in
const response = await fetch('/api/auth/me');
if (!response.ok) {
// User not logged in, redirect to login
window.location.href = '/app#login';
return;
}
const subscribeResponse = await fetch('/api/billing/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ plan_name: planName })
});
if (!subscribeResponse.ok) {
const error = await subscribeResponse.json();
throw new Error(error.detail || 'Failed to subscribe');
}
const result = await subscribeResponse.json();
alert(result.message);
} catch (error) {
console.error('Subscription error:', error);
alert(error.message || 'Failed to subscribe. Please try again.');
}
}
function calculatePrice() {
const storage = parseFloat(document.getElementById('storage').value) || 0;
const bandwidth = parseFloat(document.getElementById('bandwidth').value) || 0;
// Professional tier pricing (featured)
const storageCost = storage * 0.004;
const bandwidthCost = bandwidth * 0.007;
const total = storageCost + bandwidthCost;
document.getElementById('result').textContent = '$' + total.toFixed(2);
}
// Load plans when page loads
document.addEventListener('DOMContentLoaded', function() {
loadSubscriptionPlans();
calculatePrice();
});
</script>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}MyWebdav - Pay-As-You-Go Cloud Storage{% endblock %}
{% block description %}Store what you need, only pay for what you use. $5/TB cloud storage with MyWebdav.{% endblock %}
{% block content %}
<div class="hero-section">
<div class="hero-content">
<h1 class="hero-title">PAY-AS-YOU-GO<br>CLOUD STORAGE</h1>
<div class="hero-price">$5/TB</div>
<p class="hero-subtitle">Store what you need, only pay for what you use</p>
<div class="hero-actions">
<a href="/login" class="btn btn-secondary">Login</a>
<a href="/app" class="btn btn-primary">Sign Up</a>
</div>
</div>
<div class="hero-image">
<svg class="cloud-icon" viewBox="0 0 200 150" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="cloudGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#b3d9f2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7fc4ef;stop-opacity:1" />
</linearGradient>
</defs>
<path d="M150,70 Q170,50 170,70 Q190,70 190,90 Q190,110 170,110 L60,110 Q40,110 40,90 Q40,75 50,65 Q50,45 70,45 Q80,30 100,30 Q120,30 130,45 Q150,50 150,70 Z"
fill="url(#cloudGradient)"
opacity="0.8"/>
<polygon points="140,90 160,70 150,90 165,90 145,115 150,95 135,95"
fill="#ef5350"
opacity="0.9"/>
</svg>
</div>
</div>
{% endblock %}
+409
View File
@@ -0,0 +1,409 @@
{% extends "base.html" %}
{% block title %}Support - MyWebdav Cloud Storage{% endblock %}
{% block description %}Get help with MyWebdav. Contact our support team or find answers in our knowledge base.{% endblock %}
{% block extra_css %}
<style>
.content-section {
max-width: 1200px;
margin: 0 auto;
padding: 4rem 2rem;
}
.page-title {
font-size: 3rem;
font-weight: 700;
color: #1565c0;
text-align: center;
margin-bottom: 1rem;
}
.page-subtitle {
font-size: 1.25rem;
color: #666;
text-align: center;
margin-bottom: 3rem;
}
.support-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.support-card {
background: white;
border-radius: 8px;
padding: 2rem;
border: 2px solid #e0e0e0;
text-align: center;
transition: all 0.3s;
}
.support-card:hover {
border-color: #1976d2;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.1);
transform: translateY(-2px);
}
.support-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.support-title {
font-size: 1.5rem;
font-weight: 600;
color: #333;
margin-bottom: 1rem;
}
.support-description {
color: #666;
line-height: 1.6;
margin-bottom: 1.5rem;
}
.contact-section {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
color: white;
padding: 3rem;
border-radius: 12px;
margin-top: 3rem;
}
.contact-title {
font-size: 2rem;
font-weight: 600;
text-align: center;
margin-bottom: 2rem;
}
.contact-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.contact-item {
text-align: center;
padding: 1rem;
}
.contact-item-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.contact-item-label {
font-weight: 600;
margin-bottom: 0.5rem;
}
.contact-item-value {
opacity: 0.95;
}
.contact-item-value a {
color: white;
text-decoration: none;
}
.contact-item-value a:hover {
text-decoration: underline;
}
.kb-section {
margin-top: 4rem;
}
.kb-title {
font-size: 2rem;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 2rem;
}
.kb-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.kb-category {
background: white;
border-radius: 8px;
padding: 1.5rem;
border: 2px solid #e0e0e0;
}
.kb-category-title {
font-weight: 600;
color: #1976d2;
font-size: 1.25rem;
margin-bottom: 1rem;
}
.kb-links {
list-style: none;
padding: 0;
margin: 0;
}
.kb-links li {
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
}
.kb-links li:last-child {
border-bottom: none;
}
.kb-links a {
color: #555;
text-decoration: none;
transition: color 0.2s;
}
.kb-links a:hover {
color: #1976d2;
}
.status-section {
background: #f5f5f5;
padding: 2rem;
border-radius: 12px;
margin-top: 3rem;
text-align: center;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #4caf50;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 24px;
font-weight: 600;
font-size: 1.125rem;
}
.status-indicator {
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@media (max-width: 768px) {
.page-title {
font-size: 2rem;
}
.support-grid,
.contact-grid,
.kb-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="content-section">
<h1 class="page-title">WE'RE HERE TO HELP</h1>
<p class="page-subtitle">Get the support you need, when you need it</p>
<div class="support-grid">
<div class="support-card">
<div class="support-icon">📧</div>
<h2 class="support-title">Email Support</h2>
<p class="support-description">Send us an email and we'll respond within 24 hours.</p>
<a href="mailto:support@mywebdav.eu" class="btn btn-primary">Email Us</a>
</div>
<div class="support-card">
<div class="support-icon">📚</div>
<h2 class="support-title">Documentation</h2>
<p class="support-description">Browse our comprehensive guides and API documentation.</p>
<a href="#knowledge-base" class="btn btn-secondary">View Docs</a>
</div>
</div>
<div class="status-section">
<h2 style="color: #333; margin-bottom: 1rem;">Service Status</h2>
<div class="status-badge">
<span class="status-indicator"></span>
All Systems Operational
</div>
<p style="color: #666; margin-top: 1rem;">99.9% uptime over the last 30 days</p>
</div>
<div class="contact-section">
<h2 class="contact-title">Contact Information</h2>
<div class="contact-grid">
<div class="contact-item">
<div class="contact-item-icon">📧</div>
<div class="contact-item-label">Support Team</div>
<div class="contact-item-value">
<a href="mailto:support@mywebdav.eu">support@mywebdav.eu</a>
</div>
</div>
<div class="contact-item">
<div class="contact-item-icon">💰</div>
<div class="contact-item-label">Billing Inquiries</div>
<div class="contact-item-value">
<a href="mailto:billing@mywebdav.eu">billing@mywebdav.eu</a>
</div>
</div>
<div class="contact-item">
<div class="contact-item-icon">⚖️</div>
<div class="contact-item-label">Legal & Privacy</div>
<div class="contact-item-value">
<a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a>
</div>
</div>
<div class="contact-item">
<div class="contact-item-icon">🏢</div>
<div class="contact-item-label">Sales</div>
<div class="contact-item-value">
<a href="mailto:sales@mywebdav.eu">sales@mywebdav.eu</a>
</div>
</div>
</div>
</div>
<div class="kb-section" id="knowledge-base">
<h2 class="kb-title">Knowledge Base</h2>
<div class="kb-grid">
<div class="kb-category">
<div class="kb-category-title">Getting Started</div>
<ul class="kb-links">
<li><a href="#kb">Creating your account</a></li>
<li><a href="#kb">Uploading your first file</a></li>
<li><a href="#kb">Setting up WebDAV</a></li>
<li><a href="#kb">Organizing with folders</a></li>
<li><a href="#kb">Understanding pricing</a></li>
</ul>
</div>
<div class="kb-category">
<div class="kb-category-title">Account Management</div>
<ul class="kb-links">
<li><a href="#kb">Updating account details</a></li>
<li><a href="#kb">Enabling two-factor auth</a></li>
<li><a href="#kb">Managing billing</a></li>
<li><a href="#kb">Viewing usage statistics</a></li>
<li><a href="#kb">Deleting your account</a></li>
</ul>
</div>
<div class="kb-category">
<div class="kb-category-title">File Management</div>
<ul class="kb-links">
<li><a href="#kb">Upload and download files</a></li>
<li><a href="#kb">File versioning</a></li>
<li><a href="#kb">Sharing files</a></li>
<li><a href="#kb">Search and filter</a></li>
<li><a href="#kb">Bulk operations</a></li>
</ul>
</div>
<div class="kb-category">
<div class="kb-category-title">WebDAV Setup</div>
<ul class="kb-links">
<li><a href="#kb">Windows setup</a></li>
<li><a href="#kb">macOS setup</a></li>
<li><a href="#kb">Linux setup</a></li>
<li><a href="#kb">Mobile setup</a></li>
<li><a href="#kb">Troubleshooting WebDAV</a></li>
</ul>
</div>
<div class="kb-category">
<div class="kb-category-title">Security & Privacy</div>
<ul class="kb-links">
<li><a href="#kb">Understanding encryption</a></li>
<li><a href="#kb">Two-factor authentication</a></li>
<li><a href="#kb">Data residency</a></li>
<li><a href="#kb">GDPR compliance</a></li>
<li><a href="#kb">Security best practices</a></li>
</ul>
</div>
<div class="kb-category">
<div class="kb-category-title">API & Integration</div>
<ul class="kb-links">
<li><a href="#kb">API documentation</a></li>
<li><a href="#kb">Authentication</a></li>
<li><a href="#kb">Code examples</a></li>
<li><a href="#kb">Rate limits</a></li>
<li><a href="#kb">Webhooks</a></li>
</ul>
</div>
<div class="kb-category">
<div class="kb-category-title">Billing & Pricing</div>
<ul class="kb-links">
<li><a href="#kb">How pricing works</a></li>
<li><a href="#kb">Reading your invoice</a></li>
<li><a href="#kb">Payment methods</a></li>
<li><a href="#kb">Free tier details</a></li>
<li><a href="#kb">Enterprise pricing</a></li>
</ul>
</div>
<div class="kb-category">
<div class="kb-category-title">Troubleshooting</div>
<ul class="kb-links">
<li><a href="#kb">Upload errors</a></li>
<li><a href="#kb">Connection issues</a></li>
<li><a href="#kb">Performance problems</a></li>
<li><a href="#kb">Login issues</a></li>
<li><a href="#kb">Common error messages</a></li>
</ul>
</div>
</div>
</div>
<div style="background: white; padding: 3rem; border-radius: 12px; border: 2px solid #e0e0e0; margin-top: 3rem; text-align: center;">
<h2 style="color: #333; margin-bottom: 1rem;">Response Times</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 2rem; margin-top: 2rem;">
<div>
<div style="font-size: 2rem; color: #d32f2f; font-weight: 700;">24 hours</div>
<div style="color: #666;">General Inquiries</div>
</div>
<div>
<div style="font-size: 2rem; color: #d32f2f; font-weight: 700;">4 hours</div>
<div style="color: #666;">Technical Issues</div>
</div>
<div>
<div style="font-size: 2rem; color: #d32f2f; font-weight: 700;">5 days</div>
<div style="color: #666;">Complaint Response</div>
</div>
<div>
<div style="font-size: 2rem; color: #d32f2f; font-weight: 700;">30 days</div>
<div style="color: #666;">GDPR Requests</div>
</div>
</div>
</div>
</div>
{% endblock %}
+312 -333
View File
@@ -13,6 +13,8 @@ from .auth import get_current_user, verify_password
from .models import User, File, Folder, WebDAVProperty
from .storage import storage_manager
from .activity import log_activity
from .settings import settings
from jose import JWTError, jwt
router = APIRouter(
prefix="/webdav",
@@ -20,28 +22,45 @@ router = APIRouter(
)
def get_persistent_locks():
try:
from .concurrency.webdav_locks import get_webdav_locks
return get_webdav_locks()
except RuntimeError:
return None
class WebDAVLock:
locks = {}
_fallback_locks: dict = {}
@classmethod
def create_lock(cls, path: str, user_id: int, timeout: int = 3600):
async def create_lock(cls, path: str, user_id: int, owner: str = "", timeout: int = 3600):
locks = get_persistent_locks()
if locks:
return await locks.acquire_lock(path, owner or str(user_id), user_id, timeout)
path = path.strip("/")
lock_token = f"opaquelocktoken:{hashlib.md5(f'{path}{user_id}{datetime.now()}'.encode()).hexdigest()}"
cls.locks[path] = {
"token": lock_token,
"user_id": user_id,
"created_at": datetime.now(),
"timeout": timeout,
}
cls._fallback_locks[path] = {"token": lock_token, "user_id": user_id}
return lock_token
@classmethod
def get_lock(cls, path: str):
return cls.locks.get(path)
async def get_lock(cls, path: str):
locks = get_persistent_locks()
if locks:
return await locks.check_lock(path)
path = path.strip("/")
return cls._fallback_locks.get(path)
@classmethod
def remove_lock(cls, path: str):
if path in cls.locks:
del cls.locks[path]
async def remove_lock(cls, path: str, token: str):
locks = get_persistent_locks()
if locks:
return await locks.release_lock(path, token)
path = path.strip("/")
if path in cls._fallback_locks and cls._fallback_locks[path]["token"] == token:
del cls._fallback_locks[path]
return True
return False
async def basic_auth(authorization: Optional[str] = Header(None)):
@@ -56,8 +75,24 @@ async def basic_auth(authorization: Optional[str] = Header(None)):
decoded = base64.b64decode(credentials).decode("utf-8")
username, password = decoded.split(":", 1)
user = None
try:
from .enterprise.dal import get_dal
dal = get_dal()
user = await dal.get_user_by_username(username)
if user and verify_password(password, user.hashed_password):
return user
except RuntimeError:
pass
user = await User.get_or_none(username=username)
if user and verify_password(password, user.hashed_password):
try:
from .enterprise.dal import get_dal
dal = get_dal()
await dal.refresh_user_cache(user)
except RuntimeError:
pass
return user
except (ValueError, UnicodeDecodeError, base64.binascii.Error):
return None
@@ -70,11 +105,25 @@ async def webdav_auth(request: Request, authorization: Optional[str] = Header(No
if user:
return user
try:
user = await get_current_user(request)
return user
except HTTPException:
pass
token = request.cookies.get("access_token")
if token:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
username: str = payload.get("sub")
if username:
user = await User.get_or_none(username=username)
if user:
try:
from .enterprise.dal import get_dal
dal = get_dal()
await dal.refresh_user_cache(user)
except RuntimeError:
pass
return user
except JWTError:
pass
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -83,14 +132,18 @@ async def webdav_auth(request: Request, authorization: Optional[str] = Header(No
async def resolve_path(path_str: str, user: User):
if not path_str or path_str == "/":
try:
from .enterprise.dal import get_dal
dal = get_dal()
return await dal.resolve_path(user.id, path_str)
except RuntimeError:
pass
path_str = path_str.strip("/")
if not path_str:
return None, None, True
parts = [p for p in path_str.split("/") if p]
if not parts:
return None, None, True
current_folder = None
for i, part in enumerate(parts[:-1]):
folder = await Folder.get_or_none(
@@ -114,7 +167,7 @@ async def resolve_path(path_str: str, user: User):
if file:
return file, current_folder, True
return None, current_folder, True
return None, current_folder, False
def build_href(base_path: str, name: str, is_collection: bool):
@@ -124,6 +177,38 @@ def build_href(base_path: str, name: str, is_collection: bool):
return path
def invalidate_cache_for_path(user_id: int, path_str: str, parent_id: Optional[int] = None):
try:
from .enterprise.dal import get_dal
dal = get_dal()
dal.invalidate_path(user_id, path_str)
if parent_id is not None:
dal.invalidate_folder(user_id, parent_id)
else:
dal.invalidate_folder(user_id, None)
except RuntimeError:
pass
def invalidate_cache_for_file(user_id: int, file_id: int, parent_id: Optional[int] = None):
try:
from .enterprise.dal import get_dal
dal = get_dal()
dal.invalidate_file(user_id, file_id, parent_id)
except RuntimeError:
pass
def invalidate_cache_for_folder(user_id: int, folder_id: Optional[int] = None):
try:
from .enterprise.dal import get_dal
dal = get_dal()
dal.invalidate_folder(user_id, folder_id)
dal.invalidate_user_paths(user_id)
except RuntimeError:
pass
async def get_custom_properties(resource_type: str, resource_id: int):
props = await WebDAVProperty.filter(
resource_type=resource_type, resource_id=resource_id
@@ -220,195 +305,80 @@ async def handle_propfind(
request: Request, full_path: str, current_user: User = Depends(webdav_auth)
):
depth = request.headers.get("Depth", "1")
full_path = unquote(full_path).strip("/")
full_path_str = unquote(full_path).strip("/")
body = await request.body()
requested_props = parse_propfind_body(body)
resource, parent, exists = await resolve_path(full_path, current_user)
resource, parent_folder, exists = await resolve_path(full_path_str, current_user)
if not exists and resource is None:
if not exists:
raise HTTPException(status_code=404, detail="Not found")
multistatus = ET.Element("D:multistatus", {"xmlns:D": "DAV:"})
base_href = f"/webdav/{full_path_str}" if full_path_str else "/webdav/"
base_href = f"/webdav/{full_path}" if full_path else "/webdav/"
if resource is None:
# This function adds a single resource to the multistatus response
async def add_resource_to_response(res, res_href):
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = base_href if base_href.endswith("/") else base_href + "/"
href.text = res_href
props = {
"resourcetype": "collection",
"displayname": full_path.split("/")[-1] if full_path else "Root",
"creationdate": datetime.now(),
"getlastmodified": datetime.now(),
}
response.append(create_propstat_element(props))
props = {}
custom_props = None
res_type = ""
res_id = None
if depth in ["1", "infinity"]:
folders = await Folder.filter(
owner=current_user, parent=parent, is_deleted=False
)
files = await File.filter(
owner=current_user, parent=parent, is_deleted=False
)
if isinstance(res, Folder):
res_type, res_id = "folder", res.id
props = {
"resourcetype": "collection", "displayname": res.name,
"creationdate": res.created_at, "getlastmodified": res.updated_at,
}
elif isinstance(res, File):
res_type, res_id = "file", res.id
props = {
"resourcetype": "", "displayname": res.name,
"getcontentlength": res.size, "getcontenttype": res.mime_type,
"creationdate": res.created_at, "getlastmodified": res.updated_at,
"getetag": f'"{res.file_hash}"',
}
elif res is None and (full_path_str == "" or isinstance(resource, Folder)): # Root or empty folder
props = {
"resourcetype": "collection", "displayname": resource.name if resource else "Root",
"creationdate": resource.created_at if resource else datetime.now(),
"getlastmodified": resource.updated_at if resource else datetime.now(),
}
for folder in folders:
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = build_href(base_href, folder.name, True)
if res_type and (requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props))):
custom_props = await get_custom_properties(res_type, res_id)
props = {
"resourcetype": "collection",
"displayname": folder.name,
"creationdate": folder.created_at,
"getlastmodified": folder.updated_at,
}
custom_props = (
await get_custom_properties("folder", folder.id)
if requested_props == "allprop"
or (
isinstance(requested_props, list)
and any(ns != "DAV:" for ns, _ in requested_props)
)
else None
)
response.append(create_propstat_element(props, custom_props))
for file in files:
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = build_href(base_href, file.name, False)
props = {
"resourcetype": "",
"displayname": file.name,
"getcontentlength": file.size,
"getcontenttype": file.mime_type,
"creationdate": file.created_at,
"getlastmodified": file.updated_at,
"getetag": f'"{file.file_hash}"',
}
custom_props = (
await get_custom_properties("file", file.id)
if requested_props == "allprop"
or (
isinstance(requested_props, list)
and any(ns != "DAV:" for ns, _ in requested_props)
)
else None
)
response.append(create_propstat_element(props, custom_props))
elif isinstance(resource, Folder):
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = base_href if base_href.endswith("/") else base_href + "/"
props = {
"resourcetype": "collection",
"displayname": resource.name,
"creationdate": resource.created_at,
"getlastmodified": resource.updated_at,
}
custom_props = (
await get_custom_properties("folder", resource.id)
if requested_props == "allprop"
or (
isinstance(requested_props, list)
and any(ns != "DAV:" for ns, _ in requested_props)
)
else None
)
response.append(create_propstat_element(props, custom_props))
if depth in ["1", "infinity"]:
folders = await Folder.filter(
owner=current_user, parent=resource, is_deleted=False
)
files = await File.filter(
owner=current_user, parent=resource, is_deleted=False
)
# Add the main resource itself to the response
res_href = base_href if isinstance(resource, File) else (base_href if base_href.endswith('/') else base_href + '/')
await add_resource_to_response(resource, res_href)
for folder in folders:
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = build_href(base_href, folder.name, True)
if depth in ["1", "infinity"]:
target_folder = resource if isinstance(resource, Folder) else (None if full_path_str == "" else parent_folder)
target_folder_id = target_folder.id if target_folder else None
props = {
"resourcetype": "collection",
"displayname": folder.name,
"creationdate": folder.created_at,
"getlastmodified": folder.updated_at,
}
custom_props = (
await get_custom_properties("folder", folder.id)
if requested_props == "allprop"
or (
isinstance(requested_props, list)
and any(ns != "DAV:" for ns, _ in requested_props)
)
else None
)
response.append(create_propstat_element(props, custom_props))
try:
from .enterprise.dal import get_dal
dal = get_dal()
folders, files = await dal.get_folder_contents(current_user.id, target_folder_id)
except RuntimeError:
folders = await Folder.filter(owner=current_user, parent=target_folder, is_deleted=False)
files = await File.filter(owner=current_user, parent=target_folder, is_deleted=False)
for file in files:
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = build_href(base_href, file.name, False)
props = {
"resourcetype": "",
"displayname": file.name,
"getcontentlength": file.size,
"getcontenttype": file.mime_type,
"creationdate": file.created_at,
"getlastmodified": file.updated_at,
"getetag": f'"{file.file_hash}"',
}
custom_props = (
await get_custom_properties("file", file.id)
if requested_props == "allprop"
or (
isinstance(requested_props, list)
and any(ns != "DAV:" for ns, _ in requested_props)
)
else None
)
response.append(create_propstat_element(props, custom_props))
elif isinstance(resource, File):
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = base_href
props = {
"resourcetype": "",
"displayname": resource.name,
"getcontentlength": resource.size,
"getcontenttype": resource.mime_type,
"creationdate": resource.created_at,
"getlastmodified": resource.updated_at,
"getetag": f'"{resource.file_hash}"',
}
custom_props = (
await get_custom_properties("file", resource.id)
if requested_props == "allprop"
or (
isinstance(requested_props, list)
and any(ns != "DAV:" for ns, _ in requested_props)
)
else None
)
response.append(create_propstat_element(props, custom_props))
for folder in folders:
child_href = build_href(base_href, folder.name, True)
await add_resource_to_response(folder, child_href)
for file in files:
child_href = build_href(base_href, file.name, False)
await add_resource_to_response(file, child_href)
xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True)
return Response(
content=xml_content,
media_type="application/xml; charset=utf-8",
status_code=207,
)
return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207)
@router.api_route("/{full_path:path}", methods=["GET", "HEAD"])
@@ -416,10 +386,9 @@ async def handle_get(
request: Request, full_path: str, current_user: User = Depends(webdav_auth)
):
full_path = unquote(full_path).strip("/")
resource, _, exists = await resolve_path(full_path, current_user)
resource, parent, exists = await resolve_path(full_path, current_user)
if not isinstance(resource, File):
if not exists or not isinstance(resource, File):
raise HTTPException(status_code=404, detail="File not found")
try:
@@ -430,9 +399,7 @@ async def handle_get(
"Content-Length": str(resource.size),
"Content-Type": resource.mime_type,
"ETag": f'"{resource.file_hash}"',
"Last-Modified": resource.updated_at.strftime(
"%a, %d %b %Y %H:%M:%S GMT"
),
"Last-Modified": resource.updated_at.strftime("%a, %d %b %Y %H:%M:%S GMT"),
},
)
@@ -446,9 +413,7 @@ async def handle_get(
headers={
"Content-Disposition": f'attachment; filename="{resource.name}"',
"ETag": f'"{resource.file_hash}"',
"Last-Modified": resource.updated_at.strftime(
"%a, %d %b %Y %H:%M:%S GMT"
),
"Last-Modified": resource.updated_at.strftime("%a, %d %b %Y %H:%M:%S GMT"),
},
)
except FileNotFoundError:
@@ -460,18 +425,19 @@ async def handle_put(
request: Request, full_path: str, current_user: User = Depends(webdav_auth)
):
full_path = unquote(full_path).strip("/")
if not full_path:
raise HTTPException(status_code=400, detail="Cannot PUT to root")
parts = [p for p in full_path.split("/") if p]
file_name = parts[-1]
parent_path = "/".join(parts[:-1])
parent_path = "/".join(parts[:-1]) if len(parts) > 1 else ""
_, parent_folder, exists = await resolve_path(parent_path, current_user)
parent_resource, _, parent_exists = await resolve_path(parent_path, current_user)
if not exists:
raise HTTPException(status_code=409, detail="Parent folder does not exist")
if not parent_exists or (parent_path and not isinstance(parent_resource, Folder)):
raise HTTPException(status_code=409, detail="Parent collection does not exist")
parent_folder = parent_resource if isinstance(parent_resource, Folder) else None
file_content = await request.body()
file_size = len(file_content)
@@ -487,43 +453,32 @@ async def handle_put(
await storage_manager.save_file(current_user.id, storage_path, file_content)
mime_type, _ = mimetypes.guess_type(file_name)
if not mime_type:
mime_type = "application/octet-stream"
mime_type = mime_type or "application/octet-stream"
existing_file = await File.get_or_none(
name=file_name, parent=parent_folder, owner=current_user, is_deleted=False
)
parent_id = parent_folder.id if parent_folder else None
if existing_file:
old_size = existing_file.size
existing_file.path = storage_path
existing_file.size = file_size
existing_file.mime_type = mime_type
existing_file.file_hash = file_hash
existing_file.path, existing_file.size, existing_file.mime_type, existing_file.file_hash = \
storage_path, file_size, mime_type, file_hash
existing_file.updated_at = datetime.now()
await existing_file.save()
current_user.used_storage_bytes = (
current_user.used_storage_bytes - old_size + file_size
)
current_user.used_storage_bytes += (file_size - old_size)
await current_user.save()
invalidate_cache_for_file(current_user.id, existing_file.id, parent_id)
await log_activity(current_user, "file_updated", "file", existing_file.id)
return Response(status_code=204)
else:
db_file = await File.create(
name=file_name,
path=storage_path,
size=file_size,
mime_type=mime_type,
file_hash=file_hash,
owner=current_user,
parent=parent_folder,
name=file_name, path=storage_path, size=file_size, mime_type=mime_type,
file_hash=file_hash, owner=current_user, parent=parent_folder
)
current_user.used_storage_bytes += file_size
await current_user.save()
invalidate_cache_for_path(current_user.id, full_path, parent_id)
await log_activity(current_user, "file_created", "file", db_file.id)
return Response(status_code=201)
@@ -533,23 +488,32 @@ async def handle_delete(
request: Request, full_path: str, current_user: User = Depends(webdav_auth)
):
full_path = unquote(full_path).strip("/")
if not full_path:
raise HTTPException(status_code=400, detail="Cannot DELETE root")
resource, parent, exists = await resolve_path(full_path, current_user)
resource, _, exists = await resolve_path(full_path, current_user)
if not resource:
if not exists:
raise HTTPException(status_code=404, detail="Resource not found")
if isinstance(resource, File):
parent_id = resource.parent_id
resource.is_deleted = True
resource.deleted_at = datetime.now()
await resource.save()
invalidate_cache_for_file(current_user.id, resource.id, parent_id)
await log_activity(current_user, "file_deleted", "file", resource.id)
elif isinstance(resource, Folder):
child_files = await File.filter(parent=resource, is_deleted=False).count()
child_folders = await Folder.filter(parent=resource, is_deleted=False).count()
if child_files > 0 or child_folders > 0:
raise HTTPException(status_code=409, detail="Folder is not empty")
parent_id = resource.parent_id
resource.is_deleted = True
resource.deleted_at = datetime.now()
await resource.save()
invalidate_cache_for_folder(current_user.id, parent_id)
await log_activity(current_user, "folder_deleted", "folder", resource.id)
return Response(status_code=204)
@@ -560,30 +524,27 @@ async def handle_mkcol(
request: Request, full_path: str, current_user: User = Depends(webdav_auth)
):
full_path = unquote(full_path).strip("/")
if not full_path:
raise HTTPException(status_code=400, detail="Cannot MKCOL at root")
raise HTTPException(status_code=405, detail="Cannot MKCOL at root")
resource, _, exists = await resolve_path(full_path, current_user)
if exists:
raise HTTPException(status_code=405, detail="Resource already exists")
parts = [p for p in full_path.split("/") if p]
folder_name = parts[-1]
parent_path = "/".join(parts[:-1])
parent_path = "/".join(parts[:-1]) if len(parts) > 1 else ""
_, parent_folder, exists = await resolve_path(parent_path, current_user)
parent_resource, _, parent_exists = await resolve_path(parent_path, current_user)
if not exists:
raise HTTPException(status_code=409, detail="Parent folder does not exist")
if not parent_exists or (parent_path and not isinstance(parent_resource, Folder)):
raise HTTPException(status_code=409, detail="Parent collection does not exist")
existing = await Folder.get_or_none(
name=folder_name, parent=parent_folder, owner=current_user, is_deleted=False
)
if existing:
raise HTTPException(status_code=405, detail="Folder already exists")
folder = await Folder.create(
name=folder_name, parent=parent_folder, owner=current_user
)
parent_folder = parent_resource if isinstance(parent_resource, Folder) else None
parent_id = parent_folder.id if parent_folder else None
folder = await Folder.create(name=folder_name, parent=parent_folder, owner=current_user)
invalidate_cache_for_folder(current_user.id, parent_id)
await log_activity(current_user, "folder_created", "folder", folder.id)
return Response(status_code=201)
@@ -594,55 +555,54 @@ async def handle_copy(
):
full_path = unquote(full_path).strip("/")
destination = request.headers.get("Destination")
overwrite = request.headers.get("Overwrite", "T")
overwrite = request.headers.get("Overwrite", "T").upper()
if not destination:
raise HTTPException(status_code=400, detail="Destination header required")
dest_path = unquote(urlparse(destination).path)
dest_path = dest_path.replace("/webdav/", "").strip("/")
dest_path = unquote(urlparse(destination).path).replace("/webdav/", "", 1).strip("/")
source_resource, _, source_exists = await resolve_path(full_path, current_user)
source_resource, _, exists = await resolve_path(full_path, current_user)
if not source_resource:
if not source_exists:
raise HTTPException(status_code=404, detail="Source not found")
if not isinstance(source_resource, File):
raise HTTPException(status_code=501, detail="Only file copy is implemented")
dest_parts = [p for p in dest_path.split("/") if p]
dest_name = dest_parts[-1]
dest_parent_path = "/".join(dest_parts[:-1]) if len(dest_parts) > 1 else ""
dest_name = dest_path.split("/")[-1]
dest_parent_path = "/".join(dest_path.split("/")[:-1])
dest_parent_resource, _, dest_parent_exists = await resolve_path(dest_parent_path, current_user)
_, dest_parent, exists = await resolve_path(dest_parent_path, current_user)
if not dest_parent_exists or (dest_parent_path and not isinstance(dest_parent_resource, Folder)):
raise HTTPException(status_code=409, detail="Destination parent collection does not exist")
dest_parent_folder = dest_parent_resource if isinstance(dest_parent_resource, Folder) else None
if not exists:
raise HTTPException(status_code=409, detail="Destination parent does not exist")
existing_dest, _, existing_dest_exists = await resolve_path(dest_path, current_user)
existing_dest = await File.get_or_none(
name=dest_name, parent=dest_parent, owner=current_user, is_deleted=False
)
if existing_dest_exists and overwrite == "F":
raise HTTPException(status_code=412, detail="Destination exists and Overwrite is 'F'")
if existing_dest and overwrite == "F":
raise HTTPException(
status_code=412, detail="Destination exists and overwrite is false"
dest_parent_id = dest_parent_folder.id if dest_parent_folder else None
if existing_dest_exists:
existing_dest.path = source_resource.path
existing_dest.size = source_resource.size
existing_dest.mime_type = source_resource.mime_type
existing_dest.file_hash = source_resource.file_hash
existing_dest.updated_at = datetime.now()
await existing_dest.save()
invalidate_cache_for_file(current_user.id, existing_dest.id, dest_parent_id)
await log_activity(current_user, "file_copied", "file", existing_dest.id)
return Response(status_code=204)
else:
new_file = await File.create(
name=dest_name, path=source_resource.path, size=source_resource.size,
mime_type=source_resource.mime_type, file_hash=source_resource.file_hash,
owner=current_user, parent=dest_parent_folder
)
if existing_dest:
await existing_dest.delete()
new_file = await File.create(
name=dest_name,
path=source_resource.path,
size=source_resource.size,
mime_type=source_resource.mime_type,
file_hash=source_resource.file_hash,
owner=current_user,
parent=dest_parent,
)
await log_activity(current_user, "file_copied", "file", new_file.id)
return Response(status_code=201 if not existing_dest else 204)
invalidate_cache_for_path(current_user.id, dest_path, dest_parent_id)
await log_activity(current_user, "file_copied", "file", new_file.id)
return Response(status_code=201)
@router.api_route("/{full_path:path}", methods=["MOVE"])
@@ -651,68 +611,77 @@ async def handle_move(
):
full_path = unquote(full_path).strip("/")
destination = request.headers.get("Destination")
overwrite = request.headers.get("Overwrite", "T")
overwrite = request.headers.get("Overwrite", "T").upper()
if not destination:
raise HTTPException(status_code=400, detail="Destination header required")
dest_path = unquote(urlparse(destination).path)
dest_path = dest_path.replace("/webdav/", "").strip("/")
dest_path = unquote(urlparse(destination).path).replace("/webdav/", "", 1).strip("/")
source_resource, _, source_exists = await resolve_path(full_path, current_user)
source_resource, _, exists = await resolve_path(full_path, current_user)
if not source_resource:
if not source_exists:
raise HTTPException(status_code=404, detail="Source not found")
dest_parts = [p for p in dest_path.split("/") if p]
dest_name = dest_parts[-1]
dest_parent_path = "/".join(dest_parts[:-1]) if len(dest_parts) > 1 else ""
dest_name = dest_path.split("/")[-1]
dest_parent_path = "/".join(dest_path.split("/")[:-1])
dest_parent_resource, _, dest_parent_exists = await resolve_path(dest_parent_path, current_user)
_, dest_parent, exists = await resolve_path(dest_parent_path, current_user)
if not dest_parent_exists or (dest_parent_path and not isinstance(dest_parent_resource, Folder)):
raise HTTPException(status_code=409, detail="Destination parent collection does not exist")
dest_parent_folder = dest_parent_resource if isinstance(dest_parent_resource, Folder) else None
if not exists:
raise HTTPException(status_code=409, detail="Destination parent does not exist")
existing_dest, _, existing_dest_exists = await resolve_path(dest_path, current_user)
if isinstance(source_resource, File):
existing_dest = await File.get_or_none(
name=dest_name, parent=dest_parent, owner=current_user, is_deleted=False
)
if existing_dest_exists and overwrite == "F":
raise HTTPException(status_code=412, detail="Destination exists and Overwrite is 'F'")
if existing_dest and overwrite == "F":
raise HTTPException(
status_code=412, detail="Destination exists and overwrite is false"
)
dest_parent_id = dest_parent_folder.id if dest_parent_folder else None
source_parent_id = source_resource.parent_id if hasattr(source_resource, 'parent_id') else None
if existing_dest:
await existing_dest.delete()
source_resource.name = dest_name
source_resource.parent = dest_parent
await source_resource.save()
await log_activity(current_user, "file_moved", "file", source_resource.id)
return Response(status_code=201 if not existing_dest else 204)
elif isinstance(source_resource, Folder):
existing_dest = await Folder.get_or_none(
name=dest_name, parent=dest_parent, owner=current_user, is_deleted=False
)
if existing_dest and overwrite == "F":
raise HTTPException(
status_code=412, detail="Destination exists and overwrite is false"
)
if existing_dest:
existing_dest.is_deleted = True
if existing_dest_exists:
if isinstance(source_resource, File):
existing_dest.name = dest_name
existing_dest.path = source_resource.path
existing_dest.size = source_resource.size
existing_dest.mime_type = source_resource.mime_type
existing_dest.file_hash = source_resource.file_hash
existing_dest.parent = dest_parent_folder
existing_dest.updated_at = datetime.now()
await existing_dest.save()
invalidate_cache_for_file(current_user.id, existing_dest.id, dest_parent_id)
await log_activity(current_user, "file_moved_overwrite", "file", existing_dest.id)
elif isinstance(source_resource, Folder):
raise HTTPException(status_code=501, detail="Folder move overwrite not implemented")
source_resource.name = dest_name
source_resource.parent = dest_parent
source_resource.is_deleted = True
source_resource.deleted_at = datetime.now()
await source_resource.save()
invalidate_cache_for_file(current_user.id, source_resource.id, source_parent_id)
await log_activity(current_user, "file_deleted_after_move", "file", source_resource.id)
await log_activity(current_user, "folder_moved", "folder", source_resource.id)
return Response(status_code=201 if not existing_dest else 204)
return Response(status_code=204)
else:
if isinstance(source_resource, File):
new_file = await File.create(
name=dest_name, path=source_resource.path, size=source_resource.size,
mime_type=source_resource.mime_type, file_hash=source_resource.file_hash,
owner=current_user, parent=dest_parent_folder
)
invalidate_cache_for_path(current_user.id, dest_path, dest_parent_id)
await log_activity(current_user, "file_moved_created", "file", new_file.id)
elif isinstance(source_resource, Folder):
raise HTTPException(status_code=501, detail="Folder move not implemented")
source_resource.is_deleted = True
source_resource.deleted_at = datetime.now()
await source_resource.save()
invalidate_cache_for_file(current_user.id, source_resource.id, source_parent_id)
await log_activity(current_user, "file_deleted_after_move", "file", source_resource.id)
return Response(status_code=201)
@router.api_route("/{full_path:path}", methods=["LOCK"])
@@ -729,7 +698,9 @@ async def handle_lock(
except (ValueError, IndexError):
pass
lock_token = WebDAVLock.create_lock(full_path, current_user.id, timeout)
lock_token = await WebDAVLock.create_lock(full_path, current_user.id, current_user.username, timeout)
if not lock_token:
raise HTTPException(status_code=423, detail="Resource is locked")
lockinfo = ET.Element("D:prop", {"xmlns:D": "DAV:"})
lockdiscovery = ET.SubElement(lockinfo, "D:lockdiscovery")
@@ -776,15 +747,23 @@ async def handle_unlock(
raise HTTPException(status_code=400, detail="Lock-Token header required")
lock_token = lock_token_header.strip("<>")
existing_lock = WebDAVLock.get_lock(full_path)
existing_lock = await WebDAVLock.get_lock(full_path)
if not existing_lock or existing_lock["token"] != lock_token:
raise HTTPException(status_code=409, detail="Invalid lock token")
if not existing_lock:
raise HTTPException(status_code=409, detail="No lock exists for this resource")
if existing_lock["user_id"] != current_user.id:
raise HTTPException(status_code=403, detail="Not lock owner")
if hasattr(existing_lock, 'token'):
if existing_lock.token != lock_token:
raise HTTPException(status_code=409, detail="Invalid lock token")
if existing_lock.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not lock owner")
else:
if existing_lock.get("token") != lock_token:
raise HTTPException(status_code=409, detail="Invalid lock token")
if existing_lock.get("user_id") != current_user.id:
raise HTTPException(status_code=403, detail="Not lock owner")
WebDAVLock.remove_lock(full_path)
await WebDAVLock.remove_lock(full_path, lock_token)
return Response(status_code=204)
+3
View File
@@ -0,0 +1,3 @@
from .queue import TaskQueue, get_task_queue
__all__ = ["TaskQueue", "get_task_queue"]
+251
View File
@@ -0,0 +1,251 @@
import asyncio
import time
import uuid
from typing import Dict, List, Any, Optional, Callable, Awaitable
from dataclasses import dataclass, field
from enum import Enum
from collections import deque
import logging
logger = logging.getLogger(__name__)
class TaskStatus(Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class TaskPriority(Enum):
LOW = 0
NORMAL = 1
HIGH = 2
CRITICAL = 3
@dataclass
class Task:
id: str
queue_name: str
handler_name: str
payload: Dict[str, Any]
priority: TaskPriority = TaskPriority.NORMAL
status: TaskStatus = TaskStatus.PENDING
created_at: float = field(default_factory=time.time)
started_at: Optional[float] = None
completed_at: Optional[float] = None
result: Optional[Any] = None
error: Optional[str] = None
retry_count: int = 0
max_retries: int = 3
class TaskQueue:
QUEUES = [
"thumbnails",
"cleanup",
"billing",
"notifications",
"default",
]
def __init__(self, max_workers: int = 4):
self.max_workers = max_workers
self.queues: Dict[str, deque] = {q: deque() for q in self.QUEUES}
self.handlers: Dict[str, Callable] = {}
self.tasks: Dict[str, Task] = {}
self.workers: List[asyncio.Task] = []
self._lock = asyncio.Lock()
self._running = False
self._stats = {
"enqueued": 0,
"completed": 0,
"failed": 0,
"retried": 0,
}
async def start(self):
self._running = True
for i in range(self.max_workers):
worker = asyncio.create_task(self._worker_loop(i))
self.workers.append(worker)
logger.info(f"TaskQueue started with {self.max_workers} workers")
async def stop(self):
self._running = False
for worker in self.workers:
worker.cancel()
await asyncio.gather(*self.workers, return_exceptions=True)
self.workers.clear()
logger.info("TaskQueue stopped")
def register_handler(self, name: str, handler: Callable[..., Awaitable[Any]]):
self.handlers[name] = handler
logger.debug(f"Registered handler: {name}")
async def enqueue(
self,
queue_name: str,
handler_name: str,
payload: Dict[str, Any],
priority: TaskPriority = TaskPriority.NORMAL,
max_retries: int = 3
) -> str:
if queue_name not in self.queues:
queue_name = "default"
task_id = str(uuid.uuid4())
task = Task(
id=task_id,
queue_name=queue_name,
handler_name=handler_name,
payload=payload,
priority=priority,
max_retries=max_retries
)
async with self._lock:
self.tasks[task_id] = task
if priority == TaskPriority.HIGH or priority == TaskPriority.CRITICAL:
self.queues[queue_name].appendleft(task_id)
else:
self.queues[queue_name].append(task_id)
self._stats["enqueued"] += 1
logger.debug(f"Enqueued task {task_id} to {queue_name}")
return task_id
async def get_task_status(self, task_id: str) -> Optional[Task]:
async with self._lock:
return self.tasks.get(task_id)
async def cancel_task(self, task_id: str) -> bool:
async with self._lock:
if task_id in self.tasks:
task = self.tasks[task_id]
if task.status == TaskStatus.PENDING:
task.status = TaskStatus.CANCELLED
return True
return False
async def _worker_loop(self, worker_id: int):
logger.debug(f"Worker {worker_id} started")
while self._running:
try:
task = await self._get_next_task()
if task:
await self._process_task(task, worker_id)
else:
await asyncio.sleep(0.1)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Worker {worker_id} error: {e}")
await asyncio.sleep(1)
logger.debug(f"Worker {worker_id} stopped")
async def _get_next_task(self) -> Optional[Task]:
async with self._lock:
for priority in [TaskPriority.CRITICAL, TaskPriority.HIGH, TaskPriority.NORMAL, TaskPriority.LOW]:
for queue_name in self.QUEUES:
if self.queues[queue_name]:
task_id = None
for tid in list(self.queues[queue_name]):
if tid in self.tasks and self.tasks[tid].priority == priority:
task_id = tid
break
if task_id:
self.queues[queue_name].remove(task_id)
task = self.tasks.get(task_id)
if task and task.status == TaskStatus.PENDING:
task.status = TaskStatus.RUNNING
task.started_at = time.time()
return task
return None
async def _process_task(self, task: Task, worker_id: int):
handler = self.handlers.get(task.handler_name)
if not handler:
logger.error(f"No handler found for {task.handler_name}")
task.status = TaskStatus.FAILED
task.error = f"Handler not found: {task.handler_name}"
async with self._lock:
self._stats["failed"] += 1
return
try:
result = await handler(**task.payload)
task.status = TaskStatus.COMPLETED
task.completed_at = time.time()
task.result = result
async with self._lock:
self._stats["completed"] += 1
logger.debug(f"Task {task.id} completed by worker {worker_id}")
except Exception as e:
task.error = str(e)
task.retry_count += 1
if task.retry_count < task.max_retries:
task.status = TaskStatus.PENDING
task.started_at = None
async with self._lock:
self.queues[task.queue_name].append(task.id)
self._stats["retried"] += 1
logger.warning(f"Task {task.id} failed, retrying ({task.retry_count}/{task.max_retries})")
else:
task.status = TaskStatus.FAILED
task.completed_at = time.time()
async with self._lock:
self._stats["failed"] += 1
logger.error(f"Task {task.id} failed permanently: {e}")
async def cleanup_completed_tasks(self, max_age: float = 3600):
now = time.time()
async with self._lock:
to_remove = [
tid for tid, task in self.tasks.items()
if task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]
and task.completed_at and now - task.completed_at > max_age
]
for tid in to_remove:
del self.tasks[tid]
if to_remove:
logger.debug(f"Cleaned up {len(to_remove)} completed tasks")
async def get_stats(self) -> Dict:
async with self._lock:
queue_sizes = {name: len(queue) for name, queue in self.queues.items()}
pending = sum(1 for t in self.tasks.values() if t.status == TaskStatus.PENDING)
running = sum(1 for t in self.tasks.values() if t.status == TaskStatus.RUNNING)
return {
**self._stats,
"queue_sizes": queue_sizes,
"pending_tasks": pending,
"running_tasks": running,
"total_tasks": len(self.tasks),
}
_task_queue: Optional[TaskQueue] = None
async def init_task_queue(max_workers: int = 4) -> TaskQueue:
global _task_queue
_task_queue = TaskQueue(max_workers=max_workers)
await _task_queue.start()
return _task_queue
async def shutdown_task_queue():
global _task_queue
if _task_queue:
await _task_queue.stop()
_task_queue = None
def get_task_queue() -> TaskQueue:
if not _task_queue:
raise RuntimeError("Task queue not initialized")
return _task_queue
+1
View File
@@ -32,6 +32,7 @@ ffmpeg-python = "*"
gunicorn = "*"
aiosmtplib = "*"
stripe = "*"
jinja2 = "*"
[tool.poetry.group.dev.dependencies]
black = "*"
+1
View File
@@ -118,3 +118,4 @@ websockets==15.0.1
yarl==1.22.0
zstandard==0.25.0
aiosmtplib==5.0.0
jinja2
+924
View File
@@ -0,0 +1,924 @@
/* retoor <retoor@molodetz.nl> */
/* Admin Panel Styles */
:root {
--primary-color: #003399;
--secondary-color: #CC0000;
--accent-color: #FFFFFF;
--background-color: #F0F2F5;
--text-color: #333333;
--text-color-light: #666666;
--border-color: #DDDDDD;
--shadow-color: rgba(0, 0, 0, 0.1);
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
--info-color: #17a2b8;
--sidebar-width: 250px;
--header-height: 60px;
--font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
background-color: var(--background-color);
color: var(--text-color);
line-height: 1.5;
}
a {
color: var(--primary-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.admin-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.admin-header {
height: var(--header-height);
background-color: var(--accent-color);
border-bottom: 2px solid var(--primary-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
box-shadow: 0 2px 4px var(--shadow-color);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.hamburger-btn {
display: none;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 40px;
height: 40px;
padding: 8px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
}
.hamburger-btn span {
display: block;
width: 24px;
height: 2px;
background-color: var(--primary-color);
border-radius: 2px;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.hamburger-btn:hover {
background-color: var(--background-color);
}
.admin-logo {
display: flex;
align-items: center;
gap: 8px;
}
.logo-icon {
color: var(--primary-color);
font-size: 1.5rem;
}
.logo-text {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
.logo-accent {
color: var(--secondary-color);
}
.admin-badge {
background-color: var(--primary-color);
color: var(--accent-color);
font-size: 0.7rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.admin-user {
color: var(--text-color-light);
font-size: 0.9rem;
}
.admin-body {
display: flex;
margin-top: var(--header-height);
min-height: calc(100vh - var(--header-height));
}
.admin-sidebar {
width: var(--sidebar-width);
background-color: var(--accent-color);
border-right: 1px solid var(--border-color);
position: fixed;
top: var(--header-height);
left: 0;
bottom: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-nav {
padding: 16px 0;
flex: 1;
}
.sidebar-footer {
padding: 16px 0;
border-top: 1px solid var(--border-color);
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
color: var(--text-color);
text-decoration: none;
transition: background-color 0.2s, color 0.2s;
}
.nav-item:hover {
background-color: var(--background-color);
text-decoration: none;
}
.nav-item.active {
background-color: var(--primary-color);
color: var(--accent-color);
}
.nav-icon {
font-size: 1.1rem;
width: 24px;
text-align: center;
}
.nav-text {
font-weight: 500;
}
.sidebar-overlay {
display: none;
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 89;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.sidebar-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.admin-main {
flex: 1;
margin-left: var(--sidebar-width);
padding: 24px;
min-height: calc(100vh - var(--header-height));
}
.page-header {
margin-bottom: 24px;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 8px;
}
.page-subtitle {
color: var(--text-color-light);
font-size: 0.95rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
transition: background-color 0.2s, transform 0.1s;
}
.btn:hover {
text-decoration: none;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background-color: var(--primary-color);
color: var(--accent-color);
}
.btn-primary:hover {
background-color: #002277;
}
.btn-secondary {
background-color: var(--text-color-light);
color: var(--accent-color);
}
.btn-danger {
background-color: var(--danger-color);
color: var(--accent-color);
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-success {
background-color: var(--success-color);
color: var(--accent-color);
}
.btn-outline {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-color);
}
.btn-outline:hover {
background-color: var(--background-color);
}
.btn-sm {
padding: 4px 12px;
font-size: 0.8rem;
}
.card {
background-color: var(--accent-color);
border-radius: 8px;
box-shadow: 0 1px 3px var(--shadow-color);
padding: 20px;
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.card-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background-color: var(--accent-color);
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 3px var(--shadow-color);
}
.stat-label {
font-size: 0.85rem;
color: var(--text-color-light);
margin-bottom: 8px;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-color);
}
.stat-value.success {
color: var(--success-color);
}
.stat-value.warning {
color: var(--warning-color);
}
.stat-value.danger {
color: var(--danger-color);
}
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.data-table th,
.data-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background-color: var(--background-color);
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
}
.data-table tr:hover {
background-color: var(--background-color);
}
.data-table .actions {
display: flex;
gap: 8px;
}
.badge {
display: inline-block;
padding: 4px 10px;
font-size: 0.75rem;
font-weight: 600;
border-radius: 12px;
text-transform: uppercase;
}
.badge-success {
background-color: #d4edda;
color: #155724;
}
.badge-warning {
background-color: #fff3cd;
color: #856404;
}
.badge-danger {
background-color: #f8d7da;
color: #721c24;
}
.badge-info {
background-color: #d1ecf1;
color: #0c5460;
}
.badge-secondary {
background-color: #e9ecef;
color: #495057;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-color);
}
.form-input {
width: 100%;
padding: 10px 12px;
font-size: 0.95rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--accent-color);
color: var(--text-color);
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 51, 153, 0.1);
}
.form-select {
width: 100%;
padding: 10px 12px;
font-size: 0.95rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--accent-color);
color: var(--text-color);
cursor: pointer;
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.form-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
}
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 0.9rem;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-warning {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 20px;
}
.pagination a,
.pagination span {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-color);
text-decoration: none;
font-size: 0.9rem;
}
.pagination a:hover {
background-color: var(--background-color);
text-decoration: none;
}
.pagination .active {
background-color: var(--primary-color);
color: var(--accent-color);
border-color: var(--primary-color);
}
.pagination .disabled {
color: var(--text-color-light);
cursor: not-allowed;
}
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-bar .form-input {
flex: 1;
min-width: 200px;
}
.search-bar .form-select {
min-width: 150px;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--primary-color);
transition: width 0.3s ease;
}
.progress-fill.warning {
background-color: var(--warning-color);
}
.progress-fill.danger {
background-color: var(--danger-color);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.detail-section {
background-color: var(--accent-color);
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 3px var(--shadow-color);
}
.detail-section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--background-color);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
color: var(--text-color-light);
font-size: 0.9rem;
}
.detail-value {
font-weight: 500;
color: var(--text-color);
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-color-light);
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state-text {
font-size: 1.1rem;
margin-bottom: 16px;
}
.activity-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background-color: var(--background-color);
border-radius: 6px;
}
.activity-icon {
width: 32px;
height: 32px;
background-color: var(--primary-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-color);
font-size: 0.8rem;
flex-shrink: 0;
}
.activity-content {
flex: 1;
}
.activity-text {
font-size: 0.9rem;
color: var(--text-color);
}
.activity-time {
font-size: 0.8rem;
color: var(--text-color-light);
margin-top: 4px;
}
.confirm-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog-content {
background-color: var(--accent-color);
border-radius: 8px;
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 12px var(--shadow-color);
}
.confirm-dialog-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 12px;
}
.confirm-dialog-text {
color: var(--text-color-light);
margin-bottom: 20px;
}
.confirm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 767px) {
.hamburger-btn {
display: flex;
}
.admin-sidebar {
position: fixed;
left: 0;
top: var(--header-height);
bottom: 0;
width: 280px;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 90;
}
.admin-sidebar.open {
transform: translateX(0);
box-shadow: 4px 0 12px var(--shadow-color);
}
.sidebar-overlay {
display: block;
}
.admin-main {
margin-left: 0;
padding: 16px;
}
.page-title {
font-size: 1.4rem;
}
.stats-grid {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-card {
padding: 16px;
}
.stat-value {
font-size: 1.4rem;
}
.card {
padding: 16px;
}
.search-bar {
flex-direction: column;
}
.search-bar .form-input,
.search-bar .form-select {
min-width: 100%;
}
.data-table {
font-size: 0.85rem;
}
.data-table th,
.data-table td {
padding: 10px 12px;
}
.form-actions {
flex-direction: column;
}
.form-actions .btn {
width: 100%;
}
.detail-grid {
grid-template-columns: 1fr;
}
.header-right .admin-user {
display: none;
}
.pagination {
flex-wrap: wrap;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.table-container {
margin: 0 -16px;
}
.data-table th:nth-child(n+3),
.data-table td:nth-child(n+3) {
display: none;
}
.data-table .show-mobile {
display: table-cell;
}
}
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-color) 0%, #001f5c 100%);
padding: 20px;
}
.login-box {
background-color: var(--accent-color);
border-radius: 8px;
padding: 40px;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.login-logo .logo-icon {
font-size: 2.5rem;
}
.login-logo .logo-text {
font-size: 1.5rem;
display: block;
margin-top: 8px;
}
.login-title {
text-align: center;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 24px;
}
.login-form .form-group {
margin-bottom: 20px;
}
.login-form .btn {
width: 100%;
padding: 12px;
font-size: 1rem;
}
.login-error {
background-color: #f8d7da;
color: #721c24;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
text-align: center;
font-size: 0.9rem;
}
@media (max-width: 480px) {
.login-box {
padding: 24px;
}
}
+84
View File
@@ -384,3 +384,87 @@
border: 1px solid #e5e7eb;
border-radius: 8px;
}
@media (max-width: 767px) {
.billing-dashboard,
.admin-billing {
padding: 16px;
}
.billing-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.billing-cards {
grid-template-columns: 1fr;
gap: 16px;
}
.stats-cards {
grid-template-columns: 1fr;
gap: 16px;
}
.estimated-cost {
font-size: 1.5rem;
}
.stat-value {
font-size: 1.5rem;
}
.invoices-section,
.payment-methods-section,
.pricing-config-section,
.invoice-generation-section {
padding: 16px;
}
.invoices-table {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.invoices-table table {
min-width: 600px;
}
.pricing-table {
min-width: 500px;
}
.invoice-gen-form {
flex-direction: column;
align-items: stretch;
}
.invoice-gen-form label {
width: 100%;
}
.invoice-gen-form input {
width: 100%;
}
.modal-content {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
border-radius: 0;
}
.modal-actions {
flex-direction: column;
}
.modal-actions .button {
width: 100%;
}
.payment-methods-section .button {
width: 100%;
}
}
+54
View File
@@ -88,3 +88,57 @@
.code-editor-body textarea {
display: none;
}
@media (max-width: 767px) {
.code-editor-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
}
.code-editor-header {
padding: 12px 16px;
padding-top: calc(12px + env(safe-area-inset-top, 0));
flex-wrap: wrap;
gap: 8px;
}
.code-editor-header .header-left {
gap: 8px;
flex: 1;
min-width: 0;
}
.code-editor-header .preview-actions {
gap: 4px;
}
.code-editor-header .button {
min-width: 44px;
min-height: 44px;
padding: 8px 12px;
font-size: 14px;
}
.editor-filename {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.code-editor-body {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.code-editor-body .CodeMirror {
font-size: 13px;
}
.code-editor-body .CodeMirror-linenumber {
padding: 0 4px;
}
}
+61
View File
@@ -143,3 +143,64 @@
color: #dc3545;
font-weight: 500;
}
@media (max-width: 767px) {
.file-upload-view {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
}
.file-upload-header {
padding: 12px 16px;
padding-top: calc(12px + env(safe-area-inset-top, 0));
}
.file-upload-header .header-left {
gap: 12px;
}
.file-upload-header h2 {
font-size: 16px;
}
.file-upload-header .button {
min-width: 44px;
min-height: 44px;
}
.file-upload-body {
padding: 16px;
gap: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0));
}
.drop-zone {
padding: 32px 16px;
}
.drop-zone-icon {
font-size: 48px;
}
.drop-zone h3 {
font-size: 16px;
}
.upload-item {
padding: 12px;
}
.upload-item-info {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.upload-item-name {
word-break: break-all;
}
}
File diff suppressed because it is too large Load Diff
+320
View File
@@ -0,0 +1,320 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
color: #333;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: white;
border-bottom: 2px solid #e0e0e0;
padding: 1rem 0;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.5rem;
font-weight: 600;
}
.logo-icon {
color: #1976d2;
font-size: 1.8rem;
}
.logo-text {
color: #333;
}
.logo-webdav {
color: #d32f2f;
}
.nav-menu {
display: flex;
gap: 2rem;
list-style: none;
}
.nav-menu a {
color: #1976d2;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.nav-menu a:hover {
color: #1565c0;
}
.hamburger {
display: none;
flex-direction: column;
gap: 4px;
background: none;
border: none;
cursor: pointer;
padding: 8px;
z-index: 1001;
}
.hamburger span {
width: 25px;
height: 3px;
background: #1976d2;
border-radius: 2px;
transition: all 0.3s ease;
}
.hamburger.active span:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.hamburger.active span:nth-child(2) {
opacity: 0;
}
.hamburger.active span:nth-child(3) {
transform: rotate(-45deg) translate(7px, -6px);
}
.hero-section {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
padding: 4rem 2rem;
gap: 4rem;
}
.hero-content {
flex: 1;
max-width: 600px;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
color: #1565c0;
line-height: 1.2;
margin-bottom: 1rem;
}
.hero-price {
font-size: 4rem;
font-weight: 700;
color: #d32f2f;
margin-bottom: 1rem;
}
.hero-subtitle {
font-size: 1.125rem;
color: #555;
margin-bottom: 2rem;
}
.hero-actions {
display: flex;
gap: 1rem;
}
.btn {
padding: 0.875rem 2rem;
border-radius: 4px;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
transition: all 0.2s;
display: inline-block;
border: none;
cursor: pointer;
}
.btn-primary {
background: #d32f2f;
color: white;
}
.btn-primary:hover {
background: #c62828;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(211, 47, 47, 0.3);
}
.btn-secondary {
background: transparent;
color: #1976d2;
border: 2px solid #1976d2;
}
.btn-secondary:hover {
background: #1976d2;
color: white;
}
.hero-image {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.cloud-icon {
width: 100%;
max-width: 400px;
height: auto;
}
.footer {
background: white;
border-top: 2px solid #e0e0e0;
padding: 2rem;
text-align: center;
}
.footer-links {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
.footer-links a {
color: #1976d2;
text-decoration: none;
font-size: 0.875rem;
}
.footer-links a:hover {
text-decoration: underline;
}
.footer-copyright {
color: #666;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.nav-container {
padding: 0 1rem;
}
.hamburger {
display: flex;
}
.nav-menu {
position: fixed;
left: -100%;
top: 0;
flex-direction: column;
background-color: white;
width: 70%;
max-width: 300px;
height: 100vh;
padding: 5rem 2rem 2rem;
gap: 1.5rem;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s ease;
z-index: 1000;
}
.nav-menu.active {
left: 0;
}
.nav-menu li {
width: 100%;
}
.nav-menu a {
display: block;
padding: 0.75rem 0;
font-size: 1.125rem;
}
.hero-section {
flex-direction: column;
text-align: center;
padding: 2rem 1rem;
gap: 2rem;
}
.hero-title {
font-size: 2rem;
}
.hero-price {
font-size: 3rem;
}
.hero-subtitle {
font-size: 1rem;
}
.hero-actions {
justify-content: center;
flex-wrap: wrap;
}
.footer {
padding: 1.5rem 1rem;
}
.footer-links {
flex-direction: column;
gap: 0.75rem;
}
}
@media (max-width: 480px) {
.logo {
font-size: 1.25rem;
}
.logo-icon {
font-size: 1.5rem;
}
.hero-title {
font-size: 1.5rem;
}
.hero-price {
font-size: 2.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
font-size: 0.875rem;
}
.nav-menu {
width: 80%;
}
}
+1 -48
View File
@@ -579,53 +579,6 @@ body {
font-size: 1.25rem;
}
@media (max-width: 768px) {
.app-header {
flex-direction: column;
height: auto;
padding: var(--spacing-unit);
}
.header-center {
width: 100%;
max-width: none;
margin: var(--spacing-unit) 0;
}
.header-right {
width: 100%;
justify-content: space-between;
}
.app-body {
flex-direction: column;
}
.app-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border-color);
max-height: 200px;
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
@media (max-width: 480px) {
.file-grid {
grid-template-columns: 1fr;
}
.file-actions {
flex-direction: column;
}
.file-actions button {
width: 100%;
}
}
body.dark-mode {
--background-color: #222222;
@@ -993,7 +946,7 @@ body.dark-mode {
border-color: var(--primary-color);
background-color: rgba(0, 51, 153, 0.05);
}
-e
.shared-items-container {
background-color: var(--accent-color);
border-radius: 8px;
+45 -1
View File
@@ -3,12 +3,56 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MyWebdav Cloud Storage</title>
<title>MyWebdav - Cloud Storage SaaS</title>
<meta name="description" content="MyWebdav is a powerful cloud storage SaaS for secure file management, sharing, and collaboration. Control your data with WebDAV, SFTP, and encrypted storage.">
<meta name="keywords" content="cloud storage SaaS, file sharing, WebDAV server, SFTP support, secure file sync, data privacy, file collaboration, encrypted storage">
<meta name="author" content="MyWebdav Team">
<meta name="robots" content="index, follow">
<meta property="og:title" content="MyWebdav - Cloud Storage SaaS">
<meta property="og:description" content="Secure, scalable cloud storage SaaS with WebDAV, SFTP, and enterprise features. Take control of your data privacy.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://your-domain.com">
<meta property="og:image" content="/static/icons/icon-192x192.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="MyWebdav - Cloud Storage SaaS">
<meta name="twitter:description" content="Cloud storage SaaS for complete data control and privacy.">
<meta name="twitter:image" content="/static/icons/icon-192x192.png">
<link rel="canonical" href="https://your-domain.com">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "MyWebdav",
"description": "Cloud storage SaaS for secure file management and sharing",
"url": "https://your-domain.com",
"applicationCategory": "CloudStorage",
"operatingSystem": "Linux, Windows, macOS",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"author": {
"@type": "Organization",
"name": "MyWebdav Team"
},
"featureList": [
"File Management",
"WebDAV Protocol",
"SFTP Support",
"Secure Sharing",
"User Authentication",
"Photo Gallery",
"Real-time Activity Feed"
]
}
</script>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/billing.css">
<link rel="stylesheet" href="/static/lib/codemirror/codemirror.min.css">
<link rel="stylesheet" href="/static/css/code-editor-view.css">
<link rel="stylesheet" href="/static/css/file-upload-view.css">
<link rel="stylesheet" href="/static/css/mobile.css">
<link rel="manifest" href="/static/manifest.json">
<script src="https://js.stripe.com/v3/"></script>
<script src="/static/lib/codemirror/codemirror.min.js"></script>
+145 -2
View File
@@ -1,4 +1,5 @@
import { api } from '../api.js';
import { GestureHandler, PullToRefreshIndicator, ContextMenu, isMobile } from '../gesture-handler.js';
export class FileList extends HTMLElement {
constructor() {
@@ -12,6 +13,9 @@ export class FileList extends HTMLElement {
this.boundHandleClick = this.handleClick.bind(this);
this.boundHandleDblClick = this.handleDblClick.bind(this);
this.boundHandleChange = this.handleChange.bind(this);
this.gestureHandler = null;
this.pullIndicator = null;
this.contextMenu = new ContextMenu();
}
async connectedCallback() {
@@ -27,6 +31,12 @@ export class FileList extends HTMLElement {
this.removeEventListener('click', this.boundHandleClick);
this.removeEventListener('dblclick', this.boundHandleDblClick);
this.removeEventListener('change', this.boundHandleChange);
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
if (this.pullIndicator) {
this.pullIndicator.destroy();
}
}
async loadContents(folderId) {
@@ -128,7 +138,7 @@ export class FileList extends HTMLElement {
renderFolder(folder) {
const isSelected = this.selectedFolders.has(folder.id);
const starIcon = folder.is_starred ? '&#9733;' : '&#9734;'; // Filled star or empty star
const starIcon = folder.is_starred ? '&#9733;' : '&#9734;';
const starAction = folder.is_starred ? 'unstar-folder' : 'star-folder';
return `
<div class="file-item folder-item" data-folder-id="${folder.id}">
@@ -139,6 +149,7 @@ export class FileList extends HTMLElement {
<button class="action-btn" data-action="delete-folder" data-id="${folder.id}">Delete</button>
<button class="action-btn star-btn" data-action="${starAction}" data-id="${folder.id}">${starIcon}</button>
</div>
<button class="mobile-more-btn" data-folder-id="${folder.id}" aria-label="More actions">&#8942;</button>
</div>
`;
}
@@ -147,7 +158,7 @@ export class FileList extends HTMLElement {
const isSelected = this.selectedFiles.has(file.id);
const icon = this.getFileIcon(file.mime_type);
const size = this.formatFileSize(file.size);
const starIcon = file.is_starred ? '&#9733;' : '&#9734;'; // Filled star or empty star
const starIcon = file.is_starred ? '&#9733;' : '&#9734;';
const starAction = file.is_starred ? 'unstar-file' : 'star-file';
return `
@@ -163,6 +174,7 @@ export class FileList extends HTMLElement {
<button class="action-btn" data-action="share" data-id="${file.id}">Share</button>
<button class="action-btn star-btn" data-action="${starAction}" data-id="${file.id}">${starIcon}</button>
</div>
<button class="mobile-more-btn" data-file-id="${file.id}" aria-label="More actions">&#8942;</button>
</div>
`;
}
@@ -250,6 +262,26 @@ export class FileList extends HTMLElement {
return;
}
if (target.classList.contains('mobile-more-btn')) {
e.stopPropagation();
const fileId = target.dataset.fileId;
const folderId = target.dataset.folderId;
const rect = target.getBoundingClientRect();
if (fileId) {
const file = this.files.find(f => f.id === parseInt(fileId));
if (file) {
this.showFileContextMenu(rect.left, rect.bottom, file);
}
} else if (folderId) {
const folder = this.folders.find(f => f.id === parseInt(folderId));
if (folder) {
this.showFolderContextMenu(rect.left, rect.bottom, folder);
}
}
return;
}
if (target.classList.contains('select-item')) {
e.stopPropagation();
return;
@@ -305,6 +337,117 @@ export class FileList extends HTMLElement {
attachListeners() {
this.updateBatchActionVisibility();
this.initGestures();
}
initGestures() {
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
if (this.pullIndicator) {
this.pullIndicator.destroy();
}
const container = this.querySelector('.file-list-container');
if (!container) return;
this.pullIndicator = new PullToRefreshIndicator(container);
this.gestureHandler = new GestureHandler(container);
this.gestureHandler.on('pullToRefresh', async () => {
this.pullIndicator.showRefreshing();
await this.loadContents(this.currentFolderId);
this.pullIndicator.hide();
});
this.gestureHandler.on('longPress', (data) => {
if (!isMobile()) return;
const fileItem = data.target?.closest('.file-item');
if (!fileItem) return;
const folderId = fileItem.dataset.folderId;
const fileId = fileItem.dataset.fileId;
if (folderId) {
const folder = this.folders.find(f => f.id === parseInt(folderId));
if (folder) {
this.showFolderContextMenu(data.x, data.y, folder);
}
} else if (fileId) {
const file = this.files.find(f => f.id === parseInt(fileId));
if (file) {
this.showFileContextMenu(data.x, data.y, file);
}
}
});
container.addEventListener('pull-progress', (e) => {
this.pullIndicator.setProgress(e.detail.progress);
});
container.addEventListener('pull-end', () => {
if (this.pullIndicator) {
this.pullIndicator.hide();
}
});
}
showFileContextMenu(x, y, file) {
const items = [
{
label: 'Download',
icon: '⬇',
action: () => this.handleAction('download', file.id)
},
{
label: 'Rename',
icon: '✏',
action: () => this.handleAction('rename', file.id)
},
{
label: 'Share',
icon: '🔗',
action: () => this.handleAction('share', file.id)
},
{ separator: true },
{
label: file.is_starred ? 'Unstar' : 'Star',
icon: file.is_starred ? '★' : '☆',
action: () => this.handleAction(file.is_starred ? 'unstar-file' : 'star-file', file.id)
},
{ separator: true },
{
label: 'Delete',
icon: '🗑',
destructive: true,
action: () => this.handleAction('delete', file.id)
}
];
this.contextMenu.show(x, y, items);
}
showFolderContextMenu(x, y, folder) {
const items = [
{
label: 'Open',
icon: '📂',
action: () => this.loadContents(folder.id)
},
{
label: folder.is_starred ? 'Unstar' : 'Star',
icon: folder.is_starred ? '★' : '☆',
action: () => this.handleAction(folder.is_starred ? 'unstar-folder' : 'star-folder', folder.id)
},
{ separator: true },
{
label: 'Delete',
icon: '🗑',
destructive: true,
action: () => this.handleAction('delete-folder', folder.id)
}
];
this.contextMenu.show(x, y, items);
}
toggleSelectItem(type, id, checked) {
+21
View File
@@ -1,15 +1,36 @@
import { api } from '../api.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
class FilePreview extends HTMLElement {
constructor() {
super();
this.file = null;
this.handleEscape = this.handleEscape.bind(this);
this.gestureHandler = null;
}
connectedCallback() {
this.render();
this.setupEventListeners();
this.initGestures();
}
disconnectedCallback() {
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
}
initGestures() {
const overlay = this.querySelector('.file-preview-overlay');
if (!overlay) return;
this.gestureHandler = new GestureHandler(overlay);
this.gestureHandler.on('swipeDown', () => {
if (isMobile()) {
this.close();
}
});
}
setupEventListeners() {
+19
View File
@@ -1,4 +1,5 @@
import { api } from '../api.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
export class FileUploadView extends HTMLElement {
constructor() {
@@ -6,6 +7,7 @@ export class FileUploadView extends HTMLElement {
this.folderId = null;
this.handleEscape = this.handleEscape.bind(this);
this.uploadItems = new Map();
this.gestureHandler = null;
}
connectedCallback() {
@@ -14,6 +16,21 @@ export class FileUploadView extends HTMLElement {
disconnectedCallback() {
document.removeEventListener('keydown', this.handleEscape);
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
}
initGestures() {
const view = this.querySelector('.file-upload-view');
if (!view) return;
this.gestureHandler = new GestureHandler(view);
this.gestureHandler.on('swipeDown', () => {
if (isMobile()) {
this.close();
}
});
}
setFolder(folderId) {
@@ -57,6 +74,8 @@ export class FileUploadView extends HTMLElement {
backBtn.addEventListener('click', () => this.close());
}
this.initGestures();
if (fileInput) {
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
+75 -4
View File
@@ -15,8 +15,9 @@ import './billing-dashboard.js';
import './admin-billing.js';
import './code-editor-view.js';
import './cookie-consent.js';
import './user-settings.js'; // Import the new user settings component
import './user-settings.js';
import { shortcuts } from '../shortcuts.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
const api = app.getAPI();
const logger = app.getLogger();
@@ -31,6 +32,8 @@ export class MyWebdavApp extends HTMLElement {
this.boundHandlePopState = this.handlePopState.bind(this);
this.popstateAttached = false;
this.currentSearchId = 0;
this.gestureHandler = null;
this.sidebarOpen = false;
}
async connectedCallback() {
@@ -97,6 +100,7 @@ export class MyWebdavApp extends HTMLElement {
}
showLogin() {
document.body.classList.remove('logged-in');
this.innerHTML = `
<div class="login-container">
<login-view></login-view>
@@ -123,14 +127,20 @@ export class MyWebdavApp extends HTMLElement {
}
render() {
document.body.classList.add('logged-in');
this.innerHTML = `
<div class="app-container">
<header class="app-header">
<div class="header-left">
<button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle navigation">
<span></span>
<span></span>
<span></span>
</button>
<h1 class="app-title">MyWebdav</h1>
</div>
<div class="header-center">
<input type="search" placeholder="Search..." class="search-input" id="search-input">
<input type="search" placeholder="Search..." class="search-input" id="search-input" inputmode="search">
</div>
<div class="header-right">
<span class="user-info">${this.user.username}</span>
@@ -139,7 +149,8 @@ export class MyWebdavApp extends HTMLElement {
</header>
<div class="app-body">
<aside class="app-sidebar">
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<aside class="app-sidebar" id="app-sidebar">
<nav class="sidebar-nav">
<h3 class="nav-title">Navigation</h3>
<ul class="nav-list">
@@ -160,7 +171,7 @@ export class MyWebdavApp extends HTMLElement {
</nav>
</aside>
<main class="app-main">
<main class="app-main" id="app-main">
<div id="main-content">
<file-list></file-list>
</div>
@@ -191,6 +202,55 @@ export class MyWebdavApp extends HTMLElement {
this.initializeNavigation();
this.attachListeners();
this.registerShortcuts();
this.initGestures();
}
initGestures() {
const appMain = this.querySelector('#app-main');
if (!appMain) return;
this.gestureHandler = new GestureHandler(appMain);
this.gestureHandler.on('edgeSwipeRight', () => {
if (isMobile()) {
this.openSidebar();
}
});
}
toggleSidebar() {
if (this.sidebarOpen) {
this.closeSidebar();
} else {
this.openSidebar();
}
}
openSidebar() {
const sidebar = this.querySelector('#app-sidebar');
const overlay = this.querySelector('#sidebar-overlay');
const hamburger = this.querySelector('#hamburger-btn');
if (sidebar && overlay) {
sidebar.classList.add('open');
overlay.classList.add('visible');
hamburger?.classList.add('active');
this.sidebarOpen = true;
document.body.style.overflow = 'hidden';
}
}
closeSidebar() {
const sidebar = this.querySelector('#app-sidebar');
const overlay = this.querySelector('#sidebar-overlay');
const hamburger = this.querySelector('#hamburger-btn');
if (sidebar && overlay) {
sidebar.classList.remove('open');
overlay.classList.remove('visible');
hamburger?.classList.remove('active');
this.sidebarOpen = false;
document.body.style.overflow = '';
}
}
initializeNavigation() {
@@ -402,11 +462,22 @@ export class MyWebdavApp extends HTMLElement {
api.logout();
});
this.querySelector('#hamburger-btn')?.addEventListener('click', () => {
this.toggleSidebar();
});
this.querySelector('#sidebar-overlay')?.addEventListener('click', () => {
this.closeSidebar();
});
this.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const view = link.dataset.view;
this.switchView(view);
if (isMobile()) {
this.closeSidebar();
}
});
});
+34
View File
@@ -1,20 +1,54 @@
import { api } from '../api.js';
import { GestureHandler, PullToRefreshIndicator } from '../gesture-handler.js';
class PhotoGallery extends HTMLElement {
constructor() {
super();
this.photos = [];
this.boundHandleClick = this.handleClick.bind(this);
this.gestureHandler = null;
this.pullIndicator = null;
}
connectedCallback() {
this.addEventListener('click', this.boundHandleClick);
this.render();
this.loadPhotos();
this.initGestures();
}
disconnectedCallback() {
this.removeEventListener('click', this.boundHandleClick);
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
if (this.pullIndicator) {
this.pullIndicator.destroy();
}
}
initGestures() {
const container = this.querySelector('.photo-gallery');
if (!container) return;
this.pullIndicator = new PullToRefreshIndicator(container);
this.gestureHandler = new GestureHandler(container);
this.gestureHandler.on('pullToRefresh', async () => {
this.pullIndicator.showRefreshing();
await this.loadPhotos();
this.pullIndicator.hide();
});
container.addEventListener('pull-progress', (e) => {
this.pullIndicator.setProgress(e.detail.progress);
});
container.addEventListener('pull-end', () => {
if (this.pullIndicator) {
this.pullIndicator.hide();
}
});
}
async loadPhotos() {
+21
View File
@@ -1,4 +1,5 @@
import { api } from '../api.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
export class ShareModal extends HTMLElement {
constructor() {
@@ -6,10 +7,29 @@ export class ShareModal extends HTMLElement {
this.fileId = null;
this.folderId = null;
this.handleEscape = this.handleEscape.bind(this);
this.gestureHandler = null;
this.render();
this.attachListeners();
}
disconnectedCallback() {
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
}
initGestures() {
const modal = this.querySelector('.share-modal-content');
if (!modal || this.gestureHandler) return;
this.gestureHandler = new GestureHandler(modal);
this.gestureHandler.on('swipeDown', () => {
if (isMobile()) {
this.hide();
}
});
}
render() {
this.innerHTML = `
<div class="share-modal" id="share-modal" style="display: none;">
@@ -90,6 +110,7 @@ export class ShareModal extends HTMLElement {
this.querySelector('#share-result').style.display = 'none';
this.querySelector('#share-form').reset();
document.addEventListener('keydown', this.handleEscape);
this.initGestures();
}
hide() {
+362
View File
@@ -0,0 +1,362 @@
// retoor <retoor@molodetz.nl>
export class GestureHandler {
constructor(element, options = {}) {
this.element = element;
this.options = {
swipeThreshold: 50,
swipeVelocityThreshold: 0.3,
longPressDelay: 500,
pullToRefreshThreshold: 80,
edgeSwipeWidth: 20,
...options
};
this.touchStartX = 0;
this.touchStartY = 0;
this.touchStartTime = 0;
this.longPressTimer = null;
this.isPulling = false;
this.pullDistance = 0;
this.isLongPress = false;
this.callbacks = {
swipeLeft: [],
swipeRight: [],
swipeUp: [],
swipeDown: [],
longPress: [],
pullToRefresh: [],
edgeSwipeRight: []
};
this.boundHandlers = {
touchStart: this.handleTouchStart.bind(this),
touchMove: this.handleTouchMove.bind(this),
touchEnd: this.handleTouchEnd.bind(this),
touchCancel: this.handleTouchCancel.bind(this)
};
this.attach();
}
attach() {
this.element.addEventListener('touchstart', this.boundHandlers.touchStart, { passive: false });
this.element.addEventListener('touchmove', this.boundHandlers.touchMove, { passive: false });
this.element.addEventListener('touchend', this.boundHandlers.touchEnd, { passive: true });
this.element.addEventListener('touchcancel', this.boundHandlers.touchCancel, { passive: true });
}
destroy() {
this.element.removeEventListener('touchstart', this.boundHandlers.touchStart);
this.element.removeEventListener('touchmove', this.boundHandlers.touchMove);
this.element.removeEventListener('touchend', this.boundHandlers.touchEnd);
this.element.removeEventListener('touchcancel', this.boundHandlers.touchCancel);
this.clearLongPressTimer();
}
handleTouchStart(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
this.touchStartTime = Date.now();
this.isLongPress = false;
this.startLongPressTimer(e);
if (this.touchStartX <= this.options.edgeSwipeWidth) {
this.isEdgeSwipe = true;
} else {
this.isEdgeSwipe = false;
}
const scrollTop = this.element.scrollTop || 0;
if (scrollTop <= 0 && this.callbacks.pullToRefresh.length > 0) {
this.isPulling = true;
this.pullDistance = 0;
}
}
handleTouchMove(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaX = touch.clientX - this.touchStartX;
const deltaY = touch.clientY - this.touchStartY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance > 10) {
this.clearLongPressTimer();
}
if (this.isPulling && deltaY > 0) {
this.pullDistance = Math.min(deltaY, this.options.pullToRefreshThreshold * 1.5);
if (this.pullDistance > 0) {
e.preventDefault();
this.element.dispatchEvent(new CustomEvent('pull-progress', {
detail: {
progress: Math.min(this.pullDistance / this.options.pullToRefreshThreshold, 1),
distance: this.pullDistance
}
}));
}
}
}
handleTouchEnd(e) {
this.clearLongPressTimer();
if (this.isLongPress) {
this.isLongPress = false;
return;
}
const touch = e.changedTouches[0];
const deltaX = touch.clientX - this.touchStartX;
const deltaY = touch.clientY - this.touchStartY;
const deltaTime = Date.now() - this.touchStartTime;
const velocity = Math.sqrt(deltaX * deltaX + deltaY * deltaY) / deltaTime;
if (this.isPulling && this.pullDistance >= this.options.pullToRefreshThreshold) {
this.emit('pullToRefresh');
}
this.isPulling = false;
this.pullDistance = 0;
this.element.dispatchEvent(new CustomEvent('pull-end'));
if (Math.abs(deltaX) < this.options.swipeThreshold &&
Math.abs(deltaY) < this.options.swipeThreshold) {
return;
}
if (velocity < this.options.swipeVelocityThreshold && deltaTime > 300) {
return;
}
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY);
if (isHorizontal) {
if (deltaX > this.options.swipeThreshold) {
if (this.isEdgeSwipe) {
this.emit('edgeSwipeRight');
} else {
this.emit('swipeRight');
}
} else if (deltaX < -this.options.swipeThreshold) {
this.emit('swipeLeft');
}
} else {
if (deltaY > this.options.swipeThreshold) {
this.emit('swipeDown');
} else if (deltaY < -this.options.swipeThreshold) {
this.emit('swipeUp');
}
}
}
handleTouchCancel() {
this.clearLongPressTimer();
this.isPulling = false;
this.pullDistance = 0;
this.isLongPress = false;
this.element.dispatchEvent(new CustomEvent('pull-end'));
}
startLongPressTimer(e) {
this.clearLongPressTimer();
if (this.callbacks.longPress.length === 0) return;
const touch = e.touches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY);
this.longPressTimer = setTimeout(() => {
this.isLongPress = true;
this.emit('longPress', {
x: touch.clientX,
y: touch.clientY,
target: target
});
if (navigator.vibrate) {
navigator.vibrate(50);
}
}, this.options.longPressDelay);
}
clearLongPressTimer() {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
on(event, callback) {
if (this.callbacks[event]) {
this.callbacks[event].push(callback);
}
return this;
}
off(event, callback) {
if (this.callbacks[event]) {
const index = this.callbacks[event].indexOf(callback);
if (index !== -1) {
this.callbacks[event].splice(index, 1);
}
}
return this;
}
emit(event, data = {}) {
if (this.callbacks[event]) {
this.callbacks[event].forEach(callback => callback(data));
}
}
}
export class PullToRefreshIndicator {
constructor(container) {
this.container = container;
this.indicator = null;
this.create();
}
create() {
this.indicator = document.createElement('div');
this.indicator.className = 'pull-to-refresh-indicator';
this.indicator.innerHTML = `
<div class="pull-spinner"></div>
<span class="pull-text">Pull to refresh</span>
`;
this.container.insertBefore(this.indicator, this.container.firstChild);
}
setProgress(progress) {
const height = Math.min(progress * 60, 60);
this.indicator.style.height = `${height}px`;
this.indicator.style.opacity = progress;
if (progress >= 1) {
this.indicator.querySelector('.pull-text').textContent = 'Release to refresh';
this.indicator.classList.add('ready');
} else {
this.indicator.querySelector('.pull-text').textContent = 'Pull to refresh';
this.indicator.classList.remove('ready');
}
}
showRefreshing() {
this.indicator.style.height = '60px';
this.indicator.style.opacity = 1;
this.indicator.querySelector('.pull-text').textContent = 'Refreshing...';
this.indicator.classList.add('refreshing');
}
hide() {
this.indicator.style.height = '0';
this.indicator.style.opacity = 0;
this.indicator.classList.remove('ready', 'refreshing');
}
destroy() {
if (this.indicator && this.indicator.parentNode) {
this.indicator.parentNode.removeChild(this.indicator);
}
}
}
export class ContextMenu {
constructor() {
this.menu = null;
this.isVisible = false;
this.boundClose = this.close.bind(this);
}
show(x, y, items) {
this.close();
this.menu = document.createElement('div');
this.menu.className = 'context-menu';
items.forEach(item => {
if (item.separator) {
const sep = document.createElement('div');
sep.className = 'context-menu-separator';
this.menu.appendChild(sep);
return;
}
const menuItem = document.createElement('button');
menuItem.className = 'context-menu-item';
if (item.destructive) {
menuItem.classList.add('destructive');
}
menuItem.innerHTML = `
${item.icon ? `<span class="context-menu-icon">${item.icon}</span>` : ''}
<span class="context-menu-label">${item.label}</span>
`;
menuItem.addEventListener('click', () => {
item.action();
this.close();
});
this.menu.appendChild(menuItem);
});
document.body.appendChild(this.menu);
const rect = this.menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let finalX = x;
let finalY = y;
if (x + rect.width > viewportWidth) {
finalX = viewportWidth - rect.width - 10;
}
if (y + rect.height > viewportHeight) {
finalY = viewportHeight - rect.height - 10;
}
this.menu.style.left = `${Math.max(10, finalX)}px`;
this.menu.style.top = `${Math.max(10, finalY)}px`;
this.isVisible = true;
requestAnimationFrame(() => {
this.menu.classList.add('visible');
});
setTimeout(() => {
document.addEventListener('touchstart', this.boundClose);
document.addEventListener('click', this.boundClose);
}, 100);
}
close() {
if (this.menu) {
this.menu.classList.remove('visible');
setTimeout(() => {
if (this.menu && this.menu.parentNode) {
this.menu.parentNode.removeChild(this.menu);
}
this.menu = null;
}, 200);
}
this.isVisible = false;
document.removeEventListener('touchstart', this.boundClose);
document.removeEventListener('click', this.boundClose);
}
}
export function isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}
export function isMobile() {
return window.innerWidth < 768;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "MyWebdav Cloud Storage",
"short_name": "MyWebdav",
"description": "A self-hosted cloud storage web application",
"description": "A cloud storage SaaS web application",
"start_url": "/",
"display": "standalone",
"background_color": "#F0F2F5",
+2 -18
View File
@@ -59,7 +59,7 @@ async def pricing_config():
configs.append(
await PricingConfig.create(
config_key="storage_per_gb_month",
config_value=Decimal("0.0045"),
config_value=Decimal("0.005"),
description="Storage cost per GB per month",
unit="per_gb_month",
)
@@ -67,27 +67,11 @@ async def pricing_config():
configs.append(
await PricingConfig.create(
config_key="bandwidth_egress_per_gb",
config_value=Decimal("0.009"),
config_value=Decimal("0.008"),
description="Bandwidth egress cost per GB",
unit="per_gb",
)
)
configs.append(
await PricingConfig.create(
config_key="free_tier_storage_gb",
config_value=Decimal("15"),
description="Free tier storage in GB",
unit="gb",
)
)
configs.append(
await PricingConfig.create(
config_key="free_tier_bandwidth_gb",
config_value=Decimal("15"),
description="Free tier bandwidth in GB per month",
unit="gb",
)
)
yield configs
for config in configs:
await config.delete()
+7 -20
View File
@@ -31,7 +31,7 @@ async def pricing_config():
configs.append(
await PricingConfig.create(
config_key="storage_per_gb_month",
config_value=Decimal("0.0045"),
config_value=Decimal("0.005"),
description="Storage cost per GB per month",
unit="per_gb_month",
)
@@ -39,27 +39,11 @@ async def pricing_config():
configs.append(
await PricingConfig.create(
config_key="bandwidth_egress_per_gb",
config_value=Decimal("0.009"),
config_value=Decimal("0.008"),
description="Bandwidth egress cost per GB",
unit="per_gb",
)
)
configs.append(
await PricingConfig.create(
config_key="free_tier_storage_gb",
config_value=Decimal("15"),
description="Free tier storage in GB",
unit="gb",
)
)
configs.append(
await PricingConfig.create(
config_key="free_tier_bandwidth_gb",
config_value=Decimal("15"),
description="Free tier bandwidth in GB per month",
unit="gb",
)
)
configs.append(
await PricingConfig.create(
config_key="tax_rate_default",
@@ -103,7 +87,7 @@ async def test_generate_monthly_invoice_with_usage(test_user, pricing_config):
@pytest.mark.asyncio
async def test_generate_monthly_invoice_below_free_tier(test_user, pricing_config):
async def test_generate_monthly_invoice_with_small_usage(test_user, pricing_config):
today = date.today()
await UsageAggregate.create(
@@ -119,8 +103,11 @@ async def test_generate_monthly_invoice_below_free_tier(test_user, pricing_confi
test_user, today.year, today.month
)
assert invoice is None
# Should always generate invoice now (no free tier)
assert invoice is not None
assert invoice.total > 0
await invoice.delete()
await UsageAggregate.filter(user=test_user).delete()
+2 -2
View File
@@ -143,14 +143,14 @@ async def test_invoice_line_item_creation(test_user):
async def test_pricing_config_creation(test_user):
config = await PricingConfig.create(
config_key="storage_per_gb_month",
config_value=Decimal("0.0045"),
config_value=Decimal("0.005"),
description="Storage cost per GB per month",
unit="per_gb_month",
updated_by=test_user,
)
assert config.config_key == "storage_per_gb_month"
assert config.config_value == Decimal("0.0045")
assert config.config_value == Decimal("0.005")
await config.delete()
View File
+87
View File
@@ -0,0 +1,87 @@
import pytest
import pytest_asyncio
import asyncio
import tempfile
import shutil
from pathlib import Path
@pytest_asyncio.fixture
async def temp_db_path():
path = tempfile.mkdtemp()
yield path
shutil.rmtree(path, ignore_errors=True)
@pytest_asyncio.fixture
async def db_manager(temp_db_path):
from mywebdav.database.manager import UserDatabaseManager
manager = UserDatabaseManager(Path(temp_db_path), cache_size=10, flush_interval=1)
await manager.start()
yield manager
await manager.stop()
@pytest_asyncio.fixture
async def cache():
from mywebdav.cache.layer import CacheLayer
cache = CacheLayer(maxsize=100, flush_interval=60)
await cache.start()
yield cache
await cache.stop()
@pytest_asyncio.fixture
async def lock_manager():
from mywebdav.concurrency.locks import LockManager
manager = LockManager(default_timeout=5.0, cleanup_interval=60.0)
await manager.start()
yield manager
await manager.stop()
@pytest_asyncio.fixture
async def webdav_locks():
from mywebdav.concurrency.webdav_locks import PersistentWebDAVLocks
lock_manager = PersistentWebDAVLocks()
await lock_manager.start()
yield lock_manager
await lock_manager.stop()
@pytest_asyncio.fixture
async def token_manager():
from mywebdav.auth_tokens import TokenManager
manager = TokenManager()
await manager.start()
yield manager
await manager.stop()
@pytest_asyncio.fixture
async def rate_limiter():
from mywebdav.middleware.rate_limit import RateLimiter
limiter = RateLimiter()
await limiter.start()
yield limiter
await limiter.stop()
@pytest_asyncio.fixture
async def task_queue():
from mywebdav.workers.queue import TaskQueue
queue = TaskQueue(max_workers=2)
await queue.start()
yield queue
await queue.stop()
@pytest_asyncio.fixture
async def atomic_ops():
from mywebdav.concurrency.locks import init_lock_manager, shutdown_lock_manager
from mywebdav.concurrency.atomic import AtomicOperations, init_atomic_ops
await init_lock_manager(default_timeout=5.0)
ops = init_atomic_ops()
yield ops
await shutdown_lock_manager()
+185
View File
@@ -0,0 +1,185 @@
import pytest
import asyncio
from dataclasses import dataclass
@dataclass
class MockUser:
id: int
used_storage_bytes: int
storage_quota_bytes: int
class TestAtomicOperations:
@pytest.mark.asyncio
async def test_quota_check_allowed(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=1000, storage_quota_bytes=10000)
save_called = False
async def save_callback(u):
nonlocal save_called
save_called = True
result = await atomic_ops.atomic_quota_check_and_update(user, 500, save_callback)
assert result.allowed is True
assert result.current_usage == 1000
assert result.quota == 10000
assert result.requested == 500
assert result.remaining == 9000
assert save_called is True
assert user.used_storage_bytes == 1500
@pytest.mark.asyncio
async def test_quota_check_denied(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=9500, storage_quota_bytes=10000)
save_called = False
async def save_callback(u):
nonlocal save_called
save_called = True
result = await atomic_ops.atomic_quota_check_and_update(user, 1000, save_callback)
assert result.allowed is False
assert result.remaining == 500
assert save_called is False
assert user.used_storage_bytes == 9500
@pytest.mark.asyncio
async def test_quota_check_concurrent_requests(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=1000)
async def save_callback(u):
pass
async def request_quota(amount):
return await atomic_ops.atomic_quota_check_and_update(user, amount, save_callback)
results = await asyncio.gather(*[request_quota(200) for _ in range(10)])
allowed_count = sum(1 for r in results if r.allowed)
assert allowed_count == 5
assert user.used_storage_bytes == 1000
@pytest.mark.asyncio
async def test_file_create_success(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
async def check_exists():
return None
async def create_file():
return {"id": 1, "name": "test.txt"}
result = await atomic_ops.atomic_file_create(
user, None, "test.txt", check_exists, create_file
)
assert result["name"] == "test.txt"
@pytest.mark.asyncio
async def test_file_create_exists(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
async def check_exists():
return {"id": 1, "name": "test.txt"}
async def create_file():
return {"id": 2, "name": "test.txt"}
with pytest.raises(FileExistsError):
await atomic_ops.atomic_file_create(
user, None, "test.txt", check_exists, create_file
)
@pytest.mark.asyncio
async def test_file_create_concurrent_same_name(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
created_files = []
async def check_exists():
return len(created_files) > 0
async def create_file():
file = {"id": len(created_files) + 1, "name": "test.txt"}
created_files.append(file)
return file
async def try_create():
try:
return await atomic_ops.atomic_file_create(
user, 1, "test.txt", check_exists, create_file
)
except FileExistsError:
return None
results = await asyncio.gather(*[try_create() for _ in range(5)])
successful = [r for r in results if r is not None]
assert len(successful) == 1
assert len(created_files) == 1
@pytest.mark.asyncio
async def test_folder_create_success(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
async def check_exists():
return None
async def create_folder():
return {"id": 1, "name": "Documents"}
result = await atomic_ops.atomic_folder_create(
user, None, "Documents", check_exists, create_folder
)
assert result["name"] == "Documents"
@pytest.mark.asyncio
async def test_folder_create_exists(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
async def check_exists():
return {"id": 1, "name": "Documents"}
async def create_folder():
return {"id": 2, "name": "Documents"}
with pytest.raises(FileExistsError):
await atomic_ops.atomic_folder_create(
user, None, "Documents", check_exists, create_folder
)
@pytest.mark.asyncio
async def test_file_update(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
update_called = False
async def update_callback():
nonlocal update_called
update_called = True
return {"id": 1, "updated": True}
result = await atomic_ops.atomic_file_update(user, 1, update_callback)
assert update_called is True
assert result["updated"] is True
@pytest.mark.asyncio
async def test_batch_operation(self, atomic_ops):
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
async def process_item(item):
if item == "fail":
raise ValueError("Failed item")
return f"processed_{item}"
result = await atomic_ops.atomic_batch_operation(
user, "test_batch", ["a", "b", "fail", "c"], process_item
)
assert len(result["results"]) == 3
assert len(result["errors"]) == 1
assert "processed_a" in result["results"]
assert result["errors"][0]["item"] == "fail"
+167
View File
@@ -0,0 +1,167 @@
import pytest
import asyncio
import time
from mywebdav.cache.layer import LRUCache
class TestLRUCache:
@pytest.mark.asyncio
async def test_set_and_get(self):
cache = LRUCache(maxsize=100)
await cache.set("key1", "value1")
entry = await cache.get("key1")
assert entry is not None
assert entry.value == "value1"
@pytest.mark.asyncio
async def test_get_missing_key(self):
cache = LRUCache(maxsize=100)
entry = await cache.get("nonexistent")
assert entry is None
@pytest.mark.asyncio
async def test_ttl_expiration(self):
cache = LRUCache(maxsize=100)
await cache.set("key1", "value1", ttl=0.1)
await asyncio.sleep(0.2)
entry = await cache.get("key1")
assert entry is None
@pytest.mark.asyncio
async def test_lru_eviction(self):
cache = LRUCache(maxsize=3)
await cache.set("key1", "value1")
await cache.set("key2", "value2")
await cache.set("key3", "value3")
await cache.set("key4", "value4")
entry1 = await cache.get("key1")
assert entry1 is None
entry4 = await cache.get("key4")
assert entry4 is not None
@pytest.mark.asyncio
async def test_delete(self):
cache = LRUCache(maxsize=100)
await cache.set("key1", "value1")
result = await cache.delete("key1")
assert result is True
entry = await cache.get("key1")
assert entry is None
@pytest.mark.asyncio
async def test_dirty_tracking(self):
cache = LRUCache(maxsize=100)
await cache.set("key1", "value1", dirty=True)
await cache.set("key2", "value2", dirty=False)
dirty_keys = await cache.get_dirty_keys()
assert "key1" in dirty_keys
assert "key2" not in dirty_keys
@pytest.mark.asyncio
async def test_mark_clean(self):
cache = LRUCache(maxsize=100)
await cache.set("key1", "value1", dirty=True)
await cache.mark_clean("key1")
dirty_keys = await cache.get_dirty_keys()
assert "key1" not in dirty_keys
@pytest.mark.asyncio
async def test_invalidate_pattern(self):
cache = LRUCache(maxsize=100)
await cache.set("user:1:profile", "data1")
await cache.set("user:1:files", "data2")
await cache.set("user:2:profile", "data3")
count = await cache.invalidate_pattern("user:1:")
assert count == 2
entry1 = await cache.get("user:1:profile")
assert entry1 is None
entry2 = await cache.get("user:2:profile")
assert entry2 is not None
class TestCacheLayer:
@pytest.mark.asyncio
async def test_get_with_loader(self, cache):
call_count = 0
async def loader():
nonlocal call_count
call_count += 1
return "loaded_value"
result1 = await cache.get("test_key", loader)
assert result1 == "loaded_value"
assert call_count == 1
result2 = await cache.get("test_key", loader)
assert result2 == "loaded_value"
assert call_count == 1
@pytest.mark.asyncio
async def test_set_and_get(self, cache):
await cache.set("key1", {"data": "value"})
result = await cache.get("key1")
assert result == {"data": "value"}
@pytest.mark.asyncio
async def test_delete(self, cache):
await cache.set("key1", "value1")
await cache.delete("key1")
result = await cache.get("key1")
assert result is None
@pytest.mark.asyncio
async def test_invalidate_user_cache(self, cache):
await cache.set("user:1:profile", "data1")
await cache.set("user:1:files", "data2")
await cache.set("user:2:profile", "data3")
await cache.invalidate_user_cache(1)
result1 = await cache.get("user:1:profile")
assert result1 is None
result2 = await cache.get("user:2:profile")
assert result2 == "data3"
@pytest.mark.asyncio
async def test_stats(self, cache):
await cache.get("miss1")
await cache.set("hit1", "value")
await cache.get("hit1")
await cache.get("hit1")
stats = cache.get_stats()
assert stats["hits"] == 2
assert stats["misses"] == 1
assert stats["sets"] == 1
@pytest.mark.asyncio
async def test_build_key(self, cache):
key = cache.build_key("user_profile", user_id=123)
assert key == "user:123:profile"
key = cache.build_key("folder_contents", user_id=1, folder_id=5)
assert key == "user:1:folder:5:contents"
@pytest.mark.asyncio
async def test_ttl_by_key_type(self, cache):
assert cache._get_ttl_for_key("user:1:profile") == 300.0
assert cache._get_ttl_for_key("folder_contents:1") == 30.0
assert cache._get_ttl_for_key("unknown_key") == 300.0
@pytest.mark.asyncio
async def test_persist_flag(self, cache):
await cache.set("persistent_key", "value", persist=True)
assert "persistent_key" in cache.dirty_keys
await cache.set("non_persistent_key", "value", persist=False)
assert "non_persistent_key" not in cache.dirty_keys
+131
View File
@@ -0,0 +1,131 @@
import pytest
from pathlib import Path
class TestUserDatabaseManager:
@pytest.mark.asyncio
async def test_master_db_initialization(self, db_manager, temp_db_path):
assert db_manager.master_db is not None
assert db_manager.master_db.connection is not None
master_path = Path(temp_db_path) / "master.db"
assert master_path.exists()
@pytest.mark.asyncio
async def test_master_tables_created(self, db_manager):
async with db_manager.get_master_connection() as conn:
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
tables = [row[0] for row in await cursor.fetchall()]
assert "users" in tables
assert "revoked_tokens" in tables
assert "rate_limits" in tables
assert "webdav_locks" in tables
@pytest.mark.asyncio
async def test_user_database_creation(self, db_manager, temp_db_path):
user_id = 1
async with db_manager.get_user_connection(user_id) as conn:
assert conn is not None
user_db_path = Path(temp_db_path) / "users" / "1" / "database.db"
assert user_db_path.exists()
@pytest.mark.asyncio
async def test_user_tables_created(self, db_manager):
user_id = 1
async with db_manager.get_user_connection(user_id) as conn:
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
tables = [row[0] for row in await cursor.fetchall()]
assert "files" in tables
assert "folders" in tables
assert "shares" in tables
assert "activities" in tables
@pytest.mark.asyncio
async def test_connection_caching(self, db_manager):
user_id = 1
async with db_manager.get_user_connection(user_id):
pass
assert user_id in db_manager.databases
async with db_manager.get_user_connection(user_id):
pass
assert len(db_manager.databases) == 1
@pytest.mark.asyncio
async def test_connection_eviction(self, db_manager):
for i in range(15):
async with db_manager.get_user_connection(i):
pass
assert len(db_manager.databases) <= db_manager.cache_size
@pytest.mark.asyncio
async def test_buffered_write(self, db_manager):
user_id = 1
async with db_manager.get_user_connection(user_id):
pass
await db_manager.execute_buffered(
user_id,
"INSERT INTO folders (name, owner_id) VALUES (?, ?)",
("test_folder", user_id)
)
user_db = db_manager.databases[user_id]
assert user_db.dirty is True
assert len(user_db.write_buffer) == 1
@pytest.mark.asyncio
async def test_flush_user(self, db_manager):
user_id = 1
async with db_manager.get_user_connection(user_id):
pass
await db_manager.execute_buffered(
user_id,
"INSERT INTO folders (name, owner_id) VALUES (?, ?)",
("test_folder", user_id)
)
await db_manager.flush_user(user_id)
user_db = db_manager.databases[user_id]
assert user_db.dirty is False
assert len(user_db.write_buffer) == 0
async with db_manager.get_user_connection(user_id) as conn:
cursor = await conn.execute("SELECT name FROM folders WHERE owner_id = ?", (user_id,))
rows = await cursor.fetchall()
assert len(rows) == 1
assert rows[0][0] == "test_folder"
@pytest.mark.asyncio
async def test_master_buffered_write(self, db_manager):
await db_manager.execute_master_buffered(
"INSERT INTO users (username, email, hashed_password) VALUES (?, ?, ?)",
("testuser", "test@test.com", "hash123")
)
assert db_manager.master_db.dirty is True
await db_manager.flush_master()
assert db_manager.master_db.dirty is False
@pytest.mark.asyncio
async def test_multiple_users_isolated(self, db_manager):
for user_id in [1, 2, 3]:
async with db_manager.get_user_connection(user_id) as conn:
await conn.execute(
"INSERT INTO folders (name, owner_id) VALUES (?, ?)",
(f"folder_user_{user_id}", user_id)
)
await conn.commit()
for user_id in [1, 2, 3]:
async with db_manager.get_user_connection(user_id) as conn:
cursor = await conn.execute("SELECT COUNT(*) FROM folders")
count = (await cursor.fetchone())[0]
assert count == 1
+158
View File
@@ -0,0 +1,158 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, AsyncMock, MagicMock
from mywebdav.monitoring.health import router, check_database, check_cache, check_locks, check_task_queue, check_storage
class TestHealthChecks:
@pytest.mark.asyncio
async def test_check_database_success(self):
with patch('mywebdav.database.get_user_db_manager') as mock_db:
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.fetchone = AsyncMock(return_value=(1,))
mock_conn.execute = AsyncMock(return_value=mock_cursor)
mock_manager = MagicMock()
mock_manager.get_master_connection.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
mock_manager.get_master_connection.return_value.__aexit__ = AsyncMock(return_value=None)
mock_db.return_value = mock_manager
result = await check_database()
assert result["ok"] is True
@pytest.mark.asyncio
async def test_check_database_failure(self):
with patch('mywebdav.database.get_user_db_manager') as mock_db:
mock_db.side_effect = Exception("Connection failed")
result = await check_database()
assert result["ok"] is False
assert "Connection failed" in result["message"]
@pytest.mark.asyncio
async def test_check_cache_success(self):
with patch('mywebdav.cache.get_cache') as mock_cache:
mock_cache_instance = MagicMock()
mock_cache_instance.get_stats.return_value = {"hits": 100, "misses": 10}
mock_cache.return_value = mock_cache_instance
result = await check_cache()
assert result["ok"] is True
assert "stats" in result
@pytest.mark.asyncio
async def test_check_cache_failure(self):
with patch('mywebdav.cache.get_cache') as mock_cache:
mock_cache.side_effect = RuntimeError("Cache not initialized")
result = await check_cache()
assert result["ok"] is False
@pytest.mark.asyncio
async def test_check_locks_success(self):
with patch('mywebdav.concurrency.get_lock_manager') as mock_locks:
mock_lock_manager = MagicMock()
mock_lock_manager.get_stats = AsyncMock(return_value={"total_locks": 5, "active_locks": 2})
mock_locks.return_value = mock_lock_manager
result = await check_locks()
assert result["ok"] is True
assert result["stats"]["total_locks"] == 5
@pytest.mark.asyncio
async def test_check_task_queue_success(self):
with patch('mywebdav.workers.get_task_queue') as mock_queue:
mock_queue_instance = MagicMock()
mock_queue_instance.get_stats = AsyncMock(return_value={"pending_tasks": 3})
mock_queue.return_value = mock_queue_instance
result = await check_task_queue()
assert result["ok"] is True
@pytest.mark.asyncio
async def test_check_storage_success(self):
with patch('mywebdav.settings.settings') as mock_settings:
mock_settings.STORAGE_PATH = "/tmp"
with patch('os.path.exists', return_value=True):
with patch('os.statvfs') as mock_statvfs:
mock_stat = type('obj', (object,), {
'f_bavail': 1000000,
'f_blocks': 2000000,
'f_frsize': 4096
})()
mock_statvfs.return_value = mock_stat
result = await check_storage()
assert result["ok"] is True
assert "free_gb" in result
assert "used_percent" in result
@pytest.mark.asyncio
async def test_check_storage_path_not_exists(self):
with patch('mywebdav.settings.settings') as mock_settings:
mock_settings.STORAGE_PATH = "/nonexistent/path"
with patch('os.path.exists', return_value=False):
result = await check_storage()
assert result["ok"] is False
class TestHealthEndpoints:
@pytest.fixture
def client(self):
from fastapi import FastAPI
app = FastAPI()
app.include_router(router)
return TestClient(app)
def test_liveness_check(self, client):
response = client.get("/health/live")
assert response.status_code == 200
assert response.json()["alive"] is True
def test_readiness_check_success(self, client):
with patch('mywebdav.monitoring.health.check_database') as mock_check:
mock_check.return_value = {"ok": True}
response = client.get("/health/ready")
assert response.status_code == 200
def test_health_check_all_healthy(self, client):
with patch('mywebdav.monitoring.health.check_database') as mock_db, \
patch('mywebdav.monitoring.health.check_cache') as mock_cache, \
patch('mywebdav.monitoring.health.check_locks') as mock_locks, \
patch('mywebdav.monitoring.health.check_task_queue') as mock_queue, \
patch('mywebdav.monitoring.health.check_storage') as mock_storage:
mock_db.return_value = {"ok": True}
mock_cache.return_value = {"ok": True}
mock_locks.return_value = {"ok": True}
mock_queue.return_value = {"ok": True}
mock_storage.return_value = {"ok": True}
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
def test_health_check_degraded(self, client):
with patch('mywebdav.monitoring.health.check_database') as mock_db, \
patch('mywebdav.monitoring.health.check_cache') as mock_cache, \
patch('mywebdav.monitoring.health.check_locks') as mock_locks, \
patch('mywebdav.monitoring.health.check_task_queue') as mock_queue, \
patch('mywebdav.monitoring.health.check_storage') as mock_storage:
mock_db.return_value = {"ok": True}
mock_cache.return_value = {"ok": False, "message": "Cache error"}
mock_locks.return_value = {"ok": True}
mock_queue.return_value = {"ok": True}
mock_storage.return_value = {"ok": True}
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "degraded"
+140
View File
@@ -0,0 +1,140 @@
import pytest
import asyncio
class TestLockManager:
@pytest.mark.asyncio
async def test_acquire_and_release(self, lock_manager):
async with lock_manager.acquire("resource1", owner="test", user_id=1) as token:
assert token is not None
assert await lock_manager.is_locked("resource1")
assert not await lock_manager.is_locked("resource1")
@pytest.mark.asyncio
async def test_lock_prevents_concurrent_access(self, lock_manager):
results = []
async def task(task_id):
async with lock_manager.acquire("shared_resource", timeout=10.0, owner=f"task{task_id}", user_id=task_id):
results.append(f"start_{task_id}")
await asyncio.sleep(0.1)
results.append(f"end_{task_id}")
await asyncio.gather(task(1), task(2), task(3))
for i in range(3):
start_idx = results.index(f"start_{i+1}")
end_idx = results.index(f"end_{i+1}")
assert end_idx == start_idx + 1
@pytest.mark.asyncio
async def test_lock_timeout(self, lock_manager):
async with lock_manager.acquire("resource1", owner="holder", user_id=1):
with pytest.raises(TimeoutError):
async with lock_manager.acquire("resource1", timeout=0.1, owner="waiter", user_id=2):
pass
@pytest.mark.asyncio
async def test_try_acquire_success(self, lock_manager):
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
assert token is not None
released = await lock_manager.release("resource1", token)
assert released is True
@pytest.mark.asyncio
async def test_try_acquire_fails_when_locked(self, lock_manager):
token1 = await lock_manager.try_acquire("resource1", owner="holder", user_id=1)
assert token1 is not None
token2 = await lock_manager.try_acquire("resource1", owner="waiter", user_id=2)
assert token2 is None
await lock_manager.release("resource1", token1)
@pytest.mark.asyncio
async def test_extend_lock(self, lock_manager):
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
assert token is not None
extended = await lock_manager.extend("resource1", token, extension=60.0)
assert extended is True
info = await lock_manager.get_lock_info("resource1")
assert info is not None
assert info.extend_count == 1
await lock_manager.release("resource1", token)
@pytest.mark.asyncio
async def test_get_lock_info(self, lock_manager):
async with lock_manager.acquire("resource1", owner="testowner", user_id=42):
info = await lock_manager.get_lock_info("resource1")
assert info is not None
assert info.owner == "testowner"
assert info.user_id == 42
@pytest.mark.asyncio
async def test_release_with_wrong_token(self, lock_manager):
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
assert token is not None
released = await lock_manager.release("resource1", "wrong_token")
assert released is False
assert await lock_manager.is_locked("resource1")
await lock_manager.release("resource1", token)
@pytest.mark.asyncio
async def test_force_release(self, lock_manager):
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
assert token is not None
released = await lock_manager.force_release("resource1", user_id=1)
assert released is True
assert not await lock_manager.is_locked("resource1")
@pytest.mark.asyncio
async def test_force_release_wrong_user(self, lock_manager):
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
assert token is not None
released = await lock_manager.force_release("resource1", user_id=2)
assert released is False
assert await lock_manager.is_locked("resource1")
await lock_manager.release("resource1", token)
@pytest.mark.asyncio
async def test_build_lock_key(self, lock_manager):
key = lock_manager.build_lock_key("quota_update", user_id=123)
assert "123" in key
assert "quota" in key
key = lock_manager.build_lock_key("file_create", user_id=1, parent_id=5, name_hash="abc")
assert "1" in key
assert "5" in key
@pytest.mark.asyncio
async def test_stats(self, lock_manager):
token1 = await lock_manager.try_acquire("resource1", owner="test1", user_id=1)
token2 = await lock_manager.try_acquire("resource2", owner="test2", user_id=2)
stats = await lock_manager.get_stats()
assert stats["total_locks"] == 2
assert stats["active_locks"] == 2
await lock_manager.release("resource1", token1)
await lock_manager.release("resource2", token2)
@pytest.mark.asyncio
async def test_concurrent_different_resources(self, lock_manager):
async def acquire_resource(resource_id):
async with lock_manager.acquire(f"resource_{resource_id}", owner=f"owner{resource_id}", user_id=resource_id):
await asyncio.sleep(0.05)
return resource_id
results = await asyncio.gather(*[acquire_resource(i) for i in range(10)])
assert sorted(results) == list(range(10))
+115
View File
@@ -0,0 +1,115 @@
import pytest
import asyncio
from mywebdav.middleware.rate_limit import RateLimitMiddleware
class TestRateLimiter:
@pytest.mark.asyncio
async def test_first_request_allowed(self, rate_limiter):
allowed, remaining, retry_after = await rate_limiter.check_rate_limit(
"192.168.1.1", "api"
)
assert allowed is True
assert remaining == 99
@pytest.mark.asyncio
async def test_rate_limit_exhausted(self, rate_limiter):
for i in range(100):
await rate_limiter.check_rate_limit("192.168.1.1", "api")
allowed, remaining, retry_after = await rate_limiter.check_rate_limit(
"192.168.1.1", "api"
)
assert allowed is False
assert remaining == 0
assert retry_after > 0
@pytest.mark.asyncio
async def test_different_keys_independent(self, rate_limiter):
for i in range(100):
await rate_limiter.check_rate_limit("192.168.1.1", "api")
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
assert allowed is False
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.2", "api")
assert allowed is True
@pytest.mark.asyncio
async def test_different_limit_types(self, rate_limiter):
for i in range(5):
await rate_limiter.check_rate_limit("192.168.1.1", "login")
allowed_login, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "login")
assert allowed_login is False
allowed_api, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
assert allowed_api is True
@pytest.mark.asyncio
async def test_reset_rate_limit(self, rate_limiter):
for i in range(100):
await rate_limiter.check_rate_limit("192.168.1.1", "api")
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
assert allowed is False
await rate_limiter.reset_rate_limit("192.168.1.1", "api")
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
assert allowed is True
@pytest.mark.asyncio
async def test_login_limit(self, rate_limiter):
for i in range(5):
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "login")
assert allowed is True
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "login")
assert allowed is False
@pytest.mark.asyncio
async def test_upload_limit(self, rate_limiter):
for i in range(20):
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "upload")
assert allowed is True
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "upload")
assert allowed is False
@pytest.mark.asyncio
async def test_concurrent_requests(self, rate_limiter):
async def make_request():
return await rate_limiter.check_rate_limit("192.168.1.1", "api")
results = await asyncio.gather(*[make_request() for _ in range(150)])
allowed_count = sum(1 for allowed, _, _ in results if allowed)
assert allowed_count == 100
class TestRateLimitMiddleware:
def test_get_limit_type_login(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/api/auth/login") == "login"
def test_get_limit_type_register(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/api/auth/register") == "register"
def test_get_limit_type_upload(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/files/upload") == "upload"
def test_get_limit_type_download(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/files/download/123") == "download"
def test_get_limit_type_webdav(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/webdav/folder/file.txt") == "webdav"
def test_get_limit_type_default(self):
middleware = RateLimitMiddleware(app=None)
assert middleware._get_limit_type("/api/users/me") == "api"
+231
View File
@@ -0,0 +1,231 @@
import pytest
import asyncio
from mywebdav.workers.queue import TaskQueue, TaskStatus, TaskPriority, Task
class TestTaskQueue:
@pytest.mark.asyncio
async def test_enqueue_task(self, task_queue):
async def handler(**kwargs):
return "success"
task_queue.register_handler("test_handler", handler)
task_id = await task_queue.enqueue(
"default",
"test_handler",
{"key": "value"}
)
assert task_id is not None
await asyncio.sleep(0.2)
task = await task_queue.get_task_status(task_id)
assert task.status == TaskStatus.COMPLETED
assert task.result == "success"
@pytest.mark.asyncio
async def test_task_with_payload(self, task_queue):
received_payload = {}
async def handler(**kwargs):
received_payload.update(kwargs)
return kwargs
task_queue.register_handler("payload_handler", handler)
task_id = await task_queue.enqueue(
"default",
"payload_handler",
{"user_id": 1, "file_id": 123}
)
await asyncio.sleep(0.2)
task = await task_queue.get_task_status(task_id)
assert task.status == TaskStatus.COMPLETED
assert received_payload["user_id"] == 1
assert received_payload["file_id"] == 123
@pytest.mark.asyncio
async def test_failed_task_retry(self, task_queue):
attempt_count = 0
async def failing_handler(**kwargs):
nonlocal attempt_count
attempt_count += 1
if attempt_count < 3:
raise ValueError("Temporary failure")
return "success after retries"
task_queue.register_handler("retry_handler", failing_handler)
task_id = await task_queue.enqueue(
"default",
"retry_handler",
{},
max_retries=3
)
await asyncio.sleep(0.5)
task = await task_queue.get_task_status(task_id)
assert task.status == TaskStatus.COMPLETED
assert attempt_count == 3
@pytest.mark.asyncio
async def test_permanently_failed_task(self, task_queue):
async def always_failing_handler(**kwargs):
raise ValueError("Permanent failure")
task_queue.register_handler("failing_handler", always_failing_handler)
task_id = await task_queue.enqueue(
"default",
"failing_handler",
{},
max_retries=2
)
await asyncio.sleep(0.5)
task = await task_queue.get_task_status(task_id)
assert task.status == TaskStatus.FAILED
assert task.retry_count == 2
@pytest.mark.asyncio
async def test_cancel_task(self, task_queue):
async def slow_handler(**kwargs):
await asyncio.sleep(10)
return "done"
task_queue.register_handler("slow_handler", slow_handler)
task_id = await task_queue.enqueue(
"default",
"slow_handler",
{}
)
cancelled = await task_queue.cancel_task(task_id)
task = await task_queue.get_task_status(task_id)
if task.status == TaskStatus.PENDING:
assert cancelled is True
@pytest.mark.asyncio
async def test_priority_ordering(self, task_queue):
execution_order = []
async def order_handler(**kwargs):
execution_order.append(kwargs["priority"])
return kwargs["priority"]
task_queue.register_handler("order_handler", order_handler)
await task_queue.stop()
task_queue = TaskQueue(max_workers=1)
task_queue.register_handler("order_handler", order_handler)
await task_queue.enqueue("default", "order_handler", {"priority": "low"}, priority=TaskPriority.LOW)
await task_queue.enqueue("default", "order_handler", {"priority": "normal"}, priority=TaskPriority.NORMAL)
await task_queue.enqueue("default", "order_handler", {"priority": "high"}, priority=TaskPriority.HIGH)
await task_queue.enqueue("default", "order_handler", {"priority": "critical"}, priority=TaskPriority.CRITICAL)
await task_queue.start()
await asyncio.sleep(0.5)
assert execution_order[0] == "critical"
assert execution_order[1] == "high"
await task_queue.stop()
@pytest.mark.asyncio
async def test_different_queues(self, task_queue):
results = {"thumbnails": False, "cleanup": False}
async def thumbnail_handler(**kwargs):
results["thumbnails"] = True
async def cleanup_handler(**kwargs):
results["cleanup"] = True
task_queue.register_handler("thumbnail_handler", thumbnail_handler)
task_queue.register_handler("cleanup_handler", cleanup_handler)
await task_queue.enqueue("thumbnails", "thumbnail_handler", {})
await task_queue.enqueue("cleanup", "cleanup_handler", {})
await asyncio.sleep(0.3)
assert results["thumbnails"] is True
assert results["cleanup"] is True
@pytest.mark.asyncio
async def test_stats(self, task_queue):
async def handler(**kwargs):
return "done"
task_queue.register_handler("stats_handler", handler)
for _ in range(5):
await task_queue.enqueue("default", "stats_handler", {})
await asyncio.sleep(0.5)
stats = await task_queue.get_stats()
assert stats["enqueued"] == 5
assert stats["completed"] == 5
@pytest.mark.asyncio
async def test_unknown_handler(self, task_queue):
task_id = await task_queue.enqueue(
"default",
"unknown_handler",
{}
)
await asyncio.sleep(0.2)
task = await task_queue.get_task_status(task_id)
assert task.status == TaskStatus.FAILED
assert "Handler not found" in task.error
@pytest.mark.asyncio
async def test_cleanup_completed_tasks(self, task_queue):
async def handler(**kwargs):
return "done"
task_queue.register_handler("cleanup_test", handler)
task_ids = []
for _ in range(5):
task_id = await task_queue.enqueue("default", "cleanup_test", {})
task_ids.append(task_id)
await asyncio.sleep(0.3)
for task_id in task_ids:
task = await task_queue.get_task_status(task_id)
task.completed_at = 1.0
await task_queue.cleanup_completed_tasks(max_age=0)
for task_id in task_ids:
task = await task_queue.get_task_status(task_id)
assert task is None
class TestTask:
def test_task_creation(self):
task = Task(
id="task_123",
queue_name="default",
handler_name="test_handler",
payload={"key": "value"}
)
assert task.id == "task_123"
assert task.status == TaskStatus.PENDING
assert task.retry_count == 0
+138
View File
@@ -0,0 +1,138 @@
import pytest
import asyncio
import time
from jose import jwt
from mywebdav.auth_tokens import TokenInfo
from mywebdav.settings import settings
class TestTokenManager:
@pytest.mark.asyncio
async def test_create_access_token(self, token_manager):
token, jti = token_manager.create_access_token(
user_id=1,
username="testuser",
two_factor_verified=False
)
assert token is not None
assert jti is not None
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
assert payload["sub"] == "testuser"
assert payload["user_id"] == 1
assert payload["jti"] == jti
assert payload["type"] == "access"
assert payload["2fa_verified"] is False
@pytest.mark.asyncio
async def test_create_access_token_with_2fa(self, token_manager):
token, jti = token_manager.create_access_token(
user_id=1,
username="testuser",
two_factor_verified=True
)
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
assert payload["2fa_verified"] is True
@pytest.mark.asyncio
async def test_create_refresh_token(self, token_manager):
token, jti = token_manager.create_refresh_token(
user_id=1,
username="testuser"
)
assert token is not None
assert jti is not None
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
assert payload["sub"] == "testuser"
assert payload["type"] == "refresh"
@pytest.mark.asyncio
async def test_revoke_token(self, token_manager):
token, jti = token_manager.create_access_token(
user_id=1,
username="testuser"
)
await asyncio.sleep(0.01)
is_revoked_before = await token_manager.is_revoked(jti)
assert is_revoked_before is False
await token_manager.revoke_token(jti, user_id=1)
is_revoked_after = await token_manager.is_revoked(jti)
assert is_revoked_after is True
@pytest.mark.asyncio
async def test_revoke_all_user_tokens(self, token_manager):
jtis = []
for i in range(5):
_, jti = token_manager.create_access_token(
user_id=1,
username="testuser"
)
jtis.append(jti)
_, other_jti = token_manager.create_access_token(
user_id=2,
username="otheruser"
)
await asyncio.sleep(0.01)
revoked_count = await token_manager.revoke_all_user_tokens(1)
assert revoked_count == 5
for jti in jtis:
assert await token_manager.is_revoked(jti) is True
assert await token_manager.is_revoked(other_jti) is False
@pytest.mark.asyncio
async def test_is_revoked_unknown_token(self, token_manager):
is_revoked = await token_manager.is_revoked("unknown_jti")
assert is_revoked is False
@pytest.mark.asyncio
async def test_stats(self, token_manager):
token_manager.create_access_token(user_id=1, username="user1")
token_manager.create_access_token(user_id=2, username="user2")
await asyncio.sleep(0.01)
stats = await token_manager.get_stats()
assert stats["active_tokens"] >= 2
@pytest.mark.asyncio
async def test_token_tracking(self, token_manager):
_, jti = token_manager.create_access_token(
user_id=1,
username="testuser"
)
await asyncio.sleep(0.01)
assert jti in token_manager.active_tokens
await token_manager.revoke_token(jti)
assert jti not in token_manager.active_tokens
assert jti in token_manager.blacklist
class TestTokenInfo:
def test_token_info_creation(self):
info = TokenInfo(
jti="test_jti",
user_id=1,
token_type="access",
expires_at=time.time() + 3600
)
assert info.jti == "test_jti"
assert info.user_id == 1
assert info.token_type == "access"
+212
View File
@@ -0,0 +1,212 @@
import pytest
import asyncio
import time
from mywebdav.concurrency.webdav_locks import WebDAVLockInfo
class TestWebDAVLockInfo:
def test_is_expired_false(self):
lock = WebDAVLockInfo(
token="token123",
path="/test/file.txt",
path_hash="abc123",
owner="user1",
user_id=1,
timeout=3600
)
assert lock.is_expired is False
def test_is_expired_true(self):
lock = WebDAVLockInfo(
token="token123",
path="/test/file.txt",
path_hash="abc123",
owner="user1",
user_id=1,
timeout=0,
created_at=time.time() - 1
)
assert lock.is_expired is True
def test_remaining_seconds(self):
lock = WebDAVLockInfo(
token="token123",
path="/test/file.txt",
path_hash="abc123",
owner="user1",
user_id=1,
timeout=100
)
assert 99 <= lock.remaining_seconds <= 100
def test_to_dict(self):
lock = WebDAVLockInfo(
token="token123",
path="/test/file.txt",
path_hash="abc123",
owner="user1",
user_id=1
)
d = lock.to_dict()
assert d["token"] == "token123"
assert d["path"] == "/test/file.txt"
assert d["owner"] == "user1"
def test_from_dict(self):
data = {
"token": "token123",
"path": "/test/file.txt",
"path_hash": "abc123",
"owner": "user1",
"user_id": 1,
"scope": "exclusive",
"depth": "0",
"timeout": 3600,
"created_at": time.time()
}
lock = WebDAVLockInfo.from_dict(data)
assert lock.token == "token123"
assert lock.user_id == 1
class TestPersistentWebDAVLocks:
@pytest.mark.asyncio
async def test_acquire_lock(self, webdav_locks):
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
assert token is not None
assert token.startswith("opaquelocktoken:")
@pytest.mark.asyncio
async def test_acquire_same_path_same_user(self, webdav_locks):
token1 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
token2 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
assert token1 == token2
@pytest.mark.asyncio
async def test_acquire_same_path_different_user(self, webdav_locks):
token1 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
token2 = await webdav_locks.acquire_lock("/test/file.txt", "user2", user_id=2)
assert token1 is not None
assert token2 is None
@pytest.mark.asyncio
async def test_release_lock(self, webdav_locks):
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
released = await webdav_locks.release_lock("/test/file.txt", token)
assert released is True
lock_info = await webdav_locks.check_lock("/test/file.txt")
assert lock_info is None
@pytest.mark.asyncio
async def test_release_wrong_token(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
released = await webdav_locks.release_lock("/test/file.txt", "wrong_token")
assert released is False
@pytest.mark.asyncio
async def test_check_lock(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
lock_info = await webdav_locks.check_lock("/test/file.txt")
assert lock_info is not None
assert lock_info.owner == "user1"
assert lock_info.user_id == 1
@pytest.mark.asyncio
async def test_check_lock_nonexistent(self, webdav_locks):
lock_info = await webdav_locks.check_lock("/nonexistent/file.txt")
assert lock_info is None
@pytest.mark.asyncio
async def test_is_locked(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
assert await webdav_locks.is_locked("/test/file.txt") is True
assert await webdav_locks.is_locked("/test/file.txt", user_id=1) is False
assert await webdav_locks.is_locked("/test/file.txt", user_id=2) is True
@pytest.mark.asyncio
async def test_refresh_lock(self, webdav_locks):
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1, timeout=10)
lock_before = await webdav_locks.check_lock("/test/file.txt")
created_at_before = lock_before.created_at
await asyncio.sleep(0.1)
refreshed = await webdav_locks.refresh_lock("/test/file.txt", token, timeout=100)
assert refreshed is True
lock_after = await webdav_locks.check_lock("/test/file.txt")
assert lock_after.timeout == 100
assert lock_after.created_at > created_at_before
@pytest.mark.asyncio
async def test_refresh_wrong_token(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
refreshed = await webdav_locks.refresh_lock("/test/file.txt", "wrong_token")
assert refreshed is False
@pytest.mark.asyncio
async def test_force_unlock(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
unlocked = await webdav_locks.force_unlock("/test/file.txt", user_id=1)
assert unlocked is True
lock_info = await webdav_locks.check_lock("/test/file.txt")
assert lock_info is None
@pytest.mark.asyncio
async def test_force_unlock_wrong_user(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
unlocked = await webdav_locks.force_unlock("/test/file.txt", user_id=2)
assert unlocked is False
@pytest.mark.asyncio
async def test_get_user_locks(self, webdav_locks):
await webdav_locks.acquire_lock("/file1.txt", "user1", user_id=1)
await webdav_locks.acquire_lock("/file2.txt", "user1", user_id=1)
await webdav_locks.acquire_lock("/file3.txt", "user2", user_id=2)
user1_locks = await webdav_locks.get_user_locks(1)
assert len(user1_locks) == 2
user2_locks = await webdav_locks.get_user_locks(2)
assert len(user2_locks) == 1
@pytest.mark.asyncio
async def test_get_lock_by_token(self, webdav_locks):
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
lock_info = await webdav_locks.get_lock_by_token(token)
assert lock_info is not None
assert lock_info.path == "test/file.txt"
@pytest.mark.asyncio
async def test_expired_lock_cleanup(self, webdav_locks):
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1, timeout=0)
await asyncio.sleep(0.1)
lock_info = await webdav_locks.check_lock("/test/file.txt")
assert lock_info is None
@pytest.mark.asyncio
async def test_stats(self, webdav_locks):
await webdav_locks.acquire_lock("/file1.txt", "user1", user_id=1)
await webdav_locks.acquire_lock("/file2.txt", "user1", user_id=1)
stats = await webdav_locks.get_stats()
assert stats["total_locks"] == 2
assert stats["active_locks"] == 2
@pytest.mark.asyncio
async def test_path_normalization(self, webdav_locks):
token1 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
token2 = await webdav_locks.acquire_lock("test/file.txt", "user1", user_id=1)
token3 = await webdav_locks.acquire_lock("/test/file.txt/", "user1", user_id=1)
assert token1 == token2 == token3
+72
View File
@@ -0,0 +1,72 @@
import pytest
from httpx import AsyncClient, ASGITransport
from fastapi import status
from mywebdav.main import app
from mywebdav.models import User, Folder, File, Share
from mywebdav.auth import get_password_hash
import secrets
from datetime import datetime, timedelta
@pytest.mark.asyncio
async def test_share_subfolder_navigation():
# 1. Setup: User, Parent Folder, Subfolder, File in Subfolder
user = await User.create(
username="shareuser",
email="share@example.com",
hashed_password=get_password_hash("testpass"),
is_active=True
)
parent_folder = await Folder.create(name="parent", owner=user)
subfolder = await Folder.create(name="subfolder", parent=parent_folder, owner=user)
file_in_sub = await File.create(
name="deep_file.txt",
path="parent/subfolder/deep_file.txt",
size=10,
mime_type="text/plain",
file_hash="hash",
owner=user,
parent=subfolder
)
# 2. Create Share for Parent Folder
token = secrets.token_urlsafe(16)
share = await Share.create(
token=token,
folder=parent_folder,
owner=user,
permission_level="viewer"
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
# 3. Access Share Root
resp = await client.post(f"/shares/{token}/access")
assert resp.status_code == 200
data = resp.json()
assert data["type"] == "folder"
assert data["folder"]["id"] == parent_folder.id
# Should see subfolder
assert any(f["id"] == subfolder.id for f in data["folders"])
# 4. Try to Access Subfolder (Expecting this to fail or return root currently)
# We'll try passing subfolder_id as a query param, which is a common pattern
resp_sub = await client.post(f"/shares/{token}/access?subfolder_id={subfolder.id}")
# If the feature is missing, this might just ignore the param and return root,
# or fail if the param isn't expected.
# We WANT it to return the subfolder content.
assert resp_sub.status_code == 200
sub_data = resp_sub.json()
# This assertion will fail if the feature is not implemented (it will likely return parent folder again)
assert sub_data["folder"]["id"] == subfolder.id
# Should see the file inside
assert any(f["id"] == file_in_sub.id for f in sub_data["files"])
# Cleanup
await file_in_sub.delete()
await subfolder.delete()
await share.delete()
await parent_folder.delete()
await user.delete()
+737
View File
@@ -0,0 +1,737 @@
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from fastapi import status
from mywebdav.main import app
from mywebdav.models import User, Folder, File, WebDAVProperty
from mywebdav.auth import get_password_hash
import base64
from datetime import datetime
@pytest_asyncio.fixture
async def test_user():
user = await User.create(
username="testuser",
email="test@example.com",
hashed_password=get_password_hash("testpass"),
is_active=True,
is_superuser=False,
)
yield user
await user.delete()
@pytest_asyncio.fixture
async def test_folder(test_user):
folder = await Folder.create(
name="testfolder",
owner=test_user,
)
yield folder
await folder.delete()
@pytest_asyncio.fixture
async def test_file(test_user, test_folder):
file = await File.create(
name="testfile.txt",
path="testfile.txt",
size=13,
mime_type="text/plain",
file_hash="dummyhash",
owner=test_user,
parent=test_folder,
)
yield file
await file.delete()
def get_basic_auth_header(username, password):
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {credentials}"}
@pytest.mark.asyncio
async def test_webdav_options():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.options("/webdav/")
assert response.status_code == status.HTTP_200_OK
assert "DAV" in response.headers
assert "Allow" in response.headers
@pytest.mark.asyncio
async def test_webdav_propfind_root_unauthorized():
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"PROPFIND",
"/webdav/",
headers={"Depth": "1"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "WWW-Authenticate" in response.headers
assert 'Basic realm="MyWebdav WebDAV"' in response.headers["WWW-Authenticate"]
@pytest.mark.asyncio
async def test_webdav_propfind_root(test_user):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"PROPFIND",
"/webdav/",
headers={
"Depth": "1",
**get_basic_auth_header("testuser", "testpass")
},
)
assert response.status_code == status.HTTP_207_MULTI_STATUS
# Parse XML response
content = response.text
assert "<D:multistatus" in content
assert "<D:response>" in content
@pytest.mark.asyncio
async def test_webdav_propfind_folder(test_user, test_folder):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"PROPFIND",
f"/webdav/{test_folder.name}/",
headers={
"Depth": "1",
**get_basic_auth_header("testuser", "testpass")
},
)
assert response.status_code == status.HTTP_207_MULTI_STATUS
content = response.text
assert test_folder.name in content
@pytest.mark.asyncio
async def test_webdav_propfind_file(test_user, test_file):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"PROPFIND",
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers={
"Depth": "0",
**get_basic_auth_header("testuser", "testpass")
},
)
assert response.status_code == status.HTTP_207_MULTI_STATUS
content = response.text
assert test_file.name in content
assert "text/plain" in content
@pytest.mark.asyncio
async def test_webdav_get_file(test_user, test_file):
# Mock storage manager to return file content
from mywebdav import storage
original_get_file = storage.storage_manager.get_file
async def mock_get_file(user_id, path):
if path == test_file.path:
yield b"Hello, World!"
else:
raise FileNotFoundError()
storage.storage_manager.get_file = mock_get_file
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get(
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers=get_basic_auth_header("testuser", "testpass")
)
assert response.status_code == status.HTTP_200_OK
assert response.content == b"Hello, World!"
finally:
storage.storage_manager.get_file = original_get_file
@pytest.mark.asyncio
async def test_webdav_put_file(test_user, test_folder):
from mywebdav import storage
original_save_file = storage.storage_manager.save_file
saved_content = None
saved_path = None
async def mock_save_file(user_id, path, content):
nonlocal saved_content, saved_path
saved_content = content
saved_path = path
storage.storage_manager.save_file = mock_save_file
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.put(
f"/webdav/{test_folder.name}/newfile.txt",
content=b"New file content",
headers=get_basic_auth_header("testuser", "testpass")
)
assert response.status_code == status.HTTP_201_CREATED
assert saved_content == b"New file content"
# Check if file was created in DB
file = await File.get_or_none(
name="newfile.txt", parent=test_folder, owner=test_user, is_deleted=False
)
assert file is not None
assert file.path == saved_path
await file.delete()
finally:
storage.storage_manager.save_file = original_save_file
@pytest.mark.asyncio
async def test_webdav_mkcol(test_user):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"MKCOL",
"/webdav/newfolder/",
headers=get_basic_auth_header("testuser", "testpass")
)
assert response.status_code == status.HTTP_201_CREATED
# Check if folder was created
folder = await Folder.get_or_none(
name="newfolder", parent=None, owner=test_user, is_deleted=False
)
assert folder is not None
await folder.delete()
@pytest.mark.asyncio
async def test_webdav_delete_file(test_user, test_file):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.delete(
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers=get_basic_auth_header("testuser", "testpass")
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Check if file was marked as deleted
updated_file = await File.get(id=test_file.id)
assert updated_file.is_deleted == True
@pytest.mark.asyncio
async def test_webdav_delete_folder(test_user, test_folder):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.delete(
f"/webdav/{test_folder.name}/",
headers=get_basic_auth_header("testuser", "testpass")
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Check if folder was marked as deleted
updated_folder = await Folder.get(id=test_folder.id)
assert updated_folder.is_deleted == True
@pytest.mark.asyncio
async def test_webdav_copy_file(test_user, test_file):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"COPY",
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers={
"Destination": f"http://test/webdav/{test_file.parent.name}/copied_{test_file.name}",
**get_basic_auth_header("testuser", "testpass")
}
)
assert response.status_code == status.HTTP_201_CREATED
# Check if copy was created
copied_file = await File.get_or_none(
name=f"copied_{test_file.name}", parent=test_file.parent, owner=test_user, is_deleted=False
)
assert copied_file is not None
await copied_file.delete()
@pytest.mark.asyncio
async def test_webdav_move_file(test_user, test_file, test_folder):
# Create another folder
dest_folder = await Folder.create(
name="destfolder",
owner=test_user,
)
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"MOVE",
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers={
"Destination": f"http://test/webdav/{dest_folder.name}/{test_file.name}",
**get_basic_auth_header("testuser", "testpass")
}
)
assert response.status_code == status.HTTP_201_CREATED
# Check if file was moved
moved_file = await File.get_or_none(
name=test_file.name, parent=dest_folder, owner=test_user, is_deleted=False
)
assert moved_file is not None
# Original should be gone
original_file = await File.get_or_none(
id=test_file.id, is_deleted=False
)
assert original_file is None
finally:
await dest_folder.delete()
@pytest.mark.asyncio
async def test_webdav_lock_unlock(test_user, test_file):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
# Lock
lock_xml = """<?xml version="1.0" encoding="utf-8"?>
<D:lockinfo xmlns:D="DAV:">
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockinfo>"""
response = await client.request(
"LOCK",
f"/webdav/{test_file.parent.name}/{test_file.name}",
content=lock_xml,
headers={
"Content-Type": "application/xml",
"Timeout": "Second-3600",
**get_basic_auth_header("testuser", "testpass")
}
)
assert response.status_code == status.HTTP_200_OK
lock_token = response.headers.get("Lock-Token")
assert lock_token is not None
# Unlock
response = await client.request(
"UNLOCK",
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers={
"Lock-Token": lock_token,
**get_basic_auth_header("testuser", "testpass")
}
)
assert response.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.asyncio
async def test_webdav_propfind_allprop(test_user, test_file):
propfind_xml = """<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:allprop/>
</D:propfind>"""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"PROPFIND",
f"/webdav/{test_file.parent.name}/{test_file.name}",
content=propfind_xml,
headers={
"Content-Type": "application/xml",
"Depth": "0",
**get_basic_auth_header("testuser", "testpass")
}
)
assert response.status_code == status.HTTP_207_MULTI_STATUS
content = response.text
assert "getcontentlength" in content
assert "getcontenttype" in content
@pytest.mark.asyncio
async def test_webdav_proppatch(test_user, test_file):
proppatch_xml = """<?xml version="1.0" encoding="utf-8"?>
<D:propertyupdate xmlns:D="DAV:">
<D:set>
<D:prop>
<custom:author xmlns:custom="http://example.com">Test Author</custom:author>
</D:prop>
</D:set>
</D:propertyupdate>"""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"PROPPATCH",
f"/webdav/{test_file.parent.name}/{test_file.name}",
content=proppatch_xml,
headers={
"Content-Type": "application/xml",
**get_basic_auth_header("testuser", "testpass")
}
)
assert response.status_code == status.HTTP_207_MULTI_STATUS
# Check if property was set
from mywebdav.models import WebDAVProperty
prop = await WebDAVProperty.get_or_none(
resource_type="file",
resource_id=test_file.id,
namespace="http://example.com",
name="author"
)
await prop.delete()
@pytest.mark.asyncio
async def test_webdav_head_file(test_user, test_file):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.head(
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers=get_basic_auth_header("testuser", "testpass"),
)
assert response.status_code == status.HTTP_200_OK
assert response.headers["Content-Length"] == str(test_file.size)
assert response.headers["Content-Type"] == test_file.mime_type
assert response.content == b""
@pytest.mark.asyncio
async def test_webdav_put_update_file(test_user, test_file):
from mywebdav import storage
original_save_file = storage.storage_manager.save_file
saved_content = None
async def mock_save_file(user_id, path, content):
nonlocal saved_content
saved_content = content
storage.storage_manager.save_file = mock_save_file
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.put(
f"/webdav/{test_file.parent.name}/{test_file.name}",
content=b"Updated content",
headers=get_basic_auth_header("testuser", "testpass"),
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert saved_content == b"Updated content"
updated_file = await File.get(id=test_file.id)
assert updated_file.size == len(b"Updated content")
finally:
storage.storage_manager.save_file = original_save_file
@pytest.mark.asyncio
async def test_webdav_copy_file_overwrite_true(test_user, test_file):
dest_file = await File.create(
name="destination.txt", parent=test_file.parent, owner=test_user,
path="dest.txt", size=1, mime_type="text/plain", file_hash="oldhash"
)
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"COPY",
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers={
"Destination": f"http://test/webdav/{test_file.parent.name}/destination.txt",
"Overwrite": "T",
**get_basic_auth_header("testuser", "testpass"),
},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# The original destination file should have been updated
updated_dest_file = await File.get(id=dest_file.id)
assert updated_dest_file.is_deleted == False
assert updated_dest_file.file_hash == test_file.file_hash
assert updated_dest_file.size == test_file.size
assert updated_dest_file.name == dest_file.name # Name should remain the same
assert updated_dest_file.parent_id == test_file.parent_id # Parent should remain the same
# No new file should have been created with the destination name
new_file_check = await File.get_or_none(name="destination.txt", parent=test_file.parent, is_deleted=False)
assert new_file_check.id == updated_dest_file.id # Should be the same updated file
finally:
await dest_file.delete()
@pytest.mark.asyncio
async def test_webdav_move_file_overwrite_true(test_user, test_file):
dest_folder = await Folder.create(name="destfolder", owner=test_user)
existing_dest_file = await File.create(
name=test_file.name, parent=dest_folder, owner=test_user,
path="existing.txt", size=1, mime_type="text/plain", file_hash="oldhash"
)
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"MOVE",
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers={
"Destination": f"http://test/webdav/{dest_folder.name}/{test_file.name}",
"Overwrite": "T",
**get_basic_auth_header("testuser", "testpass"),
},
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# The original source file should be marked as deleted
original_source_file = await File.get_or_none(id=test_file.id, is_deleted=True)
assert original_source_file is not None
# The existing destination file should have been updated
updated_dest_file = await File.get(id=existing_dest_file.id)
assert updated_dest_file.is_deleted == False
assert updated_dest_file.file_hash == test_file.file_hash # Should have source's hash
assert updated_dest_file.size == test_file.size # Should have source's size
assert updated_dest_file.name == existing_dest_file.name # Name should remain the same
assert updated_dest_file.parent_id == dest_folder.id # Parent should remain the same
# No new file should have been created with the destination name
new_file_check = await File.get_or_none(name=test_file.name, parent=dest_folder, is_deleted=False)
assert new_file_check.id == updated_dest_file.id # Should be the same updated file
finally:
await dest_folder.delete()
await existing_dest_file.delete()
@pytest.mark.asyncio
async def test_webdav_proppatch_remove(test_user, test_file):
# First, set a property
prop = await WebDAVProperty.create(
resource_type="file", resource_id=test_file.id,
namespace="http://example.com", name="author", value="Test Author"
)
# Now, remove it
proppatch_xml = """<?xml version="1.0" encoding="utf-8"?>
<D:propertyupdate xmlns:D="DAV:">
<D:remove>
<D:prop>
<custom:author xmlns:custom="http://example.com"/>
</D:prop>
</D:remove>
</D:propertyupdate>"""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"PROPPATCH",
f"/webdav/{test_file.parent.name}/{test_file.name}",
content=proppatch_xml,
headers={
"Content-Type": "application/xml",
**get_basic_auth_header("testuser", "testpass"),
},
)
assert response.status_code == status.HTTP_207_MULTI_STATUS
assert "200 OK" in response.text
# Check if property was removed
removed_prop = await WebDAVProperty.get_or_none(id=prop.id)
assert removed_prop is None
@pytest.mark.asyncio
async def test_webdav_propfind_not_found(test_user):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"PROPFIND",
"/webdav/nonexistentfolder/",
headers={
"Depth": "1",
**get_basic_auth_header("testuser", "testpass")
},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
async def test_webdav_mkcol_nested_fail(test_user):
"""Test creating a nested directory where the parent does not exist."""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"MKCOL",
"/webdav/parent/newfolder/",
headers=get_basic_auth_header("testuser", "testpass"),
)
# Expect 409 Conflict because parent collection does not exist
assert response.status_code == status.HTTP_409_CONFLICT
@pytest.mark.asyncio
async def test_webdav_mkcol_already_exists(test_user, test_folder):
"""Test creating a directory that already exists."""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"MKCOL",
f"/webdav/{test_folder.name}/",
headers=get_basic_auth_header("testuser", "testpass"),
)
# Expect 405 Method Not Allowed if collection already exists
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
@pytest.mark.asyncio
async def test_webdav_delete_non_existent_file(test_user):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.delete(
"/webdav/nonexistent.txt",
headers=get_basic_auth_header("testuser", "testpass"),
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
async def test_webdav_delete_non_empty_folder(test_user, test_file):
"""A non-empty folder cannot be deleted."""
folder_to_delete = test_file.parent
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.delete(
f"/webdav/{folder_to_delete.name}/",
headers=get_basic_auth_header("testuser", "testpass"),
)
# Expect 409 Conflict as the folder is not empty
assert response.status_code == status.HTTP_409_CONFLICT
@pytest.mark.asyncio
async def test_webdav_copy_file_overwrite_false_fail(test_user, test_file):
# Create a destination file that already exists
dest_file = await File.create(
name=f"copied_{test_file.name}",
path=f"copied_{test_file.name}",
size=1,
mime_type="text/plain",
file_hash="dummyhash2",
owner=test_user,
parent=test_file.parent,
)
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"COPY",
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers={
"Destination": f"http://test/webdav/{test_file.parent.name}/{dest_file.name}",
"Overwrite": "F",
**get_basic_auth_header("testuser", "testpass"),
},
)
# 412 Precondition Failed because Overwrite is 'F' and destination exists
assert response.status_code == status.HTTP_412_PRECONDITION_FAILED
finally:
await dest_file.delete()
@pytest.mark.asyncio
async def test_webdav_move_file_overwrite_false_fail(test_user, test_file):
dest_folder = await Folder.create(name="destfolder", owner=test_user)
# Create a file with the same name at the destination
existing_dest_file = await File.create(
name=test_file.name,
path=f"{dest_folder.name}/{test_file.name}",
size=1,
mime_type="text/plain",
file_hash="dummyhash3",
owner=test_user,
parent=dest_folder,
)
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.request(
"MOVE",
f"/webdav/{test_file.parent.name}/{test_file.name}",
headers={
"Destination": f"http://test/webdav/{dest_folder.name}/{test_file.name}",
"Overwrite": "F",
**get_basic_auth_header("testuser", "testpass"),
},
)
# 412 Precondition Failed because Overwrite is 'F' and destination exists
assert response.status_code == status.HTTP_412_PRECONDITION_FAILED
finally:
await dest_folder.delete()
await existing_dest_file.delete()