Compare commits
49
Commits
b25fb89df4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
619e320780 | ||
|
|
a34668941f | ||
|
|
1848660317 | ||
|
|
b8d88f5807 | ||
|
|
a8e13cb3c5 | ||
|
|
8dad8a60b6 | ||
|
|
5bdabe63a1 | ||
|
|
137a8e1232 | ||
|
|
049e477ee9 | ||
|
|
2d26306352 | ||
|
|
525784aa6f | ||
|
|
acf70b7019 | ||
|
|
a90a992172 | ||
|
|
46e1b8c9eb | ||
|
|
0564809132 | ||
|
|
6da46eaffb | ||
|
|
a2b5692811 | ||
|
|
6d47bbfa5e | ||
|
|
d6d66cd5fc | ||
|
|
209514bc20 | ||
|
|
69f3161eec | ||
|
|
aac0798305 | ||
|
|
077b11c5aa | ||
|
|
b5c3a0f5d1 | ||
|
|
e8d2456701 | ||
|
|
91f3b5c688 | ||
|
|
9a7a640400 | ||
|
|
b4a96fa82f | ||
|
|
1f3ffc221f | ||
|
|
ba0d3350be | ||
|
|
d91246967c | ||
|
|
df964a7fb1 | ||
|
|
bff5742ced | ||
|
|
987dd33a8c | ||
|
|
43acded96e | ||
|
|
decea66307 | ||
|
|
cd7014df8b | ||
|
|
299fb30387 | ||
|
|
82a57f9835 | ||
|
|
eb75c1114f | ||
|
|
76ccf48628 | ||
|
|
e0517e4300 | ||
|
|
f59cbf56dc | ||
|
|
938daea231 | ||
|
|
c140eee47f | ||
|
|
6b01dc3fa3 | ||
|
|
7c908018f6 | ||
|
|
5df31bd119 | ||
|
|
55ea78e534 |
@@ -27,3 +27,8 @@ SMTP_PASSWORD=
|
|||||||
SMTP_FROM_EMAIL=no-reply@example.com
|
SMTP_FROM_EMAIL=no-reply@example.com
|
||||||
|
|
||||||
TOTP_ISSUER=MyWebdav
|
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
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ dev:
|
|||||||
|
|
||||||
run:
|
run:
|
||||||
@echo "Starting MyWebdav application..."
|
@echo "Starting MyWebdav application..."
|
||||||
@echo "Access the application at http://localhost:8000"
|
@echo "Access the application at http://localhost:9004"
|
||||||
$(PYTHON) -m mywebdav.main
|
$(PYTHON) -m mywebdav.main
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@@ -99,11 +99,13 @@ init-db:
|
|||||||
await Tortoise.generate_schemas(); \
|
await Tortoise.generate_schemas(); \
|
||||||
count = await PricingConfig.all().count(); \
|
count = await PricingConfig.all().count(); \
|
||||||
if count == 0: \
|
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='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='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_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='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'); \
|
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'); \
|
print('Default pricing configuration created'); \
|
||||||
else: \
|
else: \
|
||||||
@@ -147,9 +149,9 @@ setup: setup-env install dev init-db
|
|||||||
@echo "Next steps:"
|
@echo "Next steps:"
|
||||||
@echo " 1. Update .env with your configuration (especially Stripe keys)"
|
@echo " 1. Update .env with your configuration (especially Stripe keys)"
|
||||||
@echo " 2. Run 'make run' or 'make all' to start the application"
|
@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:
|
docs:
|
||||||
@echo "Generating API documentation..."
|
@echo "Generating API documentation..."
|
||||||
@echo "API documentation available at http://localhost:8000/docs when running"
|
@echo "API documentation available at http://localhost:9004/docs when running"
|
||||||
@echo "ReDoc available at http://localhost:8000/redoc when running"
|
@echo "ReDoc available at http://localhost:9004/redoc when running"
|
||||||
|
|||||||
@@ -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
|
## 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
|
- **Webhook Support**: Integration with external services via webhooks
|
||||||
|
|
||||||
### Administration
|
### 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
|
- **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
|
- **API Access**: RESTful API for third-party integrations
|
||||||
|
|
||||||
## Installation
|
## Pricing
|
||||||
|
|
||||||
### Prerequisites
|
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.
|
||||||
- Python 3.12+
|
|
||||||
- PostgreSQL 15+
|
|
||||||
- Redis 7+
|
|
||||||
- Docker and Docker Compose (recommended)
|
|
||||||
|
|
||||||
### 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
|
### Commercial Alternatives Comparison
|
||||||
2. Copy the environment template:
|
| Service | Monthly Cost (2TB) | Price per GB | Hosted |
|
||||||
```bash
|
|---------|---------------------|--------------|--------|
|
||||||
cp .env.example .env
|
| MyWebdav | €6.00 | €0.003 | Yes |
|
||||||
```
|
| Dropbox | €9.99 | €0.005 | Yes |
|
||||||
3. Edit `.env` with your configuration (database credentials, secrets, etc.)
|
| Google Drive | €9.99 | €0.005 | Yes |
|
||||||
4. Start the services:
|
| OneDrive | €9.99 | €0.005 | Yes |
|
||||||
```bash
|
| Nextcloud (self-hosted) | Variable | Variable | No |
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
5. Access the application at `https://your-domain.com`
|
|
||||||
|
|
||||||
### Manual Installation
|
## Getting Started
|
||||||
|
|
||||||
1. Install dependencies:
|
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.
|
||||||
```bash
|
|
||||||
pip install poetry
|
|
||||||
poetry install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Set up the database:
|
### Features Overview
|
||||||
```bash
|
- **Web Interface**: Access your files from any browser
|
||||||
createdb mywebdav
|
- **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`
|
### Support
|
||||||
|
For support, visit our [help center](https://mywebdav.com/support) or contact support@mywebdav.com.
|
||||||
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.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -109,7 +97,15 @@ Access the web application through your browser. The interface provides:
|
|||||||
- Folder management and navigation
|
- Folder management and navigation
|
||||||
- Search and filtering capabilities
|
- Search and filtering capabilities
|
||||||
- User profile and settings
|
- 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
|
### API Usage
|
||||||
MyWebdav provides a comprehensive REST API. Example requests:
|
MyWebdav provides a comprehensive REST API. Example requests:
|
||||||
@@ -140,47 +136,27 @@ https://your-domain.com/webdav/
|
|||||||
### SFTP Access
|
### SFTP Access
|
||||||
Connect via SFTP using your MyWebdav credentials on port 22.
|
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
|
## Security
|
||||||
- **app**: FastAPI application with Gunicorn
|
|
||||||
- **db**: PostgreSQL database
|
|
||||||
- **redis**: Caching and session storage
|
|
||||||
- **nginx**: Reverse proxy and static file serving
|
|
||||||
- **certbot**: SSL certificate management
|
|
||||||
|
|
||||||
### Environment Variables
|
MyWebdav employs enterprise-grade security measures to protect your data:
|
||||||
Configure all services through the `.env` file. Sensitive data is automatically loaded and validated.
|
|
||||||
|
|
||||||
## Security Considerations
|
- End-to-end encryption for all stored files
|
||||||
|
- Multi-factor authentication (MFA) support
|
||||||
- Change default secrets in production
|
- Regular security audits and compliance with GDPR
|
||||||
- Enable HTTPS with valid certificates
|
- 24/7 monitoring and threat detection
|
||||||
- Regularly update dependencies
|
- Secure data centers with physical and digital protections
|
||||||
- Monitor access logs
|
|
||||||
- Implement backup strategies
|
|
||||||
- Use strong passwords and enable 2FA
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
If you encounter issues, our support team is here to help. Common solutions include:
|
||||||
- **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
|
|
||||||
|
|
||||||
### Logs
|
- **Login issues**: Reset your password or enable MFA
|
||||||
Application logs are available in the Docker containers:
|
- **Upload problems**: Check your plan limits or contact support
|
||||||
```bash
|
- **Sync errors**: Reconnect your devices or update clients
|
||||||
docker-compose logs app
|
|
||||||
```
|
For detailed help, visit our [troubleshooting guide](https://mywebdav.com/troubleshooting).
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
@@ -189,6 +165,3 @@ For issues and questions:
|
|||||||
- Review configuration examples
|
- Review configuration examples
|
||||||
- Consult the API documentation at `/docs` when running
|
- Consult the API documentation at `/docs` when running
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License. See the LICENSE file for details.
|
|
||||||
+45
-9
@@ -1,17 +1,53 @@
|
|||||||
from typing import Optional
|
from typing import Optional, List, Dict
|
||||||
from .models import Activity, User
|
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(
|
async def log_activity(
|
||||||
user: Optional[User],
|
user: Optional[User],
|
||||||
action: str,
|
action: str,
|
||||||
target_type: str,
|
target_type: str,
|
||||||
target_id: int,
|
target_id: int,
|
||||||
ip_address: Optional[str] = None
|
ip_address: Optional[str] = None,
|
||||||
):
|
):
|
||||||
await Activity.create(
|
try:
|
||||||
user=user,
|
from .enterprise.write_buffer import get_write_buffer, WriteType
|
||||||
action=action,
|
buffer = get_write_buffer()
|
||||||
target_type=target_type,
|
await buffer.buffer(
|
||||||
target_id=target_id,
|
WriteType.ACTIVITY,
|
||||||
ip_address=ip_address
|
{
|
||||||
)
|
"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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
+69
-19
@@ -1,5 +1,6 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
@@ -9,20 +10,37 @@ import bcrypt
|
|||||||
from .schemas import TokenData
|
from .schemas import TokenData
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
from .models import User
|
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")
|
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):
|
def verify_password(plain_password, hashed_password):
|
||||||
password_bytes = plain_password[:72].encode('utf-8')
|
password_bytes = plain_password[:72].encode("utf-8")
|
||||||
hashed_bytes = hashed_password.encode('utf-8') if isinstance(hashed_password, str) else hashed_password
|
hashed_bytes = (
|
||||||
|
hashed_password.encode("utf-8")
|
||||||
|
if isinstance(hashed_password, str)
|
||||||
|
else hashed_password
|
||||||
|
)
|
||||||
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||||
|
|
||||||
def get_password_hash(password):
|
|
||||||
password_bytes = password[:72].encode('utf-8')
|
|
||||||
return bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode('utf-8')
|
|
||||||
|
|
||||||
async def authenticate_user(username: str, password: str, two_factor_code: Optional[str] = None):
|
def get_password_hash(password):
|
||||||
|
password_bytes = password[:72].encode("utf-8")
|
||||||
|
return bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_user(
|
||||||
|
username: str, password: str, two_factor_code: Optional[str] = None
|
||||||
|
):
|
||||||
user = await User.get_or_none(username=username)
|
user = await User.get_or_none(username=username)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
@@ -33,19 +51,29 @@ async def authenticate_user(username: str, password: str, two_factor_code: Optio
|
|||||||
if not two_factor_code:
|
if not two_factor_code:
|
||||||
return {"user": user, "2fa_required": True}
|
return {"user": user, "2fa_required": True}
|
||||||
if not verify_totp_code(user.two_factor_secret, two_factor_code):
|
if not verify_totp_code(user.two_factor_secret, two_factor_code):
|
||||||
return None # 2FA code is incorrect
|
return None # 2FA code is incorrect
|
||||||
return {"user": user, "2fa_required": False}
|
return {"user": user, "2fa_required": False}
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, two_factor_verified: bool = False):
|
|
||||||
|
def create_access_token(
|
||||||
|
data: dict,
|
||||||
|
expires_delta: Optional[timedelta] = None,
|
||||||
|
two_factor_verified: bool = False,
|
||||||
|
):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
expire = datetime.utcnow() + expires_delta
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
else:
|
else:
|
||||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
|
)
|
||||||
to_encode.update({"exp": expire, "2fa_verified": two_factor_verified})
|
to_encode.update({"exp": expire, "2fa_verified": two_factor_verified})
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
encoded_jwt = jwt.encode(
|
||||||
|
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||||
|
)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
@@ -53,31 +81,53 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
payload = jwt.decode(
|
||||||
|
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||||
|
)
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
two_factor_verified: bool = payload.get("2fa_verified", False)
|
two_factor_verified: bool = payload.get("2fa_verified", False)
|
||||||
|
jti: str = payload.get("jti")
|
||||||
if username is None:
|
if username is None:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
token_data = TokenData(username=username, two_factor_verified=two_factor_verified)
|
|
||||||
|
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
|
||||||
|
)
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
user = await User.get_or_none(username=token_data.username)
|
user = await User.get_or_none(username=token_data.username)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
user.token_data = token_data # Attach token_data to user for easy access
|
user.token_data = token_data
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_current_active_user(current_user: User = Depends(get_current_user)):
|
async def get_current_active_user(current_user: User = Depends(get_current_user)):
|
||||||
if not current_user.is_active:
|
if not current_user.is_active:
|
||||||
raise HTTPException(status_code=400, detail="Inactive user")
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
async def get_current_verified_user(current_user: User = Depends(get_current_user)):
|
async def get_current_verified_user(current_user: User = Depends(get_current_user)):
|
||||||
if current_user.is_2fa_enabled and not current_user.token_data.two_factor_verified:
|
if current_user.is_2fa_enabled and not current_user.token_data.two_factor_verified:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="2FA required and not verified")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="2FA required and not verified",
|
||||||
|
)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
async def get_current_admin_user(current_user: User = Depends(get_current_verified_user)):
|
|
||||||
|
async def get_current_admin_user(
|
||||||
|
current_user: User = Depends(get_current_verified_user),
|
||||||
|
):
|
||||||
if not current_user.is_superuser:
|
if not current_user.is_superuser:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
|
||||||
|
)
|
||||||
return current_user
|
return current_user
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from calendar import monthrange
|
from calendar import monthrange
|
||||||
from .models import Invoice, InvoiceLineItem, PricingConfig, UsageAggregate, UserSubscription
|
from .models import Invoice, InvoiceLineItem, PricingConfig, UserSubscription
|
||||||
from .usage_tracker import UsageTracker
|
from .usage_tracker import UsageTracker
|
||||||
from .stripe_client import StripeClient
|
from .stripe_client import StripeClient
|
||||||
from ..models import User
|
from ..models import User
|
||||||
|
|
||||||
|
|
||||||
class InvoiceGenerator:
|
class InvoiceGenerator:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def generate_monthly_invoice(user: User, year: int, month: int) -> Optional[Invoice]:
|
async def generate_monthly_invoice(
|
||||||
|
user: User, year: int, month: int
|
||||||
|
) -> Optional[Invoice]:
|
||||||
period_start = date(year, month, 1)
|
period_start = date(year, month, 1)
|
||||||
days_in_month = monthrange(year, month)[1]
|
days_in_month = monthrange(year, month)[1]
|
||||||
period_end = date(year, month, days_in_month)
|
period_end = date(year, month, days_in_month)
|
||||||
@@ -19,19 +22,59 @@ class InvoiceGenerator:
|
|||||||
pricing = await PricingConfig.all()
|
pricing = await PricingConfig.all()
|
||||||
pricing_dict = {p.config_key: p.config_value for p in pricing}
|
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'))
|
# Get user's subscription plan
|
||||||
bandwidth_price_per_gb = pricing_dict.get('bandwidth_egress_per_gb', Decimal('0.009'))
|
user_subscription = await UserSubscription.get_or_none(user=user)
|
||||||
free_storage_gb = pricing_dict.get('free_tier_storage_gb', Decimal('15'))
|
plan_name = "starter" # Default to starter
|
||||||
free_bandwidth_gb = pricing_dict.get('free_tier_bandwidth_gb', Decimal('15'))
|
if user_subscription and user_subscription.plan:
|
||||||
tax_rate = pricing_dict.get('tax_rate_default', Decimal('0'))
|
plan_name = user_subscription.plan.name
|
||||||
|
|
||||||
storage_gb = Decimal(str(usage['storage_gb_avg']))
|
# Set pricing based on subscription tier
|
||||||
bandwidth_gb = Decimal(str(usage['bandwidth_down_gb']))
|
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")
|
||||||
|
)
|
||||||
|
|
||||||
billable_storage = max(Decimal('0'), storage_gb - free_storage_gb)
|
# No free tier - charge from first GB
|
||||||
billable_bandwidth = max(Decimal('0'), bandwidth_gb - free_bandwidth_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"]))
|
||||||
|
bandwidth_gb = Decimal(str(usage["bandwidth_down_gb"]))
|
||||||
|
|
||||||
|
billable_storage = max(Decimal("0"), storage_gb - free_storage_gb)
|
||||||
|
billable_bandwidth = max(Decimal("0"), bandwidth_gb - free_bandwidth_gb)
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
billable_storage_rounded = Decimal(math.ceil(float(billable_storage)))
|
billable_storage_rounded = Decimal(math.ceil(float(billable_storage)))
|
||||||
billable_bandwidth_rounded = Decimal(math.ceil(float(billable_bandwidth)))
|
billable_bandwidth_rounded = Decimal(math.ceil(float(billable_bandwidth)))
|
||||||
|
|
||||||
@@ -65,9 +108,9 @@ class InvoiceGenerator:
|
|||||||
"usage": usage,
|
"usage": usage,
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"storage_per_gb": float(storage_price_per_gb),
|
"storage_per_gb": float(storage_price_per_gb),
|
||||||
"bandwidth_per_gb": float(bandwidth_price_per_gb)
|
"bandwidth_per_gb": float(bandwidth_price_per_gb),
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if billable_storage_rounded > 0:
|
if billable_storage_rounded > 0:
|
||||||
@@ -78,7 +121,10 @@ class InvoiceGenerator:
|
|||||||
unit_price=storage_price_per_gb,
|
unit_price=storage_price_per_gb,
|
||||||
amount=storage_cost,
|
amount=storage_cost,
|
||||||
item_type="storage",
|
item_type="storage",
|
||||||
metadata={"avg_gb": float(storage_gb), "free_gb": float(free_storage_gb)}
|
metadata={
|
||||||
|
"avg_gb": float(storage_gb),
|
||||||
|
"free_gb": float(free_storage_gb),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if billable_bandwidth_rounded > 0:
|
if billable_bandwidth_rounded > 0:
|
||||||
@@ -89,7 +135,10 @@ class InvoiceGenerator:
|
|||||||
unit_price=bandwidth_price_per_gb,
|
unit_price=bandwidth_price_per_gb,
|
||||||
amount=bandwidth_cost,
|
amount=bandwidth_cost,
|
||||||
item_type="bandwidth",
|
item_type="bandwidth",
|
||||||
metadata={"total_gb": float(bandwidth_gb), "free_gb": float(free_bandwidth_gb)}
|
metadata={
|
||||||
|
"total_gb": float(bandwidth_gb),
|
||||||
|
"free_gb": float(free_bandwidth_gb),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if subscription and subscription.stripe_customer_id:
|
if subscription and subscription.stripe_customer_id:
|
||||||
@@ -100,7 +149,7 @@ class InvoiceGenerator:
|
|||||||
"amount": item.amount,
|
"amount": item.amount,
|
||||||
"currency": "usd",
|
"currency": "usd",
|
||||||
"description": item.description,
|
"description": item.description,
|
||||||
"metadata": item.metadata or {}
|
"metadata": item.metadata or {},
|
||||||
}
|
}
|
||||||
for item in line_items
|
for item in line_items
|
||||||
]
|
]
|
||||||
@@ -109,7 +158,7 @@ class InvoiceGenerator:
|
|||||||
customer_id=subscription.stripe_customer_id,
|
customer_id=subscription.stripe_customer_id,
|
||||||
description=f"MyWebdav Usage Invoice for {period_start.strftime('%B %Y')}",
|
description=f"MyWebdav Usage Invoice for {period_start.strftime('%B %Y')}",
|
||||||
line_items=stripe_line_items,
|
line_items=stripe_line_items,
|
||||||
metadata={"mywebdav_invoice_id": str(invoice.id)}
|
metadata={"mywebdav_invoice_id": str(invoice.id)},
|
||||||
)
|
)
|
||||||
|
|
||||||
invoice.stripe_invoice_id = stripe_invoice.id
|
invoice.stripe_invoice_id = stripe_invoice.id
|
||||||
@@ -135,8 +184,11 @@ class InvoiceGenerator:
|
|||||||
|
|
||||||
# Send invoice email
|
# Send invoice email
|
||||||
from ..mail import queue_email
|
from ..mail import queue_email
|
||||||
|
|
||||||
line_items = await invoice.line_items.all()
|
line_items = await invoice.line_items.all()
|
||||||
items_text = "\n".join([f"- {item.description}: ${item.amount}" for item in line_items])
|
items_text = "\n".join(
|
||||||
|
[f"- {item.description}: ${item.amount}" for item in line_items]
|
||||||
|
)
|
||||||
body = f"""Dear {invoice.user.username},
|
body = f"""Dear {invoice.user.username},
|
||||||
|
|
||||||
Your invoice {invoice.invoice_number} for the period {invoice.period_start} to {invoice.period_end} is now available.
|
Your invoice {invoice.invoice_number} for the period {invoice.period_start} to {invoice.period_end} is now available.
|
||||||
@@ -174,7 +226,7 @@ The MyWebdav Team
|
|||||||
to_email=invoice.user.email,
|
to_email=invoice.user.email,
|
||||||
subject=f"Your MyWebdav Invoice {invoice.invoice_number}",
|
subject=f"Your MyWebdav Invoice {invoice.invoice_number}",
|
||||||
body=body,
|
body=body,
|
||||||
html=html
|
html=html,
|
||||||
)
|
)
|
||||||
|
|
||||||
return invoice
|
return invoice
|
||||||
@@ -182,6 +234,6 @@ The MyWebdav Team
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def mark_invoice_paid(invoice: Invoice) -> Invoice:
|
async def mark_invoice_paid(invoice: Invoice) -> Invoice:
|
||||||
invoice.status = "paid"
|
invoice.status = "paid"
|
||||||
invoice.paid_at = datetime.utcnow()
|
invoice.paid_at = datetime.now(timezone.utc)
|
||||||
await invoice.save()
|
await invoice.save()
|
||||||
return invoice
|
return invoice
|
||||||
|
|||||||
+45
-31
@@ -1,13 +1,13 @@
|
|||||||
from tortoise import fields, models
|
from tortoise import fields, models
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
class SubscriptionPlan(models.Model):
|
class SubscriptionPlan(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
name = fields.CharField(max_length=100, unique=True)
|
name = fields.CharField(max_length=100, unique=True)
|
||||||
display_name = fields.CharField(max_length=255)
|
display_name = fields.CharField(max_length=255)
|
||||||
description = fields.TextField(null=True)
|
description = fields.TextField(null=True)
|
||||||
storage_gb = fields.IntField()
|
storage_gb = fields.IntField(null=True) # null means unlimited/usage-based
|
||||||
bandwidth_gb = fields.IntField()
|
bandwidth_gb = fields.IntField(null=True) # null means unlimited/usage-based
|
||||||
price_monthly = fields.DecimalField(max_digits=10, decimal_places=2)
|
price_monthly = fields.DecimalField(max_digits=10, decimal_places=2)
|
||||||
price_yearly = fields.DecimalField(max_digits=10, decimal_places=2, null=True)
|
price_yearly = fields.DecimalField(max_digits=10, decimal_places=2, null=True)
|
||||||
stripe_price_id = fields.CharField(max_length=255, null=True)
|
stripe_price_id = fields.CharField(max_length=255, null=True)
|
||||||
@@ -18,14 +18,17 @@ class SubscriptionPlan(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
table = "subscription_plans"
|
table = "subscription_plans"
|
||||||
|
|
||||||
|
|
||||||
class UserSubscription(models.Model):
|
class UserSubscription(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
user = fields.ForeignKeyField("models.User", related_name="subscription")
|
user = fields.ForeignKeyField("models.User", related_name="subscription")
|
||||||
plan = fields.ForeignKeyField("billing.SubscriptionPlan", related_name="subscriptions", null=True)
|
plan = fields.ForeignKeyField(
|
||||||
billing_type = fields.CharField(max_length=20, default="pay_as_you_go")
|
"billing.SubscriptionPlan", related_name="subscriptions", null=True
|
||||||
|
)
|
||||||
|
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_customer_id = fields.CharField(max_length=255, unique=True, null=True)
|
||||||
stripe_subscription_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_start = fields.DatetimeField(null=True)
|
||||||
current_period_end = fields.DatetimeField(null=True)
|
current_period_end = fields.DatetimeField(null=True)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
@@ -35,14 +38,15 @@ class UserSubscription(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
table = "user_subscriptions"
|
table = "user_subscriptions"
|
||||||
|
|
||||||
|
|
||||||
class UsageRecord(models.Model):
|
class UsageRecord(models.Model):
|
||||||
id = fields.BigIntField(pk=True)
|
id = fields.BigIntField(primary_key=True)
|
||||||
user = fields.ForeignKeyField("models.User", related_name="usage_records")
|
user = fields.ForeignKeyField("models.User", related_name="usage_records")
|
||||||
record_type = fields.CharField(max_length=50, index=True)
|
record_type = fields.CharField(max_length=100, db_index=True)
|
||||||
amount_bytes = fields.BigIntField()
|
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)
|
resource_id = fields.IntField(null=True)
|
||||||
timestamp = fields.DatetimeField(auto_now_add=True, index=True)
|
timestamp = fields.DatetimeField(auto_now_add=True, db_index=True)
|
||||||
idempotency_key = fields.CharField(max_length=255, unique=True, null=True)
|
idempotency_key = fields.CharField(max_length=255, unique=True, null=True)
|
||||||
metadata = fields.JSONField(null=True)
|
metadata = fields.JSONField(null=True)
|
||||||
|
|
||||||
@@ -50,8 +54,9 @@ class UsageRecord(models.Model):
|
|||||||
table = "usage_records"
|
table = "usage_records"
|
||||||
indexes = [("user_id", "record_type", "timestamp")]
|
indexes = [("user_id", "record_type", "timestamp")]
|
||||||
|
|
||||||
|
|
||||||
class UsageAggregate(models.Model):
|
class UsageAggregate(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
user = fields.ForeignKeyField("models.User", related_name="usage_aggregates")
|
user = fields.ForeignKeyField("models.User", related_name="usage_aggregates")
|
||||||
date = fields.DateField()
|
date = fields.DateField()
|
||||||
storage_bytes_avg = fields.BigIntField(default=0)
|
storage_bytes_avg = fields.BigIntField(default=0)
|
||||||
@@ -64,21 +69,22 @@ class UsageAggregate(models.Model):
|
|||||||
table = "usage_aggregates"
|
table = "usage_aggregates"
|
||||||
unique_together = (("user", "date"),)
|
unique_together = (("user", "date"),)
|
||||||
|
|
||||||
|
|
||||||
class Invoice(models.Model):
|
class Invoice(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
user = fields.ForeignKeyField("models.User", related_name="invoices")
|
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)
|
stripe_invoice_id = fields.CharField(max_length=255, unique=True, null=True)
|
||||||
period_start = fields.DateField(index=True)
|
period_start = fields.DateField(db_index=True)
|
||||||
period_end = fields.DateField()
|
period_end = fields.DateField()
|
||||||
subtotal = fields.DecimalField(max_digits=10, decimal_places=4)
|
subtotal = fields.DecimalField(max_digits=10, decimal_places=4)
|
||||||
tax = fields.DecimalField(max_digits=10, decimal_places=4, default=0)
|
tax = fields.DecimalField(max_digits=10, decimal_places=4, default=0)
|
||||||
total = fields.DecimalField(max_digits=10, decimal_places=4)
|
total = fields.DecimalField(max_digits=10, decimal_places=4)
|
||||||
currency = fields.CharField(max_length=3, default="USD")
|
currency = fields.CharField(max_length=100, default="USD")
|
||||||
status = fields.CharField(max_length=50, default="draft", index=True)
|
status = fields.CharField(max_length=100, default="draft", db_index=True)
|
||||||
due_date = fields.DateField(null=True)
|
due_date = fields.DateField(null=True)
|
||||||
paid_at = fields.DatetimeField(null=True)
|
paid_at = fields.DatetimeField(null=True)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True, index=True)
|
created_at = fields.DatetimeField(auto_now_add=True, db_index=True)
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
metadata = fields.JSONField(null=True)
|
metadata = fields.JSONField(null=True)
|
||||||
|
|
||||||
@@ -86,40 +92,45 @@ class Invoice(models.Model):
|
|||||||
table = "invoices"
|
table = "invoices"
|
||||||
indexes = [("user_id", "status", "created_at")]
|
indexes = [("user_id", "status", "created_at")]
|
||||||
|
|
||||||
|
|
||||||
class InvoiceLineItem(models.Model):
|
class InvoiceLineItem(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
invoice = fields.ForeignKeyField("billing.Invoice", related_name="line_items")
|
invoice = fields.ForeignKeyField("billing.Invoice", related_name="line_items")
|
||||||
description = fields.TextField()
|
description = fields.TextField()
|
||||||
quantity = fields.DecimalField(max_digits=15, decimal_places=6)
|
quantity = fields.DecimalField(max_digits=15, decimal_places=6)
|
||||||
unit_price = fields.DecimalField(max_digits=10, decimal_places=6)
|
unit_price = fields.DecimalField(max_digits=10, decimal_places=6)
|
||||||
amount = fields.DecimalField(max_digits=10, decimal_places=4)
|
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)
|
metadata = fields.JSONField(null=True)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "invoice_line_items"
|
table = "invoice_line_items"
|
||||||
|
|
||||||
|
|
||||||
class PricingConfig(models.Model):
|
class PricingConfig(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
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)
|
config_value = fields.DecimalField(max_digits=10, decimal_places=6)
|
||||||
description = fields.TextField(null=True)
|
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)
|
updated_by = fields.ForeignKeyField(
|
||||||
|
"models.User", related_name="pricing_updates", null=True
|
||||||
|
)
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "pricing_config"
|
table = "pricing_config"
|
||||||
|
|
||||||
|
|
||||||
class PaymentMethod(models.Model):
|
class PaymentMethod(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
user = fields.ForeignKeyField("models.User", related_name="payment_methods")
|
user = fields.ForeignKeyField("models.User", related_name="payment_methods")
|
||||||
stripe_payment_method_id = fields.CharField(max_length=255)
|
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)
|
is_default = fields.BooleanField(default=False)
|
||||||
last4 = fields.CharField(max_length=4, null=True)
|
last4 = fields.CharField(max_length=100, null=True)
|
||||||
brand = fields.CharField(max_length=50, null=True)
|
brand = fields.CharField(max_length=100, null=True)
|
||||||
exp_month = fields.IntField(null=True)
|
exp_month = fields.IntField(null=True)
|
||||||
exp_year = fields.IntField(null=True)
|
exp_year = fields.IntField(null=True)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
@@ -128,9 +139,12 @@ class PaymentMethod(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
table = "payment_methods"
|
table = "payment_methods"
|
||||||
|
|
||||||
|
|
||||||
class BillingEvent(models.Model):
|
class BillingEvent(models.Model):
|
||||||
id = fields.BigIntField(pk=True)
|
id = fields.BigIntField(primary_key=True)
|
||||||
user = fields.ForeignKeyField("models.User", related_name="billing_events", null=True)
|
user = fields.ForeignKeyField(
|
||||||
|
"models.User", related_name="billing_events", null=True
|
||||||
|
)
|
||||||
event_type = fields.CharField(max_length=100)
|
event_type = fields.CharField(max_length=100)
|
||||||
stripe_event_id = fields.CharField(max_length=255, unique=True, null=True)
|
stripe_event_id = fields.CharField(max_length=255, unique=True, null=True)
|
||||||
data = fields.JSONField(null=True)
|
data = fields.JSONField(null=True)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from .usage_tracker import UsageTracker
|
from .usage_tracker import UsageTracker
|
||||||
from .invoice_generator import InvoiceGenerator
|
from .invoice_generator import InvoiceGenerator
|
||||||
@@ -9,6 +8,7 @@ from ..models import User
|
|||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
|
||||||
async def aggregate_daily_usage_for_all_users():
|
async def aggregate_daily_usage_for_all_users():
|
||||||
users = await User.filter(is_active=True).all()
|
users = await User.filter(is_active=True).all()
|
||||||
yesterday = date.today() - timedelta(days=1)
|
yesterday = date.today() - timedelta(days=1)
|
||||||
@@ -19,6 +19,7 @@ async def aggregate_daily_usage_for_all_users():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to aggregate usage for user {user.id}: {e}")
|
print(f"Failed to aggregate usage for user {user.id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def generate_monthly_invoices():
|
async def generate_monthly_invoices():
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
last_month = now.month - 1 if now.month > 1 else 12
|
last_month = now.month - 1 if now.month > 1 else 12
|
||||||
@@ -28,19 +29,22 @@ async def generate_monthly_invoices():
|
|||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
try:
|
try:
|
||||||
invoice = await InvoiceGenerator.generate_monthly_invoice(user, year, last_month)
|
invoice = await InvoiceGenerator.generate_monthly_invoice(
|
||||||
|
user, year, last_month
|
||||||
|
)
|
||||||
if invoice:
|
if invoice:
|
||||||
await InvoiceGenerator.finalize_invoice(invoice)
|
await InvoiceGenerator.finalize_invoice(invoice)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to generate invoice for user {user.id}: {e}")
|
print(f"Failed to generate invoice for user {user.id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler():
|
def start_scheduler():
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
aggregate_daily_usage_for_all_users,
|
aggregate_daily_usage_for_all_users,
|
||||||
CronTrigger(hour=1, minute=0),
|
CronTrigger(hour=1, minute=0),
|
||||||
id="aggregate_daily_usage",
|
id="aggregate_daily_usage",
|
||||||
name="Aggregate daily usage for all users",
|
name="Aggregate daily usage for all users",
|
||||||
replace_existing=True
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
@@ -48,10 +52,11 @@ def start_scheduler():
|
|||||||
CronTrigger(day=1, hour=2, minute=0),
|
CronTrigger(day=1, hour=2, minute=0),
|
||||||
id="generate_monthly_invoices",
|
id="generate_monthly_invoices",
|
||||||
name="Generate monthly invoices",
|
name="Generate monthly invoices",
|
||||||
replace_existing=True
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
def stop_scheduler():
|
def stop_scheduler():
|
||||||
scheduler.shutdown()
|
scheduler.shutdown()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import stripe
|
import stripe
|
||||||
from decimal import Decimal
|
from typing import Dict
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
from ..settings import settings
|
from ..settings import settings
|
||||||
|
|
||||||
|
|
||||||
class StripeClient:
|
class StripeClient:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ensure_api_key():
|
def _ensure_api_key():
|
||||||
@@ -11,13 +11,12 @@ class StripeClient:
|
|||||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||||
else:
|
else:
|
||||||
raise ValueError("Stripe API key not configured")
|
raise ValueError("Stripe API key not configured")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def create_customer(email: str, name: str, metadata: Dict = None) -> str:
|
async def create_customer(email: str, name: str, metadata: Dict = None) -> str:
|
||||||
StripeClient._ensure_api_key()
|
StripeClient._ensure_api_key()
|
||||||
customer = stripe.Customer.create(
|
customer = stripe.Customer.create(
|
||||||
email=email,
|
email=email, name=name, metadata=metadata or {}
|
||||||
name=name,
|
|
||||||
metadata=metadata or {}
|
|
||||||
)
|
)
|
||||||
return customer.id
|
return customer.id
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ class StripeClient:
|
|||||||
amount: int,
|
amount: int,
|
||||||
currency: str = "usd",
|
currency: str = "usd",
|
||||||
customer_id: str = None,
|
customer_id: str = None,
|
||||||
metadata: Dict = None
|
metadata: Dict = None,
|
||||||
) -> stripe.PaymentIntent:
|
) -> stripe.PaymentIntent:
|
||||||
StripeClient._ensure_api_key()
|
StripeClient._ensure_api_key()
|
||||||
return stripe.PaymentIntent.create(
|
return stripe.PaymentIntent.create(
|
||||||
@@ -34,32 +33,29 @@ class StripeClient:
|
|||||||
currency=currency,
|
currency=currency,
|
||||||
customer=customer_id,
|
customer=customer_id,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
automatic_payment_methods={"enabled": True}
|
automatic_payment_methods={"enabled": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def create_invoice(
|
async def create_invoice(
|
||||||
customer_id: str,
|
customer_id: str, description: str, line_items: list, metadata: Dict = None
|
||||||
description: str,
|
|
||||||
line_items: list,
|
|
||||||
metadata: Dict = None
|
|
||||||
) -> stripe.Invoice:
|
) -> stripe.Invoice:
|
||||||
StripeClient._ensure_api_key()
|
StripeClient._ensure_api_key()
|
||||||
for item in line_items:
|
for item in line_items:
|
||||||
stripe.InvoiceItem.create(
|
stripe.InvoiceItem.create(
|
||||||
customer=customer_id,
|
customer=customer_id,
|
||||||
amount=int(item['amount'] * 100),
|
amount=int(item["amount"] * 100),
|
||||||
currency=item.get('currency', 'usd'),
|
currency=item.get("currency", "usd"),
|
||||||
description=item['description'],
|
description=item["description"],
|
||||||
metadata=item.get('metadata', {})
|
metadata=item.get("metadata", {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
invoice = stripe.Invoice.create(
|
invoice = stripe.Invoice.create(
|
||||||
customer=customer_id,
|
customer=customer_id,
|
||||||
description=description,
|
description=description,
|
||||||
auto_advance=True,
|
auto_advance=True,
|
||||||
collection_method='charge_automatically',
|
collection_method="charge_automatically",
|
||||||
metadata=metadata or {}
|
metadata=metadata or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
return invoice
|
return invoice
|
||||||
@@ -76,18 +72,15 @@ class StripeClient:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def attach_payment_method(
|
async def attach_payment_method(
|
||||||
payment_method_id: str,
|
payment_method_id: str, customer_id: str
|
||||||
customer_id: str
|
|
||||||
) -> stripe.PaymentMethod:
|
) -> stripe.PaymentMethod:
|
||||||
StripeClient._ensure_api_key()
|
StripeClient._ensure_api_key()
|
||||||
payment_method = stripe.PaymentMethod.attach(
|
payment_method = stripe.PaymentMethod.attach(
|
||||||
payment_method_id,
|
payment_method_id, customer=customer_id
|
||||||
customer=customer_id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
stripe.Customer.modify(
|
stripe.Customer.modify(
|
||||||
customer_id,
|
customer_id, invoice_settings={"default_payment_method": payment_method_id}
|
||||||
invoice_settings={'default_payment_method': payment_method_id}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return payment_method
|
return payment_method
|
||||||
@@ -95,22 +88,15 @@ class StripeClient:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def list_payment_methods(customer_id: str, type: str = "card"):
|
async def list_payment_methods(customer_id: str, type: str = "card"):
|
||||||
StripeClient._ensure_api_key()
|
StripeClient._ensure_api_key()
|
||||||
return stripe.PaymentMethod.list(
|
return stripe.PaymentMethod.list(customer=customer_id, type=type)
|
||||||
customer=customer_id,
|
|
||||||
type=type
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def create_subscription(
|
async def create_subscription(
|
||||||
customer_id: str,
|
customer_id: str, price_id: str, metadata: Dict = None
|
||||||
price_id: str,
|
|
||||||
metadata: Dict = None
|
|
||||||
) -> stripe.Subscription:
|
) -> stripe.Subscription:
|
||||||
StripeClient._ensure_api_key()
|
StripeClient._ensure_api_key()
|
||||||
return stripe.Subscription.create(
|
return stripe.Subscription.create(
|
||||||
customer=customer_id,
|
customer=customer_id, items=[{"price": price_id}], metadata=metadata or {}
|
||||||
items=[{'price': price_id}],
|
|
||||||
metadata=metadata or {}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,11 +1,35 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date
|
import logging
|
||||||
from decimal import Decimal
|
from datetime import datetime, date, timezone, timedelta
|
||||||
from typing import Optional
|
from typing import List, Dict
|
||||||
|
|
||||||
from tortoise.transactions import in_transaction
|
from tortoise.transactions import in_transaction
|
||||||
|
|
||||||
from .models import UsageRecord, UsageAggregate
|
from .models import UsageRecord, UsageAggregate
|
||||||
from ..models import User
|
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:
|
class UsageTracker:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def track_storage(
|
async def track_storage(
|
||||||
@@ -13,19 +37,35 @@ class UsageTracker:
|
|||||||
amount_bytes: int,
|
amount_bytes: int,
|
||||||
resource_type: str = None,
|
resource_type: str = None,
|
||||||
resource_id: int = None,
|
resource_id: int = None,
|
||||||
metadata: dict = None
|
metadata: dict = None,
|
||||||
):
|
):
|
||||||
idempotency_key = f"storage_{user.id}_{datetime.utcnow().timestamp()}_{uuid.uuid4().hex[:8]}"
|
idempotency_key = f"storage_{user.id}_{datetime.now(timezone.utc).timestamp()}_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
await UsageRecord.create(
|
try:
|
||||||
user=user,
|
from ..enterprise.write_buffer import get_write_buffer, WriteType
|
||||||
record_type="storage",
|
buffer = get_write_buffer()
|
||||||
amount_bytes=amount_bytes,
|
await buffer.buffer(
|
||||||
resource_type=resource_type,
|
WriteType.USAGE_RECORD,
|
||||||
resource_id=resource_id,
|
{
|
||||||
idempotency_key=idempotency_key,
|
"user_id": user.id,
|
||||||
metadata=metadata
|
"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
|
@staticmethod
|
||||||
async def track_bandwidth(
|
async def track_bandwidth(
|
||||||
@@ -34,20 +74,36 @@ class UsageTracker:
|
|||||||
direction: str = "down",
|
direction: str = "down",
|
||||||
resource_type: str = None,
|
resource_type: str = None,
|
||||||
resource_id: int = None,
|
resource_id: int = None,
|
||||||
metadata: dict = None
|
metadata: dict = None,
|
||||||
):
|
):
|
||||||
record_type = f"bandwidth_{direction}"
|
record_type = f"bandwidth_{direction}"
|
||||||
idempotency_key = f"{record_type}_{user.id}_{datetime.utcnow().timestamp()}_{uuid.uuid4().hex[:8]}"
|
idempotency_key = f"{record_type}_{user.id}_{datetime.now(timezone.utc).timestamp()}_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
await UsageRecord.create(
|
try:
|
||||||
user=user,
|
from ..enterprise.write_buffer import get_write_buffer, WriteType
|
||||||
record_type=record_type,
|
buffer = get_write_buffer()
|
||||||
amount_bytes=amount_bytes,
|
await buffer.buffer(
|
||||||
resource_type=resource_type,
|
WriteType.USAGE_RECORD,
|
||||||
resource_id=resource_id,
|
{
|
||||||
idempotency_key=idempotency_key,
|
"user_id": user.id,
|
||||||
metadata=metadata
|
"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
|
@staticmethod
|
||||||
async def aggregate_daily_usage(user: User, target_date: date = None):
|
async def aggregate_daily_usage(user: User, target_date: date = None):
|
||||||
@@ -55,30 +111,32 @@ class UsageTracker:
|
|||||||
target_date = date.today()
|
target_date = date.today()
|
||||||
|
|
||||||
start_of_day = datetime.combine(target_date, datetime.min.time())
|
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(
|
storage_records = await UsageRecord.filter(
|
||||||
user=user,
|
user=user,
|
||||||
record_type="storage",
|
record_type="storage",
|
||||||
timestamp__gte=start_of_day,
|
timestamp__gte=start_of_day,
|
||||||
timestamp__lte=end_of_day
|
timestamp__lte=end_of_day,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
storage_avg = sum(r.amount_bytes for r in storage_records) // max(len(storage_records), 1)
|
storage_avg = sum(r.amount_bytes for r in storage_records) // max(
|
||||||
|
len(storage_records), 1
|
||||||
|
)
|
||||||
storage_peak = max((r.amount_bytes for r in storage_records), default=0)
|
storage_peak = max((r.amount_bytes for r in storage_records), default=0)
|
||||||
|
|
||||||
bandwidth_up = await UsageRecord.filter(
|
bandwidth_up = await UsageRecord.filter(
|
||||||
user=user,
|
user=user,
|
||||||
record_type="bandwidth_up",
|
record_type="bandwidth_up",
|
||||||
timestamp__gte=start_of_day,
|
timestamp__gte=start_of_day,
|
||||||
timestamp__lte=end_of_day
|
timestamp__lte=end_of_day,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
bandwidth_down = await UsageRecord.filter(
|
bandwidth_down = await UsageRecord.filter(
|
||||||
user=user,
|
user=user,
|
||||||
record_type="bandwidth_down",
|
record_type="bandwidth_down",
|
||||||
timestamp__gte=start_of_day,
|
timestamp__gte=start_of_day,
|
||||||
timestamp__lte=end_of_day
|
timestamp__lte=end_of_day,
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
total_up = sum(r.amount_bytes for r in bandwidth_up)
|
total_up = sum(r.amount_bytes for r in bandwidth_up)
|
||||||
@@ -92,8 +150,8 @@ class UsageTracker:
|
|||||||
"storage_bytes_avg": storage_avg,
|
"storage_bytes_avg": storage_avg,
|
||||||
"storage_bytes_peak": storage_peak,
|
"storage_bytes_peak": storage_peak,
|
||||||
"bandwidth_up_bytes": total_up,
|
"bandwidth_up_bytes": total_up,
|
||||||
"bandwidth_down_bytes": total_down
|
"bandwidth_down_bytes": total_down,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not created:
|
if not created:
|
||||||
@@ -108,6 +166,7 @@ class UsageTracker:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_current_storage(user: User) -> int:
|
async def get_current_storage(user: User) -> int:
|
||||||
from ..models import File
|
from ..models import File
|
||||||
|
|
||||||
files = await File.filter(owner=user, is_deleted=False).all()
|
files = await File.filter(owner=user, is_deleted=False).all()
|
||||||
return sum(f.size for f in files)
|
return sum(f.size for f in files)
|
||||||
|
|
||||||
@@ -121,9 +180,7 @@ class UsageTracker:
|
|||||||
end_date = date(year, month, last_day)
|
end_date = date(year, month, last_day)
|
||||||
|
|
||||||
aggregates = await UsageAggregate.filter(
|
aggregates = await UsageAggregate.filter(
|
||||||
user=user,
|
user=user, date__gte=start_date, date__lte=end_date
|
||||||
date__gte=start_date,
|
|
||||||
date__lte=end_date
|
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
if not aggregates:
|
if not aggregates:
|
||||||
@@ -132,7 +189,7 @@ class UsageTracker:
|
|||||||
"storage_gb_peak": 0,
|
"storage_gb_peak": 0,
|
||||||
"bandwidth_up_gb": 0,
|
"bandwidth_up_gb": 0,
|
||||||
"bandwidth_down_gb": 0,
|
"bandwidth_down_gb": 0,
|
||||||
"total_bandwidth_gb": 0
|
"total_bandwidth_gb": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
storage_avg = sum(a.storage_bytes_avg for a in aggregates) / len(aggregates)
|
storage_avg = sum(a.storage_bytes_avg for a in aggregates) / len(aggregates)
|
||||||
@@ -145,5 +202,5 @@ class UsageTracker:
|
|||||||
"storage_gb_peak": round(storage_peak / (1024**3), 4),
|
"storage_gb_peak": round(storage_peak / (1024**3), 4),
|
||||||
"bandwidth_up_gb": round(bandwidth_up / (1024**3), 4),
|
"bandwidth_up_gb": round(bandwidth_up / (1024**3), 4),
|
||||||
"bandwidth_down_gb": round(bandwidth_down / (1024**3), 4),
|
"bandwidth_down_gb": round(bandwidth_down / (1024**3), 4),
|
||||||
"total_bandwidth_gb": round((bandwidth_up + bandwidth_down) / (1024**3), 4)
|
"total_bandwidth_gb": round((bandwidth_up + bandwidth_down) / (1024**3), 4),
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
from .layer import CacheLayer, get_cache
|
||||||
|
|
||||||
|
__all__ = ["CacheLayer", "get_cache"]
|
||||||
Vendored
+264
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .manager import UserDatabaseManager, get_user_db_manager
|
||||||
|
|
||||||
|
__all__ = ["UserDatabaseManager", "get_user_db_manager"]
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+137
-66
@@ -7,7 +7,7 @@ for various legal policies required for a European cloud storage provider.
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
class LegalDocument(ABC):
|
class LegalDocument(ABC):
|
||||||
@@ -17,10 +17,13 @@ class LegalDocument(ABC):
|
|||||||
Provides common structure and methods for generating legal content.
|
Provides common structure and methods for generating legal content.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, company_name: str = "MyWebdav Technologies",
|
def __init__(
|
||||||
last_updated: str = None,
|
self,
|
||||||
contact_email: str = "legal@mywebdav.eu",
|
company_name: str = "MyWebdav Technologies",
|
||||||
website: str = "https://mywebdav.eu"):
|
last_updated: str = None,
|
||||||
|
contact_email: str = "legal@mywebdav.eu",
|
||||||
|
website: str = "https://mywebdav.eu",
|
||||||
|
):
|
||||||
self.company_name = company_name
|
self.company_name = company_name
|
||||||
self.last_updated = last_updated or datetime.now().strftime("%B %d, %Y")
|
self.last_updated = last_updated or datetime.now().strftime("%B %d, %Y")
|
||||||
self.contact_email = contact_email
|
self.contact_email = contact_email
|
||||||
@@ -50,48 +53,122 @@ class LegalDocument(ABC):
|
|||||||
return self.get_header() + self.get_content() + self.get_footer()
|
return self.get_header() + self.get_content() + self.get_footer()
|
||||||
|
|
||||||
def to_html(self) -> str:
|
def to_html(self) -> str:
|
||||||
"""Generate the complete document in HTML format."""
|
"""Generate the complete document as Jinja2 template extending base.html."""
|
||||||
# Content is already HTML, just wrap in basic HTML structure
|
|
||||||
html_content = self.get_content()
|
html_content = self.get_content()
|
||||||
html_content = f"""<html>
|
template_content = f"""{{% extends "base.html" %}}
|
||||||
<head>
|
|
||||||
<title>{self.title}</title>
|
{{% block title %}}{self.title} - MyWebdav{{% endblock %}}
|
||||||
<style>
|
|
||||||
body {{
|
{{% block description %}}{self.title} for MyWebdav cloud storage service.{{% endblock %}}
|
||||||
font-family: 'Times New Roman', serif;
|
|
||||||
line-height: 1.6;
|
{{% block extra_css %}}
|
||||||
max-width: 800px;
|
<style>
|
||||||
margin: 0 auto;
|
.legal-content {{
|
||||||
padding: 20px;
|
max-width: 900px;
|
||||||
color: #333;
|
margin: 0 auto;
|
||||||
}}
|
padding: 3rem 2rem;
|
||||||
h1, h2, h3 {{
|
background: white;
|
||||||
color: #2c3e50;
|
border-radius: 8px;
|
||||||
margin-top: 30px;
|
}}
|
||||||
}}
|
|
||||||
h1 {{ font-size: 2em; border-bottom: 2px solid #3498db; padding-bottom: 10px; }}
|
.legal-title {{
|
||||||
h2 {{ font-size: 1.5em; border-bottom: 1px solid #bdc3c7; padding-bottom: 5px; }}
|
font-size: 2.5rem;
|
||||||
ul {{ margin-left: 20px; }}
|
font-weight: 700;
|
||||||
li {{ margin-bottom: 8px; }}
|
color: #1565c0;
|
||||||
strong {{ color: #2c3e50; }}
|
margin-bottom: 1rem;
|
||||||
</style>
|
padding-bottom: 1rem;
|
||||||
</head>
|
border-bottom: 3px solid #1976d2;
|
||||||
<body>
|
}}
|
||||||
<h1>{self.title}</h1>
|
|
||||||
<p><em>Last Updated: {self.last_updated}</em></p>
|
.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}
|
{html_content}
|
||||||
<hr>
|
|
||||||
<h3>Contact Information</h3>
|
<div class="legal-contact">
|
||||||
<p>If you have any questions about this {self.title.lower()}, please contact us:</p>
|
<h3>Contact Information</h3>
|
||||||
<ul>
|
<p>If you have any questions about this {self.title.lower()}, please contact us:</p>
|
||||||
<li><strong>Email:</strong> <a href="mailto:{self.contact_email}">{self.contact_email}</a></li>
|
<ul>
|
||||||
<li><strong>Website:</strong> <a href="{self.website}">{self.website}</a></li>
|
<li><strong>Email:</strong> <a href="mailto:{self.contact_email}">{self.contact_email}</a></li>
|
||||||
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
|
<li><strong>Website:</strong> <a href="{self.website}">{self.website}</a></li>
|
||||||
</ul>
|
<li><strong>Address:</strong> MyWebdav Technologies, European Union</li>
|
||||||
<p>MyWebdav Technologies</p>
|
</ul>
|
||||||
</body>
|
</div>
|
||||||
</html>"""
|
</div>
|
||||||
return html_content
|
{{% endblock %}}
|
||||||
|
"""
|
||||||
|
return template_content
|
||||||
|
|
||||||
|
|
||||||
class PrivacyPolicy(LegalDocument):
|
class PrivacyPolicy(LegalDocument):
|
||||||
@@ -853,38 +930,32 @@ class ContactComplaintMechanism(LegalDocument):
|
|||||||
def get_all_legal_documents() -> Dict[str, LegalDocument]:
|
def get_all_legal_documents() -> Dict[str, LegalDocument]:
|
||||||
"""Return a dictionary of all legal document instances."""
|
"""Return a dictionary of all legal document instances."""
|
||||||
return {
|
return {
|
||||||
'privacy_policy': PrivacyPolicy(),
|
"privacy_policy": PrivacyPolicy(),
|
||||||
'terms_of_service': TermsOfService(),
|
"terms_of_service": TermsOfService(),
|
||||||
'security_policy': SecurityPolicy(),
|
"security_policy": SecurityPolicy(),
|
||||||
'cookie_policy': CookiePolicy(),
|
"cookie_policy": CookiePolicy(),
|
||||||
'data_processing_agreement': DataProcessingAgreement(),
|
"data_processing_agreement": DataProcessingAgreement(),
|
||||||
'compliance_statement': ComplianceStatement(),
|
"compliance_statement": ComplianceStatement(),
|
||||||
'data_portability_deletion_policy': DataPortabilityDeletionPolicy(),
|
"data_portability_deletion_policy": DataPortabilityDeletionPolicy(),
|
||||||
'contact_complaint_mechanism': ContactComplaintMechanism(),
|
"contact_complaint_mechanism": ContactComplaintMechanism(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_legal_documents(output_dir: str = "static/legal"):
|
def generate_legal_documents(template_dir: str = "mywebdav/templates/legal"):
|
||||||
"""Generate all legal documents as Markdown and HTML files."""
|
"""Generate all legal documents as Jinja2 templates."""
|
||||||
import os
|
import os
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
|
os.makedirs(template_dir, exist_ok=True)
|
||||||
|
|
||||||
documents = get_all_legal_documents()
|
documents = get_all_legal_documents()
|
||||||
|
|
||||||
for doc_name, doc in documents.items():
|
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_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:
|
with open(html_path, "w") as f:
|
||||||
f.write(doc.to_html())
|
f.write(doc.to_html())
|
||||||
|
|
||||||
print(f"Generated {md_filename} and {html_filename}")
|
print(f"Generated {html_filename}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+47
-15
@@ -2,17 +2,26 @@ import asyncio
|
|||||||
import aiosmtplib
|
import aiosmtplib
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
|
|
||||||
class EmailTask:
|
class EmailTask:
|
||||||
def __init__(self, to_email: str, subject: str, body: str, html: Optional[str] = None, **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
html: Optional[str] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
self.to_email = to_email
|
self.to_email = to_email
|
||||||
self.subject = subject
|
self.subject = subject
|
||||||
self.body = body
|
self.body = body
|
||||||
self.html = html
|
self.html = html
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
|
||||||
class EmailService:
|
class EmailService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.queue = asyncio.Queue()
|
self.queue = asyncio.Queue()
|
||||||
@@ -38,7 +47,14 @@ class EmailService:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def send_email(self, to_email: str, subject: str, body: str, html: Optional[str] = None, **kwargs):
|
async def send_email(
|
||||||
|
self,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
html: Optional[str] = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
"""Queue an email for sending"""
|
"""Queue an email for sending"""
|
||||||
task = EmailTask(to_email, subject, body, html, **kwargs)
|
task = EmailTask(to_email, subject, body, html, **kwargs)
|
||||||
await self.queue.put(task)
|
await self.queue.put(task)
|
||||||
@@ -60,22 +76,26 @@ class EmailService:
|
|||||||
|
|
||||||
async def _send_email_task(self, task: EmailTask):
|
async def _send_email_task(self, task: EmailTask):
|
||||||
"""Send a single email task"""
|
"""Send a single email task"""
|
||||||
if not settings.SMTP_HOST or not settings.SMTP_USERNAME or not settings.SMTP_PASSWORD:
|
if (
|
||||||
|
not settings.SMTP_HOST
|
||||||
|
or not settings.SMTP_USERNAME
|
||||||
|
or not settings.SMTP_PASSWORD
|
||||||
|
):
|
||||||
print("SMTP not configured, skipping email send")
|
print("SMTP not configured, skipping email send")
|
||||||
return
|
return
|
||||||
|
|
||||||
msg = MIMEMultipart('alternative')
|
msg = MIMEMultipart("alternative")
|
||||||
msg['From'] = settings.SMTP_SENDER_EMAIL
|
msg["From"] = settings.SMTP_SENDER_EMAIL
|
||||||
msg['To'] = task.to_email
|
msg["To"] = task.to_email
|
||||||
msg['Subject'] = task.subject
|
msg["Subject"] = task.subject
|
||||||
|
|
||||||
# Add text part
|
# Add text part
|
||||||
text_part = MIMEText(task.body, 'plain')
|
text_part = MIMEText(task.body, "plain")
|
||||||
msg.attach(text_part)
|
msg.attach(text_part)
|
||||||
|
|
||||||
# Add HTML part if provided
|
# Add HTML part if provided
|
||||||
if task.html:
|
if task.html:
|
||||||
html_part = MIMEText(task.html, 'html')
|
html_part = MIMEText(task.html, "html")
|
||||||
msg.attach(html_part)
|
msg.attach(html_part)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -86,7 +106,7 @@ class EmailService:
|
|||||||
port=settings.SMTP_PORT,
|
port=settings.SMTP_PORT,
|
||||||
username=settings.SMTP_USERNAME,
|
username=settings.SMTP_USERNAME,
|
||||||
password=settings.SMTP_PASSWORD,
|
password=settings.SMTP_PASSWORD,
|
||||||
use_tls=True
|
use_tls=True,
|
||||||
) as smtp:
|
) as smtp:
|
||||||
await smtp.send_message(msg)
|
await smtp.send_message(msg)
|
||||||
print(f"Email sent to {task.to_email}")
|
print(f"Email sent to {task.to_email}")
|
||||||
@@ -99,7 +119,10 @@ class EmailService:
|
|||||||
try:
|
try:
|
||||||
await smtp.starttls()
|
await smtp.starttls()
|
||||||
except Exception as tls_error:
|
except Exception as tls_error:
|
||||||
if "already using" in str(tls_error).lower() or "tls" in str(tls_error).lower():
|
if (
|
||||||
|
"already using" in str(tls_error).lower()
|
||||||
|
or "tls" in str(tls_error).lower()
|
||||||
|
):
|
||||||
# Connection is already using TLS, proceed without starttls
|
# Connection is already using TLS, proceed without starttls
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@@ -111,14 +134,23 @@ class EmailService:
|
|||||||
print(f"Failed to send email to {task.to_email}: {e}")
|
print(f"Failed to send email to {task.to_email}: {e}")
|
||||||
raise # Re-raise to let caller handle
|
raise # Re-raise to let caller handle
|
||||||
|
|
||||||
|
|
||||||
# Global email service instance
|
# Global email service instance
|
||||||
email_service = EmailService()
|
email_service = EmailService()
|
||||||
|
|
||||||
|
|
||||||
# Convenience functions
|
# Convenience functions
|
||||||
async def send_email(to_email: str, subject: str, body: str, html: Optional[str] = None, **kwargs):
|
async def send_email(
|
||||||
|
to_email: str, subject: str, body: str, html: Optional[str] = None, **kwargs
|
||||||
|
):
|
||||||
"""Send an email asynchronously"""
|
"""Send an email asynchronously"""
|
||||||
await email_service.send_email(to_email, subject, body, html, **kwargs)
|
await email_service.send_email(to_email, subject, body, html, **kwargs)
|
||||||
|
|
||||||
def queue_email(to_email: str, subject: str, body: str, html: Optional[str] = None, **kwargs):
|
|
||||||
|
def queue_email(
|
||||||
|
to_email: str, subject: str, body: str, html: Optional[str] = None, **kwargs
|
||||||
|
):
|
||||||
"""Queue an email for sending (fire and forget)"""
|
"""Queue an email for sending (fire and forget)"""
|
||||||
asyncio.create_task(email_service.send_email(to_email, subject, body, html, **kwargs))
|
asyncio.create_task(
|
||||||
|
email_service.send_email(to_email, subject, body, html, **kwargs)
|
||||||
|
)
|
||||||
|
|||||||
+293
-27
@@ -2,56 +2,270 @@ import argparse
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request, status, HTTPException
|
from fastapi import FastAPI, Request, HTTPException, status
|
||||||
from fastapi.staticfiles import StaticFiles
|
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 tortoise.contrib.fastapi import register_tortoise
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
from .routers import auth, users, folders, files, shares, search, admin, starred, billing, admin_billing
|
from .routers import (
|
||||||
|
auth,
|
||||||
|
users,
|
||||||
|
folders,
|
||||||
|
files,
|
||||||
|
shares,
|
||||||
|
search,
|
||||||
|
admin,
|
||||||
|
starred,
|
||||||
|
billing,
|
||||||
|
admin_billing,
|
||||||
|
manage,
|
||||||
|
)
|
||||||
from . import webdav
|
from . import webdav
|
||||||
from .schemas import ErrorResponse
|
from .schemas import ErrorResponse
|
||||||
|
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')
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Starting up...")
|
logger.info("Starting up...")
|
||||||
|
|
||||||
|
await init_enterprise_components()
|
||||||
|
logger.info("All enterprise components initialized")
|
||||||
|
|
||||||
logger.info("Database connected.")
|
logger.info("Database connected.")
|
||||||
from .billing.scheduler import start_scheduler
|
from .billing.scheduler import start_scheduler
|
||||||
from .billing.models import PricingConfig
|
from .billing.models import PricingConfig, SubscriptionPlan
|
||||||
from .mail import email_service
|
from .mail import email_service
|
||||||
|
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
logger.info("Billing scheduler started")
|
logger.info("Billing scheduler started")
|
||||||
await email_service.start()
|
await email_service.start()
|
||||||
logger.info("Email service started")
|
logger.info("Email service started")
|
||||||
pricing_count = await PricingConfig.all().count()
|
plan_count = await SubscriptionPlan.all().count()
|
||||||
if pricing_count == 0:
|
if plan_count == 0:
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
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')
|
# Create subscription plans
|
||||||
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 SubscriptionPlan.create(
|
||||||
await PricingConfig.create(config_key='free_tier_storage_gb', config_value=Decimal('15'), description='Free tier storage in GB', unit='gb')
|
name="starter",
|
||||||
await PricingConfig.create(config_key='free_tier_bandwidth_gb', config_value=Decimal('15'), description='Free tier bandwidth in GB per month', unit='gb')
|
display_name="Starter",
|
||||||
await PricingConfig.create(config_key='tax_rate_default', config_value=Decimal('0.0'), description='Default tax rate (0 = no tax)', unit='percentage')
|
description="Perfect for individuals and small projects",
|
||||||
logger.info("Default pricing configuration initialized")
|
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_starter",
|
||||||
|
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_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(
|
||||||
|
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="tax_rate_default",
|
||||||
|
config_value=Decimal("0.0"),
|
||||||
|
description="Default tax rate (0 = no tax)",
|
||||||
|
unit="percentage",
|
||||||
|
)
|
||||||
|
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
|
yield
|
||||||
|
|
||||||
from .billing.scheduler import stop_scheduler
|
from .billing.scheduler import stop_scheduler
|
||||||
|
|
||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
logger.info("Billing scheduler stopped")
|
logger.info("Billing scheduler stopped")
|
||||||
await email_service.stop()
|
await email_service.stop()
|
||||||
logger.info("Email service stopped")
|
logger.info("Email service stopped")
|
||||||
|
|
||||||
|
await shutdown_enterprise_components()
|
||||||
|
logger.info("All enterprise components shut down")
|
||||||
print("Shutting down...")
|
print("Shutting down...")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="MyWebdav Cloud Storage",
|
title="MyWebdav Cloud Storage",
|
||||||
description="A commercial cloud storage web application",
|
description="A commercial cloud storage web application",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="mywebdav/templates")
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(folders.router)
|
app.include_router(folders.router)
|
||||||
@@ -62,10 +276,12 @@ app.include_router(admin.router)
|
|||||||
app.include_router(starred.router)
|
app.include_router(starred.router)
|
||||||
app.include_router(billing.router)
|
app.include_router(billing.router)
|
||||||
app.include_router(admin_billing.router)
|
app.include_router(admin_billing.router)
|
||||||
|
app.include_router(manage.router)
|
||||||
app.include_router(webdav.router)
|
app.include_router(webdav.router)
|
||||||
|
app.include_router(health_router)
|
||||||
|
|
||||||
from .middleware.usage_tracking import UsageTrackingMiddleware
|
app.add_middleware(SecurityHeadersMiddleware, enable_hsts=False, enable_csp=True)
|
||||||
|
app.add_middleware(RateLimitMiddleware)
|
||||||
app.add_middleware(UsageTrackingMiddleware)
|
app.add_middleware(UsageTrackingMiddleware)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
@@ -73,35 +289,85 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
|||||||
register_tortoise(
|
register_tortoise(
|
||||||
app,
|
app,
|
||||||
db_url=settings.DATABASE_URL,
|
db_url=settings.DATABASE_URL,
|
||||||
modules={
|
modules={"models": ["mywebdav.models"], "billing": ["mywebdav.billing.models"]},
|
||||||
"models": ["mywebdav.models"],
|
|
||||||
"billing": ["mywebdav.billing.models"]
|
|
||||||
},
|
|
||||||
generate_schemas=True,
|
generate_schemas=True,
|
||||||
add_exception_handlers=True,
|
add_exception_handlers=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
logger.error(f"HTTPException: {exc.status_code} - {exc.detail} for URL: {request.url}")
|
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(
|
return JSONResponse(
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
content=ErrorResponse(code=exc.status_code, message=exc.detail).dict(),
|
content=ErrorResponse(code=exc.status_code, message=exc.detail).model_dump(),
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse) # Change response_class to HTMLResponse
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def read_root():
|
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:
|
with open("static/index.html", "r") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Run the MyWebdav application.")
|
parser = argparse.ArgumentParser(description="Run the MyWebdav application.")
|
||||||
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host address to bind to")
|
parser.add_argument(
|
||||||
parser.add_argument("--port", type=int, default=8000, help="Port to listen on")
|
"--host", type=str, default="0.0.0.0", help="Host address to bind to"
|
||||||
|
)
|
||||||
|
parser.add_argument("--port", type=int, default=9004, help="Port to listen on")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
uvicorn.run(app, host=args.host, port=args.port)
|
uvicorn.run(app, host=args.host, port=args.port)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .usage_tracking import UsageTrackingMiddleware
|
||||||
|
from .rate_limit import RateLimitMiddleware
|
||||||
|
from .security import SecurityHeadersMiddleware
|
||||||
|
|
||||||
|
__all__ = ["UsageTrackingMiddleware", "RateLimitMiddleware", "SecurityHeadersMiddleware"]
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -1,32 +1,41 @@
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
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):
|
class UsageTrackingMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
response = await call_next(request)
|
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
|
user = request.state.user
|
||||||
|
|
||||||
if request.method in ['POST', 'PUT'] and '/files/upload' in request.url.path:
|
if (
|
||||||
content_length = response.headers.get('content-length')
|
request.method in ["POST", "PUT"]
|
||||||
|
and "/files/upload" in request.url.path
|
||||||
|
):
|
||||||
|
content_length = response.headers.get("content-length")
|
||||||
if content_length:
|
if content_length:
|
||||||
await UsageTracker.track_bandwidth(
|
await UsageTracker.track_bandwidth(
|
||||||
user=user,
|
user=user,
|
||||||
amount_bytes=int(content_length),
|
amount_bytes=int(content_length),
|
||||||
direction='up',
|
direction="up",
|
||||||
metadata={'path': request.url.path}
|
metadata={"path": request.url.path},
|
||||||
)
|
)
|
||||||
|
|
||||||
elif request.method == 'GET' and '/files/download' in request.url.path:
|
elif request.method == "GET" and "/files/download" in request.url.path:
|
||||||
content_length = response.headers.get('content-length')
|
content_length = response.headers.get("content-length")
|
||||||
if content_length:
|
if content_length:
|
||||||
await UsageTracker.track_bandwidth(
|
await UsageTracker.track_bandwidth(
|
||||||
user=user,
|
user=user,
|
||||||
amount_bytes=int(content_length),
|
amount_bytes=int(content_length),
|
||||||
direction='down',
|
direction="down",
|
||||||
metadata={'path': request.url.path}
|
metadata={"path": request.url.path},
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
+88
-40
@@ -1,19 +1,21 @@
|
|||||||
from tortoise import fields, models
|
from tortoise import fields, models
|
||||||
from tortoise.contrib.pydantic import pydantic_model_creator
|
from tortoise.contrib.pydantic import pydantic_model_creator
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class User(models.Model):
|
class User(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
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)
|
email = fields.CharField(max_length=255, unique=True)
|
||||||
hashed_password = fields.CharField(max_length=255)
|
hashed_password = fields.CharField(max_length=255)
|
||||||
is_active = fields.BooleanField(default=True)
|
is_active = fields.BooleanField(default=True)
|
||||||
is_superuser = fields.BooleanField(default=False)
|
is_superuser = fields.BooleanField(default=False)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
storage_quota_bytes = fields.BigIntField(default=10 * 1024 * 1024 * 1024) # 10 GB default
|
storage_quota_bytes = fields.BigIntField(
|
||||||
|
default=10 * 1024 * 1024 * 1024
|
||||||
|
)
|
||||||
used_storage_bytes = fields.BigIntField(default=0)
|
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)
|
two_factor_secret = fields.CharField(max_length=255, null=True)
|
||||||
is_2fa_enabled = fields.BooleanField(default=False)
|
is_2fa_enabled = fields.BooleanField(default=False)
|
||||||
recovery_codes = fields.TextField(null=True)
|
recovery_codes = fields.TextField(null=True)
|
||||||
@@ -24,11 +26,16 @@ class User(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
|
||||||
class Folder(models.Model):
|
class Folder(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
name = fields.CharField(max_length=255)
|
name = fields.CharField(max_length=255)
|
||||||
parent: fields.ForeignKeyRelation["Folder"] = fields.ForeignKeyField("models.Folder", related_name="children", null=True)
|
parent: fields.ForeignKeyRelation["Folder"] = fields.ForeignKeyField(
|
||||||
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="folders")
|
"models.Folder", related_name="children", null=True
|
||||||
|
)
|
||||||
|
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
|
||||||
|
"models.User", related_name="folders"
|
||||||
|
)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
is_deleted = fields.BooleanField(default=False)
|
is_deleted = fields.BooleanField(default=False)
|
||||||
@@ -36,21 +43,28 @@ class Folder(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "folders"
|
table = "folders"
|
||||||
unique_together = (("name", "parent", "owner"),) # Ensure unique folder names within a parent for an owner
|
unique_together = (
|
||||||
|
("name", "parent", "owner"),
|
||||||
|
) # Ensure unique folder names within a parent for an owner
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class File(models.Model):
|
class File(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
name = fields.CharField(max_length=255)
|
name = fields.CharField(max_length=255)
|
||||||
path = fields.CharField(max_length=1024) # Internal storage path
|
path = fields.CharField(max_length=1024) # Internal storage path
|
||||||
size = fields.BigIntField()
|
size = fields.BigIntField()
|
||||||
mime_type = fields.CharField(max_length=255)
|
mime_type = fields.CharField(max_length=255)
|
||||||
file_hash = fields.CharField(max_length=64, null=True) # SHA-256
|
file_hash = fields.CharField(max_length=64, null=True) # SHA-256
|
||||||
thumbnail_path = fields.CharField(max_length=1024, null=True)
|
thumbnail_path = fields.CharField(max_length=1024, null=True)
|
||||||
parent: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField("models.Folder", related_name="files", null=True)
|
parent: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField(
|
||||||
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="files")
|
"models.Folder", related_name="files", null=True
|
||||||
|
)
|
||||||
|
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
|
||||||
|
"models.User", related_name="files"
|
||||||
|
)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
is_deleted = fields.BooleanField(default=False)
|
is_deleted = fields.BooleanField(default=False)
|
||||||
@@ -60,14 +74,19 @@ class File(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "files"
|
table = "files"
|
||||||
unique_together = (("name", "parent", "owner"),) # Ensure unique file names within a parent for an owner
|
unique_together = (
|
||||||
|
("name", "parent", "owner"),
|
||||||
|
) # Ensure unique file names within a parent for an owner
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class FileVersion(models.Model):
|
class FileVersion(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
file: fields.ForeignKeyRelation[File] = fields.ForeignKeyField("models.File", related_name="versions")
|
file: fields.ForeignKeyRelation[File] = fields.ForeignKeyField(
|
||||||
|
"models.File", related_name="versions"
|
||||||
|
)
|
||||||
version_path = fields.CharField(max_length=1024)
|
version_path = fields.CharField(max_length=1024)
|
||||||
size = fields.BigIntField()
|
size = fields.BigIntField()
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
@@ -75,61 +94,86 @@ class FileVersion(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
table = "file_versions"
|
table = "file_versions"
|
||||||
|
|
||||||
|
|
||||||
class Share(models.Model):
|
class Share(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
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)
|
file: fields.ForeignKeyRelation[File] = fields.ForeignKeyField(
|
||||||
folder: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField("models.Folder", related_name="shares", null=True)
|
"models.File", related_name="shares", null=True
|
||||||
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="shares")
|
)
|
||||||
|
folder: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField(
|
||||||
|
"models.Folder", related_name="shares", null=True
|
||||||
|
)
|
||||||
|
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
|
||||||
|
"models.User", related_name="shares"
|
||||||
|
)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
expires_at = fields.DatetimeField(null=True)
|
expires_at = fields.DatetimeField(null=True)
|
||||||
password_protected = fields.BooleanField(default=False)
|
password_protected = fields.BooleanField(default=False)
|
||||||
hashed_password = fields.CharField(max_length=255, null=True)
|
hashed_password = fields.CharField(max_length=255, null=True)
|
||||||
access_count = fields.IntField(default=0)
|
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:
|
class Meta:
|
||||||
table = "shares"
|
table = "shares"
|
||||||
|
|
||||||
|
|
||||||
class Team(models.Model):
|
class Team(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
name = fields.CharField(max_length=255, unique=True)
|
name = fields.CharField(max_length=255, unique=True)
|
||||||
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="owned_teams")
|
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
|
||||||
members: fields.ManyToManyRelation[User] = fields.ManyToManyField("models.User", related_name="teams", through="team_members")
|
"models.User", related_name="owned_teams"
|
||||||
|
)
|
||||||
|
members: fields.ManyToManyRelation[User] = fields.ManyToManyField(
|
||||||
|
"models.User", related_name="teams", through="team_members"
|
||||||
|
)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "teams"
|
table = "teams"
|
||||||
|
|
||||||
|
|
||||||
class TeamMember(models.Model):
|
class TeamMember(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
team: fields.ForeignKeyRelation[Team] = fields.ForeignKeyField("models.Team", related_name="team_members")
|
team: fields.ForeignKeyRelation[Team] = fields.ForeignKeyField(
|
||||||
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="user_teams")
|
"models.Team", related_name="team_members"
|
||||||
role = fields.CharField(max_length=50, default="member") # owner, admin, member
|
)
|
||||||
|
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
|
||||||
|
"models.User", related_name="user_teams"
|
||||||
|
)
|
||||||
|
role = fields.CharField(max_length=100, default="member")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "team_members"
|
table = "team_members"
|
||||||
unique_together = (("team", "user"),)
|
unique_together = (("team", "user"),)
|
||||||
|
|
||||||
|
|
||||||
class Activity(models.Model):
|
class Activity(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="activities", null=True)
|
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
|
||||||
|
"models.User", related_name="activities", null=True
|
||||||
|
)
|
||||||
action = fields.CharField(max_length=255)
|
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()
|
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)
|
timestamp = fields.DatetimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "activities"
|
table = "activities"
|
||||||
|
|
||||||
|
|
||||||
class FileRequest(models.Model):
|
class FileRequest(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
title = fields.CharField(max_length=255)
|
title = fields.CharField(max_length=255)
|
||||||
description = fields.TextField(null=True)
|
description = fields.TextField(null=True)
|
||||||
token = fields.CharField(max_length=64, unique=True)
|
token = fields.CharField(max_length=64, unique=True)
|
||||||
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="file_requests")
|
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
|
||||||
target_folder: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField("models.Folder", related_name="file_requests")
|
"models.User", related_name="file_requests"
|
||||||
|
)
|
||||||
|
target_folder: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField(
|
||||||
|
"models.Folder", related_name="file_requests"
|
||||||
|
)
|
||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
expires_at = fields.DatetimeField(null=True)
|
expires_at = fields.DatetimeField(null=True)
|
||||||
is_active = fields.BooleanField(default=True)
|
is_active = fields.BooleanField(default=True)
|
||||||
@@ -137,9 +181,10 @@ class FileRequest(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
table = "file_requests"
|
table = "file_requests"
|
||||||
|
|
||||||
|
|
||||||
class WebDAVProperty(models.Model):
|
class WebDAVProperty(models.Model):
|
||||||
id = fields.IntField(pk=True)
|
id = fields.IntField(primary_key=True)
|
||||||
resource_type = fields.CharField(max_length=10)
|
resource_type = fields.CharField(max_length=100)
|
||||||
resource_id = fields.IntField()
|
resource_id = fields.IntField()
|
||||||
namespace = fields.CharField(max_length=255)
|
namespace = fields.CharField(max_length=255)
|
||||||
name = fields.CharField(max_length=255)
|
name = fields.CharField(max_length=255)
|
||||||
@@ -151,5 +196,8 @@ class WebDAVProperty(models.Model):
|
|||||||
table = "webdav_properties"
|
table = "webdav_properties"
|
||||||
unique_together = (("resource_type", "resource_id", "namespace", "name"),)
|
unique_together = (("resource_type", "resource_id", "namespace", "name"),)
|
||||||
|
|
||||||
|
|
||||||
User_Pydantic = pydantic_model_creator(User, name="User_Pydantic")
|
User_Pydantic = pydantic_model_creator(User, name="User_Pydantic")
|
||||||
UserIn_Pydantic = pydantic_model_creator(User, name="UserIn_Pydantic", exclude_readonly=True)
|
UserIn_Pydantic = pydantic_model_creator(
|
||||||
|
User, name="UserIn_Pydantic", exclude_readonly=True
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .health import router as health_router
|
||||||
|
|
||||||
|
__all__ = ["health_router"]
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
+32
-10
@@ -1,4 +1,4 @@
|
|||||||
from typing import List, Optional
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
@@ -13,18 +13,25 @@ router = APIRouter(
|
|||||||
responses={403: {"description": "Not enough permissions"}},
|
responses={403: {"description": "Not enough permissions"}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=List[User_Pydantic])
|
@router.get("/users", response_model=List[User_Pydantic])
|
||||||
async def get_all_users():
|
async def get_all_users():
|
||||||
return await User.all()
|
return await User.all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{user_id}", response_model=User_Pydantic)
|
@router.get("/users/{user_id}", response_model=User_Pydantic)
|
||||||
async def get_user(user_id: int):
|
async def get_user(user_id: int):
|
||||||
user = await User.get_or_none(id=user_id)
|
user = await User.get_or_none(id=user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||||
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@router.post("/users", response_model=User_Pydantic, status_code=status.HTTP_201_CREATED)
|
|
||||||
|
@router.post(
|
||||||
|
"/users", response_model=User_Pydantic, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
async def create_user_by_admin(user_in: UserCreate):
|
async def create_user_by_admin(user_in: UserCreate):
|
||||||
user = await User.get_or_none(username=user_in.username)
|
user = await User.get_or_none(username=user_in.username)
|
||||||
if user:
|
if user:
|
||||||
@@ -44,25 +51,33 @@ async def create_user_by_admin(user_in: UserCreate):
|
|||||||
username=user_in.username,
|
username=user_in.username,
|
||||||
email=user_in.email,
|
email=user_in.email,
|
||||||
hashed_password=hashed_password,
|
hashed_password=hashed_password,
|
||||||
is_superuser=False, # Admin creates regular users by default
|
is_superuser=False, # Admin creates regular users by default
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
return await User_Pydantic.from_tortoise_orm(user)
|
return await User_Pydantic.from_tortoise_orm(user)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/users/{user_id}", response_model=User_Pydantic)
|
@router.put("/users/{user_id}", response_model=User_Pydantic)
|
||||||
async def update_user_by_admin(user_id: int, user_update: UserAdminUpdate):
|
async def update_user_by_admin(user_id: int, user_update: UserAdminUpdate):
|
||||||
user = await User.get_or_none(id=user_id)
|
user = await User.get_or_none(id=user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||||
|
)
|
||||||
|
|
||||||
if user_update.username is not None and user_update.username != user.username:
|
if user_update.username is not None and user_update.username != user.username:
|
||||||
if await User.get_or_none(username=user_update.username):
|
if await User.get_or_none(username=user_update.username):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken"
|
||||||
|
)
|
||||||
user.username = user_update.username
|
user.username = user_update.username
|
||||||
|
|
||||||
if user_update.email is not None and user_update.email != user.email:
|
if user_update.email is not None and user_update.email != user.email:
|
||||||
if await User.get_or_none(email=user_update.email):
|
if await User.get_or_none(email=user_update.email):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already registered",
|
||||||
|
)
|
||||||
user.email = user_update.email
|
user.email = user_update.email
|
||||||
|
|
||||||
if user_update.password is not None:
|
if user_update.password is not None:
|
||||||
@@ -89,21 +104,28 @@ async def update_user_by_admin(user_id: int, user_update: UserAdminUpdate):
|
|||||||
await user.save()
|
await user.save()
|
||||||
return await User_Pydantic.from_tortoise_orm(user)
|
return await User_Pydantic.from_tortoise_orm(user)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_user_by_admin(user_id: int):
|
async def delete_user_by_admin(user_id: int):
|
||||||
user = await User.get_or_none(id=user_id)
|
user = await User.get_or_none(id=user_id)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||||
|
)
|
||||||
await user.delete()
|
await user.delete()
|
||||||
return {"message": "User deleted successfully"}
|
return {"message": "User deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-email")
|
@router.post("/test-email")
|
||||||
async def send_test_email(to_email: str, subject: str = "Test Email", body: str = "This is a test email"):
|
async def send_test_email(
|
||||||
|
to_email: str, subject: str = "Test Email", body: str = "This is a test email"
|
||||||
|
):
|
||||||
from ..mail import queue_email
|
from ..mail import queue_email
|
||||||
|
|
||||||
queue_email(
|
queue_email(
|
||||||
to_email=to_email,
|
to_email=to_email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
body=body,
|
||||||
html=f"<h1>{subject}</h1><p>{body}</p>"
|
html=f"<h1>{subject}</h1><p>{body}</p>",
|
||||||
)
|
)
|
||||||
return {"message": "Test email queued"}
|
return {"message": "Test email queued"}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from typing import List
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -8,21 +7,25 @@ from ..models import User
|
|||||||
from ..billing.models import PricingConfig, Invoice, SubscriptionPlan
|
from ..billing.models import PricingConfig, Invoice, SubscriptionPlan
|
||||||
from ..billing.invoice_generator import InvoiceGenerator
|
from ..billing.invoice_generator import InvoiceGenerator
|
||||||
|
|
||||||
|
|
||||||
def require_superuser(current_user: User = Depends(get_current_user)):
|
def require_superuser(current_user: User = Depends(get_current_user)):
|
||||||
if not current_user.is_superuser:
|
if not current_user.is_superuser:
|
||||||
raise HTTPException(status_code=403, detail="Superuser privileges required")
|
raise HTTPException(status_code=403, detail="Superuser privileges required")
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/admin/billing",
|
prefix="/api/admin/billing",
|
||||||
tags=["admin", "billing"],
|
tags=["admin", "billing"],
|
||||||
dependencies=[Depends(require_superuser)]
|
dependencies=[Depends(require_superuser)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PricingConfigUpdate(BaseModel):
|
class PricingConfigUpdate(BaseModel):
|
||||||
config_key: str
|
config_key: str
|
||||||
config_value: float
|
config_value: float
|
||||||
|
|
||||||
|
|
||||||
class PlanCreate(BaseModel):
|
class PlanCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
@@ -32,6 +35,7 @@ class PlanCreate(BaseModel):
|
|||||||
price_monthly: float
|
price_monthly: float
|
||||||
price_yearly: float = None
|
price_yearly: float = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pricing")
|
@router.get("/pricing")
|
||||||
async def get_all_pricing(current_user: User = Depends(require_superuser)):
|
async def get_all_pricing(current_user: User = Depends(require_superuser)):
|
||||||
configs = await PricingConfig.all()
|
configs = await PricingConfig.all()
|
||||||
@@ -42,16 +46,17 @@ async def get_all_pricing(current_user: User = Depends(require_superuser)):
|
|||||||
"config_value": float(c.config_value),
|
"config_value": float(c.config_value),
|
||||||
"description": c.description,
|
"description": c.description,
|
||||||
"unit": c.unit,
|
"unit": c.unit,
|
||||||
"updated_at": c.updated_at
|
"updated_at": c.updated_at,
|
||||||
}
|
}
|
||||||
for c in configs
|
for c in configs
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.put("/pricing/{config_id}")
|
@router.put("/pricing/{config_id}")
|
||||||
async def update_pricing(
|
async def update_pricing(
|
||||||
config_id: int,
|
config_id: int,
|
||||||
update: PricingConfigUpdate,
|
update: PricingConfigUpdate,
|
||||||
current_user: User = Depends(require_superuser)
|
current_user: User = Depends(require_superuser),
|
||||||
):
|
):
|
||||||
config = await PricingConfig.get_or_none(id=config_id)
|
config = await PricingConfig.get_or_none(id=config_id)
|
||||||
if not config:
|
if not config:
|
||||||
@@ -63,11 +68,10 @@ async def update_pricing(
|
|||||||
|
|
||||||
return {"message": "Pricing updated successfully"}
|
return {"message": "Pricing updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate-invoices/{year}/{month}")
|
@router.post("/generate-invoices/{year}/{month}")
|
||||||
async def generate_all_invoices(
|
async def generate_all_invoices(
|
||||||
year: int,
|
year: int, month: int, current_user: User = Depends(require_superuser)
|
||||||
month: int,
|
|
||||||
current_user: User = Depends(require_superuser)
|
|
||||||
):
|
):
|
||||||
users = await User.filter(is_active=True).all()
|
users = await User.filter(is_active=True).all()
|
||||||
generated = []
|
generated = []
|
||||||
@@ -76,35 +80,36 @@ async def generate_all_invoices(
|
|||||||
for user in users:
|
for user in users:
|
||||||
invoice = await InvoiceGenerator.generate_monthly_invoice(user, year, month)
|
invoice = await InvoiceGenerator.generate_monthly_invoice(user, year, month)
|
||||||
if invoice:
|
if invoice:
|
||||||
generated.append({
|
generated.append(
|
||||||
"user_id": user.id,
|
{
|
||||||
"invoice_id": invoice.id,
|
"user_id": user.id,
|
||||||
"total": float(invoice.total)
|
"invoice_id": invoice.id,
|
||||||
})
|
"total": float(invoice.total),
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
skipped.append(user.id)
|
skipped.append(user.id)
|
||||||
|
|
||||||
return {
|
return {"generated": len(generated), "skipped": len(skipped), "invoices": generated}
|
||||||
"generated": len(generated),
|
|
||||||
"skipped": len(skipped),
|
|
||||||
"invoices": generated
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/plans")
|
@router.post("/plans")
|
||||||
async def create_plan(
|
async def create_plan(
|
||||||
plan_data: PlanCreate,
|
plan_data: PlanCreate, current_user: User = Depends(require_superuser)
|
||||||
current_user: User = Depends(require_superuser)
|
|
||||||
):
|
):
|
||||||
plan = await SubscriptionPlan.create(**plan_data.dict())
|
plan = await SubscriptionPlan.create(**plan_data.dict())
|
||||||
return {"id": plan.id, "message": "Plan created successfully"}
|
return {"id": plan.id, "message": "Plan created successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_billing_stats(current_user: User = Depends(require_superuser)):
|
async def get_billing_stats(current_user: User = Depends(require_superuser)):
|
||||||
from tortoise.functions import Sum, Count
|
from tortoise.functions import Sum
|
||||||
|
|
||||||
total_revenue = await Invoice.filter(status="paid").annotate(
|
total_revenue = (
|
||||||
total_sum=Sum("total")
|
await Invoice.filter(status="paid")
|
||||||
).values("total_sum")
|
.annotate(total_sum=Sum("total"))
|
||||||
|
.values("total_sum")
|
||||||
|
)
|
||||||
|
|
||||||
invoice_count = await Invoice.all().count()
|
invoice_count = await Invoice.all().count()
|
||||||
pending_invoices = await Invoice.filter(status="open").count()
|
pending_invoices = await Invoice.filter(status="open").count()
|
||||||
@@ -112,5 +117,5 @@ async def get_billing_stats(current_user: User = Depends(require_superuser)):
|
|||||||
return {
|
return {
|
||||||
"total_revenue": float(total_revenue[0]["total_sum"] or 0),
|
"total_revenue": float(total_revenue[0]["total_sum"] or 0),
|
||||||
"total_invoices": invoice_count,
|
"total_invoices": invoice_count,
|
||||||
"pending_invoices": pending_invoices
|
"pending_invoices": pending_invoices,
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-27
@@ -4,13 +4,23 @@ from typing import Optional, List
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ..auth import authenticate_user, create_access_token, get_password_hash, get_current_user, get_current_verified_user, verify_password
|
from ..auth import (
|
||||||
|
authenticate_user,
|
||||||
|
create_access_token,
|
||||||
|
get_password_hash,
|
||||||
|
get_current_user,
|
||||||
|
get_current_verified_user,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..schemas import Token, UserCreate, TokenData, UserLoginWith2FA
|
from ..schemas import Token, UserCreate
|
||||||
from ..two_factor import (
|
from ..two_factor import (
|
||||||
generate_totp_secret, generate_totp_uri, generate_qr_code_base64,
|
generate_totp_secret,
|
||||||
verify_totp_code, generate_recovery_codes, hash_recovery_codes,
|
generate_totp_uri,
|
||||||
verify_recovery_codes
|
generate_qr_code_base64,
|
||||||
|
verify_totp_code,
|
||||||
|
generate_recovery_codes,
|
||||||
|
hash_recovery_codes,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -18,27 +28,33 @@ router = APIRouter(
|
|||||||
tags=["auth"],
|
tags=["auth"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class TwoFactorLogin(BaseModel):
|
class TwoFactorLogin(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
two_factor_code: Optional[str] = None
|
two_factor_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TwoFactorSetupResponse(BaseModel):
|
class TwoFactorSetupResponse(BaseModel):
|
||||||
secret: str
|
secret: str
|
||||||
qr_code_base64: str
|
qr_code_base64: str
|
||||||
recovery_codes: List[str]
|
recovery_codes: List[str]
|
||||||
|
|
||||||
|
|
||||||
class TwoFactorCode(BaseModel):
|
class TwoFactorCode(BaseModel):
|
||||||
two_factor_code: str
|
two_factor_code: str
|
||||||
|
|
||||||
|
|
||||||
class TwoFactorDisable(BaseModel):
|
class TwoFactorDisable(BaseModel):
|
||||||
password: str
|
password: str
|
||||||
two_factor_code: str
|
two_factor_code: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=Token)
|
@router.post("/register", response_model=Token)
|
||||||
async def register_user(user_in: UserCreate):
|
async def register_user(user_in: UserCreate):
|
||||||
user = await User.get_or_none(username=user_in.username)
|
user = await User.get_or_none(username=user_in.username)
|
||||||
@@ -63,22 +79,26 @@ async def register_user(user_in: UserCreate):
|
|||||||
|
|
||||||
# Send welcome email
|
# Send welcome email
|
||||||
from ..mail import queue_email
|
from ..mail import queue_email
|
||||||
|
|
||||||
queue_email(
|
queue_email(
|
||||||
to_email=user.email,
|
to_email=user.email,
|
||||||
subject="Welcome to MyWebdav!",
|
subject="Welcome to MyWebdav!",
|
||||||
body=f"Hi {user.username},\n\nWelcome to MyWebdav! Your account has been created successfully.\n\nBest regards,\nThe MyWebdav Team",
|
body=f"Hi {user.username},\n\nWelcome to MyWebdav! Your account has been created successfully.\n\nBest regards,\nThe MyWebdav Team",
|
||||||
html=f"<h1>Welcome to MyWebdav!</h1><p>Hi {user.username},</p><p>Welcome to MyWebdav! Your account has been created successfully.</p><p>Best regards,<br>The MyWebdav Team</p>"
|
html=f"<h1>Welcome to MyWebdav!</h1><p>Hi {user.username},</p><p>Welcome to MyWebdav! Your account has been created successfully.</p><p>Best regards,<br>The MyWebdav Team</p>",
|
||||||
)
|
)
|
||||||
|
|
||||||
access_token_expires = timedelta(minutes=30) # Use settings
|
access_token_expires = timedelta(minutes=30) # Use settings
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"sub": user.username}, expires_delta=access_token_expires
|
data={"sub": user.username}, expires_delta=access_token_expires
|
||||||
)
|
)
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/token", response_model=Token)
|
@router.post("/token", response_model=Token)
|
||||||
async def login_for_access_token(login_data: LoginRequest):
|
async def login_for_access_token(login_data: LoginRequest):
|
||||||
auth_result = await authenticate_user(login_data.username, login_data.password, None)
|
auth_result = await authenticate_user(
|
||||||
|
login_data.username, login_data.password, None
|
||||||
|
)
|
||||||
|
|
||||||
if not auth_result:
|
if not auth_result:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -97,16 +117,26 @@ async def login_for_access_token(login_data: LoginRequest):
|
|||||||
|
|
||||||
access_token_expires = timedelta(minutes=30)
|
access_token_expires = timedelta(minutes=30)
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"sub": user.username}, expires_delta=access_token_expires, two_factor_verified=True
|
data={"sub": user.username},
|
||||||
|
expires_delta=access_token_expires,
|
||||||
|
two_factor_verified=True,
|
||||||
)
|
)
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
|
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
|
||||||
async def setup_two_factor_authentication(current_user: User = Depends(get_current_user)):
|
async def setup_two_factor_authentication(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
if current_user.is_2fa_enabled:
|
if current_user.is_2fa_enabled:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled.")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled."
|
||||||
|
)
|
||||||
if current_user.two_factor_secret:
|
if current_user.two_factor_secret:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA setup already initiated. Verify or disable first.")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="2FA setup already initiated. Verify or disable first.",
|
||||||
|
)
|
||||||
|
|
||||||
secret = generate_totp_secret()
|
secret = generate_totp_secret()
|
||||||
current_user.two_factor_secret = secret
|
current_user.two_factor_secret = secret
|
||||||
@@ -120,39 +150,66 @@ async def setup_two_factor_authentication(current_user: User = Depends(get_curre
|
|||||||
current_user.recovery_codes = ",".join(hashed_recovery_codes)
|
current_user.recovery_codes = ",".join(hashed_recovery_codes)
|
||||||
await current_user.save()
|
await current_user.save()
|
||||||
|
|
||||||
return TwoFactorSetupResponse(secret=secret, qr_code_base64=qr_code_base64, recovery_codes=recovery_codes)
|
return TwoFactorSetupResponse(
|
||||||
|
secret=secret, qr_code_base64=qr_code_base64, recovery_codes=recovery_codes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/2fa/verify", response_model=Token)
|
@router.post("/2fa/verify", response_model=Token)
|
||||||
async def verify_two_factor_authentication(two_factor_code_data: TwoFactorCode, current_user: User = Depends(get_current_user)):
|
async def verify_two_factor_authentication(
|
||||||
|
two_factor_code_data: TwoFactorCode, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
if current_user.is_2fa_enabled:
|
if current_user.is_2fa_enabled:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled.")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled."
|
||||||
|
)
|
||||||
if not current_user.two_factor_secret:
|
if not current_user.two_factor_secret:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA setup not initiated.")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="2FA setup not initiated."
|
||||||
|
)
|
||||||
|
|
||||||
if not verify_totp_code(current_user.two_factor_secret, two_factor_code_data.two_factor_code):
|
if not verify_totp_code(
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code.")
|
current_user.two_factor_secret, two_factor_code_data.two_factor_code
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code."
|
||||||
|
)
|
||||||
|
|
||||||
current_user.is_2fa_enabled = True
|
current_user.is_2fa_enabled = True
|
||||||
await current_user.save()
|
await current_user.save()
|
||||||
|
|
||||||
access_token_expires = timedelta(minutes=30) # Use settings
|
access_token_expires = timedelta(minutes=30) # Use settings
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"sub": current_user.username}, expires_delta=access_token_expires, two_factor_verified=True
|
data={"sub": current_user.username},
|
||||||
|
expires_delta=access_token_expires,
|
||||||
|
two_factor_verified=True,
|
||||||
)
|
)
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/2fa/disable", response_model=dict)
|
@router.post("/2fa/disable", response_model=dict)
|
||||||
async def disable_two_factor_authentication(disable_data: TwoFactorDisable, current_user: User = Depends(get_current_verified_user)):
|
async def disable_two_factor_authentication(
|
||||||
|
disable_data: TwoFactorDisable,
|
||||||
|
current_user: User = Depends(get_current_verified_user),
|
||||||
|
):
|
||||||
if not current_user.is_2fa_enabled:
|
if not current_user.is_2fa_enabled:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled.")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled."
|
||||||
|
)
|
||||||
|
|
||||||
# Verify password
|
# Verify password
|
||||||
if not verify_password(disable_data.password, current_user.hashed_password):
|
if not verify_password(disable_data.password, current_user.hashed_password):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password.")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password."
|
||||||
|
)
|
||||||
|
|
||||||
# Verify 2FA code
|
# Verify 2FA code
|
||||||
if not verify_totp_code(current_user.two_factor_secret, disable_data.two_factor_code):
|
if not verify_totp_code(
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code.")
|
current_user.two_factor_secret, disable_data.two_factor_code
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code."
|
||||||
|
)
|
||||||
|
|
||||||
current_user.two_factor_secret = None
|
current_user.two_factor_secret = None
|
||||||
current_user.is_2fa_enabled = False
|
current_user.is_2fa_enabled = False
|
||||||
@@ -161,10 +218,15 @@ async def disable_two_factor_authentication(disable_data: TwoFactorDisable, curr
|
|||||||
|
|
||||||
return {"message": "2FA disabled successfully."}
|
return {"message": "2FA disabled successfully."}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/2fa/recovery-codes", response_model=List[str])
|
@router.get("/2fa/recovery-codes", response_model=List[str])
|
||||||
async def get_new_recovery_codes(current_user: User = Depends(get_current_verified_user)):
|
async def get_new_recovery_codes(
|
||||||
|
current_user: User = Depends(get_current_verified_user),
|
||||||
|
):
|
||||||
if not current_user.is_2fa_enabled:
|
if not current_user.is_2fa_enabled:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled.")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled."
|
||||||
|
)
|
||||||
|
|
||||||
recovery_codes = generate_recovery_codes()
|
recovery_codes = generate_recovery_codes()
|
||||||
hashed_recovery_codes = hash_recovery_codes(recovery_codes)
|
hashed_recovery_codes = hash_recovery_codes(recovery_codes)
|
||||||
@@ -172,3 +234,17 @@ async def get_new_recovery_codes(current_user: User = Depends(get_current_verifi
|
|||||||
await current_user.save()
|
await current_user.save()
|
||||||
|
|
||||||
return recovery_codes
|
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,
|
||||||
|
}
|
||||||
|
|||||||
+256
-74
@@ -1,25 +1,25 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from decimal import Decimal
|
|
||||||
import calendar
|
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..billing.models import (
|
from ..billing.models import (
|
||||||
Invoice, InvoiceLineItem, UserSubscription, PricingConfig,
|
Invoice,
|
||||||
PaymentMethod, UsageAggregate, SubscriptionPlan
|
UserSubscription,
|
||||||
|
PricingConfig,
|
||||||
|
PaymentMethod,
|
||||||
|
UsageAggregate,
|
||||||
|
SubscriptionPlan,
|
||||||
)
|
)
|
||||||
from ..billing.usage_tracker import UsageTracker
|
from ..billing.usage_tracker import UsageTracker
|
||||||
from ..billing.invoice_generator import InvoiceGenerator
|
from ..billing.invoice_generator import InvoiceGenerator
|
||||||
from ..billing.stripe_client import StripeClient
|
from ..billing.stripe_client import StripeClient
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(prefix="/api/billing", tags=["billing"])
|
||||||
prefix="/api/billing",
|
|
||||||
tags=["billing"]
|
|
||||||
)
|
|
||||||
|
|
||||||
class UsageResponse(BaseModel):
|
class UsageResponse(BaseModel):
|
||||||
storage_gb_avg: float
|
storage_gb_avg: float
|
||||||
@@ -29,6 +29,7 @@ class UsageResponse(BaseModel):
|
|||||||
total_bandwidth_gb: float
|
total_bandwidth_gb: float
|
||||||
period: str
|
period: str
|
||||||
|
|
||||||
|
|
||||||
class InvoiceResponse(BaseModel):
|
class InvoiceResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
invoice_number: str
|
invoice_number: str
|
||||||
@@ -42,6 +43,7 @@ class InvoiceResponse(BaseModel):
|
|||||||
paid_at: Optional[datetime]
|
paid_at: Optional[datetime]
|
||||||
line_items: List[dict]
|
line_items: List[dict]
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionResponse(BaseModel):
|
class SubscriptionResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
billing_type: str
|
billing_type: str
|
||||||
@@ -50,6 +52,7 @@ class SubscriptionResponse(BaseModel):
|
|||||||
current_period_start: Optional[datetime]
|
current_period_start: Optional[datetime]
|
||||||
current_period_end: Optional[datetime]
|
current_period_end: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/usage/current")
|
@router.get("/usage/current")
|
||||||
async def get_current_usage(current_user: User = Depends(get_current_user)):
|
async def get_current_usage(current_user: User = Depends(get_current_user)):
|
||||||
try:
|
try:
|
||||||
@@ -61,25 +64,32 @@ async def get_current_usage(current_user: User = Depends(get_current_user)):
|
|||||||
if usage_today:
|
if usage_today:
|
||||||
return {
|
return {
|
||||||
"storage_gb": round(storage_bytes / (1024**3), 4),
|
"storage_gb": round(storage_bytes / (1024**3), 4),
|
||||||
"bandwidth_down_gb_today": round(usage_today.bandwidth_down_bytes / (1024**3), 4),
|
"bandwidth_down_gb_today": round(
|
||||||
"bandwidth_up_gb_today": round(usage_today.bandwidth_up_bytes / (1024**3), 4),
|
usage_today.bandwidth_down_bytes / (1024**3), 4
|
||||||
"as_of": today.isoformat()
|
),
|
||||||
|
"bandwidth_up_gb_today": round(
|
||||||
|
usage_today.bandwidth_up_bytes / (1024**3), 4
|
||||||
|
),
|
||||||
|
"as_of": today.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"storage_gb": round(storage_bytes / (1024**3), 4),
|
"storage_gb": round(storage_bytes / (1024**3), 4),
|
||||||
"bandwidth_down_gb_today": 0,
|
"bandwidth_down_gb_today": 0,
|
||||||
"bandwidth_up_gb_today": 0,
|
"bandwidth_up_gb_today": 0,
|
||||||
"as_of": today.isoformat()
|
"as_of": today.isoformat(),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to fetch usage data: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to fetch usage data: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/usage/monthly")
|
@router.get("/usage/monthly")
|
||||||
async def get_monthly_usage(
|
async def get_monthly_usage(
|
||||||
year: Optional[int] = None,
|
year: Optional[int] = None,
|
||||||
month: Optional[int] = None,
|
month: Optional[int] = None,
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
) -> UsageResponse:
|
) -> UsageResponse:
|
||||||
try:
|
try:
|
||||||
if year is None or month is None:
|
if year is None or month is None:
|
||||||
@@ -88,71 +98,85 @@ async def get_monthly_usage(
|
|||||||
month = now.month
|
month = now.month
|
||||||
|
|
||||||
if not (1 <= month <= 12):
|
if not (1 <= month <= 12):
|
||||||
raise HTTPException(status_code=400, detail="Month must be between 1 and 12")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Month must be between 1 and 12"
|
||||||
|
)
|
||||||
if not (2020 <= year <= 2100):
|
if not (2020 <= year <= 2100):
|
||||||
raise HTTPException(status_code=400, detail="Year must be between 2020 and 2100")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Year must be between 2020 and 2100"
|
||||||
|
)
|
||||||
|
|
||||||
usage = await UsageTracker.get_monthly_usage(current_user, year, month)
|
usage = await UsageTracker.get_monthly_usage(current_user, year, month)
|
||||||
|
|
||||||
return UsageResponse(
|
return UsageResponse(**usage, period=f"{year}-{month:02d}")
|
||||||
**usage,
|
|
||||||
period=f"{year}-{month:02d}"
|
|
||||||
)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to fetch monthly usage: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to fetch monthly usage: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/invoices")
|
@router.get("/invoices")
|
||||||
async def list_invoices(
|
async def list_invoices(
|
||||||
limit: int = 50,
|
limit: int = 50, offset: int = 0, current_user: User = Depends(get_current_user)
|
||||||
offset: int = 0,
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
) -> List[InvoiceResponse]:
|
) -> List[InvoiceResponse]:
|
||||||
try:
|
try:
|
||||||
if limit < 1 or limit > 100:
|
if limit < 1 or limit > 100:
|
||||||
raise HTTPException(status_code=400, detail="Limit must be between 1 and 100")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Limit must be between 1 and 100"
|
||||||
|
)
|
||||||
if offset < 0:
|
if offset < 0:
|
||||||
raise HTTPException(status_code=400, detail="Offset must be non-negative")
|
raise HTTPException(status_code=400, detail="Offset must be non-negative")
|
||||||
|
|
||||||
invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all()
|
invoices = (
|
||||||
|
await Invoice.filter(user=current_user)
|
||||||
|
.order_by("-created_at")
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for invoice in invoices:
|
for invoice in invoices:
|
||||||
line_items = await invoice.line_items.all()
|
line_items = await invoice.line_items.all()
|
||||||
result.append(InvoiceResponse(
|
result.append(
|
||||||
id=invoice.id,
|
InvoiceResponse(
|
||||||
invoice_number=invoice.invoice_number,
|
id=invoice.id,
|
||||||
period_start=invoice.period_start,
|
invoice_number=invoice.invoice_number,
|
||||||
period_end=invoice.period_end,
|
period_start=invoice.period_start,
|
||||||
subtotal=float(invoice.subtotal),
|
period_end=invoice.period_end,
|
||||||
tax=float(invoice.tax),
|
subtotal=float(invoice.subtotal),
|
||||||
total=float(invoice.total),
|
tax=float(invoice.tax),
|
||||||
status=invoice.status,
|
total=float(invoice.total),
|
||||||
due_date=invoice.due_date,
|
status=invoice.status,
|
||||||
paid_at=invoice.paid_at,
|
due_date=invoice.due_date,
|
||||||
line_items=[
|
paid_at=invoice.paid_at,
|
||||||
{
|
line_items=[
|
||||||
"description": item.description,
|
{
|
||||||
"quantity": float(item.quantity),
|
"description": item.description,
|
||||||
"unit_price": float(item.unit_price),
|
"quantity": float(item.quantity),
|
||||||
"amount": float(item.amount),
|
"unit_price": float(item.unit_price),
|
||||||
"type": item.item_type
|
"amount": float(item.amount),
|
||||||
}
|
"type": item.item_type,
|
||||||
for item in line_items
|
}
|
||||||
]
|
for item in line_items
|
||||||
))
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to fetch invoices: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to fetch invoices: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/invoices/{invoice_id}")
|
@router.get("/invoices/{invoice_id}")
|
||||||
async def get_invoice(
|
async def get_invoice(
|
||||||
invoice_id: int,
|
invoice_id: int, current_user: User = Depends(get_current_user)
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
) -> InvoiceResponse:
|
) -> InvoiceResponse:
|
||||||
invoice = await Invoice.get_or_none(id=invoice_id, user=current_user)
|
invoice = await Invoice.get_or_none(id=invoice_id, user=current_user)
|
||||||
if not invoice:
|
if not invoice:
|
||||||
@@ -177,21 +201,22 @@ async def get_invoice(
|
|||||||
"quantity": float(item.quantity),
|
"quantity": float(item.quantity),
|
||||||
"unit_price": float(item.unit_price),
|
"unit_price": float(item.unit_price),
|
||||||
"amount": float(item.amount),
|
"amount": float(item.amount),
|
||||||
"type": item.item_type
|
"type": item.item_type,
|
||||||
}
|
}
|
||||||
for item in line_items
|
for item in line_items
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subscription")
|
@router.get("/subscription")
|
||||||
async def get_subscription(current_user: User = Depends(get_current_user)) -> SubscriptionResponse:
|
async def get_subscription(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> SubscriptionResponse:
|
||||||
subscription = await UserSubscription.get_or_none(user=current_user)
|
subscription = await UserSubscription.get_or_none(user=current_user)
|
||||||
|
|
||||||
if not subscription:
|
if not subscription:
|
||||||
subscription = await UserSubscription.create(
|
subscription = await UserSubscription.create(
|
||||||
user=current_user,
|
user=current_user, billing_type="pay_as_you_go", status="active"
|
||||||
billing_type="pay_as_you_go",
|
|
||||||
status="active"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
plan_name = None
|
plan_name = None
|
||||||
@@ -205,15 +230,19 @@ async def get_subscription(current_user: User = Depends(get_current_user)) -> Su
|
|||||||
plan_name=plan_name,
|
plan_name=plan_name,
|
||||||
status=subscription.status,
|
status=subscription.status,
|
||||||
current_period_start=subscription.current_period_start,
|
current_period_start=subscription.current_period_start,
|
||||||
current_period_end=subscription.current_period_end
|
current_period_end=subscription.current_period_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/payment-methods/setup-intent")
|
@router.post("/payment-methods/setup-intent")
|
||||||
async def create_setup_intent(current_user: User = Depends(get_current_user)):
|
async def create_setup_intent(current_user: User = Depends(get_current_user)):
|
||||||
try:
|
try:
|
||||||
from ..settings import settings
|
from ..settings import settings
|
||||||
|
|
||||||
if not settings.STRIPE_SECRET_KEY:
|
if not settings.STRIPE_SECRET_KEY:
|
||||||
raise HTTPException(status_code=503, detail="Payment processing not configured")
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Payment processing not configured"
|
||||||
|
)
|
||||||
|
|
||||||
subscription = await UserSubscription.get_or_none(user=current_user)
|
subscription = await UserSubscription.get_or_none(user=current_user)
|
||||||
|
|
||||||
@@ -221,7 +250,7 @@ async def create_setup_intent(current_user: User = Depends(get_current_user)):
|
|||||||
customer_id = await StripeClient.create_customer(
|
customer_id = await StripeClient.create_customer(
|
||||||
email=current_user.email,
|
email=current_user.email,
|
||||||
name=current_user.username,
|
name=current_user.username,
|
||||||
metadata={"user_id": str(current_user.id)}
|
metadata={"user_id": str(current_user.id)},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not subscription:
|
if not subscription:
|
||||||
@@ -229,27 +258,30 @@ async def create_setup_intent(current_user: User = Depends(get_current_user)):
|
|||||||
user=current_user,
|
user=current_user,
|
||||||
billing_type="pay_as_you_go",
|
billing_type="pay_as_you_go",
|
||||||
stripe_customer_id=customer_id,
|
stripe_customer_id=customer_id,
|
||||||
status="active"
|
status="active",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
subscription.stripe_customer_id = customer_id
|
subscription.stripe_customer_id = customer_id
|
||||||
await subscription.save()
|
await subscription.save()
|
||||||
|
|
||||||
import stripe
|
import stripe
|
||||||
|
|
||||||
StripeClient._ensure_api_key()
|
StripeClient._ensure_api_key()
|
||||||
setup_intent = stripe.SetupIntent.create(
|
setup_intent = stripe.SetupIntent.create(
|
||||||
customer=subscription.stripe_customer_id,
|
customer=subscription.stripe_customer_id, payment_method_types=["card"]
|
||||||
payment_method_types=["card"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"client_secret": setup_intent.client_secret,
|
"client_secret": setup_intent.client_secret,
|
||||||
"customer_id": subscription.stripe_customer_id
|
"customer_id": subscription.stripe_customer_id,
|
||||||
}
|
}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to create setup intent: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to create setup intent: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/payment-methods")
|
@router.get("/payment-methods")
|
||||||
async def list_payment_methods(current_user: User = Depends(get_current_user)):
|
async def list_payment_methods(current_user: User = Depends(get_current_user)):
|
||||||
@@ -262,11 +294,12 @@ async def list_payment_methods(current_user: User = Depends(get_current_user)):
|
|||||||
"brand": m.brand,
|
"brand": m.brand,
|
||||||
"exp_month": m.exp_month,
|
"exp_month": m.exp_month,
|
||||||
"exp_year": m.exp_year,
|
"exp_year": m.exp_year,
|
||||||
"is_default": m.is_default
|
"is_default": m.is_default,
|
||||||
}
|
}
|
||||||
for m in methods
|
for m in methods
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhooks/stripe")
|
@router.post("/webhooks/stripe")
|
||||||
async def stripe_webhook(request: Request):
|
async def stripe_webhook(request: Request):
|
||||||
import stripe
|
import stripe
|
||||||
@@ -298,12 +331,14 @@ async def stripe_webhook(request: Request):
|
|||||||
event_type=event["type"],
|
event_type=event["type"],
|
||||||
stripe_event_id=event_id,
|
stripe_event_id=event_id,
|
||||||
data=event["data"],
|
data=event["data"],
|
||||||
processed=False
|
processed=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if event["type"] == "invoice.payment_succeeded":
|
if event["type"] == "invoice.payment_succeeded":
|
||||||
invoice_data = event["data"]["object"]
|
invoice_data = event["data"]["object"]
|
||||||
mywebdav_invoice_id = invoice_data.get("metadata", {}).get("mywebdav_invoice_id")
|
mywebdav_invoice_id = invoice_data.get("metadata", {}).get(
|
||||||
|
"mywebdav_invoice_id"
|
||||||
|
)
|
||||||
|
|
||||||
if mywebdav_invoice_id:
|
if mywebdav_invoice_id:
|
||||||
invoice = await Invoice.get_or_none(id=int(mywebdav_invoice_id))
|
invoice = await Invoice.get_or_none(id=int(mywebdav_invoice_id))
|
||||||
@@ -317,7 +352,9 @@ async def stripe_webhook(request: Request):
|
|||||||
payment_method = event["data"]["object"]
|
payment_method = event["data"]["object"]
|
||||||
customer_id = payment_method["customer"]
|
customer_id = payment_method["customer"]
|
||||||
|
|
||||||
subscription = await UserSubscription.get_or_none(stripe_customer_id=customer_id)
|
subscription = await UserSubscription.get_or_none(
|
||||||
|
stripe_customer_id=customer_id
|
||||||
|
)
|
||||||
if subscription:
|
if subscription:
|
||||||
await PaymentMethod.create(
|
await PaymentMethod.create(
|
||||||
user=subscription.user,
|
user=subscription.user,
|
||||||
@@ -327,7 +364,7 @@ async def stripe_webhook(request: Request):
|
|||||||
brand=payment_method.get("card", {}).get("brand"),
|
brand=payment_method.get("card", {}).get("brand"),
|
||||||
exp_month=payment_method.get("card", {}).get("exp_month"),
|
exp_month=payment_method.get("card", {}).get("exp_month"),
|
||||||
exp_year=payment_method.get("card", {}).get("exp_year"),
|
exp_year=payment_method.get("card", {}).get("exp_year"),
|
||||||
is_default=True
|
is_default=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
await BillingEvent.filter(stripe_event_id=event_id).update(processed=True)
|
await BillingEvent.filter(stripe_event_id=event_id).update(processed=True)
|
||||||
@@ -335,7 +372,10 @@ async def stripe_webhook(request: Request):
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Webhook processing failed: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Webhook processing failed: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pricing")
|
@router.get("/pricing")
|
||||||
async def get_pricing():
|
async def get_pricing():
|
||||||
@@ -344,11 +384,12 @@ async def get_pricing():
|
|||||||
config.config_key: {
|
config.config_key: {
|
||||||
"value": float(config.config_value),
|
"value": float(config.config_value),
|
||||||
"description": config.description,
|
"description": config.description,
|
||||||
"unit": config.unit
|
"unit": config.unit,
|
||||||
}
|
}
|
||||||
for config in configs
|
for config in configs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/plans")
|
@router.get("/plans")
|
||||||
async def list_plans():
|
async def list_plans():
|
||||||
plans = await SubscriptionPlan.filter(is_active=True).all()
|
plans = await SubscriptionPlan.filter(is_active=True).all()
|
||||||
@@ -361,14 +402,155 @@ async def list_plans():
|
|||||||
"storage_gb": plan.storage_gb,
|
"storage_gb": plan.storage_gb,
|
||||||
"bandwidth_gb": plan.bandwidth_gb,
|
"bandwidth_gb": plan.bandwidth_gb,
|
||||||
"price_monthly": float(plan.price_monthly),
|
"price_monthly": float(plan.price_monthly),
|
||||||
"price_yearly": float(plan.price_yearly) if plan.price_yearly else None
|
"price_yearly": float(plan.price_yearly) if plan.price_yearly else None,
|
||||||
}
|
}
|
||||||
for plan in plans
|
for plan in 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")
|
@router.get("/stripe-key")
|
||||||
async def get_stripe_key():
|
async def get_stripe_key():
|
||||||
from ..settings import settings
|
from ..settings import settings
|
||||||
|
|
||||||
if not settings.STRIPE_PUBLISHABLE_KEY:
|
if not settings.STRIPE_PUBLISHABLE_KEY:
|
||||||
raise HTTPException(status_code=503, detail="Payment processing not configured")
|
raise HTTPException(status_code=503, detail="Payment processing not configured")
|
||||||
return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}
|
return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}
|
||||||
|
|||||||
+392
-130
@@ -1,4 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends, UploadFile, File as FastAPIFile, HTTPException, status, Response, Form
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
UploadFile,
|
||||||
|
File as FastAPIFile,
|
||||||
|
HTTPException,
|
||||||
|
status,
|
||||||
|
Form,
|
||||||
|
)
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -11,92 +19,160 @@ from ..auth import get_current_user
|
|||||||
from ..models import User, File, Folder
|
from ..models import User, File, Folder
|
||||||
from ..schemas import FileOut
|
from ..schemas import FileOut
|
||||||
from ..storage import storage_manager
|
from ..storage import storage_manager
|
||||||
from ..settings import settings
|
|
||||||
from ..activity import log_activity
|
from ..activity import log_activity
|
||||||
from ..thumbnails import generate_thumbnail, delete_thumbnail
|
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(
|
router = APIRouter(
|
||||||
prefix="/files",
|
prefix="/files",
|
||||||
tags=["files"],
|
tags=["files"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FileMove(BaseModel):
|
class FileMove(BaseModel):
|
||||||
target_folder_id: Optional[int] = None
|
target_folder_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class FileRename(BaseModel):
|
class FileRename(BaseModel):
|
||||||
new_name: str
|
new_name: str
|
||||||
|
|
||||||
|
|
||||||
class FileCopy(BaseModel):
|
class FileCopy(BaseModel):
|
||||||
target_folder_id: Optional[int] = None
|
target_folder_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class BatchFileOperation(BaseModel):
|
class BatchFileOperation(BaseModel):
|
||||||
file_ids: List[int]
|
file_ids: List[int]
|
||||||
operation: str # e.g., "delete", "star", "unstar", "move", "copy"
|
operation: str # e.g., "delete", "star", "unstar", "move", "copy"
|
||||||
|
|
||||||
|
|
||||||
class BatchMoveCopyPayload(BaseModel):
|
class BatchMoveCopyPayload(BaseModel):
|
||||||
target_folder_id: Optional[int] = None
|
target_folder_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class FileContentUpdate(BaseModel):
|
class FileContentUpdate(BaseModel):
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
@router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
file: UploadFile = FastAPIFile(...),
|
file: UploadFile = FastAPIFile(...),
|
||||||
folder_id: Optional[int] = Form(None),
|
folder_id: Optional[int] = Form(None),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
if folder_id:
|
if folder_id:
|
||||||
parent_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
parent_folder = await Folder.get_or_none(
|
||||||
|
id=folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not parent_folder:
|
if not parent_folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
parent_folder = None
|
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_content = await file.read()
|
||||||
file_size = len(file_content)
|
file_size = len(file_content)
|
||||||
file_hash = hashlib.sha256(file_content).hexdigest()
|
file_hash = hashlib.sha256(file_content).hexdigest()
|
||||||
|
|
||||||
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
|
if ATOMIC_OPS_AVAILABLE:
|
||||||
raise HTTPException(
|
atomic_ops = get_atomic_ops()
|
||||||
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
|
||||||
detail="Storage quota exceeded",
|
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
|
current_user.used_storage_bytes += file_size
|
||||||
file_extension = os.path.splitext(file.filename)[1]
|
await current_user.save()
|
||||||
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()
|
|
||||||
|
|
||||||
thumbnail_path = await generate_thumbnail(storage_path, mime_type, current_user.id)
|
thumbnail_path = await generate_thumbnail(storage_path, mime_type, current_user.id)
|
||||||
if thumbnail_path:
|
if thumbnail_path:
|
||||||
@@ -105,16 +181,20 @@ async def upload_file(
|
|||||||
|
|
||||||
return await FileOut.from_tortoise_orm(db_file)
|
return await FileOut.from_tortoise_orm(db_file)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/download/{file_id}")
|
@router.get("/download/{file_id}")
|
||||||
async def download_file(file_id: int, current_user: User = Depends(get_current_user)):
|
async def download_file(file_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
db_file.last_accessed_at = datetime.now()
|
db_file.last_accessed_at = datetime.now()
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
async def file_iterator():
|
async def file_iterator():
|
||||||
async for chunk in storage_manager.get_file(current_user.id, db_file.path):
|
async for chunk in storage_manager.get_file(current_user.id, db_file.path):
|
||||||
yield chunk
|
yield chunk
|
||||||
@@ -122,16 +202,21 @@ async def download_file(file_id: int, current_user: User = Depends(get_current_u
|
|||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
file_iterator(),
|
file_iterator(),
|
||||||
media_type=db_file.mime_type,
|
media_type=db_file.mime_type,
|
||||||
headers={"Content-Disposition": f"attachment; filename=\"{db_file.name}\""}
|
headers={"Content-Disposition": f'attachment; filename="{db_file.name}"'},
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found in storage")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found in storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_file(file_id: int, current_user: User = Depends(get_current_user)):
|
async def delete_file(file_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
db_file.is_deleted = True
|
db_file.is_deleted = True
|
||||||
db_file.deleted_at = datetime.now()
|
db_file.deleted_at = datetime.now()
|
||||||
@@ -141,68 +226,110 @@ async def delete_file(file_id: int, current_user: User = Depends(get_current_use
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{file_id}/move", response_model=FileOut)
|
@router.post("/{file_id}/move", response_model=FileOut)
|
||||||
async def move_file(file_id: int, move_data: FileMove, current_user: User = Depends(get_current_user)):
|
async def move_file(
|
||||||
|
file_id: int, move_data: FileMove, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
target_folder = None
|
target_folder = None
|
||||||
if move_data.target_folder_id:
|
if move_data.target_folder_id:
|
||||||
target_folder = await Folder.get_or_none(id=move_data.target_folder_id, owner=current_user, is_deleted=False)
|
target_folder = await Folder.get_or_none(
|
||||||
|
id=move_data.target_folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not target_folder:
|
if not target_folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target folder not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Target folder not found"
|
||||||
|
)
|
||||||
|
|
||||||
existing_file = await File.get_or_none(
|
existing_file = await File.get_or_none(
|
||||||
name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False
|
name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False
|
||||||
)
|
)
|
||||||
if existing_file and existing_file.id != file_id:
|
if existing_file and existing_file.id != file_id:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="File with this name already exists in target folder")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="File with this name already exists in target folder",
|
||||||
|
)
|
||||||
|
|
||||||
db_file.parent = target_folder
|
db_file.parent = target_folder
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
|
|
||||||
await log_activity(user=current_user, action="file_moved", target_type="file", target_id=file_id)
|
await log_activity(
|
||||||
|
user=current_user, action="file_moved", target_type="file", target_id=file_id
|
||||||
|
)
|
||||||
|
|
||||||
return await FileOut.from_tortoise_orm(db_file)
|
return await FileOut.from_tortoise_orm(db_file)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{file_id}/rename", response_model=FileOut)
|
@router.post("/{file_id}/rename", response_model=FileOut)
|
||||||
async def rename_file(file_id: int, rename_data: FileRename, current_user: User = Depends(get_current_user)):
|
async def rename_file(
|
||||||
|
file_id: int,
|
||||||
|
rename_data: FileRename,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
existing_file = await File.get_or_none(
|
existing_file = await File.get_or_none(
|
||||||
name=rename_data.new_name, parent_id=db_file.parent_id, owner=current_user, is_deleted=False
|
name=rename_data.new_name,
|
||||||
|
parent_id=db_file.parent_id,
|
||||||
|
owner=current_user,
|
||||||
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
if existing_file and existing_file.id != file_id:
|
if existing_file and existing_file.id != file_id:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="File with this name already exists in the same folder")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="File with this name already exists in the same folder",
|
||||||
|
)
|
||||||
|
|
||||||
db_file.name = rename_data.new_name
|
db_file.name = rename_data.new_name
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
|
|
||||||
await log_activity(user=current_user, action="file_renamed", target_type="file", target_id=file_id)
|
await log_activity(
|
||||||
|
user=current_user, action="file_renamed", target_type="file", target_id=file_id
|
||||||
|
)
|
||||||
|
|
||||||
return await FileOut.from_tortoise_orm(db_file)
|
return await FileOut.from_tortoise_orm(db_file)
|
||||||
|
|
||||||
@router.post("/{file_id}/copy", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def copy_file(file_id: int, copy_data: FileCopy, current_user: User = Depends(get_current_user)):
|
@router.post(
|
||||||
|
"/{file_id}/copy", response_model=FileOut, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def copy_file(
|
||||||
|
file_id: int, copy_data: FileCopy, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
target_folder = None
|
target_folder = None
|
||||||
if copy_data.target_folder_id:
|
if copy_data.target_folder_id:
|
||||||
target_folder = await Folder.get_or_none(id=copy_data.target_folder_id, owner=current_user, is_deleted=False)
|
target_folder = await Folder.get_or_none(
|
||||||
|
id=copy_data.target_folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not target_folder:
|
if not target_folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target folder not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Target folder not found"
|
||||||
|
)
|
||||||
|
|
||||||
base_name = db_file.name
|
base_name = db_file.name
|
||||||
name_parts = os.path.splitext(base_name)
|
name_parts = os.path.splitext(base_name)
|
||||||
counter = 1
|
counter = 1
|
||||||
new_name = base_name
|
new_name = base_name
|
||||||
|
|
||||||
while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False):
|
while await File.get_or_none(
|
||||||
|
name=new_name, parent=target_folder, owner=current_user, is_deleted=False
|
||||||
|
):
|
||||||
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
|
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
@@ -213,139 +340,205 @@ async def copy_file(file_id: int, copy_data: FileCopy, current_user: User = Depe
|
|||||||
mime_type=db_file.mime_type,
|
mime_type=db_file.mime_type,
|
||||||
file_hash=db_file.file_hash,
|
file_hash=db_file.file_hash,
|
||||||
owner=current_user,
|
owner=current_user,
|
||||||
parent=target_folder
|
parent=target_folder,
|
||||||
)
|
)
|
||||||
|
|
||||||
await log_activity(user=current_user, action="file_copied", target_type="file", target_id=new_file.id)
|
await log_activity(
|
||||||
|
user=current_user,
|
||||||
|
action="file_copied",
|
||||||
|
target_type="file",
|
||||||
|
target_id=new_file.id,
|
||||||
|
)
|
||||||
|
|
||||||
return await FileOut.from_tortoise_orm(new_file)
|
return await FileOut.from_tortoise_orm(new_file)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[FileOut])
|
@router.get("/", response_model=List[FileOut])
|
||||||
async def list_files(folder_id: Optional[int] = None, current_user: User = Depends(get_current_user)):
|
async def list_files(
|
||||||
|
folder_id: Optional[int] = None, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
if folder_id:
|
if folder_id:
|
||||||
parent_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
parent_folder = await Folder.get_or_none(
|
||||||
|
id=folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not parent_folder:
|
if not parent_folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
raise HTTPException(
|
||||||
files = await File.filter(parent=parent_folder, owner=current_user, is_deleted=False).order_by("name")
|
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
||||||
|
)
|
||||||
|
files = await File.filter(
|
||||||
|
parent=parent_folder, owner=current_user, is_deleted=False
|
||||||
|
).order_by("name")
|
||||||
else:
|
else:
|
||||||
files = await File.filter(parent=None, owner=current_user, is_deleted=False).order_by("name")
|
files = await File.filter(
|
||||||
|
parent=None, owner=current_user, is_deleted=False
|
||||||
|
).order_by("name")
|
||||||
return [await FileOut.from_tortoise_orm(f) for f in files]
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/thumbnail/{file_id}")
|
@router.get("/thumbnail/{file_id}")
|
||||||
async def get_thumbnail(file_id: int, current_user: User = Depends(get_current_user)):
|
async def get_thumbnail(file_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
db_file.last_accessed_at = datetime.now()
|
db_file.last_accessed_at = datetime.now()
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
|
|
||||||
thumbnail_path = getattr(db_file, 'thumbnail_path', None)
|
thumbnail_path = getattr(db_file, "thumbnail_path", None)
|
||||||
|
|
||||||
if not thumbnail_path:
|
if not thumbnail_path:
|
||||||
thumbnail_path = await generate_thumbnail(db_file.path, db_file.mime_type, current_user.id)
|
thumbnail_path = await generate_thumbnail(
|
||||||
|
db_file.path, db_file.mime_type, current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
if thumbnail_path:
|
if thumbnail_path:
|
||||||
db_file.thumbnail_path = thumbnail_path
|
db_file.thumbnail_path = thumbnail_path
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thumbnail not available")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Thumbnail not available"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
async def thumbnail_iterator():
|
async def thumbnail_iterator():
|
||||||
async for chunk in storage_manager.get_file(current_user.id, thumbnail_path):
|
async for chunk in storage_manager.get_file(
|
||||||
|
current_user.id, thumbnail_path
|
||||||
|
):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(thumbnail_iterator(), media_type="image/jpeg")
|
||||||
thumbnail_iterator(),
|
|
||||||
media_type="image/jpeg"
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thumbnail not found in storage")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Thumbnail not found in storage",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/photos", response_model=List[FileOut])
|
@router.get("/photos", response_model=List[FileOut])
|
||||||
async def list_photos(current_user: User = Depends(get_current_user)):
|
async def list_photos(current_user: User = Depends(get_current_user)):
|
||||||
files = await File.filter(
|
files = await File.filter(
|
||||||
owner=current_user,
|
owner=current_user, is_deleted=False, mime_type__istartswith="image/"
|
||||||
is_deleted=False,
|
|
||||||
mime_type__istartswith="image/"
|
|
||||||
).order_by("-created_at")
|
).order_by("-created_at")
|
||||||
return [await FileOut.from_tortoise_orm(f) for f in files]
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recent", response_model=List[FileOut])
|
@router.get("/recent", response_model=List[FileOut])
|
||||||
async def list_recent_files(current_user: User = Depends(get_current_user), limit: int = 10):
|
async def list_recent_files(
|
||||||
files = await File.filter(
|
current_user: User = Depends(get_current_user), limit: int = 10
|
||||||
owner=current_user,
|
):
|
||||||
is_deleted=False,
|
files = (
|
||||||
last_accessed_at__isnull=False
|
await File.filter(
|
||||||
).order_by("-last_accessed_at").limit(limit)
|
owner=current_user, is_deleted=False, last_accessed_at__isnull=False
|
||||||
|
)
|
||||||
|
.order_by("-last_accessed_at")
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
return [await FileOut.from_tortoise_orm(f) for f in files]
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{file_id}/star", response_model=FileOut)
|
@router.post("/{file_id}/star", response_model=FileOut)
|
||||||
async def star_file(file_id: int, current_user: User = Depends(get_current_user)):
|
async def star_file(file_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
db_file.is_starred = True
|
db_file.is_starred = True
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
await log_activity(user=current_user, action="file_starred", target_type="file", target_id=file_id)
|
await log_activity(
|
||||||
|
user=current_user, action="file_starred", target_type="file", target_id=file_id
|
||||||
|
)
|
||||||
return await FileOut.from_tortoise_orm(db_file)
|
return await FileOut.from_tortoise_orm(db_file)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{file_id}/unstar", response_model=FileOut)
|
@router.post("/{file_id}/unstar", response_model=FileOut)
|
||||||
async def unstar_file(file_id: int, current_user: User = Depends(get_current_user)):
|
async def unstar_file(file_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
db_file.is_starred = False
|
db_file.is_starred = False
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
await log_activity(user=current_user, action="file_unstarred", target_type="file", target_id=file_id)
|
await log_activity(
|
||||||
|
user=current_user,
|
||||||
|
action="file_unstarred",
|
||||||
|
target_type="file",
|
||||||
|
target_id=file_id,
|
||||||
|
)
|
||||||
return await FileOut.from_tortoise_orm(db_file)
|
return await FileOut.from_tortoise_orm(db_file)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/deleted", response_model=List[FileOut])
|
@router.get("/deleted", response_model=List[FileOut])
|
||||||
async def list_deleted_files(current_user: User = Depends(get_current_user)):
|
async def list_deleted_files(current_user: User = Depends(get_current_user)):
|
||||||
files = await File.filter(owner=current_user, is_deleted=True).order_by("-deleted_at")
|
files = await File.filter(owner=current_user, is_deleted=True).order_by(
|
||||||
|
"-deleted_at"
|
||||||
|
)
|
||||||
return [await FileOut.from_tortoise_orm(f) for f in files]
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{file_id}/restore", response_model=FileOut)
|
@router.post("/{file_id}/restore", response_model=FileOut)
|
||||||
async def restore_file(file_id: int, current_user: User = Depends(get_current_user)):
|
async def restore_file(file_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=True)
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=True)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deleted file not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Deleted file not found"
|
||||||
|
)
|
||||||
|
|
||||||
# Check if a file with the same name exists in the parent folder
|
# Check if a file with the same name exists in the parent folder
|
||||||
existing_file = await File.get_or_none(
|
existing_file = await File.get_or_none(
|
||||||
name=db_file.name, parent=db_file.parent, owner=current_user, is_deleted=False
|
name=db_file.name, parent=db_file.parent, owner=current_user, is_deleted=False
|
||||||
)
|
)
|
||||||
if existing_file:
|
if existing_file:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A file with the same name already exists in this location. Please rename the existing file or restore to a different location.")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="A file with the same name already exists in this location. Please rename the existing file or restore to a different location.",
|
||||||
|
)
|
||||||
|
|
||||||
db_file.is_deleted = False
|
db_file.is_deleted = False
|
||||||
db_file.deleted_at = None
|
db_file.deleted_at = None
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
await log_activity(user=current_user, action="file_restored", target_type="file", target_id=file_id)
|
await log_activity(
|
||||||
|
user=current_user, action="file_restored", target_type="file", target_id=file_id
|
||||||
|
)
|
||||||
return await FileOut.from_tortoise_orm(db_file)
|
return await FileOut.from_tortoise_orm(db_file)
|
||||||
|
|
||||||
|
|
||||||
class BatchOperationResult(BaseModel):
|
class BatchOperationResult(BaseModel):
|
||||||
succeeded: List[FileOut]
|
succeeded: List[FileOut]
|
||||||
failed: List[dict]
|
failed: List[dict]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch")
|
@router.post("/batch")
|
||||||
async def batch_file_operations(
|
async def batch_file_operations(
|
||||||
batch_operation: BatchFileOperation,
|
batch_operation: BatchFileOperation,
|
||||||
payload: Optional[BatchMoveCopyPayload] = None,
|
payload: Optional[BatchMoveCopyPayload] = None,
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
if batch_operation.operation not in ["delete", "star", "unstar", "move", "copy"]:
|
if batch_operation.operation not in ["delete", "star", "unstar", "move", "copy"]:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid operation: {batch_operation.operation}")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid operation: {batch_operation.operation}",
|
||||||
|
)
|
||||||
|
|
||||||
updated_files = []
|
updated_files = []
|
||||||
failed_operations = []
|
failed_operations = []
|
||||||
|
|
||||||
for file_id in batch_operation.file_ids:
|
for file_id in batch_operation.file_ids:
|
||||||
try:
|
try:
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
db_file = await File.get_or_none(
|
||||||
|
id=file_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
failed_operations.append({"file_id": file_id, "reason": "File not found or not owned by user"})
|
failed_operations.append(
|
||||||
|
{
|
||||||
|
"file_id": file_id,
|
||||||
|
"reason": "File not found or not owned by user",
|
||||||
|
}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if batch_operation.operation == "delete":
|
if batch_operation.operation == "delete":
|
||||||
@@ -353,47 +546,87 @@ async def batch_file_operations(
|
|||||||
db_file.deleted_at = datetime.now()
|
db_file.deleted_at = datetime.now()
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
await delete_thumbnail(db_file.id)
|
await delete_thumbnail(db_file.id)
|
||||||
await log_activity(user=current_user, action="file_deleted_batch", target_type="file", target_id=file_id)
|
await log_activity(
|
||||||
|
user=current_user,
|
||||||
|
action="file_deleted_batch",
|
||||||
|
target_type="file",
|
||||||
|
target_id=file_id,
|
||||||
|
)
|
||||||
updated_files.append(db_file)
|
updated_files.append(db_file)
|
||||||
elif batch_operation.operation == "star":
|
elif batch_operation.operation == "star":
|
||||||
db_file.is_starred = True
|
db_file.is_starred = True
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
await log_activity(user=current_user, action="file_starred_batch", target_type="file", target_id=file_id)
|
await log_activity(
|
||||||
|
user=current_user,
|
||||||
|
action="file_starred_batch",
|
||||||
|
target_type="file",
|
||||||
|
target_id=file_id,
|
||||||
|
)
|
||||||
updated_files.append(db_file)
|
updated_files.append(db_file)
|
||||||
elif batch_operation.operation == "unstar":
|
elif batch_operation.operation == "unstar":
|
||||||
db_file.is_starred = False
|
db_file.is_starred = False
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
await log_activity(user=current_user, action="file_unstarred_batch", target_type="file", target_id=file_id)
|
await log_activity(
|
||||||
|
user=current_user,
|
||||||
|
action="file_unstarred_batch",
|
||||||
|
target_type="file",
|
||||||
|
target_id=file_id,
|
||||||
|
)
|
||||||
updated_files.append(db_file)
|
updated_files.append(db_file)
|
||||||
elif batch_operation.operation == "move":
|
elif batch_operation.operation == "move":
|
||||||
if not payload or payload.target_folder_id is None:
|
if not payload or payload.target_folder_id is None:
|
||||||
failed_operations.append({"file_id": file_id, "reason": "Target folder not specified"})
|
failed_operations.append(
|
||||||
|
{"file_id": file_id, "reason": "Target folder not specified"}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
target_folder = await Folder.get_or_none(
|
||||||
|
id=payload.target_folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not target_folder:
|
if not target_folder:
|
||||||
failed_operations.append({"file_id": file_id, "reason": "Target folder not found"})
|
failed_operations.append(
|
||||||
|
{"file_id": file_id, "reason": "Target folder not found"}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
existing_file = await File.get_or_none(
|
existing_file = await File.get_or_none(
|
||||||
name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False
|
name=db_file.name,
|
||||||
|
parent=target_folder,
|
||||||
|
owner=current_user,
|
||||||
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
if existing_file and existing_file.id != file_id:
|
if existing_file and existing_file.id != file_id:
|
||||||
failed_operations.append({"file_id": file_id, "reason": "File with same name exists in target folder"})
|
failed_operations.append(
|
||||||
|
{
|
||||||
|
"file_id": file_id,
|
||||||
|
"reason": "File with same name exists in target folder",
|
||||||
|
}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
db_file.parent = target_folder
|
db_file.parent = target_folder
|
||||||
await db_file.save()
|
await db_file.save()
|
||||||
await log_activity(user=current_user, action="file_moved_batch", target_type="file", target_id=file_id)
|
await log_activity(
|
||||||
|
user=current_user,
|
||||||
|
action="file_moved_batch",
|
||||||
|
target_type="file",
|
||||||
|
target_id=file_id,
|
||||||
|
)
|
||||||
updated_files.append(db_file)
|
updated_files.append(db_file)
|
||||||
elif batch_operation.operation == "copy":
|
elif batch_operation.operation == "copy":
|
||||||
if not payload or payload.target_folder_id is None:
|
if not payload or payload.target_folder_id is None:
|
||||||
failed_operations.append({"file_id": file_id, "reason": "Target folder not specified"})
|
failed_operations.append(
|
||||||
|
{"file_id": file_id, "reason": "Target folder not specified"}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
target_folder = await Folder.get_or_none(
|
||||||
|
id=payload.target_folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not target_folder:
|
if not target_folder:
|
||||||
failed_operations.append({"file_id": file_id, "reason": "Target folder not found"})
|
failed_operations.append(
|
||||||
|
{"file_id": file_id, "reason": "Target folder not found"}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
base_name = db_file.name
|
base_name = db_file.name
|
||||||
@@ -401,7 +634,12 @@ async def batch_file_operations(
|
|||||||
counter = 1
|
counter = 1
|
||||||
new_name = base_name
|
new_name = base_name
|
||||||
|
|
||||||
while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False):
|
while await File.get_or_none(
|
||||||
|
name=new_name,
|
||||||
|
parent=target_folder,
|
||||||
|
owner=current_user,
|
||||||
|
is_deleted=False,
|
||||||
|
):
|
||||||
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
|
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
@@ -412,48 +650,70 @@ async def batch_file_operations(
|
|||||||
mime_type=db_file.mime_type,
|
mime_type=db_file.mime_type,
|
||||||
file_hash=db_file.file_hash,
|
file_hash=db_file.file_hash,
|
||||||
owner=current_user,
|
owner=current_user,
|
||||||
parent=target_folder
|
parent=target_folder,
|
||||||
|
)
|
||||||
|
await log_activity(
|
||||||
|
user=current_user,
|
||||||
|
action="file_copied_batch",
|
||||||
|
target_type="file",
|
||||||
|
target_id=new_file.id,
|
||||||
)
|
)
|
||||||
await log_activity(user=current_user, action="file_copied_batch", target_type="file", target_id=new_file.id)
|
|
||||||
updated_files.append(new_file)
|
updated_files.append(new_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed_operations.append({"file_id": file_id, "reason": str(e)})
|
failed_operations.append({"file_id": file_id, "reason": str(e)})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"succeeded": [await FileOut.from_tortoise_orm(f) for f in updated_files],
|
"succeeded": [await FileOut.from_tortoise_orm(f) for f in updated_files],
|
||||||
"failed": failed_operations
|
"failed": failed_operations,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{file_id}/content", response_model=FileOut)
|
@router.put("/{file_id}/content", response_model=FileOut)
|
||||||
async def update_file_content(
|
async def update_file_content(
|
||||||
file_id: int,
|
file_id: int,
|
||||||
payload: FileContentUpdate,
|
payload: FileContentUpdate,
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not db_file.mime_type or not db_file.mime_type.startswith('text/'):
|
if not db_file.mime_type or not db_file.mime_type.startswith("text/"):
|
||||||
editableExtensions = [
|
editableExtensions = [
|
||||||
'txt', 'md', 'log', 'json', 'js', 'py', 'html', 'css',
|
"txt",
|
||||||
'xml', 'yaml', 'yml', 'sh', 'bat', 'ini', 'conf', 'cfg'
|
"md",
|
||||||
|
"log",
|
||||||
|
"json",
|
||||||
|
"js",
|
||||||
|
"py",
|
||||||
|
"html",
|
||||||
|
"css",
|
||||||
|
"xml",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"sh",
|
||||||
|
"bat",
|
||||||
|
"ini",
|
||||||
|
"conf",
|
||||||
|
"cfg",
|
||||||
]
|
]
|
||||||
file_extension = os.path.splitext(db_file.name)[1][1:].lower()
|
file_extension = os.path.splitext(db_file.name)[1][1:].lower()
|
||||||
if file_extension not in editableExtensions:
|
if file_extension not in editableExtensions:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="File type is not editable"
|
detail="File type is not editable",
|
||||||
)
|
)
|
||||||
|
|
||||||
content_bytes = payload.content.encode('utf-8')
|
content_bytes = payload.content.encode("utf-8")
|
||||||
new_size = len(content_bytes)
|
new_size = len(content_bytes)
|
||||||
size_diff = new_size - db_file.size
|
size_diff = new_size - db_file.size
|
||||||
|
|
||||||
if current_user.used_storage_bytes + size_diff > current_user.storage_quota_bytes:
|
if current_user.used_storage_bytes + size_diff > current_user.storage_quota_bytes:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
||||||
detail="Storage quota exceeded"
|
detail="Storage quota exceeded",
|
||||||
)
|
)
|
||||||
|
|
||||||
new_hash = hashlib.sha256(content_bytes).hexdigest()
|
new_hash = hashlib.sha256(content_bytes).hexdigest()
|
||||||
@@ -465,7 +725,7 @@ async def update_file_content(
|
|||||||
if new_storage_path != db_file.path:
|
if new_storage_path != db_file.path:
|
||||||
try:
|
try:
|
||||||
await storage_manager.delete_file(current_user.id, db_file.path)
|
await storage_manager.delete_file(current_user.id, db_file.path)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
db_file.path = new_storage_path
|
db_file.path = new_storage_path
|
||||||
@@ -477,6 +737,8 @@ async def update_file_content(
|
|||||||
current_user.used_storage_bytes += size_diff
|
current_user.used_storage_bytes += size_diff
|
||||||
await current_user.save()
|
await current_user.save()
|
||||||
|
|
||||||
await log_activity(user=current_user, action="file_updated", target_type="file", target_id=file_id)
|
await log_activity(
|
||||||
|
user=current_user, action="file_updated", target_type="file", target_id=file_id
|
||||||
|
)
|
||||||
|
|
||||||
return await FileOut.from_tortoise_orm(db_file)
|
return await FileOut.from_tortoise_orm(db_file)
|
||||||
|
|||||||
+106
-31
@@ -3,7 +3,13 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..models import User, Folder
|
from ..models import User, Folder
|
||||||
from ..schemas import FolderCreate, FolderOut, FolderUpdate, BatchFolderOperation, BatchMoveCopyPayload
|
from ..schemas import (
|
||||||
|
FolderCreate,
|
||||||
|
FolderOut,
|
||||||
|
FolderUpdate,
|
||||||
|
BatchFolderOperation,
|
||||||
|
BatchMoveCopyPayload,
|
||||||
|
)
|
||||||
from ..activity import log_activity
|
from ..activity import log_activity
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -11,12 +17,17 @@ router = APIRouter(
|
|||||||
tags=["folders"],
|
tags=["folders"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_folder(folder_in: FolderCreate, current_user: User = Depends(get_current_user)):
|
async def create_folder(
|
||||||
|
folder_in: FolderCreate, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
# Check if parent folder exists and belongs to the current user
|
# Check if parent folder exists and belongs to the current user
|
||||||
parent_folder = None
|
parent_folder = None
|
||||||
if folder_in.parent_id:
|
if folder_in.parent_id:
|
||||||
parent_folder = await Folder.get_or_none(id=folder_in.parent_id, owner=current_user)
|
parent_folder = await Folder.get_or_none(
|
||||||
|
id=folder_in.parent_id, owner=current_user
|
||||||
|
)
|
||||||
if not parent_folder:
|
if not parent_folder:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
@@ -39,50 +50,82 @@ async def create_folder(folder_in: FolderCreate, current_user: User = Depends(ge
|
|||||||
await log_activity(current_user, "folder_created", "folder", folder.id)
|
await log_activity(current_user, "folder_created", "folder", folder.id)
|
||||||
return await FolderOut.from_tortoise_orm(folder)
|
return await FolderOut.from_tortoise_orm(folder)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{folder_id}/path", response_model=List[FolderOut])
|
@router.get("/{folder_id}/path", response_model=List[FolderOut])
|
||||||
async def get_folder_path(folder_id: int, current_user: User = Depends(get_current_user)):
|
async def get_folder_path(
|
||||||
folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
folder_id: int, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
folder = await Folder.get_or_none(
|
||||||
|
id=folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not folder:
|
if not folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
||||||
|
)
|
||||||
|
|
||||||
path = []
|
path = []
|
||||||
current = folder
|
current = folder
|
||||||
while current:
|
while current:
|
||||||
path.insert(0, await FolderOut.from_tortoise_orm(current))
|
path.insert(0, await FolderOut.from_tortoise_orm(current))
|
||||||
if current.parent_id:
|
if current.parent_id:
|
||||||
current = await Folder.get_or_none(id=current.parent_id, owner=current_user, is_deleted=False)
|
current = await Folder.get_or_none(
|
||||||
|
id=current.parent_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
current = None
|
current = None
|
||||||
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{folder_id}", response_model=FolderOut)
|
@router.get("/{folder_id}", response_model=FolderOut)
|
||||||
async def get_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
async def get_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
||||||
folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
folder = await Folder.get_or_none(
|
||||||
|
id=folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not folder:
|
if not folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
||||||
|
)
|
||||||
return await FolderOut.from_tortoise_orm(folder)
|
return await FolderOut.from_tortoise_orm(folder)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[FolderOut])
|
@router.get("/", response_model=List[FolderOut])
|
||||||
async def list_folders(parent_id: Optional[int] = None, current_user: User = Depends(get_current_user)):
|
async def list_folders(
|
||||||
|
parent_id: Optional[int] = None, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
if parent_id:
|
if parent_id:
|
||||||
parent_folder = await Folder.get_or_none(id=parent_id, owner=current_user, is_deleted=False)
|
parent_folder = await Folder.get_or_none(
|
||||||
|
id=parent_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not parent_folder:
|
if not parent_folder:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Parent folder not found or does not belong to the current user",
|
detail="Parent folder not found or does not belong to the current user",
|
||||||
)
|
)
|
||||||
folders = await Folder.filter(parent=parent_folder, owner=current_user, is_deleted=False).order_by("name")
|
folders = await Folder.filter(
|
||||||
|
parent=parent_folder, owner=current_user, is_deleted=False
|
||||||
|
).order_by("name")
|
||||||
else:
|
else:
|
||||||
# List root folders (folders with no parent)
|
# List root folders (folders with no parent)
|
||||||
folders = await Folder.filter(parent=None, owner=current_user, is_deleted=False).order_by("name")
|
folders = await Folder.filter(
|
||||||
|
parent=None, owner=current_user, is_deleted=False
|
||||||
|
).order_by("name")
|
||||||
return [await FolderOut.from_tortoise_orm(folder) for folder in folders]
|
return [await FolderOut.from_tortoise_orm(folder) for folder in folders]
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{folder_id}", response_model=FolderOut)
|
@router.put("/{folder_id}", response_model=FolderOut)
|
||||||
async def update_folder(folder_id: int, folder_in: FolderUpdate, current_user: User = Depends(get_current_user)):
|
async def update_folder(
|
||||||
folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
folder_id: int,
|
||||||
|
folder_in: FolderUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
folder = await Folder.get_or_none(
|
||||||
|
id=folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not folder:
|
if not folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
||||||
|
)
|
||||||
|
|
||||||
if folder_in.name:
|
if folder_in.name:
|
||||||
existing_folder = await Folder.get_or_none(
|
existing_folder = await Folder.get_or_none(
|
||||||
@@ -97,11 +140,16 @@ async def update_folder(folder_id: int, folder_in: FolderUpdate, current_user: U
|
|||||||
|
|
||||||
if folder_in.parent_id is not None:
|
if folder_in.parent_id is not None:
|
||||||
if folder_in.parent_id == folder_id:
|
if folder_in.parent_id == folder_id:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot set folder as its own parent")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot set folder as its own parent",
|
||||||
|
)
|
||||||
|
|
||||||
new_parent_folder = None
|
new_parent_folder = None
|
||||||
if folder_in.parent_id != 0: # 0 could represent moving to root
|
if folder_in.parent_id != 0: # 0 could represent moving to root
|
||||||
new_parent_folder = await Folder.get_or_none(id=folder_in.parent_id, owner=current_user, is_deleted=False)
|
new_parent_folder = await Folder.get_or_none(
|
||||||
|
id=folder_in.parent_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not new_parent_folder:
|
if not new_parent_folder:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
@@ -113,48 +161,66 @@ async def update_folder(folder_id: int, folder_in: FolderUpdate, current_user: U
|
|||||||
await log_activity(current_user, "folder_updated", "folder", folder.id)
|
await log_activity(current_user, "folder_updated", "folder", folder.id)
|
||||||
return await FolderOut.from_tortoise_orm(folder)
|
return await FolderOut.from_tortoise_orm(folder)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
async def delete_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
||||||
folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
folder = await Folder.get_or_none(
|
||||||
|
id=folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not folder:
|
if not folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
||||||
|
)
|
||||||
|
|
||||||
folder.is_deleted = True
|
folder.is_deleted = True
|
||||||
await folder.save()
|
await folder.save()
|
||||||
await log_activity(current_user, "folder_deleted", "folder", folder.id)
|
await log_activity(current_user, "folder_deleted", "folder", folder.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{folder_id}/star", response_model=FolderOut)
|
@router.post("/{folder_id}/star", response_model=FolderOut)
|
||||||
async def star_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
async def star_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
db_folder = await Folder.get_or_none(
|
||||||
|
id=folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not db_folder:
|
if not db_folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
||||||
|
)
|
||||||
db_folder.is_starred = True
|
db_folder.is_starred = True
|
||||||
await db_folder.save()
|
await db_folder.save()
|
||||||
await log_activity(current_user, "folder_starred", "folder", folder_id)
|
await log_activity(current_user, "folder_starred", "folder", folder_id)
|
||||||
return await FolderOut.from_tortoise_orm(db_folder)
|
return await FolderOut.from_tortoise_orm(db_folder)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{folder_id}/unstar", response_model=FolderOut)
|
@router.post("/{folder_id}/unstar", response_model=FolderOut)
|
||||||
async def unstar_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
async def unstar_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
||||||
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
db_folder = await Folder.get_or_none(
|
||||||
|
id=folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not db_folder:
|
if not db_folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
||||||
|
)
|
||||||
db_folder.is_starred = False
|
db_folder.is_starred = False
|
||||||
await db_folder.save()
|
await db_folder.save()
|
||||||
await log_activity(current_user, "folder_unstarred", "folder", folder_id)
|
await log_activity(current_user, "folder_unstarred", "folder", folder_id)
|
||||||
return await FolderOut.from_tortoise_orm(db_folder)
|
return await FolderOut.from_tortoise_orm(db_folder)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch", response_model=List[FolderOut])
|
@router.post("/batch", response_model=List[FolderOut])
|
||||||
async def batch_folder_operations(
|
async def batch_folder_operations(
|
||||||
batch_operation: BatchFolderOperation,
|
batch_operation: BatchFolderOperation,
|
||||||
payload: Optional[BatchMoveCopyPayload] = None,
|
payload: Optional[BatchMoveCopyPayload] = None,
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
updated_folders = []
|
updated_folders = []
|
||||||
for folder_id in batch_operation.folder_ids:
|
for folder_id in batch_operation.folder_ids:
|
||||||
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
db_folder = await Folder.get_or_none(
|
||||||
|
id=folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not db_folder:
|
if not db_folder:
|
||||||
continue # Skip if folder not found or not owned by user
|
continue # Skip if folder not found or not owned by user
|
||||||
|
|
||||||
if batch_operation.operation == "delete":
|
if batch_operation.operation == "delete":
|
||||||
db_folder.is_deleted = True
|
db_folder.is_deleted = True
|
||||||
@@ -171,13 +237,22 @@ async def batch_folder_operations(
|
|||||||
await db_folder.save()
|
await db_folder.save()
|
||||||
await log_activity(current_user, "folder_unstarred", "folder", folder_id)
|
await log_activity(current_user, "folder_unstarred", "folder", folder_id)
|
||||||
updated_folders.append(db_folder)
|
updated_folders.append(db_folder)
|
||||||
elif batch_operation.operation == "move" and payload and payload.target_folder_id is not None:
|
elif (
|
||||||
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
batch_operation.operation == "move"
|
||||||
|
and payload
|
||||||
|
and payload.target_folder_id is not None
|
||||||
|
):
|
||||||
|
target_folder = await Folder.get_or_none(
|
||||||
|
id=payload.target_folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not target_folder:
|
if not target_folder:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
existing_folder = await Folder.get_or_none(
|
existing_folder = await Folder.get_or_none(
|
||||||
name=db_folder.name, parent=target_folder, owner=current_user, is_deleted=False
|
name=db_folder.name,
|
||||||
|
parent=target_folder,
|
||||||
|
owner=current_user,
|
||||||
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
if existing_folder and existing_folder.id != folder_id:
|
if existing_folder and existing_folder.id != folder_id:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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)
|
||||||
+18
-10
@@ -11,15 +11,22 @@ router = APIRouter(
|
|||||||
tags=["search"],
|
tags=["search"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files", response_model=List[FileOut])
|
@router.get("/files", response_model=List[FileOut])
|
||||||
async def search_files(
|
async def search_files(
|
||||||
q: str = Query(..., min_length=1, description="Search query"),
|
q: str = Query(..., min_length=1, description="Search query"),
|
||||||
file_type: Optional[str] = Query(None, description="Filter by MIME type prefix (e.g., 'image', 'video')"),
|
file_type: Optional[str] = Query(
|
||||||
|
None, description="Filter by MIME type prefix (e.g., 'image', 'video')"
|
||||||
|
),
|
||||||
min_size: Optional[int] = Query(None, description="Minimum file size in bytes"),
|
min_size: Optional[int] = Query(None, description="Minimum file size in bytes"),
|
||||||
max_size: Optional[int] = Query(None, description="Maximum file size in bytes"),
|
max_size: Optional[int] = Query(None, description="Maximum file size in bytes"),
|
||||||
date_from: Optional[datetime] = Query(None, description="Filter files created after this date"),
|
date_from: Optional[datetime] = Query(
|
||||||
date_to: Optional[datetime] = Query(None, description="Filter files created before this date"),
|
None, description="Filter files created after this date"
|
||||||
current_user: User = Depends(get_current_user)
|
),
|
||||||
|
date_to: Optional[datetime] = Query(
|
||||||
|
None, description="Filter files created before this date"
|
||||||
|
),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
query = File.filter(owner=current_user, is_deleted=False, name__icontains=q)
|
query = File.filter(owner=current_user, is_deleted=False, name__icontains=q)
|
||||||
|
|
||||||
@@ -41,15 +48,16 @@ async def search_files(
|
|||||||
files = await query.order_by("-created_at").limit(100)
|
files = await query.order_by("-created_at").limit(100)
|
||||||
return [await FileOut.from_tortoise_orm(f) for f in files]
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/folders", response_model=List[FolderOut])
|
@router.get("/folders", response_model=List[FolderOut])
|
||||||
async def search_folders(
|
async def search_folders(
|
||||||
q: str = Query(..., min_length=1, description="Search query"),
|
q: str = Query(..., min_length=1, description="Search query"),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
folders = await Folder.filter(
|
folders = (
|
||||||
owner=current_user,
|
await Folder.filter(owner=current_user, is_deleted=False, name__icontains=q)
|
||||||
is_deleted=False,
|
.order_by("-created_at")
|
||||||
name__icontains=q
|
.limit(100)
|
||||||
).order_by("-created_at").limit(100)
|
)
|
||||||
|
|
||||||
return [await FolderOut.from_tortoise_orm(folder) for folder in folders]
|
return [await FolderOut.from_tortoise_orm(folder) for folder in folders]
|
||||||
|
|||||||
+132
-33
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..models import User, File, Folder, Share
|
from ..models import User, File, Folder, Share
|
||||||
@@ -17,25 +17,44 @@ router = APIRouter(
|
|||||||
tags=["shares"],
|
tags=["shares"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=ShareOut, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=ShareOut, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_share_link(share_in: ShareCreate, current_user: User = Depends(get_current_user)):
|
async def create_share_link(
|
||||||
|
share_in: ShareCreate, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
if not share_in.file_id and not share_in.folder_id:
|
if not share_in.file_id and not share_in.folder_id:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Either file_id or folder_id must be provided")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Either file_id or folder_id must be provided",
|
||||||
|
)
|
||||||
if share_in.file_id and share_in.folder_id:
|
if share_in.file_id and share_in.folder_id:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot share both a file and a folder simultaneously")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot share both a file and a folder simultaneously",
|
||||||
|
)
|
||||||
|
|
||||||
file = None
|
file = None
|
||||||
folder = None
|
folder = None
|
||||||
|
|
||||||
if share_in.file_id:
|
if share_in.file_id:
|
||||||
file = await File.get_or_none(id=share_in.file_id, owner=current_user, is_deleted=False)
|
file = await File.get_or_none(
|
||||||
|
id=share_in.file_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not file:
|
if not file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found or does not belong to you")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found or does not belong to you",
|
||||||
|
)
|
||||||
|
|
||||||
if share_in.folder_id:
|
if share_in.folder_id:
|
||||||
folder = await Folder.get_or_none(id=share_in.folder_id, owner=current_user, is_deleted=False)
|
folder = await Folder.get_or_none(
|
||||||
|
id=share_in.folder_id, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
if not folder:
|
if not folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found or does not belong to you")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Folder not found or does not belong to you",
|
||||||
|
)
|
||||||
|
|
||||||
token = secrets.token_urlsafe(16)
|
token = secrets.token_urlsafe(16)
|
||||||
hashed_password = None
|
hashed_password = None
|
||||||
@@ -60,8 +79,14 @@ async def create_share_link(share_in: ShareCreate, current_user: User = Depends(
|
|||||||
item_type = "file" if file else "folder"
|
item_type = "file" if file else "folder"
|
||||||
item_name = file.name if file else folder.name
|
item_name = file.name if file else folder.name
|
||||||
|
|
||||||
expiry_text = f" until {share_in.expires_at.strftime('%Y-%m-%d %H:%M')}" if share_in.expires_at else ""
|
expiry_text = (
|
||||||
password_text = f"\n\nPassword: {share_in.password}" if share_in.password else ""
|
f" until {share_in.expires_at.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
if share_in.expires_at
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
password_text = (
|
||||||
|
f"\n\nPassword: {share_in.password}" if share_in.password else ""
|
||||||
|
)
|
||||||
|
|
||||||
email_body = f"""Hello,
|
email_body = f"""Hello,
|
||||||
|
|
||||||
@@ -95,26 +120,32 @@ MyWebdav File Sharing Service"""
|
|||||||
to_email=share_in.invite_email,
|
to_email=share_in.invite_email,
|
||||||
subject=f"{current_user.username} shared {item_name} with you",
|
subject=f"{current_user.username} shared {item_name} with you",
|
||||||
body=email_body,
|
body=email_body,
|
||||||
html=email_html
|
html=email_html,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to send invitation email: {e}")
|
print(f"Failed to send invitation email: {e}")
|
||||||
|
|
||||||
return await ShareOut.from_tortoise_orm(share)
|
return await ShareOut.from_tortoise_orm(share)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/my", response_model=List[ShareOut])
|
@router.get("/my", response_model=List[ShareOut])
|
||||||
async def list_my_shares(current_user: User = Depends(get_current_user)):
|
async def list_my_shares(current_user: User = Depends(get_current_user)):
|
||||||
shares = await Share.filter(owner=current_user).order_by("-created_at")
|
shares = await Share.filter(owner=current_user).order_by("-created_at")
|
||||||
return [await ShareOut.from_tortoise_orm(share) for share in shares]
|
return [await ShareOut.from_tortoise_orm(share) for share in shares]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{share_token}", response_model=ShareOut)
|
@router.get("/{share_token}", response_model=ShareOut)
|
||||||
async def get_share_link_info(share_token: str):
|
async def get_share_link_info(share_token: str):
|
||||||
share = await Share.get_or_none(token=share_token)
|
share = await Share.get_or_none(token=share_token)
|
||||||
if not share:
|
if not share:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found"
|
||||||
|
)
|
||||||
|
|
||||||
if share.expires_at and share.expires_at < datetime.utcnow():
|
if share.expires_at and share.expires_at < datetime.utcnow():
|
||||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_410_GONE, detail="Share link has expired"
|
||||||
|
)
|
||||||
|
|
||||||
# Increment access count
|
# Increment access count
|
||||||
share.access_count += 1
|
share.access_count += 1
|
||||||
@@ -122,11 +153,17 @@ async def get_share_link_info(share_token: str):
|
|||||||
|
|
||||||
return await ShareOut.from_tortoise_orm(share)
|
return await ShareOut.from_tortoise_orm(share)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{share_id}", response_model=ShareOut)
|
@router.put("/{share_id}", response_model=ShareOut)
|
||||||
async def update_share(share_id: int, share_in: ShareCreate, current_user: User = Depends(get_current_user)):
|
async def update_share(
|
||||||
|
share_id: int, share_in: ShareCreate, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
share = await Share.get_or_none(id=share_id, owner=current_user)
|
share = await Share.get_or_none(id=share_id, owner=current_user)
|
||||||
if not share:
|
if not share:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found or does not belong to you")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Share link not found or does not belong to you",
|
||||||
|
)
|
||||||
|
|
||||||
if share_in.expires_at is not None:
|
if share_in.expires_at is not None:
|
||||||
share.expires_at = share_in.expires_at
|
share.expires_at = share_in.expires_at
|
||||||
@@ -134,7 +171,7 @@ async def update_share(share_id: int, share_in: ShareCreate, current_user: User
|
|||||||
if share_in.password is not None:
|
if share_in.password is not None:
|
||||||
share.hashed_password = get_password_hash(share_in.password)
|
share.hashed_password = get_password_hash(share_in.password)
|
||||||
share.password_protected = True
|
share.password_protected = True
|
||||||
elif share_in.password == "": # Allow clearing password
|
elif share_in.password == "": # Allow clearing password
|
||||||
share.hashed_password = None
|
share.hashed_password = None
|
||||||
share.password_protected = False
|
share.password_protected = False
|
||||||
|
|
||||||
@@ -144,31 +181,76 @@ async def update_share(share_id: int, share_in: ShareCreate, current_user: User
|
|||||||
await share.save()
|
await share.save()
|
||||||
return await ShareOut.from_tortoise_orm(share)
|
return await ShareOut.from_tortoise_orm(share)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{share_token}/access")
|
@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)
|
share = await Share.get_or_none(token=share_token)
|
||||||
if not share:
|
if not share:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found"
|
||||||
|
)
|
||||||
|
|
||||||
if share.expires_at and share.expires_at < datetime.utcnow():
|
if share.expires_at and share.expires_at < datetime.utcnow():
|
||||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_410_GONE, detail="Share link has expired"
|
||||||
|
)
|
||||||
|
|
||||||
if share.password_protected:
|
if share.password_protected:
|
||||||
if not password or not verify_password(password, share.hashed_password):
|
if not password or not verify_password(password, share.hashed_password):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password"
|
||||||
|
)
|
||||||
|
|
||||||
result = {"message": "Access granted", "permission_level": share.permission_level}
|
result = {"message": "Access granted", "permission_level": share.permission_level}
|
||||||
|
|
||||||
if share.file_id:
|
if share.file_id:
|
||||||
file = await File.get_or_none(id=share.file_id, is_deleted=False)
|
file = await File.get_or_none(id=share.file_id, is_deleted=False)
|
||||||
if not file:
|
if not file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
result["file"] = await FileOut.from_tortoise_orm(file)
|
result["file"] = await FileOut.from_tortoise_orm(file)
|
||||||
result["type"] = "file"
|
result["type"] = "file"
|
||||||
elif share.folder_id:
|
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:
|
if not folder:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
||||||
|
)
|
||||||
result["folder"] = await FolderOut.from_tortoise_orm(folder)
|
result["folder"] = await FolderOut.from_tortoise_orm(folder)
|
||||||
result["type"] = "folder"
|
result["type"] = "folder"
|
||||||
|
|
||||||
@@ -179,29 +261,42 @@ async def access_shared_content(share_token: str, password: Optional[str] = None
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{share_token}/download")
|
@router.get("/{share_token}/download")
|
||||||
async def download_shared_file(share_token: str, password: Optional[str] = None):
|
async def download_shared_file(share_token: str, password: Optional[str] = None):
|
||||||
share = await Share.get_or_none(token=share_token)
|
share = await Share.get_or_none(token=share_token)
|
||||||
if not share:
|
if not share:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found"
|
||||||
|
)
|
||||||
|
|
||||||
if share.expires_at and share.expires_at < datetime.utcnow():
|
if share.expires_at and share.expires_at < datetime.utcnow():
|
||||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_410_GONE, detail="Share link has expired"
|
||||||
|
)
|
||||||
|
|
||||||
if share.password_protected:
|
if share.password_protected:
|
||||||
if not password or not verify_password(password, share.hashed_password):
|
if not password or not verify_password(password, share.hashed_password):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password"
|
||||||
|
)
|
||||||
|
|
||||||
if not share.file_id:
|
if not share.file_id:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This share is not for a file")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="This share is not for a file",
|
||||||
|
)
|
||||||
|
|
||||||
file = await File.get_or_none(id=share.file_id, is_deleted=False)
|
file = await File.get_or_none(id=share.file_id, is_deleted=False)
|
||||||
if not file:
|
if not file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
||||||
|
)
|
||||||
|
|
||||||
owner = await User.get(id=file.owner_id)
|
owner = await User.get(id=file.owner_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
async def file_iterator():
|
async def file_iterator():
|
||||||
async for chunk in storage_manager.get_file(owner.id, file.path):
|
async for chunk in storage_manager.get_file(owner.id, file.path):
|
||||||
yield chunk
|
yield chunk
|
||||||
@@ -209,18 +304,22 @@ async def download_shared_file(share_token: str, password: Optional[str] = None)
|
|||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
content=file_iterator(),
|
content=file_iterator(),
|
||||||
media_type=file.mime_type,
|
media_type=file.mime_type,
|
||||||
headers={
|
headers={"Content-Disposition": f'attachment; filename="{file.name}"'},
|
||||||
"Content-Disposition": f'attachment; filename="{file.name}"'
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=404, detail="File not found in storage")
|
raise HTTPException(status_code=404, detail="File not found in storage")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_share_link(share_id: int, current_user: User = Depends(get_current_user)):
|
async def delete_share_link(
|
||||||
|
share_id: int, current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
share = await Share.get_or_none(id=share_id, owner=current_user)
|
share = await Share.get_or_none(id=share_id, owner=current_user)
|
||||||
if not share:
|
if not share:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found or does not belong to you")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Share link not found or does not belong to you",
|
||||||
|
)
|
||||||
|
|
||||||
await share.delete()
|
await share.delete()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,18 +11,29 @@ router = APIRouter(
|
|||||||
tags=["starred"],
|
tags=["starred"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files", response_model=List[FileOut])
|
@router.get("/files", response_model=List[FileOut])
|
||||||
async def list_starred_files(current_user: User = Depends(get_current_user)):
|
async def list_starred_files(current_user: User = Depends(get_current_user)):
|
||||||
files = await File.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
|
files = await File.filter(
|
||||||
|
owner=current_user, is_starred=True, is_deleted=False
|
||||||
|
).order_by("name")
|
||||||
return [await FileOut.from_tortoise_orm(f) for f in files]
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/folders", response_model=List[FolderOut])
|
@router.get("/folders", response_model=List[FolderOut])
|
||||||
async def list_starred_folders(current_user: User = Depends(get_current_user)):
|
async def list_starred_folders(current_user: User = Depends(get_current_user)):
|
||||||
folders = await Folder.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
|
folders = await Folder.filter(
|
||||||
|
owner=current_user, is_starred=True, is_deleted=False
|
||||||
|
).order_by("name")
|
||||||
return [await FolderOut.from_tortoise_orm(f) for f in folders]
|
return [await FolderOut.from_tortoise_orm(f) for f in folders]
|
||||||
|
|
||||||
@router.get("/all", response_model=List[FileOut]) # This will return files and folders as files for now
|
|
||||||
|
@router.get(
|
||||||
|
"/all", response_model=List[FileOut]
|
||||||
|
) # This will return files and folders as files for now
|
||||||
async def list_all_starred(current_user: User = Depends(get_current_user)):
|
async def list_all_starred(current_user: User = Depends(get_current_user)):
|
||||||
starred_files = await File.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
|
starred_files = await File.filter(
|
||||||
|
owner=current_user, is_starred=True, is_deleted=False
|
||||||
|
).order_by("name")
|
||||||
# For simplicity, we'll return files only for now. A more complex solution would involve a union or a custom schema.
|
# For simplicity, we'll return files only for now. A more complex solution would involve a union or a custom schema.
|
||||||
return [await FileOut.from_tortoise_orm(f) for f in starred_files]
|
return [await FileOut.from_tortoise_orm(f) for f in starred_files]
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..models import User_Pydantic, User, File, Folder
|
from ..models import User_Pydantic, User, File, Folder
|
||||||
from typing import List, Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/users",
|
prefix="/users",
|
||||||
tags=["users"],
|
tags=["users"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=User_Pydantic)
|
@router.get("/me", response_model=User_Pydantic)
|
||||||
async def read_users_me(current_user: User = Depends(get_current_user)):
|
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||||
return await User_Pydantic.from_tortoise_orm(current_user)
|
return await User_Pydantic.from_tortoise_orm(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/export", response_model=Dict[str, Any])
|
@router.get("/me/export", response_model=Dict[str, Any])
|
||||||
async def export_my_data(current_user: User = Depends(get_current_user)):
|
async def export_my_data(current_user: User = Depends(get_current_user)):
|
||||||
"""
|
"""
|
||||||
@@ -35,6 +37,7 @@ async def export_my_data(current_user: User = Depends(get_current_user)):
|
|||||||
# share information, etc., would also be included.
|
# share information, etc., would also be included.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/me", status_code=204)
|
@router.delete("/me", status_code=204)
|
||||||
async def delete_my_account(current_user: User = Depends(get_current_user)):
|
async def delete_my_account(current_user: User = Depends(get_current_user)):
|
||||||
"""
|
"""
|
||||||
@@ -48,5 +51,3 @@ async def delete_my_account(current_user: User = Depends(get_current_user)):
|
|||||||
# Finally, delete the user account
|
# Finally, delete the user account
|
||||||
await current_user.delete()
|
await current_user.delete()
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+25
-10
@@ -1,20 +1,25 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, ConfigDict
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from tortoise.contrib.pydantic import pydantic_model_creator
|
from tortoise.contrib.pydantic import pydantic_model_creator
|
||||||
|
from mywebdav.models import Folder, File, Share, FileVersion
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserLogin(BaseModel):
|
class UserLogin(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserLoginWith2FA(UserLogin):
|
class UserLoginWith2FA(UserLogin):
|
||||||
two_factor_code: Optional[str] = None
|
two_factor_code: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UserAdminUpdate(BaseModel):
|
class UserAdminUpdate(BaseModel):
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
@@ -25,22 +30,27 @@ class UserAdminUpdate(BaseModel):
|
|||||||
plan_type: Optional[str] = None
|
plan_type: Optional[str] = None
|
||||||
is_2fa_enabled: Optional[bool] = None
|
is_2fa_enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
class TokenData(BaseModel):
|
class TokenData(BaseModel):
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
two_factor_verified: bool = False
|
two_factor_verified: bool = False
|
||||||
|
|
||||||
|
|
||||||
class FolderCreate(BaseModel):
|
class FolderCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
parent_id: Optional[int] = None
|
parent_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class FolderUpdate(BaseModel):
|
class FolderUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
parent_id: Optional[int] = None
|
parent_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class ShareCreate(BaseModel):
|
class ShareCreate(BaseModel):
|
||||||
file_id: Optional[int] = None
|
file_id: Optional[int] = None
|
||||||
folder_id: Optional[int] = None
|
folder_id: Optional[int] = None
|
||||||
@@ -49,17 +59,19 @@ class ShareCreate(BaseModel):
|
|||||||
permission_level: str = "viewer"
|
permission_level: str = "viewer"
|
||||||
invite_email: Optional[EmailStr] = None
|
invite_email: Optional[EmailStr] = None
|
||||||
|
|
||||||
|
|
||||||
class TeamCreate(BaseModel):
|
class TeamCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class TeamOut(BaseModel):
|
class TeamOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
owner_id: int
|
owner_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class ActivityOut(BaseModel):
|
class ActivityOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
@@ -70,8 +82,8 @@ class ActivityOut(BaseModel):
|
|||||||
ip_address: Optional[str] = None
|
ip_address: Optional[str] = None
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class FileRequestCreate(BaseModel):
|
class FileRequestCreate(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
@@ -79,6 +91,7 @@ class FileRequestCreate(BaseModel):
|
|||||||
target_folder_id: int
|
target_folder_id: int
|
||||||
expires_at: Optional[datetime] = None
|
expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class FileRequestOut(BaseModel):
|
class FileRequestOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
@@ -90,28 +103,30 @@ class FileRequestOut(BaseModel):
|
|||||||
expires_at: Optional[datetime] = None
|
expires_at: Optional[datetime] = None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
|
||||||
class Config:
|
model_config = ConfigDict(from_attributes=True)
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
from mywebdav.models import Folder, File, Share, FileVersion
|
|
||||||
|
|
||||||
FolderOut = pydantic_model_creator(Folder, name="FolderOut")
|
FolderOut = pydantic_model_creator(Folder, name="FolderOut")
|
||||||
FileOut = pydantic_model_creator(File, name="FileOut")
|
FileOut = pydantic_model_creator(File, name="FileOut")
|
||||||
ShareOut = pydantic_model_creator(Share, name="ShareOut")
|
ShareOut = pydantic_model_creator(Share, name="ShareOut")
|
||||||
FileVersionOut = pydantic_model_creator(FileVersion, name="FileVersionOut")
|
FileVersionOut = pydantic_model_creator(FileVersion, name="FileVersionOut")
|
||||||
|
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
code: int
|
code: int
|
||||||
message: str
|
message: str
|
||||||
details: Optional[str] = None
|
details: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class BatchFileOperation(BaseModel):
|
class BatchFileOperation(BaseModel):
|
||||||
file_ids: List[int]
|
file_ids: List[int]
|
||||||
operation: str # e.g., "delete", "move", "copy", "star", "unstar"
|
operation: str # e.g., "delete", "move", "copy", "star", "unstar"
|
||||||
|
|
||||||
|
|
||||||
class BatchFolderOperation(BaseModel):
|
class BatchFolderOperation(BaseModel):
|
||||||
folder_ids: List[int]
|
folder_ids: List[int]
|
||||||
operation: str # e.g., "delete", "move", "star", "unstar"
|
operation: str # e.g., "delete", "move", "star", "unstar"
|
||||||
|
|
||||||
|
|
||||||
class BatchMoveCopyPayload(BaseModel):
|
class BatchMoveCopyPayload(BaseModel):
|
||||||
target_folder_id: Optional[int] = None
|
target_folder_id: Optional[int] = None
|
||||||
|
|||||||
+17
-4
@@ -2,9 +2,11 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
RATE_LIMIT_ENABLED: bool = False
|
||||||
DATABASE_URL: str = "sqlite:///app/mywebdav.db"
|
DATABASE_URL: str = "sqlite:///app/mywebdav.db"
|
||||||
REDIS_URL: str = "redis://redis:6379/0"
|
REDIS_URL: str = "redis://redis:6379/0"
|
||||||
SECRET_KEY: str = "super_secret_key"
|
SECRET_KEY: str = "super_secret_key"
|
||||||
@@ -30,8 +32,19 @@ class Settings(BaseSettings):
|
|||||||
STRIPE_WEBHOOK_SECRET: str = ""
|
STRIPE_WEBHOOK_SECRET: str = ""
|
||||||
BILLING_ENABLED: bool = False
|
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()
|
settings = Settings()
|
||||||
|
|
||||||
if settings.SECRET_KEY == "super_secret_key" and os.getenv("ENVIRONMENT") == "production":
|
if (
|
||||||
print("ERROR: Secret key must be changed in production. Set SECRET_KEY environment variable.")
|
settings.SECRET_KEY == "super_secret_key"
|
||||||
|
and os.getenv("ENVIRONMENT") == "production"
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
"ERROR: Secret key must be changed in production. Set SECRET_KEY environment variable."
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
+7
-1
@@ -5,6 +5,7 @@ from typing import AsyncGenerator
|
|||||||
|
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
|
|
||||||
class StorageManager:
|
class StorageManager:
|
||||||
def __init__(self, base_path: str = settings.STORAGE_PATH):
|
def __init__(self, base_path: str = settings.STORAGE_PATH):
|
||||||
self.base_path = Path(base_path)
|
self.base_path = Path(base_path)
|
||||||
@@ -12,7 +13,11 @@ class StorageManager:
|
|||||||
|
|
||||||
async def _get_full_path(self, user_id: int, file_path: str) -> Path:
|
async def _get_full_path(self, user_id: int, file_path: str) -> Path:
|
||||||
# Ensure file_path is relative and safe
|
# Ensure file_path is relative and safe
|
||||||
relative_path = Path(file_path).relative_to('/') if str(file_path).startswith('/') else Path(file_path)
|
relative_path = (
|
||||||
|
Path(file_path).relative_to("/")
|
||||||
|
if str(file_path).startswith("/")
|
||||||
|
else Path(file_path)
|
||||||
|
)
|
||||||
full_path = self.base_path / str(user_id) / relative_path
|
full_path = self.base_path / str(user_id) / relative_path
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
return full_path
|
return full_path
|
||||||
@@ -52,4 +57,5 @@ class StorageManager:
|
|||||||
full_path = await self._get_full_path(user_id, file_path)
|
full_path = await self._get_full_path(user_id, file_path)
|
||||||
return full_path.exists()
|
return full_path.exists()
|
||||||
|
|
||||||
|
|
||||||
storage_manager = StorageManager()
|
storage_manager = StorageManager()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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">← Back to Payments</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}">« Previous</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="disabled">« 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 »</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="disabled">Next »</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
@@ -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"> </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 %}
|
||||||
@@ -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 %}">« Previous</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="disabled">« 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 »</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="disabled">Next »</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 & Deletion</a>
|
||||||
|
<a href="/legal/contact_complaint_mechanism">Contact & 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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
+38
-15
@@ -1,4 +1,3 @@
|
|||||||
import os
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -9,7 +8,10 @@ from .settings import settings
|
|||||||
THUMBNAIL_SIZE = (300, 300)
|
THUMBNAIL_SIZE = (300, 300)
|
||||||
THUMBNAIL_DIR = "thumbnails"
|
THUMBNAIL_DIR = "thumbnails"
|
||||||
|
|
||||||
async def generate_thumbnail(file_path: str, mime_type: str, user_id: int) -> Optional[str]:
|
|
||||||
|
async def generate_thumbnail(
|
||||||
|
file_path: str, mime_type: str, user_id: int
|
||||||
|
) -> Optional[str]:
|
||||||
try:
|
try:
|
||||||
if mime_type.startswith("image/"):
|
if mime_type.startswith("image/"):
|
||||||
return await generate_image_thumbnail(file_path, user_id)
|
return await generate_image_thumbnail(file_path, user_id)
|
||||||
@@ -20,6 +22,7 @@ async def generate_thumbnail(file_path: str, mime_type: str, user_id: int) -> Op
|
|||||||
print(f"Error generating thumbnail for {file_path}: {e}")
|
print(f"Error generating thumbnail for {file_path}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def generate_image_thumbnail(file_path: str, user_id: int) -> Optional[str]:
|
async def generate_image_thumbnail(file_path: str, user_id: int) -> Optional[str]:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
@@ -30,12 +33,16 @@ async def generate_image_thumbnail(file_path: str, user_id: int) -> Optional[str
|
|||||||
|
|
||||||
file_name = Path(file_path).name
|
file_name = Path(file_path).name
|
||||||
thumbnail_name = f"thumb_{file_name}"
|
thumbnail_name = f"thumb_{file_name}"
|
||||||
if not thumbnail_name.lower().endswith(('.jpg', '.jpeg', '.png')):
|
if not thumbnail_name.lower().endswith((".jpg", ".jpeg", ".png")):
|
||||||
thumbnail_name += ".jpg"
|
thumbnail_name += ".jpg"
|
||||||
|
|
||||||
thumbnail_path = thumbnail_dir / thumbnail_name
|
thumbnail_path = thumbnail_dir / thumbnail_name
|
||||||
|
|
||||||
actual_file_path = base_path / str(user_id) / file_path if not Path(file_path).is_absolute() else Path(file_path)
|
actual_file_path = (
|
||||||
|
base_path / str(user_id) / file_path
|
||||||
|
if not Path(file_path).is_absolute()
|
||||||
|
else Path(file_path)
|
||||||
|
)
|
||||||
|
|
||||||
with Image.open(actual_file_path) as img:
|
with Image.open(actual_file_path) as img:
|
||||||
img.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
|
img.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
|
||||||
@@ -44,7 +51,9 @@ async def generate_image_thumbnail(file_path: str, user_id: int) -> Optional[str
|
|||||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||||
if img.mode == "P":
|
if img.mode == "P":
|
||||||
img = img.convert("RGBA")
|
img = img.convert("RGBA")
|
||||||
background.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None)
|
background.paste(
|
||||||
|
img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None
|
||||||
|
)
|
||||||
img = background
|
img = background
|
||||||
|
|
||||||
img.save(str(thumbnail_path), "JPEG", quality=85, optimize=True)
|
img.save(str(thumbnail_path), "JPEG", quality=85, optimize=True)
|
||||||
@@ -53,6 +62,7 @@ async def generate_image_thumbnail(file_path: str, user_id: int) -> Optional[str
|
|||||||
|
|
||||||
return await loop.run_in_executor(None, _generate)
|
return await loop.run_in_executor(None, _generate)
|
||||||
|
|
||||||
|
|
||||||
async def generate_video_thumbnail(file_path: str, user_id: int) -> Optional[str]:
|
async def generate_video_thumbnail(file_path: str, user_id: int) -> Optional[str]:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
@@ -65,17 +75,29 @@ async def generate_video_thumbnail(file_path: str, user_id: int) -> Optional[str
|
|||||||
thumbnail_name = f"thumb_{file_name}.jpg"
|
thumbnail_name = f"thumb_{file_name}.jpg"
|
||||||
thumbnail_path = thumbnail_dir / thumbnail_name
|
thumbnail_path = thumbnail_dir / thumbnail_name
|
||||||
|
|
||||||
actual_file_path = base_path / str(user_id) / file_path if not Path(file_path).is_absolute() else Path(file_path)
|
actual_file_path = (
|
||||||
|
base_path / str(user_id) / file_path
|
||||||
|
if not Path(file_path).is_absolute()
|
||||||
|
else Path(file_path)
|
||||||
|
)
|
||||||
|
|
||||||
subprocess.run([
|
subprocess.run(
|
||||||
"ffmpeg",
|
[
|
||||||
"-i", str(actual_file_path),
|
"ffmpeg",
|
||||||
"-ss", "00:00:01",
|
"-i",
|
||||||
"-vframes", "1",
|
str(actual_file_path),
|
||||||
"-vf", f"scale={THUMBNAIL_SIZE[0]}:{THUMBNAIL_SIZE[1]}:force_original_aspect_ratio=decrease",
|
"-ss",
|
||||||
"-y",
|
"00:00:01",
|
||||||
str(thumbnail_path)
|
"-vframes",
|
||||||
], check=True, capture_output=True)
|
"1",
|
||||||
|
"-vf",
|
||||||
|
f"scale={THUMBNAIL_SIZE[0]}:{THUMBNAIL_SIZE[1]}:force_original_aspect_ratio=decrease",
|
||||||
|
"-y",
|
||||||
|
str(thumbnail_path),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
return str(thumbnail_path.relative_to(base_path / str(user_id)))
|
return str(thumbnail_path.relative_to(base_path / str(user_id)))
|
||||||
|
|
||||||
@@ -84,6 +106,7 @@ async def generate_video_thumbnail(file_path: str, user_id: int) -> Optional[str
|
|||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def delete_thumbnail(thumbnail_path: str, user_id: int):
|
async def delete_thumbnail(thumbnail_path: str, user_id: int):
|
||||||
try:
|
try:
|
||||||
base_path = Path(settings.STORAGE_PATH)
|
base_path = Path(settings.STORAGE_PATH)
|
||||||
|
|||||||
+14
-3
@@ -5,15 +5,20 @@ import base64
|
|||||||
import secrets
|
import secrets
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
def generate_totp_secret() -> str:
|
def generate_totp_secret() -> str:
|
||||||
"""Generates a random base32 TOTP secret."""
|
"""Generates a random base32 TOTP secret."""
|
||||||
return pyotp.random_base32()
|
return pyotp.random_base32()
|
||||||
|
|
||||||
|
|
||||||
def generate_totp_uri(secret: str, account_name: str, issuer_name: str) -> str:
|
def generate_totp_uri(secret: str, account_name: str, issuer_name: str) -> str:
|
||||||
"""Generates a Google Authenticator-compatible TOTP URI."""
|
"""Generates a Google Authenticator-compatible TOTP URI."""
|
||||||
return pyotp.totp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=issuer_name)
|
return pyotp.totp.TOTP(secret).provisioning_uri(
|
||||||
|
name=account_name, issuer_name=issuer_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate_qr_code_base64(uri: str) -> str:
|
def generate_qr_code_base64(uri: str) -> str:
|
||||||
"""Generates a base64 encoded QR code image for a given URI."""
|
"""Generates a base64 encoded QR code image for a given URI."""
|
||||||
@@ -31,27 +36,33 @@ def generate_qr_code_base64(uri: str) -> str:
|
|||||||
img.save(buffered, format="PNG")
|
img.save(buffered, format="PNG")
|
||||||
return base64.b64encode(buffered.getvalue()).decode("utf-8")
|
return base64.b64encode(buffered.getvalue()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def verify_totp_code(secret: str, code: str) -> bool:
|
def verify_totp_code(secret: str, code: str) -> bool:
|
||||||
"""Verifies a TOTP code against a secret."""
|
"""Verifies a TOTP code against a secret."""
|
||||||
totp = pyotp.TOTP(secret)
|
totp = pyotp.TOTP(secret)
|
||||||
return totp.verify(code)
|
return totp.verify(code)
|
||||||
|
|
||||||
|
|
||||||
def generate_recovery_codes(num_codes: int = 10) -> List[str]:
|
def generate_recovery_codes(num_codes: int = 10) -> List[str]:
|
||||||
"""Generates a list of random recovery codes."""
|
"""Generates a list of random recovery codes."""
|
||||||
return [secrets.token_urlsafe(16) for _ in range(num_codes)]
|
return [secrets.token_urlsafe(16) for _ in range(num_codes)]
|
||||||
|
|
||||||
|
|
||||||
def hash_recovery_code(code: str) -> str:
|
def hash_recovery_code(code: str) -> str:
|
||||||
"""Hashes a single recovery code using SHA256."""
|
"""Hashes a single recovery code using SHA256."""
|
||||||
return hashlib.sha256(code.encode('utf-8')).hexdigest()
|
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def verify_recovery_code(plain_code: str, hashed_code: str) -> bool:
|
def verify_recovery_code(plain_code: str, hashed_code: str) -> bool:
|
||||||
"""Verifies a plain recovery code against its hashed version."""
|
"""Verifies a plain recovery code against its hashed version."""
|
||||||
return hash_recovery_code(plain_code) == hashed_code
|
return hash_recovery_code(plain_code) == hashed_code
|
||||||
|
|
||||||
|
|
||||||
def hash_recovery_codes(codes: List[str]) -> List[str]:
|
def hash_recovery_codes(codes: List[str]) -> List[str]:
|
||||||
"""Hashes a list of recovery codes."""
|
"""Hashes a list of recovery codes."""
|
||||||
return [hash_recovery_code(code) for code in codes]
|
return [hash_recovery_code(code) for code in codes]
|
||||||
|
|
||||||
|
|
||||||
def verify_recovery_codes(plain_code: str, hashed_codes: List[str]) -> bool:
|
def verify_recovery_codes(plain_code: str, hashed_codes: List[str]) -> bool:
|
||||||
"""Verifies if a plain recovery code matches any of the hashed recovery codes."""
|
"""Verifies if a plain recovery code matches any of the hashed recovery codes."""
|
||||||
for hashed_code in hashed_codes:
|
for hashed_code in hashed_codes:
|
||||||
|
|||||||
+413
-338
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
from .queue import TaskQueue, get_task_queue
|
||||||
|
|
||||||
|
__all__ = ["TaskQueue", "get_task_queue"]
|
||||||
@@ -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
|
||||||
@@ -32,6 +32,7 @@ ffmpeg-python = "*"
|
|||||||
gunicorn = "*"
|
gunicorn = "*"
|
||||||
aiosmtplib = "*"
|
aiosmtplib = "*"
|
||||||
stripe = "*"
|
stripe = "*"
|
||||||
|
jinja2 = "*"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
black = "*"
|
black = "*"
|
||||||
|
|||||||
@@ -118,3 +118,4 @@ websockets==15.0.1
|
|||||||
yarl==1.22.0
|
yarl==1.22.0
|
||||||
zstandard==0.25.0
|
zstandard==0.25.0
|
||||||
aiosmtplib==5.0.0
|
aiosmtplib==5.0.0
|
||||||
|
jinja2
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -384,3 +384,87 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,3 +88,57 @@
|
|||||||
.code-editor-body textarea {
|
.code-editor-body textarea {
|
||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,3 +143,64 @@
|
|||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
font-weight: 500;
|
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
@@ -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
@@ -579,53 +579,6 @@ body {
|
|||||||
font-size: 1.25rem;
|
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 {
|
body.dark-mode {
|
||||||
--background-color: #222222;
|
--background-color: #222222;
|
||||||
@@ -993,7 +946,7 @@ body.dark-mode {
|
|||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
background-color: rgba(0, 51, 153, 0.05);
|
background-color: rgba(0, 51, 153, 0.05);
|
||||||
}
|
}
|
||||||
-e
|
|
||||||
.shared-items-container {
|
.shared-items-container {
|
||||||
background-color: var(--accent-color);
|
background-color: var(--accent-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
+45
-1
@@ -3,12 +3,56 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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/style.css">
|
||||||
<link rel="stylesheet" href="/static/css/billing.css">
|
<link rel="stylesheet" href="/static/css/billing.css">
|
||||||
<link rel="stylesheet" href="/static/lib/codemirror/codemirror.min.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/code-editor-view.css">
|
||||||
<link rel="stylesheet" href="/static/css/file-upload-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">
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
<script src="/static/lib/codemirror/codemirror.min.js"></script>
|
<script src="/static/lib/codemirror/codemirror.min.js"></script>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { GestureHandler, PullToRefreshIndicator, ContextMenu, isMobile } from '../gesture-handler.js';
|
||||||
|
|
||||||
export class FileList extends HTMLElement {
|
export class FileList extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -12,6 +13,9 @@ export class FileList extends HTMLElement {
|
|||||||
this.boundHandleClick = this.handleClick.bind(this);
|
this.boundHandleClick = this.handleClick.bind(this);
|
||||||
this.boundHandleDblClick = this.handleDblClick.bind(this);
|
this.boundHandleDblClick = this.handleDblClick.bind(this);
|
||||||
this.boundHandleChange = this.handleChange.bind(this);
|
this.boundHandleChange = this.handleChange.bind(this);
|
||||||
|
this.gestureHandler = null;
|
||||||
|
this.pullIndicator = null;
|
||||||
|
this.contextMenu = new ContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
@@ -27,6 +31,12 @@ export class FileList extends HTMLElement {
|
|||||||
this.removeEventListener('click', this.boundHandleClick);
|
this.removeEventListener('click', this.boundHandleClick);
|
||||||
this.removeEventListener('dblclick', this.boundHandleDblClick);
|
this.removeEventListener('dblclick', this.boundHandleDblClick);
|
||||||
this.removeEventListener('change', this.boundHandleChange);
|
this.removeEventListener('change', this.boundHandleChange);
|
||||||
|
if (this.gestureHandler) {
|
||||||
|
this.gestureHandler.destroy();
|
||||||
|
}
|
||||||
|
if (this.pullIndicator) {
|
||||||
|
this.pullIndicator.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadContents(folderId) {
|
async loadContents(folderId) {
|
||||||
@@ -128,7 +138,7 @@ export class FileList extends HTMLElement {
|
|||||||
|
|
||||||
renderFolder(folder) {
|
renderFolder(folder) {
|
||||||
const isSelected = this.selectedFolders.has(folder.id);
|
const isSelected = this.selectedFolders.has(folder.id);
|
||||||
const starIcon = folder.is_starred ? '★' : '☆'; // Filled star or empty star
|
const starIcon = folder.is_starred ? '★' : '☆';
|
||||||
const starAction = folder.is_starred ? 'unstar-folder' : 'star-folder';
|
const starAction = folder.is_starred ? 'unstar-folder' : 'star-folder';
|
||||||
return `
|
return `
|
||||||
<div class="file-item folder-item" data-folder-id="${folder.id}">
|
<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" 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>
|
<button class="action-btn star-btn" data-action="${starAction}" data-id="${folder.id}">${starIcon}</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="mobile-more-btn" data-folder-id="${folder.id}" aria-label="More actions">⋮</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -147,7 +158,7 @@ export class FileList extends HTMLElement {
|
|||||||
const isSelected = this.selectedFiles.has(file.id);
|
const isSelected = this.selectedFiles.has(file.id);
|
||||||
const icon = this.getFileIcon(file.mime_type);
|
const icon = this.getFileIcon(file.mime_type);
|
||||||
const size = this.formatFileSize(file.size);
|
const size = this.formatFileSize(file.size);
|
||||||
const starIcon = file.is_starred ? '★' : '☆'; // Filled star or empty star
|
const starIcon = file.is_starred ? '★' : '☆';
|
||||||
const starAction = file.is_starred ? 'unstar-file' : 'star-file';
|
const starAction = file.is_starred ? 'unstar-file' : 'star-file';
|
||||||
|
|
||||||
return `
|
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" data-action="share" data-id="${file.id}">Share</button>
|
||||||
<button class="action-btn star-btn" data-action="${starAction}" data-id="${file.id}">${starIcon}</button>
|
<button class="action-btn star-btn" data-action="${starAction}" data-id="${file.id}">${starIcon}</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="mobile-more-btn" data-file-id="${file.id}" aria-label="More actions">⋮</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -250,6 +262,26 @@ export class FileList extends HTMLElement {
|
|||||||
return;
|
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')) {
|
if (target.classList.contains('select-item')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
@@ -305,6 +337,117 @@ export class FileList extends HTMLElement {
|
|||||||
|
|
||||||
attachListeners() {
|
attachListeners() {
|
||||||
this.updateBatchActionVisibility();
|
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) {
|
toggleSelectItem(type, id, checked) {
|
||||||
|
|||||||
@@ -1,15 +1,36 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { GestureHandler, isMobile } from '../gesture-handler.js';
|
||||||
|
|
||||||
class FilePreview extends HTMLElement {
|
class FilePreview extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.file = null;
|
this.file = null;
|
||||||
this.handleEscape = this.handleEscape.bind(this);
|
this.handleEscape = this.handleEscape.bind(this);
|
||||||
|
this.gestureHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.render();
|
this.render();
|
||||||
this.setupEventListeners();
|
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() {
|
setupEventListeners() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { GestureHandler, isMobile } from '../gesture-handler.js';
|
||||||
|
|
||||||
export class FileUploadView extends HTMLElement {
|
export class FileUploadView extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -6,6 +7,7 @@ export class FileUploadView extends HTMLElement {
|
|||||||
this.folderId = null;
|
this.folderId = null;
|
||||||
this.handleEscape = this.handleEscape.bind(this);
|
this.handleEscape = this.handleEscape.bind(this);
|
||||||
this.uploadItems = new Map();
|
this.uploadItems = new Map();
|
||||||
|
this.gestureHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
@@ -14,6 +16,21 @@ export class FileUploadView extends HTMLElement {
|
|||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
document.removeEventListener('keydown', this.handleEscape);
|
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) {
|
setFolder(folderId) {
|
||||||
@@ -57,6 +74,8 @@ export class FileUploadView extends HTMLElement {
|
|||||||
backBtn.addEventListener('click', () => this.close());
|
backBtn.addEventListener('click', () => this.close());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.initGestures();
|
||||||
|
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.addEventListener('change', (e) => {
|
fileInput.addEventListener('change', (e) => {
|
||||||
if (e.target.files.length > 0) {
|
if (e.target.files.length > 0) {
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import './billing-dashboard.js';
|
|||||||
import './admin-billing.js';
|
import './admin-billing.js';
|
||||||
import './code-editor-view.js';
|
import './code-editor-view.js';
|
||||||
import './cookie-consent.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 { shortcuts } from '../shortcuts.js';
|
||||||
|
import { GestureHandler, isMobile } from '../gesture-handler.js';
|
||||||
|
|
||||||
const api = app.getAPI();
|
const api = app.getAPI();
|
||||||
const logger = app.getLogger();
|
const logger = app.getLogger();
|
||||||
@@ -31,6 +32,8 @@ export class MyWebdavApp extends HTMLElement {
|
|||||||
this.boundHandlePopState = this.handlePopState.bind(this);
|
this.boundHandlePopState = this.handlePopState.bind(this);
|
||||||
this.popstateAttached = false;
|
this.popstateAttached = false;
|
||||||
this.currentSearchId = 0;
|
this.currentSearchId = 0;
|
||||||
|
this.gestureHandler = null;
|
||||||
|
this.sidebarOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
@@ -97,6 +100,7 @@ export class MyWebdavApp extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showLogin() {
|
showLogin() {
|
||||||
|
document.body.classList.remove('logged-in');
|
||||||
this.innerHTML = `
|
this.innerHTML = `
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<login-view></login-view>
|
<login-view></login-view>
|
||||||
@@ -123,14 +127,20 @@ export class MyWebdavApp extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
document.body.classList.add('logged-in');
|
||||||
this.innerHTML = `
|
this.innerHTML = `
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<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>
|
<h1 class="app-title">MyWebdav</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-center">
|
<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>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<span class="user-info">${this.user.username}</span>
|
<span class="user-info">${this.user.username}</span>
|
||||||
@@ -139,7 +149,8 @@ export class MyWebdavApp extends HTMLElement {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="app-body">
|
<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">
|
<nav class="sidebar-nav">
|
||||||
<h3 class="nav-title">Navigation</h3>
|
<h3 class="nav-title">Navigation</h3>
|
||||||
<ul class="nav-list">
|
<ul class="nav-list">
|
||||||
@@ -160,7 +171,7 @@ export class MyWebdavApp extends HTMLElement {
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="app-main">
|
<main class="app-main" id="app-main">
|
||||||
<div id="main-content">
|
<div id="main-content">
|
||||||
<file-list></file-list>
|
<file-list></file-list>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,6 +202,55 @@ export class MyWebdavApp extends HTMLElement {
|
|||||||
this.initializeNavigation();
|
this.initializeNavigation();
|
||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
this.registerShortcuts();
|
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() {
|
initializeNavigation() {
|
||||||
@@ -402,11 +462,22 @@ export class MyWebdavApp extends HTMLElement {
|
|||||||
api.logout();
|
api.logout();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.querySelector('#hamburger-btn')?.addEventListener('click', () => {
|
||||||
|
this.toggleSidebar();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.querySelector('#sidebar-overlay')?.addEventListener('click', () => {
|
||||||
|
this.closeSidebar();
|
||||||
|
});
|
||||||
|
|
||||||
this.querySelectorAll('.nav-link').forEach(link => {
|
this.querySelectorAll('.nav-link').forEach(link => {
|
||||||
link.addEventListener('click', (e) => {
|
link.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const view = link.dataset.view;
|
const view = link.dataset.view;
|
||||||
this.switchView(view);
|
this.switchView(view);
|
||||||
|
if (isMobile()) {
|
||||||
|
this.closeSidebar();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,54 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { GestureHandler, PullToRefreshIndicator } from '../gesture-handler.js';
|
||||||
|
|
||||||
class PhotoGallery extends HTMLElement {
|
class PhotoGallery extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.photos = [];
|
this.photos = [];
|
||||||
this.boundHandleClick = this.handleClick.bind(this);
|
this.boundHandleClick = this.handleClick.bind(this);
|
||||||
|
this.gestureHandler = null;
|
||||||
|
this.pullIndicator = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.addEventListener('click', this.boundHandleClick);
|
this.addEventListener('click', this.boundHandleClick);
|
||||||
this.render();
|
this.render();
|
||||||
this.loadPhotos();
|
this.loadPhotos();
|
||||||
|
this.initGestures();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this.removeEventListener('click', this.boundHandleClick);
|
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() {
|
async loadPhotos() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import { GestureHandler, isMobile } from '../gesture-handler.js';
|
||||||
|
|
||||||
export class ShareModal extends HTMLElement {
|
export class ShareModal extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -6,10 +7,29 @@ export class ShareModal extends HTMLElement {
|
|||||||
this.fileId = null;
|
this.fileId = null;
|
||||||
this.folderId = null;
|
this.folderId = null;
|
||||||
this.handleEscape = this.handleEscape.bind(this);
|
this.handleEscape = this.handleEscape.bind(this);
|
||||||
|
this.gestureHandler = null;
|
||||||
this.render();
|
this.render();
|
||||||
this.attachListeners();
|
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() {
|
render() {
|
||||||
this.innerHTML = `
|
this.innerHTML = `
|
||||||
<div class="share-modal" id="share-modal" style="display: none;">
|
<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-result').style.display = 'none';
|
||||||
this.querySelector('#share-form').reset();
|
this.querySelector('#share-form').reset();
|
||||||
document.addEventListener('keydown', this.handleEscape);
|
document.addEventListener('keydown', this.handleEscape);
|
||||||
|
this.initGestures();
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "MyWebdav Cloud Storage",
|
"name": "MyWebdav Cloud Storage",
|
||||||
"short_name": "MyWebdav",
|
"short_name": "MyWebdav",
|
||||||
"description": "A self-hosted cloud storage web application",
|
"description": "A cloud storage SaaS web application",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#F0F2F5",
|
"background_color": "#F0F2F5",
|
||||||
|
|||||||
@@ -1,98 +1,90 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient, ASGITransport
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
from tortoise.contrib.test import initializer, finalizer
|
|
||||||
from mywebdav.main import app
|
from mywebdav.main import app
|
||||||
from mywebdav.models import User
|
from mywebdav.models import User
|
||||||
from mywebdav.billing.models import PricingConfig, Invoice, UsageAggregate, UserSubscription
|
from mywebdav.billing.models import (
|
||||||
|
PricingConfig,
|
||||||
|
Invoice,
|
||||||
|
UsageAggregate,
|
||||||
|
UserSubscription,
|
||||||
|
)
|
||||||
from mywebdav.auth import create_access_token
|
from mywebdav.auth import create_access_token
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def event_loop():
|
|
||||||
import asyncio
|
|
||||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
||||||
yield loop
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
@pytest_asyncio.fixture
|
||||||
async def initialize_tests():
|
|
||||||
initializer(["mywebdav.models", "mywebdav.billing.models"], db_url="sqlite://:memory:")
|
|
||||||
yield
|
|
||||||
await finalizer()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def test_user():
|
async def test_user():
|
||||||
user = await User.create(
|
user = await User.create(
|
||||||
username="testuser",
|
username="testuser",
|
||||||
email="test@example.com",
|
email="test@example.com",
|
||||||
hashed_password="hashed_password_here",
|
hashed_password="hashed_password_here",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_superuser=False
|
is_superuser=False,
|
||||||
)
|
)
|
||||||
yield user
|
yield user
|
||||||
await user.delete()
|
await user.delete()
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
async def admin_user():
|
async def admin_user():
|
||||||
user = await User.create(
|
user = await User.create(
|
||||||
username="adminuser",
|
username="adminuser",
|
||||||
email="admin@example.com",
|
email="admin@example.com",
|
||||||
hashed_password="hashed_password_here",
|
hashed_password="hashed_password_here",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_superuser=True
|
is_superuser=True,
|
||||||
)
|
)
|
||||||
yield user
|
yield user
|
||||||
await user.delete()
|
await user.delete()
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
async def auth_token(test_user):
|
async def auth_token(test_user):
|
||||||
token = create_access_token(data={"sub": test_user.username})
|
token = create_access_token(data={"sub": test_user.username})
|
||||||
return token
|
return token
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
async def admin_token(admin_user):
|
async def admin_token(admin_user):
|
||||||
token = create_access_token(data={"sub": admin_user.username})
|
token = create_access_token(data={"sub": admin_user.username})
|
||||||
return token
|
return token
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
async def pricing_config():
|
async def pricing_config():
|
||||||
configs = []
|
configs = []
|
||||||
configs.append(await PricingConfig.create(
|
configs.append(
|
||||||
config_key="storage_per_gb_month",
|
await PricingConfig.create(
|
||||||
config_value=Decimal("0.0045"),
|
config_key="storage_per_gb_month",
|
||||||
description="Storage cost per GB per month",
|
config_value=Decimal("0.005"),
|
||||||
unit="per_gb_month"
|
description="Storage cost per GB per month",
|
||||||
))
|
unit="per_gb_month",
|
||||||
configs.append(await PricingConfig.create(
|
)
|
||||||
config_key="bandwidth_egress_per_gb",
|
)
|
||||||
config_value=Decimal("0.009"),
|
configs.append(
|
||||||
description="Bandwidth egress cost per GB",
|
await PricingConfig.create(
|
||||||
unit="per_gb"
|
config_key="bandwidth_egress_per_gb",
|
||||||
))
|
config_value=Decimal("0.008"),
|
||||||
configs.append(await PricingConfig.create(
|
description="Bandwidth egress cost per GB",
|
||||||
config_key="free_tier_storage_gb",
|
unit="per_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
|
yield configs
|
||||||
for config in configs:
|
for config in configs:
|
||||||
await config.delete()
|
await config.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_current_usage(test_user, auth_token):
|
async def test_get_current_usage(test_user, auth_token):
|
||||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/billing/usage/current",
|
"/api/billing/usage/current",
|
||||||
headers={"Authorization": f"Bearer {auth_token}"}
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -100,6 +92,7 @@ async def test_get_current_usage(test_user, auth_token):
|
|||||||
assert "storage_gb" in data
|
assert "storage_gb" in data
|
||||||
assert "bandwidth_down_gb_today" in data
|
assert "bandwidth_down_gb_today" in data
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_monthly_usage(test_user, auth_token):
|
async def test_get_monthly_usage(test_user, auth_token):
|
||||||
today = date.today()
|
today = date.today()
|
||||||
@@ -107,16 +100,18 @@ async def test_get_monthly_usage(test_user, auth_token):
|
|||||||
await UsageAggregate.create(
|
await UsageAggregate.create(
|
||||||
user=test_user,
|
user=test_user,
|
||||||
date=today,
|
date=today,
|
||||||
storage_bytes_avg=1024 ** 3 * 10,
|
storage_bytes_avg=1024**3 * 10,
|
||||||
storage_bytes_peak=1024 ** 3 * 12,
|
storage_bytes_peak=1024**3 * 12,
|
||||||
bandwidth_up_bytes=1024 ** 3 * 2,
|
bandwidth_up_bytes=1024**3 * 2,
|
||||||
bandwidth_down_bytes=1024 ** 3 * 5
|
bandwidth_down_bytes=1024**3 * 5,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"/api/billing/usage/monthly?year={today.year}&month={today.month}",
|
f"/api/billing/usage/monthly?year={today.year}&month={today.month}",
|
||||||
headers={"Authorization": f"Bearer {auth_token}"}
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -125,12 +120,15 @@ async def test_get_monthly_usage(test_user, auth_token):
|
|||||||
|
|
||||||
await UsageAggregate.filter(user=test_user).delete()
|
await UsageAggregate.filter(user=test_user).delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_subscription(test_user, auth_token):
|
async def test_get_subscription(test_user, auth_token):
|
||||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/billing/subscription",
|
"/api/billing/subscription",
|
||||||
headers={"Authorization": f"Bearer {auth_token}"}
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -140,6 +138,7 @@ async def test_get_subscription(test_user, auth_token):
|
|||||||
|
|
||||||
await UserSubscription.filter(user=test_user).delete()
|
await UserSubscription.filter(user=test_user).delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_invoices(test_user, auth_token):
|
async def test_list_invoices(test_user, auth_token):
|
||||||
invoice = await Invoice.create(
|
invoice = await Invoice.create(
|
||||||
@@ -150,13 +149,14 @@ async def test_list_invoices(test_user, auth_token):
|
|||||||
subtotal=Decimal("10.00"),
|
subtotal=Decimal("10.00"),
|
||||||
tax=Decimal("0.00"),
|
tax=Decimal("0.00"),
|
||||||
total=Decimal("10.00"),
|
total=Decimal("10.00"),
|
||||||
status="open"
|
status="open",
|
||||||
)
|
)
|
||||||
|
|
||||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/billing/invoices",
|
"/api/billing/invoices", headers={"Authorization": f"Bearer {auth_token}"}
|
||||||
headers={"Authorization": f"Bearer {auth_token}"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -166,6 +166,7 @@ async def test_list_invoices(test_user, auth_token):
|
|||||||
|
|
||||||
await invoice.delete()
|
await invoice.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_invoice(test_user, auth_token):
|
async def test_get_invoice(test_user, auth_token):
|
||||||
invoice = await Invoice.create(
|
invoice = await Invoice.create(
|
||||||
@@ -176,13 +177,15 @@ async def test_get_invoice(test_user, auth_token):
|
|||||||
subtotal=Decimal("10.00"),
|
subtotal=Decimal("10.00"),
|
||||||
tax=Decimal("0.00"),
|
tax=Decimal("0.00"),
|
||||||
total=Decimal("10.00"),
|
total=Decimal("10.00"),
|
||||||
status="open"
|
status="open",
|
||||||
)
|
)
|
||||||
|
|
||||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"/api/billing/invoices/{invoice.id}",
|
f"/api/billing/invoices/{invoice.id}",
|
||||||
headers={"Authorization": f"Bearer {auth_token}"}
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -191,39 +194,45 @@ async def test_get_invoice(test_user, auth_token):
|
|||||||
|
|
||||||
await invoice.delete()
|
await invoice.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_pricing():
|
async def test_get_pricing():
|
||||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
response = await client.get("/api/billing/pricing")
|
response = await client.get("/api/billing/pricing")
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert isinstance(data, dict)
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_admin_get_pricing(admin_user, admin_token, pricing_config):
|
async def test_admin_get_pricing(admin_user, admin_token, pricing_config):
|
||||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/admin/billing/pricing",
|
"/api/admin/billing/pricing",
|
||||||
headers={"Authorization": f"Bearer {admin_token}"}
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert len(data) > 0
|
assert len(data) > 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_admin_update_pricing(admin_user, admin_token, pricing_config):
|
async def test_admin_update_pricing(admin_user, admin_token, pricing_config):
|
||||||
config_id = pricing_config[0].id
|
config_id = pricing_config[0].id
|
||||||
|
|
||||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
response = await client.put(
|
response = await client.put(
|
||||||
f"/api/admin/billing/pricing/{config_id}",
|
f"/api/admin/billing/pricing/{config_id}",
|
||||||
headers={"Authorization": f"Bearer {admin_token}"},
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
json={
|
json={"config_key": "storage_per_gb_month", "config_value": 0.005},
|
||||||
"config_key": "storage_per_gb_month",
|
|
||||||
"config_value": 0.005
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -231,12 +240,15 @@ async def test_admin_update_pricing(admin_user, admin_token, pricing_config):
|
|||||||
updated = await PricingConfig.get(id=config_id)
|
updated = await PricingConfig.get(id=config_id)
|
||||||
assert updated.config_value == Decimal("0.005")
|
assert updated.config_value == Decimal("0.005")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_admin_get_stats(admin_user, admin_token):
|
async def test_admin_get_stats(admin_user, admin_token):
|
||||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/admin/billing/stats",
|
"/api/admin/billing/stats",
|
||||||
headers={"Authorization": f"Bearer {admin_token}"}
|
headers={"Authorization": f"Bearer {admin_token}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
@@ -245,12 +257,15 @@ async def test_admin_get_stats(admin_user, admin_token):
|
|||||||
assert "total_invoices" in data
|
assert "total_invoices" in data
|
||||||
assert "pending_invoices" in data
|
assert "pending_invoices" in data
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_admin_cannot_access_admin_endpoints(test_user, auth_token):
|
async def test_non_admin_cannot_access_admin_endpoints(test_user, auth_token):
|
||||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/api/admin/billing/pricing",
|
"/api/admin/billing/pricing",
|
||||||
headers={"Authorization": f"Bearer {auth_token}"}
|
headers={"Authorization": f"Bearer {auth_token}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|||||||
@@ -1,72 +1,62 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from tortoise.contrib.test import initializer, finalizer
|
|
||||||
from mywebdav.models import User
|
from mywebdav.models import User
|
||||||
from mywebdav.billing.models import Invoice, InvoiceLineItem, PricingConfig, UsageAggregate, UserSubscription
|
from mywebdav.billing.models import (
|
||||||
|
Invoice,
|
||||||
|
InvoiceLineItem,
|
||||||
|
PricingConfig,
|
||||||
|
UsageAggregate,
|
||||||
|
UserSubscription,
|
||||||
|
)
|
||||||
from mywebdav.billing.invoice_generator import InvoiceGenerator
|
from mywebdav.billing.invoice_generator import InvoiceGenerator
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def event_loop():
|
|
||||||
import asyncio
|
|
||||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
||||||
yield loop
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
@pytest_asyncio.fixture
|
||||||
async def initialize_tests():
|
|
||||||
initializer(["mywebdav.models", "mywebdav.billing.models"], db_url="sqlite://:memory:")
|
|
||||||
yield
|
|
||||||
await finalizer()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def test_user():
|
async def test_user():
|
||||||
user = await User.create(
|
user = await User.create(
|
||||||
username="testuser",
|
username="testuser",
|
||||||
email="test@example.com",
|
email="test@example.com",
|
||||||
hashed_password="hashed_password_here",
|
hashed_password="hashed_password_here",
|
||||||
is_active=True
|
is_active=True,
|
||||||
)
|
)
|
||||||
yield user
|
yield user
|
||||||
await user.delete()
|
await user.delete()
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
async def pricing_config():
|
async def pricing_config():
|
||||||
configs = []
|
configs = []
|
||||||
configs.append(await PricingConfig.create(
|
configs.append(
|
||||||
config_key="storage_per_gb_month",
|
await PricingConfig.create(
|
||||||
config_value=Decimal("0.0045"),
|
config_key="storage_per_gb_month",
|
||||||
description="Storage cost per GB per month",
|
config_value=Decimal("0.005"),
|
||||||
unit="per_gb_month"
|
description="Storage cost per GB per month",
|
||||||
))
|
unit="per_gb_month",
|
||||||
configs.append(await PricingConfig.create(
|
)
|
||||||
config_key="bandwidth_egress_per_gb",
|
)
|
||||||
config_value=Decimal("0.009"),
|
configs.append(
|
||||||
description="Bandwidth egress cost per GB",
|
await PricingConfig.create(
|
||||||
unit="per_gb"
|
config_key="bandwidth_egress_per_gb",
|
||||||
))
|
config_value=Decimal("0.008"),
|
||||||
configs.append(await PricingConfig.create(
|
description="Bandwidth egress cost per GB",
|
||||||
config_key="free_tier_storage_gb",
|
unit="per_gb",
|
||||||
config_value=Decimal("15"),
|
)
|
||||||
description="Free tier storage in GB",
|
)
|
||||||
unit="gb"
|
configs.append(
|
||||||
))
|
await PricingConfig.create(
|
||||||
configs.append(await PricingConfig.create(
|
config_key="tax_rate_default",
|
||||||
config_key="free_tier_bandwidth_gb",
|
config_value=Decimal("0.0"),
|
||||||
config_value=Decimal("15"),
|
description="Default tax rate",
|
||||||
description="Free tier bandwidth in GB per month",
|
unit="percentage",
|
||||||
unit="gb"
|
)
|
||||||
))
|
)
|
||||||
configs.append(await PricingConfig.create(
|
|
||||||
config_key="tax_rate_default",
|
|
||||||
config_value=Decimal("0.0"),
|
|
||||||
description="Default tax rate",
|
|
||||||
unit="percentage"
|
|
||||||
))
|
|
||||||
yield configs
|
yield configs
|
||||||
for config in configs:
|
for config in configs:
|
||||||
await config.delete()
|
await config.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_generate_monthly_invoice_with_usage(test_user, pricing_config):
|
async def test_generate_monthly_invoice_with_usage(test_user, pricing_config):
|
||||||
today = date.today()
|
today = date.today()
|
||||||
@@ -74,13 +64,15 @@ async def test_generate_monthly_invoice_with_usage(test_user, pricing_config):
|
|||||||
await UsageAggregate.create(
|
await UsageAggregate.create(
|
||||||
user=test_user,
|
user=test_user,
|
||||||
date=today,
|
date=today,
|
||||||
storage_bytes_avg=1024 ** 3 * 50,
|
storage_bytes_avg=1024**3 * 50,
|
||||||
storage_bytes_peak=1024 ** 3 * 55,
|
storage_bytes_peak=1024**3 * 55,
|
||||||
bandwidth_up_bytes=1024 ** 3 * 10,
|
bandwidth_up_bytes=1024**3 * 10,
|
||||||
bandwidth_down_bytes=1024 ** 3 * 20
|
bandwidth_down_bytes=1024**3 * 20,
|
||||||
)
|
)
|
||||||
|
|
||||||
invoice = await InvoiceGenerator.generate_monthly_invoice(test_user, today.year, today.month)
|
invoice = await InvoiceGenerator.generate_monthly_invoice(
|
||||||
|
test_user, today.year, today.month
|
||||||
|
)
|
||||||
|
|
||||||
assert invoice is not None
|
assert invoice is not None
|
||||||
assert invoice.user_id == test_user.id
|
assert invoice.user_id == test_user.id
|
||||||
@@ -93,25 +85,32 @@ async def test_generate_monthly_invoice_with_usage(test_user, pricing_config):
|
|||||||
await invoice.delete()
|
await invoice.delete()
|
||||||
await UsageAggregate.filter(user=test_user).delete()
|
await UsageAggregate.filter(user=test_user).delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
today = date.today()
|
||||||
|
|
||||||
await UsageAggregate.create(
|
await UsageAggregate.create(
|
||||||
user=test_user,
|
user=test_user,
|
||||||
date=today,
|
date=today,
|
||||||
storage_bytes_avg=1024 ** 3 * 10,
|
storage_bytes_avg=1024**3 * 10,
|
||||||
storage_bytes_peak=1024 ** 3 * 12,
|
storage_bytes_peak=1024**3 * 12,
|
||||||
bandwidth_up_bytes=1024 ** 3 * 5,
|
bandwidth_up_bytes=1024**3 * 5,
|
||||||
bandwidth_down_bytes=1024 ** 3 * 10
|
bandwidth_down_bytes=1024**3 * 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
invoice = await InvoiceGenerator.generate_monthly_invoice(test_user, today.year, today.month)
|
invoice = await InvoiceGenerator.generate_monthly_invoice(
|
||||||
|
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()
|
await UsageAggregate.filter(user=test_user).delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_finalize_invoice(test_user, pricing_config):
|
async def test_finalize_invoice(test_user, pricing_config):
|
||||||
invoice = await Invoice.create(
|
invoice = await Invoice.create(
|
||||||
@@ -122,7 +121,7 @@ async def test_finalize_invoice(test_user, pricing_config):
|
|||||||
subtotal=Decimal("10.00"),
|
subtotal=Decimal("10.00"),
|
||||||
tax=Decimal("0.00"),
|
tax=Decimal("0.00"),
|
||||||
total=Decimal("10.00"),
|
total=Decimal("10.00"),
|
||||||
status="draft"
|
status="draft",
|
||||||
)
|
)
|
||||||
|
|
||||||
finalized = await InvoiceGenerator.finalize_invoice(invoice)
|
finalized = await InvoiceGenerator.finalize_invoice(invoice)
|
||||||
@@ -131,6 +130,7 @@ async def test_finalize_invoice(test_user, pricing_config):
|
|||||||
|
|
||||||
await finalized.delete()
|
await finalized.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_finalize_invoice_already_finalized(test_user, pricing_config):
|
async def test_finalize_invoice_already_finalized(test_user, pricing_config):
|
||||||
invoice = await Invoice.create(
|
invoice = await Invoice.create(
|
||||||
@@ -141,7 +141,7 @@ async def test_finalize_invoice_already_finalized(test_user, pricing_config):
|
|||||||
subtotal=Decimal("10.00"),
|
subtotal=Decimal("10.00"),
|
||||||
tax=Decimal("0.00"),
|
tax=Decimal("0.00"),
|
||||||
total=Decimal("10.00"),
|
total=Decimal("10.00"),
|
||||||
status="open"
|
status="open",
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
@@ -149,6 +149,7 @@ async def test_finalize_invoice_already_finalized(test_user, pricing_config):
|
|||||||
|
|
||||||
await invoice.delete()
|
await invoice.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mark_invoice_paid(test_user, pricing_config):
|
async def test_mark_invoice_paid(test_user, pricing_config):
|
||||||
invoice = await Invoice.create(
|
invoice = await Invoice.create(
|
||||||
@@ -159,7 +160,7 @@ async def test_mark_invoice_paid(test_user, pricing_config):
|
|||||||
subtotal=Decimal("10.00"),
|
subtotal=Decimal("10.00"),
|
||||||
tax=Decimal("0.00"),
|
tax=Decimal("0.00"),
|
||||||
total=Decimal("10.00"),
|
total=Decimal("10.00"),
|
||||||
status="open"
|
status="open",
|
||||||
)
|
)
|
||||||
|
|
||||||
paid = await InvoiceGenerator.mark_invoice_paid(invoice)
|
paid = await InvoiceGenerator.mark_invoice_paid(invoice)
|
||||||
@@ -169,22 +170,33 @@ async def test_mark_invoice_paid(test_user, pricing_config):
|
|||||||
|
|
||||||
await paid.delete()
|
await paid.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invoice_with_tax(test_user):
|
async def test_invoice_with_tax(test_user, pricing_config):
|
||||||
await PricingConfig.filter(config_key="tax_rate_default").update(config_value=Decimal("0.21"))
|
# Update tax rate
|
||||||
|
updated = await PricingConfig.filter(config_key="tax_rate_default").update(
|
||||||
|
config_value=Decimal("0.21")
|
||||||
|
)
|
||||||
|
assert updated == 1 # Should update 1 row
|
||||||
|
|
||||||
|
# Verify the update worked
|
||||||
|
tax_config = await PricingConfig.get(config_key="tax_rate_default")
|
||||||
|
assert tax_config.config_value == Decimal("0.21")
|
||||||
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|
||||||
await UsageAggregate.create(
|
await UsageAggregate.create(
|
||||||
user=test_user,
|
user=test_user,
|
||||||
date=today,
|
date=today,
|
||||||
storage_bytes_avg=1024 ** 3 * 50,
|
storage_bytes_avg=1024**3 * 50,
|
||||||
storage_bytes_peak=1024 ** 3 * 55,
|
storage_bytes_peak=1024**3 * 55,
|
||||||
bandwidth_up_bytes=1024 ** 3 * 10,
|
bandwidth_up_bytes=1024**3 * 10,
|
||||||
bandwidth_down_bytes=1024 ** 3 * 20
|
bandwidth_down_bytes=1024**3 * 20,
|
||||||
)
|
)
|
||||||
|
|
||||||
invoice = await InvoiceGenerator.generate_monthly_invoice(test_user, today.year, today.month)
|
invoice = await InvoiceGenerator.generate_monthly_invoice(
|
||||||
|
test_user, today.year, today.month
|
||||||
|
)
|
||||||
|
|
||||||
assert invoice is not None
|
assert invoice is not None
|
||||||
assert invoice.tax > 0
|
assert invoice.tax > 0
|
||||||
@@ -192,4 +204,6 @@ async def test_invoice_with_tax(test_user):
|
|||||||
|
|
||||||
await invoice.delete()
|
await invoice.delete()
|
||||||
await UsageAggregate.filter(user=test_user).delete()
|
await UsageAggregate.filter(user=test_user).delete()
|
||||||
await PricingConfig.filter(config_key="tax_rate_default").update(config_value=Decimal("0.0"))
|
await PricingConfig.filter(config_key="tax_rate_default").update(
|
||||||
|
config_value=Decimal("0.0")
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,25 +2,19 @@ import pytest
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from tortoise.contrib.test import initializer, finalizer
|
|
||||||
from mywebdav.models import User
|
from mywebdav.models import User
|
||||||
from mywebdav.billing.models import (
|
from mywebdav.billing.models import (
|
||||||
SubscriptionPlan, UserSubscription, UsageRecord, UsageAggregate,
|
SubscriptionPlan,
|
||||||
Invoice, InvoiceLineItem, PricingConfig, PaymentMethod, BillingEvent
|
UserSubscription,
|
||||||
|
UsageRecord,
|
||||||
|
UsageAggregate,
|
||||||
|
Invoice,
|
||||||
|
InvoiceLineItem,
|
||||||
|
PricingConfig,
|
||||||
|
PaymentMethod,
|
||||||
|
BillingEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def event_loop():
|
|
||||||
import asyncio
|
|
||||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
||||||
yield loop
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="module", autouse=True)
|
|
||||||
async def initialize_tests():
|
|
||||||
initializer(["mywebdav.models", "mywebdav.billing.models"], db_url="sqlite://:memory:")
|
|
||||||
yield
|
|
||||||
await finalizer()
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def test_user():
|
async def test_user():
|
||||||
@@ -28,11 +22,12 @@ async def test_user():
|
|||||||
username="testuser",
|
username="testuser",
|
||||||
email="test@example.com",
|
email="test@example.com",
|
||||||
hashed_password="hashed_password_here",
|
hashed_password="hashed_password_here",
|
||||||
is_active=True
|
is_active=True,
|
||||||
)
|
)
|
||||||
yield user
|
yield user
|
||||||
await user.delete()
|
await user.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_subscription_plan_creation():
|
async def test_subscription_plan_creation():
|
||||||
plan = await SubscriptionPlan.create(
|
plan = await SubscriptionPlan.create(
|
||||||
@@ -42,7 +37,7 @@ async def test_subscription_plan_creation():
|
|||||||
storage_gb=100,
|
storage_gb=100,
|
||||||
bandwidth_gb=100,
|
bandwidth_gb=100,
|
||||||
price_monthly=Decimal("5.00"),
|
price_monthly=Decimal("5.00"),
|
||||||
price_yearly=Decimal("50.00")
|
price_yearly=Decimal("50.00"),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert plan.name == "starter"
|
assert plan.name == "starter"
|
||||||
@@ -50,12 +45,11 @@ async def test_subscription_plan_creation():
|
|||||||
assert plan.price_monthly == Decimal("5.00")
|
assert plan.price_monthly == Decimal("5.00")
|
||||||
await plan.delete()
|
await plan.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_user_subscription_creation(test_user):
|
async def test_user_subscription_creation(test_user):
|
||||||
subscription = await UserSubscription.create(
|
subscription = await UserSubscription.create(
|
||||||
user=test_user,
|
user=test_user, billing_type="pay_as_you_go", status="active"
|
||||||
billing_type="pay_as_you_go",
|
|
||||||
status="active"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert subscription.user_id == test_user.id
|
assert subscription.user_id == test_user.id
|
||||||
@@ -63,6 +57,7 @@ async def test_user_subscription_creation(test_user):
|
|||||||
assert subscription.status == "active"
|
assert subscription.status == "active"
|
||||||
await subscription.delete()
|
await subscription.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_usage_record_creation(test_user):
|
async def test_usage_record_creation(test_user):
|
||||||
usage = await UsageRecord.create(
|
usage = await UsageRecord.create(
|
||||||
@@ -71,7 +66,7 @@ async def test_usage_record_creation(test_user):
|
|||||||
amount_bytes=1024 * 1024 * 100,
|
amount_bytes=1024 * 1024 * 100,
|
||||||
resource_type="file",
|
resource_type="file",
|
||||||
resource_id=1,
|
resource_id=1,
|
||||||
idempotency_key="test_key_123"
|
idempotency_key="test_key_123",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert usage.user_id == test_user.id
|
assert usage.user_id == test_user.id
|
||||||
@@ -79,6 +74,7 @@ async def test_usage_record_creation(test_user):
|
|||||||
assert usage.amount_bytes == 1024 * 1024 * 100
|
assert usage.amount_bytes == 1024 * 1024 * 100
|
||||||
await usage.delete()
|
await usage.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_usage_aggregate_creation(test_user):
|
async def test_usage_aggregate_creation(test_user):
|
||||||
aggregate = await UsageAggregate.create(
|
aggregate = await UsageAggregate.create(
|
||||||
@@ -87,13 +83,14 @@ async def test_usage_aggregate_creation(test_user):
|
|||||||
storage_bytes_avg=1024 * 1024 * 500,
|
storage_bytes_avg=1024 * 1024 * 500,
|
||||||
storage_bytes_peak=1024 * 1024 * 600,
|
storage_bytes_peak=1024 * 1024 * 600,
|
||||||
bandwidth_up_bytes=1024 * 1024 * 50,
|
bandwidth_up_bytes=1024 * 1024 * 50,
|
||||||
bandwidth_down_bytes=1024 * 1024 * 100
|
bandwidth_down_bytes=1024 * 1024 * 100,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert aggregate.user_id == test_user.id
|
assert aggregate.user_id == test_user.id
|
||||||
assert aggregate.storage_bytes_avg == 1024 * 1024 * 500
|
assert aggregate.storage_bytes_avg == 1024 * 1024 * 500
|
||||||
await aggregate.delete()
|
await aggregate.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invoice_creation(test_user):
|
async def test_invoice_creation(test_user):
|
||||||
invoice = await Invoice.create(
|
invoice = await Invoice.create(
|
||||||
@@ -104,7 +101,7 @@ async def test_invoice_creation(test_user):
|
|||||||
subtotal=Decimal("10.00"),
|
subtotal=Decimal("10.00"),
|
||||||
tax=Decimal("0.00"),
|
tax=Decimal("0.00"),
|
||||||
total=Decimal("10.00"),
|
total=Decimal("10.00"),
|
||||||
status="draft"
|
status="draft",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert invoice.user_id == test_user.id
|
assert invoice.user_id == test_user.id
|
||||||
@@ -112,6 +109,7 @@ async def test_invoice_creation(test_user):
|
|||||||
assert invoice.total == Decimal("10.00")
|
assert invoice.total == Decimal("10.00")
|
||||||
await invoice.delete()
|
await invoice.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invoice_line_item_creation(test_user):
|
async def test_invoice_line_item_creation(test_user):
|
||||||
invoice = await Invoice.create(
|
invoice = await Invoice.create(
|
||||||
@@ -122,7 +120,7 @@ async def test_invoice_line_item_creation(test_user):
|
|||||||
subtotal=Decimal("10.00"),
|
subtotal=Decimal("10.00"),
|
||||||
tax=Decimal("0.00"),
|
tax=Decimal("0.00"),
|
||||||
total=Decimal("10.00"),
|
total=Decimal("10.00"),
|
||||||
status="draft"
|
status="draft",
|
||||||
)
|
)
|
||||||
|
|
||||||
line_item = await InvoiceLineItem.create(
|
line_item = await InvoiceLineItem.create(
|
||||||
@@ -131,7 +129,7 @@ async def test_invoice_line_item_creation(test_user):
|
|||||||
quantity=Decimal("100.000000"),
|
quantity=Decimal("100.000000"),
|
||||||
unit_price=Decimal("0.100000"),
|
unit_price=Decimal("0.100000"),
|
||||||
amount=Decimal("10.0000"),
|
amount=Decimal("10.0000"),
|
||||||
item_type="storage"
|
item_type="storage",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert line_item.invoice_id == invoice.id
|
assert line_item.invoice_id == invoice.id
|
||||||
@@ -140,20 +138,22 @@ async def test_invoice_line_item_creation(test_user):
|
|||||||
await line_item.delete()
|
await line_item.delete()
|
||||||
await invoice.delete()
|
await invoice.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pricing_config_creation(test_user):
|
async def test_pricing_config_creation(test_user):
|
||||||
config = await PricingConfig.create(
|
config = await PricingConfig.create(
|
||||||
config_key="storage_per_gb_month",
|
config_key="storage_per_gb_month",
|
||||||
config_value=Decimal("0.0045"),
|
config_value=Decimal("0.005"),
|
||||||
description="Storage cost per GB per month",
|
description="Storage cost per GB per month",
|
||||||
unit="per_gb_month",
|
unit="per_gb_month",
|
||||||
updated_by=test_user
|
updated_by=test_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert config.config_key == "storage_per_gb_month"
|
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()
|
await config.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_payment_method_creation(test_user):
|
async def test_payment_method_creation(test_user):
|
||||||
payment_method = await PaymentMethod.create(
|
payment_method = await PaymentMethod.create(
|
||||||
@@ -164,7 +164,7 @@ async def test_payment_method_creation(test_user):
|
|||||||
last4="4242",
|
last4="4242",
|
||||||
brand="visa",
|
brand="visa",
|
||||||
exp_month=12,
|
exp_month=12,
|
||||||
exp_year=2025
|
exp_year=2025,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert payment_method.user_id == test_user.id
|
assert payment_method.user_id == test_user.id
|
||||||
@@ -172,6 +172,7 @@ async def test_payment_method_creation(test_user):
|
|||||||
assert payment_method.is_default is True
|
assert payment_method.is_default is True
|
||||||
await payment_method.delete()
|
await payment_method.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_billing_event_creation(test_user):
|
async def test_billing_event_creation(test_user):
|
||||||
event = await BillingEvent.create(
|
event = await BillingEvent.create(
|
||||||
@@ -179,7 +180,7 @@ async def test_billing_event_creation(test_user):
|
|||||||
event_type="invoice_created",
|
event_type="invoice_created",
|
||||||
stripe_event_id="evt_test_123",
|
stripe_event_id="evt_test_123",
|
||||||
data={"invoice_id": 1},
|
data={"invoice_id": 1},
|
||||||
processed=False
|
processed=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert event.user_id == test_user.id
|
assert event.user_id == test_user.id
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def test_billing_module_imports():
|
def test_billing_module_imports():
|
||||||
from mywebdav.billing import models, stripe_client, usage_tracker, invoice_generator, scheduler
|
from mywebdav.billing import (
|
||||||
|
models,
|
||||||
|
stripe_client,
|
||||||
|
usage_tracker,
|
||||||
|
invoice_generator,
|
||||||
|
scheduler,
|
||||||
|
)
|
||||||
|
|
||||||
assert models is not None
|
assert models is not None
|
||||||
assert stripe_client is not None
|
assert stripe_client is not None
|
||||||
assert usage_tracker is not None
|
assert usage_tracker is not None
|
||||||
assert invoice_generator is not None
|
assert invoice_generator is not None
|
||||||
assert scheduler is not None
|
assert scheduler is not None
|
||||||
|
|
||||||
|
|
||||||
def test_billing_models_exist():
|
def test_billing_models_exist():
|
||||||
from mywebdav.billing.models import (
|
from mywebdav.billing.models import (
|
||||||
SubscriptionPlan, UserSubscription, UsageRecord, UsageAggregate,
|
SubscriptionPlan,
|
||||||
Invoice, InvoiceLineItem, PricingConfig, PaymentMethod, BillingEvent
|
UserSubscription,
|
||||||
|
UsageRecord,
|
||||||
|
UsageAggregate,
|
||||||
|
Invoice,
|
||||||
|
InvoiceLineItem,
|
||||||
|
PricingConfig,
|
||||||
|
PaymentMethod,
|
||||||
|
BillingEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert SubscriptionPlan is not None
|
assert SubscriptionPlan is not None
|
||||||
assert UserSubscription is not None
|
assert UserSubscription is not None
|
||||||
assert UsageRecord is not None
|
assert UsageRecord is not None
|
||||||
@@ -23,55 +40,71 @@ def test_billing_models_exist():
|
|||||||
assert PaymentMethod is not None
|
assert PaymentMethod is not None
|
||||||
assert BillingEvent is not None
|
assert BillingEvent is not None
|
||||||
|
|
||||||
|
|
||||||
def test_stripe_client_exists():
|
def test_stripe_client_exists():
|
||||||
from mywebdav.billing.stripe_client import StripeClient
|
from mywebdav.billing.stripe_client import StripeClient
|
||||||
|
|
||||||
assert StripeClient is not None
|
assert StripeClient is not None
|
||||||
assert hasattr(StripeClient, 'create_customer')
|
assert hasattr(StripeClient, "create_customer")
|
||||||
assert hasattr(StripeClient, 'create_invoice')
|
assert hasattr(StripeClient, "create_invoice")
|
||||||
assert hasattr(StripeClient, 'finalize_invoice')
|
assert hasattr(StripeClient, "finalize_invoice")
|
||||||
|
|
||||||
|
|
||||||
def test_usage_tracker_exists():
|
def test_usage_tracker_exists():
|
||||||
from mywebdav.billing.usage_tracker import UsageTracker
|
from mywebdav.billing.usage_tracker import UsageTracker
|
||||||
|
|
||||||
assert UsageTracker is not None
|
assert UsageTracker is not None
|
||||||
assert hasattr(UsageTracker, 'track_storage')
|
assert hasattr(UsageTracker, "track_storage")
|
||||||
assert hasattr(UsageTracker, 'track_bandwidth')
|
assert hasattr(UsageTracker, "track_bandwidth")
|
||||||
assert hasattr(UsageTracker, 'aggregate_daily_usage')
|
assert hasattr(UsageTracker, "aggregate_daily_usage")
|
||||||
assert hasattr(UsageTracker, 'get_current_storage')
|
assert hasattr(UsageTracker, "get_current_storage")
|
||||||
assert hasattr(UsageTracker, 'get_monthly_usage')
|
assert hasattr(UsageTracker, "get_monthly_usage")
|
||||||
|
|
||||||
|
|
||||||
def test_invoice_generator_exists():
|
def test_invoice_generator_exists():
|
||||||
from mywebdav.billing.invoice_generator import InvoiceGenerator
|
from mywebdav.billing.invoice_generator import InvoiceGenerator
|
||||||
|
|
||||||
assert InvoiceGenerator is not None
|
assert InvoiceGenerator is not None
|
||||||
assert hasattr(InvoiceGenerator, 'generate_monthly_invoice')
|
assert hasattr(InvoiceGenerator, "generate_monthly_invoice")
|
||||||
assert hasattr(InvoiceGenerator, 'finalize_invoice')
|
assert hasattr(InvoiceGenerator, "finalize_invoice")
|
||||||
assert hasattr(InvoiceGenerator, 'mark_invoice_paid')
|
assert hasattr(InvoiceGenerator, "mark_invoice_paid")
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_exists():
|
def test_scheduler_exists():
|
||||||
from mywebdav.billing.scheduler import scheduler, start_scheduler, stop_scheduler
|
from mywebdav.billing.scheduler import scheduler, start_scheduler, stop_scheduler
|
||||||
|
|
||||||
assert scheduler is not None
|
assert scheduler is not None
|
||||||
assert callable(start_scheduler)
|
assert callable(start_scheduler)
|
||||||
assert callable(stop_scheduler)
|
assert callable(stop_scheduler)
|
||||||
|
|
||||||
|
|
||||||
def test_routers_exist():
|
def test_routers_exist():
|
||||||
from mywebdav.routers import billing, admin_billing
|
from mywebdav.routers import billing, admin_billing
|
||||||
|
|
||||||
assert billing is not None
|
assert billing is not None
|
||||||
assert admin_billing is not None
|
assert admin_billing is not None
|
||||||
assert hasattr(billing, 'router')
|
assert hasattr(billing, "router")
|
||||||
assert hasattr(admin_billing, 'router')
|
assert hasattr(admin_billing, "router")
|
||||||
|
|
||||||
|
|
||||||
def test_middleware_exists():
|
def test_middleware_exists():
|
||||||
from mywebdav.middleware.usage_tracking import UsageTrackingMiddleware
|
from mywebdav.middleware.usage_tracking import UsageTrackingMiddleware
|
||||||
|
|
||||||
assert UsageTrackingMiddleware is not None
|
assert UsageTrackingMiddleware is not None
|
||||||
|
|
||||||
|
|
||||||
def test_settings_updated():
|
def test_settings_updated():
|
||||||
from mywebdav.settings import settings
|
from mywebdav.settings import settings
|
||||||
assert hasattr(settings, 'STRIPE_SECRET_KEY')
|
|
||||||
assert hasattr(settings, 'STRIPE_PUBLISHABLE_KEY')
|
assert hasattr(settings, "STRIPE_SECRET_KEY")
|
||||||
assert hasattr(settings, 'STRIPE_WEBHOOK_SECRET')
|
assert hasattr(settings, "STRIPE_PUBLISHABLE_KEY")
|
||||||
assert hasattr(settings, 'BILLING_ENABLED')
|
assert hasattr(settings, "STRIPE_WEBHOOK_SECRET")
|
||||||
|
assert hasattr(settings, "BILLING_ENABLED")
|
||||||
|
|
||||||
|
|
||||||
def test_main_includes_billing():
|
def test_main_includes_billing():
|
||||||
from mywebdav.main import app
|
from mywebdav.main import app
|
||||||
|
|
||||||
routes = [route.path for route in app.routes]
|
routes = [route.path for route in app.routes]
|
||||||
billing_routes = [r for r in routes if '/billing' in r]
|
billing_routes = [r for r in routes if "/billing" in r]
|
||||||
assert len(billing_routes) > 0
|
assert len(billing_routes) > 0
|
||||||
|
|||||||
@@ -1,42 +1,31 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from tortoise.contrib.test import initializer, finalizer
|
|
||||||
from mywebdav.models import User, File, Folder
|
from mywebdav.models import User, File, Folder
|
||||||
from mywebdav.billing.models import UsageRecord, UsageAggregate
|
from mywebdav.billing.models import UsageRecord, UsageAggregate
|
||||||
from mywebdav.billing.usage_tracker import UsageTracker
|
from mywebdav.billing.usage_tracker import UsageTracker
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def event_loop():
|
|
||||||
import asyncio
|
|
||||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
||||||
yield loop
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module", autouse=True)
|
@pytest_asyncio.fixture
|
||||||
async def initialize_tests():
|
|
||||||
initializer(["mywebdav.models", "mywebdav.billing.models"], db_url="sqlite://:memory:")
|
|
||||||
yield
|
|
||||||
await finalizer()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def test_user():
|
async def test_user():
|
||||||
user = await User.create(
|
user = await User.create(
|
||||||
username="testuser",
|
username="testuser",
|
||||||
email="test@example.com",
|
email="test@example.com",
|
||||||
hashed_password="hashed_password_here",
|
hashed_password="hashed_password_here",
|
||||||
is_active=True
|
is_active=True,
|
||||||
)
|
)
|
||||||
yield user
|
yield user
|
||||||
await user.delete()
|
await user.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_track_storage(test_user):
|
async def test_track_storage(test_user):
|
||||||
await UsageTracker.track_storage(
|
await UsageTracker.track_storage(
|
||||||
user=test_user,
|
user=test_user,
|
||||||
amount_bytes=1024 * 1024 * 100,
|
amount_bytes=1024 * 1024 * 100,
|
||||||
resource_type="file",
|
resource_type="file",
|
||||||
resource_id=1
|
resource_id=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
records = await UsageRecord.filter(user=test_user, record_type="storage").all()
|
records = await UsageRecord.filter(user=test_user, record_type="storage").all()
|
||||||
@@ -45,6 +34,7 @@ async def test_track_storage(test_user):
|
|||||||
|
|
||||||
await records[0].delete()
|
await records[0].delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_track_bandwidth_upload(test_user):
|
async def test_track_bandwidth_upload(test_user):
|
||||||
await UsageTracker.track_bandwidth(
|
await UsageTracker.track_bandwidth(
|
||||||
@@ -52,7 +42,7 @@ async def test_track_bandwidth_upload(test_user):
|
|||||||
amount_bytes=1024 * 1024 * 50,
|
amount_bytes=1024 * 1024 * 50,
|
||||||
direction="up",
|
direction="up",
|
||||||
resource_type="file",
|
resource_type="file",
|
||||||
resource_id=1
|
resource_id=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
records = await UsageRecord.filter(user=test_user, record_type="bandwidth_up").all()
|
records = await UsageRecord.filter(user=test_user, record_type="bandwidth_up").all()
|
||||||
@@ -61,6 +51,7 @@ async def test_track_bandwidth_upload(test_user):
|
|||||||
|
|
||||||
await records[0].delete()
|
await records[0].delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_track_bandwidth_download(test_user):
|
async def test_track_bandwidth_download(test_user):
|
||||||
await UsageTracker.track_bandwidth(
|
await UsageTracker.track_bandwidth(
|
||||||
@@ -68,32 +59,28 @@ async def test_track_bandwidth_download(test_user):
|
|||||||
amount_bytes=1024 * 1024 * 75,
|
amount_bytes=1024 * 1024 * 75,
|
||||||
direction="down",
|
direction="down",
|
||||||
resource_type="file",
|
resource_type="file",
|
||||||
resource_id=1
|
resource_id=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
records = await UsageRecord.filter(user=test_user, record_type="bandwidth_down").all()
|
records = await UsageRecord.filter(
|
||||||
|
user=test_user, record_type="bandwidth_down"
|
||||||
|
).all()
|
||||||
assert len(records) == 1
|
assert len(records) == 1
|
||||||
assert records[0].amount_bytes == 1024 * 1024 * 75
|
assert records[0].amount_bytes == 1024 * 1024 * 75
|
||||||
|
|
||||||
await records[0].delete()
|
await records[0].delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aggregate_daily_usage(test_user):
|
async def test_aggregate_daily_usage(test_user):
|
||||||
await UsageTracker.track_storage(
|
await UsageTracker.track_storage(user=test_user, amount_bytes=1024 * 1024 * 100)
|
||||||
user=test_user,
|
|
||||||
amount_bytes=1024 * 1024 * 100
|
await UsageTracker.track_bandwidth(
|
||||||
|
user=test_user, amount_bytes=1024 * 1024 * 50, direction="up"
|
||||||
)
|
)
|
||||||
|
|
||||||
await UsageTracker.track_bandwidth(
|
await UsageTracker.track_bandwidth(
|
||||||
user=test_user,
|
user=test_user, amount_bytes=1024 * 1024 * 75, direction="down"
|
||||||
amount_bytes=1024 * 1024 * 50,
|
|
||||||
direction="up"
|
|
||||||
)
|
|
||||||
|
|
||||||
await UsageTracker.track_bandwidth(
|
|
||||||
user=test_user,
|
|
||||||
amount_bytes=1024 * 1024 * 75,
|
|
||||||
direction="down"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
aggregate = await UsageTracker.aggregate_daily_usage(test_user, date.today())
|
aggregate = await UsageTracker.aggregate_daily_usage(test_user, date.today())
|
||||||
@@ -106,12 +93,10 @@ async def test_aggregate_daily_usage(test_user):
|
|||||||
await aggregate.delete()
|
await aggregate.delete()
|
||||||
await UsageRecord.filter(user=test_user).delete()
|
await UsageRecord.filter(user=test_user).delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_current_storage(test_user):
|
async def test_get_current_storage(test_user):
|
||||||
folder = await Folder.create(
|
folder = await Folder.create(name="Test Folder", owner=test_user)
|
||||||
name="Test Folder",
|
|
||||||
owner=test_user
|
|
||||||
)
|
|
||||||
|
|
||||||
file1 = await File.create(
|
file1 = await File.create(
|
||||||
name="test1.txt",
|
name="test1.txt",
|
||||||
@@ -120,7 +105,7 @@ async def test_get_current_storage(test_user):
|
|||||||
mime_type="text/plain",
|
mime_type="text/plain",
|
||||||
owner=test_user,
|
owner=test_user,
|
||||||
parent=folder,
|
parent=folder,
|
||||||
is_deleted=False
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
file2 = await File.create(
|
file2 = await File.create(
|
||||||
@@ -130,7 +115,7 @@ async def test_get_current_storage(test_user):
|
|||||||
mime_type="text/plain",
|
mime_type="text/plain",
|
||||||
owner=test_user,
|
owner=test_user,
|
||||||
parent=folder,
|
parent=folder,
|
||||||
is_deleted=False
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
storage = await UsageTracker.get_current_storage(test_user)
|
storage = await UsageTracker.get_current_storage(test_user)
|
||||||
@@ -141,6 +126,7 @@ async def test_get_current_storage(test_user):
|
|||||||
await file2.delete()
|
await file2.delete()
|
||||||
await folder.delete()
|
await folder.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_monthly_usage(test_user):
|
async def test_get_monthly_usage(test_user):
|
||||||
today = date.today()
|
today = date.today()
|
||||||
@@ -151,7 +137,7 @@ async def test_get_monthly_usage(test_user):
|
|||||||
storage_bytes_avg=1024 * 1024 * 1024 * 10,
|
storage_bytes_avg=1024 * 1024 * 1024 * 10,
|
||||||
storage_bytes_peak=1024 * 1024 * 1024 * 12,
|
storage_bytes_peak=1024 * 1024 * 1024 * 12,
|
||||||
bandwidth_up_bytes=1024 * 1024 * 1024 * 2,
|
bandwidth_up_bytes=1024 * 1024 * 1024 * 2,
|
||||||
bandwidth_down_bytes=1024 * 1024 * 1024 * 5
|
bandwidth_down_bytes=1024 * 1024 * 1024 * 5,
|
||||||
)
|
)
|
||||||
|
|
||||||
usage = await UsageTracker.get_monthly_usage(test_user, today.year, today.month)
|
usage = await UsageTracker.get_monthly_usage(test_user, today.year, today.month)
|
||||||
@@ -163,11 +149,14 @@ async def test_get_monthly_usage(test_user):
|
|||||||
|
|
||||||
await aggregate.delete()
|
await aggregate.delete()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_monthly_usage_empty(test_user):
|
async def test_get_monthly_usage_empty(test_user):
|
||||||
future_date = date.today() + timedelta(days=365)
|
future_date = date.today() + timedelta(days=365)
|
||||||
|
|
||||||
usage = await UsageTracker.get_monthly_usage(test_user, future_date.year, future_date.month)
|
usage = await UsageTracker.get_monthly_usage(
|
||||||
|
test_user, future_date.year, future_date.month
|
||||||
|
)
|
||||||
|
|
||||||
assert usage["storage_gb_avg"] == 0
|
assert usage["storage_gb_avg"] == 0
|
||||||
assert usage["storage_gb_peak"] == 0
|
assert usage["storage_gb_peak"] == 0
|
||||||
|
|||||||
+20
-5
@@ -1,8 +1,23 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from tortoise import Tortoise
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def event_loop():
|
@pytest_asyncio.fixture(scope="session")
|
||||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
async def init_db():
|
||||||
yield loop
|
"""Initialize the database for testing."""
|
||||||
loop.close()
|
await Tortoise.init(
|
||||||
|
db_url="sqlite://:memory:",
|
||||||
|
modules={"models": ["mywebdav.models"], "billing": ["mywebdav.billing.models"]},
|
||||||
|
)
|
||||||
|
await Tortoise.generate_schemas()
|
||||||
|
yield
|
||||||
|
await Tortoise.close_connections()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def setup_db(init_db):
|
||||||
|
"""Ensure database is initialized before each test."""
|
||||||
|
# The init_db fixture handles the setup
|
||||||
|
yield
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
import asyncio
|
|
||||||
from playwright.async_api import async_playwright, Page, expect
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def event_loop():
|
|
||||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
|
||||||
yield loop
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="function")
|
|
||||||
async def browser():
|
|
||||||
async with async_playwright() as p:
|
|
||||||
browser = await p.chromium.launch(headless=False, slow_mo=500)
|
|
||||||
yield browser
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="function")
|
|
||||||
async def context(browser):
|
|
||||||
context = await browser.new_context(
|
|
||||||
viewport={"width": 1920, "height": 1080},
|
|
||||||
user_agent="Mozilla/5.0 (X11; Linux x86_64) MyWebdav E2E Tests",
|
|
||||||
ignore_https_errors=True,
|
|
||||||
service_workers='block'
|
|
||||||
)
|
|
||||||
yield context
|
|
||||||
await context.close()
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="function")
|
|
||||||
async def page(context):
|
|
||||||
page = await context.new_page()
|
|
||||||
yield page
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def base_url():
|
|
||||||
return "http://localhost:9004"
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture(scope="function", autouse=True)
|
|
||||||
async def login(page: Page, base_url):
|
|
||||||
print(f"Navigating to base_url: {base_url}")
|
|
||||||
await page.goto(f"{base_url}/")
|
|
||||||
await page.screenshot(path="01_initial_page.png")
|
|
||||||
|
|
||||||
# If already logged in, log out first to ensure a clean state
|
|
||||||
if await page.locator('a:has-text("Logout")').is_visible():
|
|
||||||
await page.click('a:has-text("Logout")')
|
|
||||||
await page.screenshot(path="02_after_logout.png")
|
|
||||||
|
|
||||||
# Now, proceed with login or registration
|
|
||||||
login_form = page.locator('#login-form:visible')
|
|
||||||
if await login_form.count() > 0:
|
|
||||||
await login_form.locator('input[name="username"]').fill('billingtest')
|
|
||||||
await login_form.locator('input[name="password"]').fill('password123')
|
|
||||||
await page.screenshot(path="03_before_login_click.png")
|
|
||||||
await expect(page.locator('h2:has-text("Files")')).to_be_visible(timeout=10000)
|
|
||||||
else:
|
|
||||||
# If no login form, try to register
|
|
||||||
await page.click('text=Sign Up')
|
|
||||||
register_form = page.locator('#register-form:visible')
|
|
||||||
await register_form.locator('input[name="username"]').fill('billingtest')
|
|
||||||
await register_form.locator('input[name="email"]').fill('billingtest@example.com')
|
|
||||||
await register_form.locator('input[name="password"]').fill('password123')
|
|
||||||
await page.screenshot(path="05_before_register_click.png")
|
|
||||||
await register_form.locator('button[type="submit"]').click()
|
|
||||||
await expect(page.locator('h1:has-text("My Files")')).to_be_visible(timeout=10000)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user