Compare commits
36
Commits
master
..
0a160e3c6e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a160e3c6e | ||
|
|
2cc2a31ab2 | ||
|
|
57c37750ec | ||
|
|
de9cd8d1ba | ||
|
|
a8fb5daa95 | ||
|
|
ec641da2f3 | ||
|
|
f6503035f6 | ||
|
|
d350ab6807 | ||
|
|
aff8dfca08 | ||
|
|
3f80a551d5 | ||
|
|
b25fb89df4 | ||
|
|
ecf78aae7a | ||
|
|
798aa67135 | ||
|
|
74c9fd82d7 | ||
|
|
160be767aa | ||
|
|
8f9ff80cb4 | ||
|
|
9cf65cce42 | ||
|
|
5a61910a93 | ||
|
|
b23fd25337 | ||
|
|
b8d30af69e | ||
|
|
f82079ff27 | ||
|
|
ec396c7809 | ||
|
|
f2735b19e7 | ||
|
|
d957968e6f | ||
|
|
3b57f4cbf6 | ||
|
|
cf800df2a9 | ||
|
|
1df5621c90 | ||
|
|
ba73b8bdf7 | ||
|
|
2325661df4 | ||
|
|
4c36a9ea41 | ||
|
|
1e5a6dbd5f | ||
|
|
1ddb2c609d | ||
|
|
17de53b9c2 | ||
|
|
6fdd4b9f0c | ||
|
|
d90b7ba852 | ||
|
|
adc861d4b4 |
@@ -27,8 +27,3 @@ SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=no-reply@example.com
|
||||
|
||||
TOTP_ISSUER=MyWebdav
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-this-password
|
||||
ADMIN_SESSION_SECRET=change-this-to-a-random-secret
|
||||
ADMIN_SESSION_EXPIRE_HOURS=24
|
||||
|
||||
@@ -45,7 +45,7 @@ dev:
|
||||
|
||||
run:
|
||||
@echo "Starting MyWebdav application..."
|
||||
@echo "Access the application at http://localhost:9004"
|
||||
@echo "Access the application at http://localhost:8000"
|
||||
$(PYTHON) -m mywebdav.main
|
||||
|
||||
test:
|
||||
@@ -99,13 +99,11 @@ init-db:
|
||||
await Tortoise.generate_schemas(); \
|
||||
count = await PricingConfig.all().count(); \
|
||||
if count == 0: \
|
||||
await PricingConfig.create(config_key='storage_per_gb_month', config_value=Decimal('0.005'), description='Storage cost per GB per month (Starter tier)', unit='per_gb_month'); \
|
||||
await PricingConfig.create(config_key='storage_per_gb_month_pro', config_value=Decimal('0.004'), description='Storage cost per GB per month (Professional tier)', unit='per_gb_month'); \
|
||||
await PricingConfig.create(config_key='storage_per_gb_month_enterprise', config_value=Decimal('0.003'), description='Storage cost per GB per month (Enterprise tier, 10TB+)', unit='per_gb_month'); \
|
||||
await PricingConfig.create(config_key='bandwidth_egress_per_gb', config_value=Decimal('0.008'), description='Bandwidth egress cost per GB (Starter tier)', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='bandwidth_egress_per_gb_pro', config_value=Decimal('0.007'), description='Bandwidth egress cost per GB (Professional tier)', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='bandwidth_egress_per_gb_enterprise', config_value=Decimal('0.005'), description='Bandwidth egress cost per GB (Enterprise tier)', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='storage_per_gb_month', config_value=Decimal('0.0045'), description='Storage cost per GB per month', unit='per_gb_month'); \
|
||||
await PricingConfig.create(config_key='bandwidth_egress_per_gb', config_value=Decimal('0.009'), description='Bandwidth egress cost per GB', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='bandwidth_ingress_per_gb', config_value=Decimal('0.0'), description='Bandwidth ingress cost per GB (free)', unit='per_gb'); \
|
||||
await PricingConfig.create(config_key='free_tier_storage_gb', config_value=Decimal('15'), description='Free tier storage in GB', unit='gb'); \
|
||||
await PricingConfig.create(config_key='free_tier_bandwidth_gb', config_value=Decimal('15'), description='Free tier bandwidth in GB per month', unit='gb'); \
|
||||
await PricingConfig.create(config_key='tax_rate_default', config_value=Decimal('0.0'), description='Default tax rate (0 = no tax)', unit='percentage'); \
|
||||
print('Default pricing configuration created'); \
|
||||
else: \
|
||||
@@ -149,9 +147,9 @@ setup: setup-env install dev init-db
|
||||
@echo "Next steps:"
|
||||
@echo " 1. Update .env with your configuration (especially Stripe keys)"
|
||||
@echo " 2. Run 'make run' or 'make all' to start the application"
|
||||
@echo " 3. Access the application at http://localhost:9004"
|
||||
@echo " 3. Access the application at http://localhost:8000"
|
||||
|
||||
docs:
|
||||
@echo "Generating API documentation..."
|
||||
@echo "API documentation available at http://localhost:9004/docs when running"
|
||||
@echo "ReDoc available at http://localhost:9004/redoc when running"
|
||||
@echo "API documentation available at http://localhost:8000/docs when running"
|
||||
@echo "ReDoc available at http://localhost:8000/redoc when running"
|
||||
|
||||
@@ -50,11 +50,8 @@ MyWebdav stands out as a premier cloud storage SaaS solution, offering the cheap
|
||||
- **Webhook Support**: Integration with external services via webhooks
|
||||
|
||||
### Administration
|
||||
- **Admin Panel**: Full-featured backend panel at `/manage/` for system administration
|
||||
- **User Management**: Create, edit, delete users; manage quotas and subscriptions
|
||||
- **Payment Overview**: Invoice tracking, payment status, revenue reporting
|
||||
- **Pricing Configuration**: Adjust storage and bandwidth pricing
|
||||
- **Usage Analytics**: Detailed reporting on storage consumption and bandwidth usage
|
||||
- **Admin Console**: Centralized user management and system monitoring
|
||||
- **API Access**: RESTful API for third-party integrations
|
||||
|
||||
## Pricing
|
||||
@@ -97,15 +94,7 @@ Access the web application through your browser. The interface provides:
|
||||
- Folder management and navigation
|
||||
- Search and filtering capabilities
|
||||
- User profile and settings
|
||||
|
||||
### 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`).
|
||||
- Administrative controls (for admins)
|
||||
|
||||
### API Usage
|
||||
MyWebdav provides a comprehensive REST API. Example requests:
|
||||
|
||||
+8
-43
@@ -1,26 +1,5 @@
|
||||
from typing import Optional, List, Dict
|
||||
from typing import Optional
|
||||
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(
|
||||
@@ -30,24 +9,10 @@ async def log_activity(
|
||||
target_id: int,
|
||||
ip_address: Optional[str] = None,
|
||||
):
|
||||
try:
|
||||
from .enterprise.write_buffer import get_write_buffer, WriteType
|
||||
buffer = get_write_buffer()
|
||||
await buffer.buffer(
|
||||
WriteType.ACTIVITY,
|
||||
{
|
||||
"user_id": user.id if user else None,
|
||||
"action": action,
|
||||
"target_type": target_type,
|
||||
"target_id": target_id,
|
||||
"ip_address": ip_address,
|
||||
},
|
||||
)
|
||||
except RuntimeError:
|
||||
await Activity.create(
|
||||
user=user,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
await Activity.create(
|
||||
user=user,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# 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)
|
||||
+2
-19
@@ -1,6 +1,5 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
@@ -10,19 +9,11 @@ import bcrypt
|
||||
from .schemas import TokenData
|
||||
from .settings import settings
|
||||
from .models import User
|
||||
from .two_factor import verify_totp_code
|
||||
from .two_factor import verify_totp_code # Import verify_totp_code
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
|
||||
def get_token_manager_safe():
|
||||
try:
|
||||
from .auth_tokens import get_token_manager
|
||||
return get_token_manager()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
password_bytes = plain_password[:72].encode("utf-8")
|
||||
hashed_bytes = (
|
||||
@@ -86,16 +77,8 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
two_factor_verified: bool = payload.get("2fa_verified", False)
|
||||
jti: str = payload.get("jti")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
|
||||
token_manager = get_token_manager_safe()
|
||||
if token_manager and jti:
|
||||
is_revoked = await token_manager.is_revoked(jti)
|
||||
if is_revoked:
|
||||
raise credentials_exception
|
||||
|
||||
token_data = TokenData(
|
||||
username=username, two_factor_verified=two_factor_verified
|
||||
)
|
||||
@@ -104,7 +87,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
user = await User.get_or_none(username=token_data.username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
user.token_data = token_data
|
||||
user.token_data = token_data # Attach token_data to user for easy access
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
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
|
||||
@@ -22,49 +22,14 @@ class InvoiceGenerator:
|
||||
pricing = await PricingConfig.all()
|
||||
pricing_dict = {p.config_key: p.config_value for p in pricing}
|
||||
|
||||
# Get user's subscription plan
|
||||
user_subscription = await UserSubscription.get_or_none(user=user)
|
||||
plan_name = "starter" # Default to starter
|
||||
if user_subscription and user_subscription.plan:
|
||||
plan_name = user_subscription.plan.name
|
||||
|
||||
# Set pricing based on subscription tier
|
||||
if plan_name == "professional":
|
||||
storage_price_per_gb = pricing_dict.get(
|
||||
"storage_per_gb_month_professional", Decimal("0.004")
|
||||
)
|
||||
bandwidth_price_per_gb = pricing_dict.get(
|
||||
"bandwidth_egress_per_gb_professional", Decimal("0.007")
|
||||
)
|
||||
elif plan_name == "enterprise":
|
||||
storage_price_per_gb = pricing_dict.get(
|
||||
"storage_per_gb_month_enterprise", Decimal("0.003")
|
||||
)
|
||||
bandwidth_price_per_gb = pricing_dict.get(
|
||||
"bandwidth_egress_per_gb_enterprise", Decimal("0.005")
|
||||
)
|
||||
# Check if user meets minimum storage requirement for enterprise pricing
|
||||
enterprise_min_tb = pricing_dict.get("enterprise_min_storage_tb", Decimal("10"))
|
||||
storage_gb = Decimal(str(usage["storage_gb_avg"]))
|
||||
if storage_gb < (enterprise_min_tb * Decimal("1024")): # Convert TB to GB
|
||||
# User doesn't meet enterprise minimum, fall back to professional
|
||||
storage_price_per_gb = pricing_dict.get(
|
||||
"storage_per_gb_month_professional", Decimal("0.004")
|
||||
)
|
||||
bandwidth_price_per_gb = pricing_dict.get(
|
||||
"bandwidth_egress_per_gb_professional", Decimal("0.007")
|
||||
)
|
||||
else: # starter
|
||||
storage_price_per_gb = pricing_dict.get(
|
||||
"storage_per_gb_month_starter", Decimal("0.005")
|
||||
)
|
||||
bandwidth_price_per_gb = pricing_dict.get(
|
||||
"bandwidth_egress_per_gb_starter", Decimal("0.008")
|
||||
)
|
||||
|
||||
# No free tier - charge from first GB
|
||||
free_storage_gb = Decimal("0")
|
||||
free_bandwidth_gb = Decimal("0")
|
||||
storage_price_per_gb = pricing_dict.get(
|
||||
"storage_per_gb_month", Decimal("0.0045")
|
||||
)
|
||||
bandwidth_price_per_gb = pricing_dict.get(
|
||||
"bandwidth_egress_per_gb", Decimal("0.009")
|
||||
)
|
||||
free_storage_gb = pricing_dict.get("free_tier_storage_gb", Decimal("15"))
|
||||
free_bandwidth_gb = pricing_dict.get("free_tier_bandwidth_gb", Decimal("15"))
|
||||
tax_rate = pricing_dict.get("tax_rate_default", Decimal("0"))
|
||||
|
||||
storage_gb = Decimal(str(usage["storage_gb_avg"]))
|
||||
|
||||
+15
-15
@@ -6,8 +6,8 @@ class SubscriptionPlan(models.Model):
|
||||
name = fields.CharField(max_length=100, unique=True)
|
||||
display_name = fields.CharField(max_length=255)
|
||||
description = fields.TextField(null=True)
|
||||
storage_gb = fields.IntField(null=True) # null means unlimited/usage-based
|
||||
bandwidth_gb = fields.IntField(null=True) # null means unlimited/usage-based
|
||||
storage_gb = fields.IntField()
|
||||
bandwidth_gb = fields.IntField()
|
||||
price_monthly = fields.DecimalField(max_digits=10, decimal_places=2)
|
||||
price_yearly = fields.DecimalField(max_digits=10, decimal_places=2, null=True)
|
||||
stripe_price_id = fields.CharField(max_length=255, null=True)
|
||||
@@ -25,10 +25,10 @@ class UserSubscription(models.Model):
|
||||
plan = fields.ForeignKeyField(
|
||||
"billing.SubscriptionPlan", related_name="subscriptions", null=True
|
||||
)
|
||||
billing_type = fields.CharField(max_length=100, default="pay_as_you_go")
|
||||
billing_type = fields.CharField(max_length=20, default="pay_as_you_go")
|
||||
stripe_customer_id = fields.CharField(max_length=255, unique=True, null=True)
|
||||
stripe_subscription_id = fields.CharField(max_length=255, unique=True, null=True)
|
||||
status = fields.CharField(max_length=100, default="active")
|
||||
status = fields.CharField(max_length=50, default="active")
|
||||
current_period_start = fields.DatetimeField(null=True)
|
||||
current_period_end = fields.DatetimeField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
@@ -42,9 +42,9 @@ class UserSubscription(models.Model):
|
||||
class UsageRecord(models.Model):
|
||||
id = fields.BigIntField(primary_key=True)
|
||||
user = fields.ForeignKeyField("models.User", related_name="usage_records")
|
||||
record_type = fields.CharField(max_length=100, db_index=True)
|
||||
record_type = fields.CharField(max_length=50, db_index=True)
|
||||
amount_bytes = fields.BigIntField()
|
||||
resource_type = fields.CharField(max_length=100, null=True)
|
||||
resource_type = fields.CharField(max_length=50, null=True)
|
||||
resource_id = fields.IntField(null=True)
|
||||
timestamp = fields.DatetimeField(auto_now_add=True, db_index=True)
|
||||
idempotency_key = fields.CharField(max_length=255, unique=True, null=True)
|
||||
@@ -73,15 +73,15 @@ class UsageAggregate(models.Model):
|
||||
class Invoice(models.Model):
|
||||
id = fields.IntField(primary_key=True)
|
||||
user = fields.ForeignKeyField("models.User", related_name="invoices")
|
||||
invoice_number = fields.CharField(max_length=100, unique=True)
|
||||
invoice_number = fields.CharField(max_length=50, unique=True)
|
||||
stripe_invoice_id = fields.CharField(max_length=255, unique=True, null=True)
|
||||
period_start = fields.DateField(db_index=True)
|
||||
period_end = fields.DateField()
|
||||
subtotal = fields.DecimalField(max_digits=10, decimal_places=4)
|
||||
tax = fields.DecimalField(max_digits=10, decimal_places=4, default=0)
|
||||
total = fields.DecimalField(max_digits=10, decimal_places=4)
|
||||
currency = fields.CharField(max_length=100, default="USD")
|
||||
status = fields.CharField(max_length=100, default="draft", db_index=True)
|
||||
currency = fields.CharField(max_length=3, default="USD")
|
||||
status = fields.CharField(max_length=50, default="draft", db_index=True)
|
||||
due_date = fields.DateField(null=True)
|
||||
paid_at = fields.DatetimeField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True, db_index=True)
|
||||
@@ -100,7 +100,7 @@ class InvoiceLineItem(models.Model):
|
||||
quantity = fields.DecimalField(max_digits=15, decimal_places=6)
|
||||
unit_price = fields.DecimalField(max_digits=10, decimal_places=6)
|
||||
amount = fields.DecimalField(max_digits=10, decimal_places=4)
|
||||
item_type = fields.CharField(max_length=100, null=True)
|
||||
item_type = fields.CharField(max_length=50, null=True)
|
||||
metadata = fields.JSONField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
@@ -110,10 +110,10 @@ class InvoiceLineItem(models.Model):
|
||||
|
||||
class PricingConfig(models.Model):
|
||||
id = fields.IntField(primary_key=True)
|
||||
config_key = fields.CharField(max_length=255, unique=True)
|
||||
config_key = fields.CharField(max_length=100, unique=True)
|
||||
config_value = fields.DecimalField(max_digits=10, decimal_places=6)
|
||||
description = fields.TextField(null=True)
|
||||
unit = fields.CharField(max_length=100, null=True)
|
||||
unit = fields.CharField(max_length=50, null=True)
|
||||
updated_by = fields.ForeignKeyField(
|
||||
"models.User", related_name="pricing_updates", null=True
|
||||
)
|
||||
@@ -127,10 +127,10 @@ class PaymentMethod(models.Model):
|
||||
id = fields.IntField(primary_key=True)
|
||||
user = fields.ForeignKeyField("models.User", related_name="payment_methods")
|
||||
stripe_payment_method_id = fields.CharField(max_length=255)
|
||||
type = fields.CharField(max_length=100)
|
||||
type = fields.CharField(max_length=50)
|
||||
is_default = fields.BooleanField(default=False)
|
||||
last4 = fields.CharField(max_length=100, null=True)
|
||||
brand = fields.CharField(max_length=100, null=True)
|
||||
last4 = fields.CharField(max_length=4, null=True)
|
||||
brand = fields.CharField(max_length=50, null=True)
|
||||
exp_month = fields.IntField(null=True)
|
||||
exp_year = fields.IntField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
from typing import List, Dict
|
||||
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from .models import UsageRecord, UsageAggregate
|
||||
from ..models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _flush_usage_records(records: List[Dict]):
|
||||
try:
|
||||
async with in_transaction():
|
||||
for record in records:
|
||||
await UsageRecord.create(
|
||||
user_id=record["user_id"],
|
||||
record_type=record["record_type"],
|
||||
amount_bytes=record["amount_bytes"],
|
||||
resource_type=record.get("resource_type"),
|
||||
resource_id=record.get("resource_id"),
|
||||
idempotency_key=record["idempotency_key"],
|
||||
metadata=record.get("metadata"),
|
||||
)
|
||||
logger.debug(f"Flushed {len(records)} usage records")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to flush usage records: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class UsageTracker:
|
||||
@staticmethod
|
||||
@@ -41,31 +18,15 @@ class UsageTracker:
|
||||
):
|
||||
idempotency_key = f"storage_{user.id}_{datetime.now(timezone.utc).timestamp()}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
try:
|
||||
from ..enterprise.write_buffer import get_write_buffer, WriteType
|
||||
buffer = get_write_buffer()
|
||||
await buffer.buffer(
|
||||
WriteType.USAGE_RECORD,
|
||||
{
|
||||
"user_id": user.id,
|
||||
"record_type": "storage",
|
||||
"amount_bytes": amount_bytes,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"metadata": metadata,
|
||||
},
|
||||
)
|
||||
except RuntimeError:
|
||||
await UsageRecord.create(
|
||||
user=user,
|
||||
record_type="storage",
|
||||
amount_bytes=amount_bytes,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
idempotency_key=idempotency_key,
|
||||
metadata=metadata,
|
||||
)
|
||||
await UsageRecord.create(
|
||||
user=user,
|
||||
record_type="storage",
|
||||
amount_bytes=amount_bytes,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
idempotency_key=idempotency_key,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def track_bandwidth(
|
||||
@@ -79,31 +40,15 @@ class UsageTracker:
|
||||
record_type = f"bandwidth_{direction}"
|
||||
idempotency_key = f"{record_type}_{user.id}_{datetime.now(timezone.utc).timestamp()}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
try:
|
||||
from ..enterprise.write_buffer import get_write_buffer, WriteType
|
||||
buffer = get_write_buffer()
|
||||
await buffer.buffer(
|
||||
WriteType.USAGE_RECORD,
|
||||
{
|
||||
"user_id": user.id,
|
||||
"record_type": record_type,
|
||||
"amount_bytes": amount_bytes,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"metadata": metadata,
|
||||
},
|
||||
)
|
||||
except RuntimeError:
|
||||
await UsageRecord.create(
|
||||
user=user,
|
||||
record_type=record_type,
|
||||
amount_bytes=amount_bytes,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
idempotency_key=idempotency_key,
|
||||
metadata=metadata,
|
||||
)
|
||||
await UsageRecord.create(
|
||||
user=user,
|
||||
record_type=record_type,
|
||||
amount_bytes=amount_bytes,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
idempotency_key=idempotency_key,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def aggregate_daily_usage(user: User, target_date: date = None):
|
||||
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
from .layer import CacheLayer, get_cache
|
||||
|
||||
__all__ = ["CacheLayer", "get_cache"]
|
||||
Vendored
-264
@@ -1,264 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
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",
|
||||
]
|
||||
@@ -1,151 +0,0 @@
|
||||
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
|
||||
@@ -1,265 +0,0 @@
|
||||
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
|
||||
@@ -1,322 +0,0 @@
|
||||
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
|
||||
@@ -1,3 +0,0 @@
|
||||
from .manager import UserDatabaseManager, get_user_db_manager
|
||||
|
||||
__all__ = ["UserDatabaseManager", "get_user_db_manager"]
|
||||
@@ -1,388 +0,0 @@
|
||||
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
|
||||
@@ -1,13 +0,0 @@
|
||||
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",
|
||||
]
|
||||
@@ -1,333 +0,0 @@
|
||||
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
|
||||
@@ -1,178 +0,0 @@
|
||||
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
|
||||
+24
-190
@@ -19,208 +19,43 @@ from .routers import (
|
||||
starred,
|
||||
billing,
|
||||
admin_billing,
|
||||
manage,
|
||||
)
|
||||
from . import webdav
|
||||
from .schemas import ErrorResponse
|
||||
from .middleware import UsageTrackingMiddleware, RateLimitMiddleware, SecurityHeadersMiddleware
|
||||
from .monitoring import health_router
|
||||
from .middleware.usage_tracking import UsageTrackingMiddleware
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
enterprise_components = {
|
||||
"db_manager": None,
|
||||
"cache": None,
|
||||
"lock_manager": None,
|
||||
"token_manager": None,
|
||||
"rate_limiter": None,
|
||||
"webdav_locks": None,
|
||||
"task_queue": None,
|
||||
"dal": None,
|
||||
"write_buffer": None,
|
||||
}
|
||||
|
||||
|
||||
async def init_enterprise_components():
|
||||
from .database.manager import init_db_manager
|
||||
from .cache.layer import init_cache
|
||||
from .concurrency.locks import init_lock_manager
|
||||
from .concurrency.webdav_locks import init_webdav_locks
|
||||
from .concurrency.atomic import init_atomic_ops
|
||||
from .auth_tokens import init_token_manager
|
||||
from .middleware.rate_limit import init_rate_limiter
|
||||
from .workers.queue import init_task_queue
|
||||
from .enterprise.dal import init_dal
|
||||
from .enterprise.write_buffer import init_write_buffer, WriteType
|
||||
|
||||
enterprise_components["db_manager"] = await init_db_manager("data")
|
||||
logger.info("Enterprise: Per-user database manager initialized")
|
||||
|
||||
enterprise_components["cache"] = await init_cache(maxsize=10000, flush_interval=30)
|
||||
logger.info("Enterprise: Cache layer initialized")
|
||||
|
||||
enterprise_components["dal"] = await init_dal(cache_size=50000)
|
||||
logger.info("Enterprise: Data Access Layer initialized")
|
||||
|
||||
enterprise_components["write_buffer"] = await init_write_buffer(
|
||||
flush_interval=60.0,
|
||||
max_buffer_size=1000,
|
||||
)
|
||||
from .activity import _flush_activities
|
||||
from .billing.usage_tracker import _flush_usage_records
|
||||
enterprise_components["write_buffer"].register_handler(WriteType.ACTIVITY, _flush_activities)
|
||||
enterprise_components["write_buffer"].register_handler(WriteType.USAGE_RECORD, _flush_usage_records)
|
||||
logger.info("Enterprise: Write buffer initialized (60s flush interval)")
|
||||
|
||||
enterprise_components["lock_manager"] = await init_lock_manager(default_timeout=30.0)
|
||||
logger.info("Enterprise: Lock manager initialized")
|
||||
|
||||
init_atomic_ops()
|
||||
logger.info("Enterprise: Atomic operations initialized")
|
||||
|
||||
enterprise_components["webdav_locks"] = await init_webdav_locks(
|
||||
enterprise_components["db_manager"]
|
||||
)
|
||||
logger.info("Enterprise: Persistent WebDAV locks initialized")
|
||||
|
||||
enterprise_components["token_manager"] = await init_token_manager(
|
||||
enterprise_components["db_manager"]
|
||||
)
|
||||
logger.info("Enterprise: Token manager with revocation initialized")
|
||||
|
||||
enterprise_components["rate_limiter"] = await init_rate_limiter()
|
||||
logger.info("Enterprise: Rate limiter initialized")
|
||||
|
||||
enterprise_components["task_queue"] = await init_task_queue(max_workers=4)
|
||||
logger.info("Enterprise: Background task queue initialized")
|
||||
|
||||
|
||||
async def shutdown_enterprise_components():
|
||||
from .database.manager import shutdown_db_manager
|
||||
from .cache.layer import shutdown_cache
|
||||
from .concurrency.locks import shutdown_lock_manager
|
||||
from .concurrency.webdav_locks import shutdown_webdav_locks
|
||||
from .auth_tokens import shutdown_token_manager
|
||||
from .middleware.rate_limit import shutdown_rate_limiter
|
||||
from .workers.queue import shutdown_task_queue
|
||||
from .enterprise.dal import shutdown_dal
|
||||
from .enterprise.write_buffer import shutdown_write_buffer
|
||||
|
||||
await shutdown_task_queue()
|
||||
logger.info("Enterprise: Task queue stopped")
|
||||
|
||||
await shutdown_rate_limiter()
|
||||
logger.info("Enterprise: Rate limiter stopped")
|
||||
|
||||
await shutdown_token_manager()
|
||||
logger.info("Enterprise: Token manager stopped")
|
||||
|
||||
await shutdown_webdav_locks()
|
||||
logger.info("Enterprise: WebDAV locks stopped")
|
||||
|
||||
await shutdown_lock_manager()
|
||||
logger.info("Enterprise: Lock manager stopped")
|
||||
|
||||
await shutdown_write_buffer()
|
||||
logger.info("Enterprise: Write buffer stopped (flushed pending writes)")
|
||||
|
||||
await shutdown_dal()
|
||||
logger.info("Enterprise: Data Access Layer stopped")
|
||||
|
||||
await shutdown_cache()
|
||||
logger.info("Enterprise: Cache layer stopped")
|
||||
|
||||
await shutdown_db_manager()
|
||||
logger.info("Enterprise: Database manager stopped")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Starting up...")
|
||||
|
||||
await init_enterprise_components()
|
||||
logger.info("All enterprise components initialized")
|
||||
|
||||
logger.info("Database connected.")
|
||||
from .billing.scheduler import start_scheduler
|
||||
from .billing.models import PricingConfig, SubscriptionPlan
|
||||
from .billing.models import PricingConfig
|
||||
from .mail import email_service
|
||||
|
||||
start_scheduler()
|
||||
logger.info("Billing scheduler started")
|
||||
await email_service.start()
|
||||
logger.info("Email service started")
|
||||
plan_count = await SubscriptionPlan.all().count()
|
||||
if plan_count == 0:
|
||||
pricing_count = await PricingConfig.all().count()
|
||||
if pricing_count == 0:
|
||||
from decimal import Decimal
|
||||
|
||||
# Create subscription plans
|
||||
await SubscriptionPlan.create(
|
||||
name="starter",
|
||||
display_name="Starter",
|
||||
description="Perfect for individuals and small projects",
|
||||
storage_gb=None,
|
||||
bandwidth_gb=None,
|
||||
price_monthly=Decimal("0.00"),
|
||||
is_active=True
|
||||
)
|
||||
await SubscriptionPlan.create(
|
||||
name="professional",
|
||||
display_name="Professional",
|
||||
description="Best for growing teams and businesses",
|
||||
storage_gb=None,
|
||||
bandwidth_gb=None,
|
||||
price_monthly=Decimal("0.00"),
|
||||
is_active=True
|
||||
)
|
||||
await SubscriptionPlan.create(
|
||||
name="enterprise",
|
||||
display_name="Enterprise",
|
||||
description="For large organizations with high volume needs",
|
||||
storage_gb=None,
|
||||
bandwidth_gb=None,
|
||||
price_monthly=Decimal("0.00"),
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Create tiered pricing configuration
|
||||
await PricingConfig.create(
|
||||
config_key="storage_per_gb_month_starter",
|
||||
config_value=Decimal("0.005"),
|
||||
description="Storage cost per GB per month (Starter tier)",
|
||||
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_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)",
|
||||
config_key="bandwidth_egress_per_gb",
|
||||
config_value=Decimal("0.009"),
|
||||
description="Bandwidth egress cost per GB",
|
||||
unit="per_gb",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
@@ -229,19 +64,25 @@ async def lifespan(app: FastAPI):
|
||||
description="Bandwidth ingress cost per GB (free)",
|
||||
unit="per_gb",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_storage_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier storage in GB",
|
||||
unit="gb",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_bandwidth_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier bandwidth in GB per month",
|
||||
unit="gb",
|
||||
)
|
||||
await PricingConfig.create(
|
||||
config_key="tax_rate_default",
|
||||
config_value=Decimal("0.0"),
|
||||
description="Default tax rate (0 = no tax)",
|
||||
unit="percentage",
|
||||
)
|
||||
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")
|
||||
logger.info("Default pricing configuration initialized")
|
||||
|
||||
yield
|
||||
|
||||
@@ -251,9 +92,6 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("Billing scheduler stopped")
|
||||
await email_service.stop()
|
||||
logger.info("Email service stopped")
|
||||
|
||||
await shutdown_enterprise_components()
|
||||
logger.info("All enterprise components shut down")
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
@@ -276,12 +114,8 @@ app.include_router(admin.router)
|
||||
app.include_router(starred.router)
|
||||
app.include_router(billing.router)
|
||||
app.include_router(admin_billing.router)
|
||||
app.include_router(manage.router)
|
||||
app.include_router(webdav.router)
|
||||
app.include_router(health_router)
|
||||
|
||||
app.add_middleware(SecurityHeadersMiddleware, enable_hsts=False, enable_csp=True)
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
app.add_middleware(UsageTrackingMiddleware)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
@@ -363,7 +197,7 @@ def main():
|
||||
parser.add_argument(
|
||||
"--host", type=str, default="0.0.0.0", help="Host address to bind to"
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=9004, help="Port to listen on")
|
||||
parser.add_argument("--port", type=int, default=8000, help="Port to listen on")
|
||||
args = parser.parse_args()
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from .usage_tracking import UsageTrackingMiddleware
|
||||
from .rate_limit import RateLimitMiddleware
|
||||
from .security import SecurityHeadersMiddleware
|
||||
|
||||
__all__ = ["UsageTrackingMiddleware", "RateLimitMiddleware", "SecurityHeadersMiddleware"]
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
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"
|
||||
@@ -1,49 +0,0 @@
|
||||
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,18 +1,13 @@
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
try:
|
||||
from ..billing.usage_tracker import UsageTracker
|
||||
BILLING_AVAILABLE = True
|
||||
except ImportError:
|
||||
BILLING_AVAILABLE = False
|
||||
from ..billing.usage_tracker import UsageTracker
|
||||
|
||||
|
||||
class UsageTrackingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
|
||||
if BILLING_AVAILABLE and hasattr(request.state, "user") and request.state.user:
|
||||
if hasattr(request.state, "user") and request.state.user:
|
||||
user = request.state.user
|
||||
|
||||
if (
|
||||
|
||||
+11
-9
@@ -4,7 +4,7 @@ from tortoise.contrib.pydantic import pydantic_model_creator
|
||||
|
||||
class User(models.Model):
|
||||
id = fields.IntField(primary_key=True)
|
||||
username = fields.CharField(max_length=255, unique=True)
|
||||
username = fields.CharField(max_length=20, unique=True)
|
||||
email = fields.CharField(max_length=255, unique=True)
|
||||
hashed_password = fields.CharField(max_length=255)
|
||||
is_active = fields.BooleanField(default=True)
|
||||
@@ -13,9 +13,9 @@ class User(models.Model):
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
storage_quota_bytes = fields.BigIntField(
|
||||
default=10 * 1024 * 1024 * 1024
|
||||
)
|
||||
) # 10 GB default
|
||||
used_storage_bytes = fields.BigIntField(default=0)
|
||||
plan_type = fields.CharField(max_length=100, default="free")
|
||||
plan_type = fields.CharField(max_length=50, default="free")
|
||||
two_factor_secret = fields.CharField(max_length=255, null=True)
|
||||
is_2fa_enabled = fields.BooleanField(default=False)
|
||||
recovery_codes = fields.TextField(null=True)
|
||||
@@ -97,7 +97,7 @@ class FileVersion(models.Model):
|
||||
|
||||
class Share(models.Model):
|
||||
id = fields.IntField(primary_key=True)
|
||||
token = fields.CharField(max_length=128, unique=True)
|
||||
token = fields.CharField(max_length=64, unique=True)
|
||||
file: fields.ForeignKeyRelation[File] = fields.ForeignKeyField(
|
||||
"models.File", related_name="shares", null=True
|
||||
)
|
||||
@@ -112,7 +112,9 @@ class Share(models.Model):
|
||||
password_protected = fields.BooleanField(default=False)
|
||||
hashed_password = fields.CharField(max_length=255, null=True)
|
||||
access_count = fields.IntField(default=0)
|
||||
permission_level = fields.CharField(max_length=100, default="viewer")
|
||||
permission_level = fields.CharField(
|
||||
max_length=50, default="viewer"
|
||||
) # viewer, uploader, editor
|
||||
|
||||
class Meta:
|
||||
table = "shares"
|
||||
@@ -141,7 +143,7 @@ class TeamMember(models.Model):
|
||||
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
|
||||
"models.User", related_name="user_teams"
|
||||
)
|
||||
role = fields.CharField(max_length=100, default="member")
|
||||
role = fields.CharField(max_length=50, default="member") # owner, admin, member
|
||||
|
||||
class Meta:
|
||||
table = "team_members"
|
||||
@@ -154,9 +156,9 @@ class Activity(models.Model):
|
||||
"models.User", related_name="activities", null=True
|
||||
)
|
||||
action = fields.CharField(max_length=255)
|
||||
target_type = fields.CharField(max_length=100)
|
||||
target_type = fields.CharField(max_length=50) # file, folder, share, user, team
|
||||
target_id = fields.IntField()
|
||||
ip_address = fields.CharField(max_length=100, null=True)
|
||||
ip_address = fields.CharField(max_length=45, null=True)
|
||||
timestamp = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
@@ -184,7 +186,7 @@ class FileRequest(models.Model):
|
||||
|
||||
class WebDAVProperty(models.Model):
|
||||
id = fields.IntField(primary_key=True)
|
||||
resource_type = fields.CharField(max_length=100)
|
||||
resource_type = fields.CharField(max_length=10)
|
||||
resource_id = fields.IntField()
|
||||
namespace = fields.CharField(max_length=255)
|
||||
name = fields.CharField(max_length=255)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .health import router as health_router
|
||||
|
||||
__all__ = ["health_router"]
|
||||
@@ -1,179 +0,0 @@
|
||||
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"
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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",
|
||||
]
|
||||
@@ -234,17 +234,3 @@ async def get_new_recovery_codes(
|
||||
await current_user.save()
|
||||
|
||||
return recovery_codes
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user information"""
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"is_active": current_user.is_active,
|
||||
"is_verified": current_user.is_verified,
|
||||
"is_2fa_enabled": current_user.is_2fa_enabled,
|
||||
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
|
||||
}
|
||||
|
||||
@@ -408,145 +408,6 @@ async def list_plans():
|
||||
]
|
||||
|
||||
|
||||
class SubscribeRequest(BaseModel):
|
||||
plan_name: str
|
||||
|
||||
|
||||
class UnsubscribeRequest(BaseModel):
|
||||
cancel_immediately: bool = False
|
||||
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_to_plan(
|
||||
request: SubscribeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
# Find the plan
|
||||
plan = await SubscriptionPlan.get_or_none(
|
||||
name=request.plan_name,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Plan '{request.plan_name}' not found"
|
||||
)
|
||||
|
||||
# Check if user already has a subscription
|
||||
existing_subscription = await UserSubscription.get_or_none(
|
||||
user=current_user
|
||||
)
|
||||
|
||||
if existing_subscription:
|
||||
# Update existing subscription
|
||||
existing_subscription.plan = plan
|
||||
existing_subscription.billing_type = "subscription"
|
||||
await existing_subscription.save()
|
||||
|
||||
return {
|
||||
"message": f"Successfully updated to {plan.display_name} plan",
|
||||
"plan": plan.display_name,
|
||||
"billing_type": "subscription"
|
||||
}
|
||||
else:
|
||||
# Create new subscription
|
||||
subscription = await UserSubscription.create(
|
||||
user=current_user,
|
||||
plan=plan,
|
||||
billing_type="subscription",
|
||||
status="active"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Successfully subscribed to {plan.display_name} plan",
|
||||
"plan": plan.display_name,
|
||||
"billing_type": "subscription",
|
||||
"subscription_id": subscription.id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to subscribe to plan: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/unsubscribe")
|
||||
async def unsubscribe_from_plan(
|
||||
request: UnsubscribeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
subscription = await UserSubscription.get_or_none(
|
||||
user=current_user
|
||||
)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No active subscription found"
|
||||
)
|
||||
|
||||
if request.cancel_immediately:
|
||||
# Cancel subscription immediately
|
||||
await subscription.delete()
|
||||
return {"message": "Subscription cancelled immediately"}
|
||||
else:
|
||||
# Mark for cancellation at end of billing period
|
||||
subscription.status = "cancelled"
|
||||
await subscription.save()
|
||||
return {"message": "Subscription will be cancelled at end of billing period"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to unsubscribe: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subscription")
|
||||
async def get_subscription(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> SubscriptionResponse:
|
||||
try:
|
||||
subscription = await UserSubscription.get_or_none(
|
||||
user=current_user
|
||||
).prefetch_related("plan")
|
||||
|
||||
if not subscription:
|
||||
# Return default starter subscription
|
||||
default_plan = await SubscriptionPlan.get_or_none(
|
||||
name="starter",
|
||||
is_active=True
|
||||
)
|
||||
|
||||
return SubscriptionResponse(
|
||||
id=0,
|
||||
billing_type="pay_as_you_go",
|
||||
plan_name=default_plan.display_name if default_plan else "Starter",
|
||||
status="active",
|
||||
current_period_start=None,
|
||||
current_period_end=None
|
||||
)
|
||||
|
||||
return SubscriptionResponse(
|
||||
id=subscription.id,
|
||||
billing_type=subscription.billing_type,
|
||||
plan_name=subscription.plan.display_name if subscription.plan else None,
|
||||
status=subscription.status,
|
||||
current_period_start=subscription.current_period_start,
|
||||
current_period_end=subscription.current_period_end
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to fetch subscription: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stripe-key")
|
||||
async def get_stripe_key():
|
||||
from ..settings import settings
|
||||
|
||||
+39
-97
@@ -22,12 +22,6 @@ from ..storage import storage_manager
|
||||
from ..activity import log_activity
|
||||
from ..thumbnails import generate_thumbnail, delete_thumbnail
|
||||
|
||||
try:
|
||||
from ..concurrency.atomic import get_atomic_ops
|
||||
ATOMIC_OPS_AVAILABLE = True
|
||||
except ImportError:
|
||||
ATOMIC_OPS_AVAILABLE = False
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/files",
|
||||
tags=["files"],
|
||||
@@ -76,103 +70,51 @@ async def upload_file(
|
||||
else:
|
||||
parent_folder = None
|
||||
|
||||
existing_file = await File.get_or_none(
|
||||
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
|
||||
)
|
||||
if existing_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="File with this name already exists in the current folder",
|
||||
)
|
||||
|
||||
file_content = await file.read()
|
||||
file_size = len(file_content)
|
||||
file_hash = hashlib.sha256(file_content).hexdigest()
|
||||
|
||||
if ATOMIC_OPS_AVAILABLE:
|
||||
atomic_ops = get_atomic_ops()
|
||||
|
||||
async def check_exists():
|
||||
return await File.get_or_none(
|
||||
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
|
||||
)
|
||||
|
||||
async def create_file():
|
||||
file_extension = os.path.splitext(file.filename)[1]
|
||||
unique_filename = f"{file_hash}{file_extension}"
|
||||
storage_path = unique_filename
|
||||
|
||||
await storage_manager.save_file(current_user.id, storage_path, file_content)
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(file.filename)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
db_file = await File.create(
|
||||
name=file.filename,
|
||||
path=storage_path,
|
||||
size=file_size,
|
||||
mime_type=mime_type,
|
||||
file_hash=file_hash,
|
||||
owner=current_user,
|
||||
parent=parent_folder,
|
||||
)
|
||||
return db_file, storage_path, mime_type
|
||||
|
||||
quota_result = await atomic_ops.atomic_quota_check_and_update(
|
||||
current_user, file_size, lambda u: u.save()
|
||||
)
|
||||
if not quota_result.allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
||||
detail=f"Storage quota exceeded. Available: {quota_result.remaining} bytes",
|
||||
)
|
||||
|
||||
try:
|
||||
db_file, storage_path, mime_type = await atomic_ops.atomic_file_create(
|
||||
current_user,
|
||||
parent_folder.id if parent_folder else None,
|
||||
file.filename,
|
||||
check_exists,
|
||||
create_file,
|
||||
)
|
||||
except FileExistsError as e:
|
||||
await atomic_ops.atomic_quota_check_and_update(
|
||||
current_user, -file_size, lambda u: u.save()
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(e),
|
||||
)
|
||||
else:
|
||||
existing_file = await File.get_or_none(
|
||||
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
|
||||
)
|
||||
if existing_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="File with this name already exists in the current folder",
|
||||
)
|
||||
|
||||
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
||||
detail="Storage quota exceeded",
|
||||
)
|
||||
|
||||
file_extension = os.path.splitext(file.filename)[1]
|
||||
unique_filename = f"{file_hash}{file_extension}"
|
||||
storage_path = unique_filename
|
||||
|
||||
await storage_manager.save_file(current_user.id, storage_path, file_content)
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(file.filename)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
db_file = await File.create(
|
||||
name=file.filename,
|
||||
path=storage_path,
|
||||
size=file_size,
|
||||
mime_type=mime_type,
|
||||
file_hash=file_hash,
|
||||
owner=current_user,
|
||||
parent=parent_folder,
|
||||
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",
|
||||
)
|
||||
|
||||
current_user.used_storage_bytes += file_size
|
||||
await current_user.save()
|
||||
# Generate a unique path for storage
|
||||
file_extension = os.path.splitext(file.filename)[1]
|
||||
unique_filename = f"{file_hash}{file_extension}" # Use hash for unique filename
|
||||
storage_path = unique_filename
|
||||
|
||||
# Save file to storage
|
||||
await storage_manager.save_file(current_user.id, storage_path, file_content)
|
||||
|
||||
# Get mime type
|
||||
mime_type, _ = mimetypes.guess_type(file.filename)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
# Create file entry in database
|
||||
db_file = await File.create(
|
||||
name=file.filename,
|
||||
path=storage_path,
|
||||
size=file_size,
|
||||
mime_type=mime_type,
|
||||
file_hash=file_hash,
|
||||
owner=current_user,
|
||||
parent=parent_folder,
|
||||
)
|
||||
|
||||
current_user.used_storage_bytes += file_size
|
||||
await current_user.save()
|
||||
|
||||
thumbnail_path = await generate_thumbnail(storage_path, mime_type, current_user.id)
|
||||
if thumbnail_path:
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
# 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)
|
||||
@@ -183,11 +183,7 @@ async def update_share(
|
||||
|
||||
|
||||
@router.post("/{share_token}/access")
|
||||
async def access_shared_content(
|
||||
share_token: str,
|
||||
password: Optional[str] = None,
|
||||
subfolder_id: Optional[int] = None,
|
||||
):
|
||||
async def access_shared_content(share_token: str, password: Optional[str] = None):
|
||||
share = await Share.get_or_none(token=share_token)
|
||||
if not share:
|
||||
raise HTTPException(
|
||||
@@ -216,37 +212,7 @@ async def access_shared_content(
|
||||
result["file"] = await FileOut.from_tortoise_orm(file)
|
||||
result["type"] = "file"
|
||||
elif share.folder_id:
|
||||
# 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)
|
||||
folder = await Folder.get_or_none(id=share.folder_id, is_deleted=False)
|
||||
if not folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
||||
|
||||
@@ -5,8 +5,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
RATE_LIMIT_ENABLED: bool = False
|
||||
|
||||
DATABASE_URL: str = "sqlite:///app/mywebdav.db"
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
SECRET_KEY: str = "super_secret_key"
|
||||
@@ -32,11 +31,6 @@ class Settings(BaseSettings):
|
||||
STRIPE_WEBHOOK_SECRET: str = ""
|
||||
BILLING_ENABLED: bool = False
|
||||
|
||||
ADMIN_USERNAME: str = "admin"
|
||||
ADMIN_PASSWORD: str = "admin"
|
||||
ADMIN_SESSION_SECRET: str = "admin_session_secret_change_me"
|
||||
ADMIN_SESSION_EXPIRE_HOURS: int = 24
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,76 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,39 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,122 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,112 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,80 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,185 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,101 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -15,14 +15,9 @@
|
||||
<nav class="nav-container">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">◆</span>
|
||||
<span class="logo-text">My<span class="logo-webdav">Webdav</span></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">
|
||||
<ul class="nav-menu">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/features">Features</a></li>
|
||||
<li><a href="/pricing">Pricing</a></li>
|
||||
@@ -48,26 +43,10 @@
|
||||
<a href="/legal/contact_complaint_mechanism">Contact & Complaints</a>
|
||||
</div>
|
||||
<div class="footer-copyright">
|
||||
© 2025 MyWebdav. All rights reserved
|
||||
© 2024 CLOUDWAVE. 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>
|
||||
|
||||
@@ -2,98 +2,97 @@
|
||||
|
||||
{% block title %}Features - MyWebdav Cloud Storage{% endblock %}
|
||||
|
||||
{% block description %}Discover MyWebdav's powerful features: secure storage, WebDAV support, file sharing, and more.{%
|
||||
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;
|
||||
}
|
||||
.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: 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;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -108,7 +107,7 @@ endblock %}
|
||||
<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>AES-256 encryption at rest</li>
|
||||
<li>TLS 1.3 encryption in transit</li>
|
||||
<li>Two-factor authentication (TOTP)</li>
|
||||
<li>Regular security audits</li>
|
||||
@@ -158,9 +157,9 @@ endblock %}
|
||||
<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>
|
||||
<p class="feature-description">Fast upload and download speeds with global CDN.</p>
|
||||
<ul class="feature-list">
|
||||
<li>High-speed transfer</li>
|
||||
<li>Global edge network</li>
|
||||
<li>Parallel upload/download</li>
|
||||
<li>Resume interrupted transfers</li>
|
||||
<li>Optimized for large files</li>
|
||||
@@ -170,10 +169,10 @@ endblock %}
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔍</div>
|
||||
<h2 class="feature-title">Instant File Search</h2>
|
||||
<h2 class="feature-title">Full-Text 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>Search file names and content</li>
|
||||
<li>Filter by type and date</li>
|
||||
<li>Advanced query syntax</li>
|
||||
<li>Instant results</li>
|
||||
@@ -264,4 +263,4 @@ endblock %}
|
||||
<a href="/app" class="btn btn-primary" style="font-size: 1.125rem; padding: 1rem 3rem;">Get Started Today</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,89 +6,89 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.legal-content {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.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: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #1565c0;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 3px solid #1976d2;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.legal-content {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -97,107 +97,104 @@
|
||||
<h1 class="legal-title">Security Policy</h1>
|
||||
<p class="legal-updated">Last Updated: November 16, 2025</p>
|
||||
|
||||
|
||||
<h2>1. Introduction</h2>
|
||||
|
||||
<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.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>
|
||||
|
||||
<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>
|
||||
|
||||
<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.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>
|
||||
|
||||
<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>
|
||||
|
||||
<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.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.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>
|
||||
|
||||
<h3>3.3 Remote Access</h3>
|
||||
<p>Secured via VPN with full logging and monitoring.</p>
|
||||
<h2>4. Data Protection and Encryption</h2>
|
||||
|
||||
<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.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>AES-256 for data at rest</li>
|
||||
<li>Secure key management and rotation</li>
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
|
||||
<h3>4.3 Data Retention and Disposal</h3>
|
||||
<p>Data retained only as necessary with secure deletion methods.</p>
|
||||
<h2>5. Network Security</h2>
|
||||
|
||||
<h2>5. Network Security</h2>
|
||||
<h3>5.1 Network Segmentation</h3>
|
||||
<p>Isolated networks with firewalls, IDS, and regular monitoring.</p>
|
||||
|
||||
<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>
|
||||
|
||||
<h3>5.2 Secure Configuration</h3>
|
||||
<p>Hardened systems following CIS Benchmarks.</p>
|
||||
<h2>6. Physical Security</h2>
|
||||
|
||||
<h2>6. Physical Security</h2>
|
||||
<h3>6.1 Facility Access</h3>
|
||||
<p>Controlled access to data centers with biometric authentication.</p>
|
||||
|
||||
<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>
|
||||
|
||||
<h3>6.2 Equipment Security</h3>
|
||||
<p>Secure storage in climate-controlled environments.</p>
|
||||
<h2>7. Incident Response</h2>
|
||||
|
||||
<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.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>
|
||||
|
||||
<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>
|
||||
|
||||
<h2>8. Secure Development</h2>
|
||||
<h3>8.1 Secure Coding Practices</h3>
|
||||
<p>Code reviews, static/dynamic analysis, and vulnerability management.</p>
|
||||
|
||||
<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>
|
||||
|
||||
<h3>8.2 Change Management</h3>
|
||||
<p>Formal approval processes for production changes.</p>
|
||||
<h2>9. Third-Party Risk Management</h2>
|
||||
|
||||
<h2>9. Third-Party Risk Management</h2>
|
||||
<h3>9.1 Vendor Assessment</h3>
|
||||
<p>Security assessments and contractual requirements for all vendors.</p>
|
||||
|
||||
<h3>9.1 Vendor Assessment</h3>
|
||||
<p>Security assessments and contractual requirements for all vendors.</p>
|
||||
<h2>10. Compliance and Auditing</h2>
|
||||
|
||||
<h2>10. Compliance and Auditing</h2>
|
||||
<h3>10.1 Regulatory Compliance</h3>
|
||||
<p>Compliance with GDPR, NIS2, and ISO/IEC 27001.</p>
|
||||
|
||||
<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.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>
|
||||
|
||||
<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>
|
||||
<h2>11. Enforcement</h2>
|
||||
<p>Compliance is mandatory. Violations may result in disciplinary action up to termination.</p>
|
||||
|
||||
|
||||
<div class="legal-contact">
|
||||
@@ -210,4 +207,4 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
+291
-388
@@ -2,233 +2,232 @@
|
||||
|
||||
{% 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 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;
|
||||
}
|
||||
.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: 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;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -238,22 +237,65 @@ endblock %}
|
||||
<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>
|
||||
<h2 style="font-size: 2rem; margin: 0;">Pay-As-You-Go Storage</h2>
|
||||
<div class="pricing-hero-amount">$5/TB</div>
|
||||
<p class="pricing-hero-description">Plus 15GB free tier included</p>
|
||||
</div>
|
||||
|
||||
<div class="pricing-grid" id="pricing-plans">
|
||||
<!-- Plans will be loaded dynamically from API -->
|
||||
<div class="pricing-grid">
|
||||
<div class="pricing-card">
|
||||
<div class="pricing-tier">Loading...</div>
|
||||
<div class="pricing-amount">$-</div>
|
||||
<div class="pricing-period">Loading plans...</div>
|
||||
<div class="pricing-tier">Free Tier</div>
|
||||
<div class="pricing-amount">$0</div>
|
||||
<div class="pricing-period">First 15GB included</div>
|
||||
<ul class="pricing-features">
|
||||
<li>Loading subscription plans...</li>
|
||||
<li>15GB storage included</li>
|
||||
<li>15GB bandwidth per month</li>
|
||||
<li>All core features</li>
|
||||
<li>WebDAV support</li>
|
||||
<li>File versioning</li>
|
||||
<li>Two-factor authentication</li>
|
||||
<li>EU data residency</li>
|
||||
</ul>
|
||||
<div class="pricing-cta">
|
||||
<button class="btn btn-secondary" style="width: 100%;" disabled>Loading...</button>
|
||||
<a href="/app" class="btn btn-secondary" style="width: 100%;">Get Started Free</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-card featured">
|
||||
<div class="pricing-tier">Pay-As-You-Go</div>
|
||||
<div class="pricing-amount">$5/TB</div>
|
||||
<div class="pricing-period">Billed monthly</div>
|
||||
<ul class="pricing-features">
|
||||
<li>$0.0045 per GB per month storage</li>
|
||||
<li>$0.009 per GB egress bandwidth</li>
|
||||
<li>Free ingress bandwidth</li>
|
||||
<li>All premium features</li>
|
||||
<li>99.9% uptime SLA</li>
|
||||
<li>24/7 support</li>
|
||||
<li>API access</li>
|
||||
<li>Priority support</li>
|
||||
</ul>
|
||||
<div class="pricing-cta">
|
||||
<a href="/app" class="btn btn-primary" style="width: 100%;">Start Using</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-card">
|
||||
<div class="pricing-tier">Enterprise</div>
|
||||
<div class="pricing-amount">Custom</div>
|
||||
<div class="pricing-period">Contact us for pricing</div>
|
||||
<ul class="pricing-features">
|
||||
<li>Volume discounts available</li>
|
||||
<li>Dedicated account manager</li>
|
||||
<li>Custom SLA options</li>
|
||||
<li>Priority support 24/7</li>
|
||||
<li>Custom integrations</li>
|
||||
<li>Training and onboarding</li>
|
||||
<li>Compliance assistance</li>
|
||||
<li>Dedicated infrastructure</li>
|
||||
</ul>
|
||||
<div class="pricing-cta">
|
||||
<a href="/support" class="btn btn-secondary" style="width: 100%;">Contact Sales</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,13 +305,11 @@ endblock %}
|
||||
<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()">
|
||||
<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()">
|
||||
<input type="number" class="calculator-input" id="bandwidth" value="50" min="0" oninput="calculatePrice()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="calculator-result">
|
||||
@@ -283,192 +323,55 @@ endblock %}
|
||||
|
||||
<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 class="faq-answer">Storage is calculated based on your average daily usage throughout the month. You're charged $0.0045 per GB per month ($5 per TB). The first 15GB is always free.</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 class="faq-answer">Bandwidth egress is data transferred out of MyWebdav (downloads). You're charged $0.009 per GB. The first 15GB per month is free. 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 class="faq-answer">No. We believe in transparent pricing. You only pay for storage and egress bandwidth as shown. There are no setup fees, minimum charges, or surprise costs.</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 class="faq-answer">Yes. There are no long-term contracts or commitments. You can delete your account at any time and will only be charged for 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 class="faq-answer">We accept all major credit cards (Visa, Mastercard, American Express) and SEPA direct debit 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 class="faq-answer">Yes. For storage over 10TB or bandwidth over 5TB per month, please contact our sales team for custom 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;
|
||||
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 freeStorage = 15;
|
||||
const freeBandwidth = 15;
|
||||
|
||||
const total = storageCost + bandwidthCost;
|
||||
const billableStorage = Math.max(0, storage - freeStorage);
|
||||
const billableBandwidth = Math.max(0, bandwidth - freeBandwidth);
|
||||
|
||||
document.getElementById('result').textContent = '$' + total.toFixed(2);
|
||||
}
|
||||
const storageCost = billableStorage * 0.0045;
|
||||
const bandwidthCost = billableBandwidth * 0.009;
|
||||
|
||||
// Load plans when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSubscriptionPlans();
|
||||
calculatePrice();
|
||||
});
|
||||
const total = storageCost + bandwidthCost;
|
||||
|
||||
document.getElementById('result').textContent = '$' + total.toFixed(2);
|
||||
}
|
||||
|
||||
calculatePrice();
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -243,6 +243,13 @@
|
||||
<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 class="support-card">
|
||||
<div class="support-icon">💬</div>
|
||||
<h2 class="support-title">Community Forum</h2>
|
||||
<p class="support-description">Connect with other users and share knowledge.</p>
|
||||
<a href="mailto:community@mywebdav.eu" class="btn btn-secondary">Join Forum</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
@@ -259,12 +266,20 @@
|
||||
<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-label">General Support</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">Technical Support</div>
|
||||
<div class="contact-item-value">
|
||||
<a href="mailto:tech-support@mywebdav.eu">tech-support@mywebdav.eu</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-item">
|
||||
<div class="contact-item-icon">💰</div>
|
||||
<div class="contact-item-label">Billing Inquiries</div>
|
||||
@@ -273,9 +288,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-item">
|
||||
<div class="contact-item-icon">🔒</div>
|
||||
<div class="contact-item-label">Data Protection</div>
|
||||
<div class="contact-item-value">
|
||||
<a href="mailto:dpo@mywebdav.eu">dpo@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-label">Legal Matters</div>
|
||||
<div class="contact-item-value">
|
||||
<a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a>
|
||||
</div>
|
||||
@@ -283,7 +306,7 @@
|
||||
|
||||
<div class="contact-item">
|
||||
<div class="contact-item-icon">🏢</div>
|
||||
<div class="contact-item-label">Sales</div>
|
||||
<div class="contact-item-label">Sales & Enterprise</div>
|
||||
<div class="contact-item-value">
|
||||
<a href="mailto:sales@mywebdav.eu">sales@mywebdav.eu</a>
|
||||
</div>
|
||||
|
||||
+53
-142
@@ -22,45 +22,28 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def get_persistent_locks():
|
||||
try:
|
||||
from .concurrency.webdav_locks import get_webdav_locks
|
||||
return get_webdav_locks()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
class WebDAVLock:
|
||||
_fallback_locks: dict = {}
|
||||
locks = {}
|
||||
|
||||
@classmethod
|
||||
async def create_lock(cls, path: str, user_id: int, owner: str = "", timeout: int = 3600):
|
||||
locks = get_persistent_locks()
|
||||
if locks:
|
||||
return await locks.acquire_lock(path, owner or str(user_id), user_id, timeout)
|
||||
path = path.strip("/")
|
||||
def create_lock(cls, path: str, user_id: int, timeout: int = 3600):
|
||||
lock_token = f"opaquelocktoken:{hashlib.md5(f'{path}{user_id}{datetime.now()}'.encode()).hexdigest()}"
|
||||
cls._fallback_locks[path] = {"token": lock_token, "user_id": user_id}
|
||||
cls.locks[path] = {
|
||||
"token": lock_token,
|
||||
"user_id": user_id,
|
||||
"created_at": datetime.now(),
|
||||
"timeout": timeout,
|
||||
}
|
||||
return lock_token
|
||||
|
||||
@classmethod
|
||||
async def get_lock(cls, path: str):
|
||||
locks = get_persistent_locks()
|
||||
if locks:
|
||||
return await locks.check_lock(path)
|
||||
path = path.strip("/")
|
||||
return cls._fallback_locks.get(path)
|
||||
def get_lock(cls, path: str):
|
||||
return cls.locks.get(path)
|
||||
|
||||
@classmethod
|
||||
async def remove_lock(cls, path: str, token: str):
|
||||
locks = get_persistent_locks()
|
||||
if locks:
|
||||
return await locks.release_lock(path, token)
|
||||
path = path.strip("/")
|
||||
if path in cls._fallback_locks and cls._fallback_locks[path]["token"] == token:
|
||||
del cls._fallback_locks[path]
|
||||
return True
|
||||
return False
|
||||
def remove_lock(cls, path: str):
|
||||
if path in cls.locks:
|
||||
del cls.locks[path]
|
||||
|
||||
|
||||
async def basic_auth(authorization: Optional[str] = Header(None)):
|
||||
@@ -75,24 +58,8 @@ async def basic_auth(authorization: Optional[str] = Header(None)):
|
||||
decoded = base64.b64decode(credentials).decode("utf-8")
|
||||
username, password = decoded.split(":", 1)
|
||||
|
||||
user = None
|
||||
try:
|
||||
from .enterprise.dal import get_dal
|
||||
dal = get_dal()
|
||||
user = await dal.get_user_by_username(username)
|
||||
if user and verify_password(password, user.hashed_password):
|
||||
return user
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
user = await User.get_or_none(username=username)
|
||||
if user and verify_password(password, user.hashed_password):
|
||||
try:
|
||||
from .enterprise.dal import get_dal
|
||||
dal = get_dal()
|
||||
await dal.refresh_user_cache(user)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return user
|
||||
except (ValueError, UnicodeDecodeError, base64.binascii.Error):
|
||||
return None
|
||||
@@ -101,10 +68,12 @@ async def basic_auth(authorization: Optional[str] = Header(None)):
|
||||
|
||||
|
||||
async def webdav_auth(request: Request, authorization: Optional[str] = Header(None)):
|
||||
# First, try Basic Auth, which is common for WebDAV clients
|
||||
user = await basic_auth(authorization)
|
||||
if user:
|
||||
return user
|
||||
|
||||
# If Basic Auth fails or is not provided, try to authenticate using the session cookie
|
||||
token = request.cookies.get("access_token")
|
||||
if token:
|
||||
try:
|
||||
@@ -115,16 +84,12 @@ async def webdav_auth(request: Request, authorization: Optional[str] = Header(No
|
||||
if username:
|
||||
user = await User.get_or_none(username=username)
|
||||
if user:
|
||||
try:
|
||||
from .enterprise.dal import get_dal
|
||||
dal = get_dal()
|
||||
await dal.refresh_user_cache(user)
|
||||
except RuntimeError:
|
||||
pass
|
||||
return user
|
||||
except JWTError:
|
||||
# Token is invalid, fall through to the final exception
|
||||
pass
|
||||
|
||||
# If all authentication methods fail, raise 401
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
headers={"WWW-Authenticate": 'Basic realm="MyWebdav WebDAV"'},
|
||||
@@ -132,29 +97,34 @@ async def webdav_auth(request: Request, authorization: Optional[str] = Header(No
|
||||
|
||||
|
||||
async def resolve_path(path_str: str, user: User):
|
||||
try:
|
||||
from .enterprise.dal import get_dal
|
||||
dal = get_dal()
|
||||
return await dal.resolve_path(user.id, path_str)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
"""
|
||||
Resolves a path string to a resource.
|
||||
Returns a tuple: (resource, parent_folder, exists)
|
||||
- resource: The File or Folder object at the path, or None if not found.
|
||||
- parent_folder: The parent Folder object, or None if root.
|
||||
- exists: Boolean indicating if the resource at the given path exists.
|
||||
"""
|
||||
path_str = path_str.strip("/")
|
||||
if not path_str:
|
||||
if not path_str: # Root directory
|
||||
# The root exists conceptually, but has no specific resource object.
|
||||
# It contains top-level files and folders.
|
||||
return None, None, True
|
||||
|
||||
parts = [p for p in path_str.split("/") if p]
|
||||
current_folder = None
|
||||
# Traverse the path to find the parent of the target resource
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
folder = await Folder.get_or_none(
|
||||
name=part, parent=current_folder, owner=user, is_deleted=False
|
||||
)
|
||||
if not folder:
|
||||
# A component in the middle of the path does not exist, so the full path cannot exist.
|
||||
return None, None, False
|
||||
current_folder = folder
|
||||
|
||||
last_part = parts[-1]
|
||||
|
||||
# Check for the target resource itself (can be a folder or a file)
|
||||
folder = await Folder.get_or_none(
|
||||
name=last_part, parent=current_folder, owner=user, is_deleted=False
|
||||
)
|
||||
@@ -167,6 +137,7 @@ async def resolve_path(path_str: str, user: User):
|
||||
if file:
|
||||
return file, current_folder, True
|
||||
|
||||
# The resource itself was not found, but the path to its parent is valid.
|
||||
return None, current_folder, False
|
||||
|
||||
|
||||
@@ -177,38 +148,6 @@ def build_href(base_path: str, name: str, is_collection: bool):
|
||||
return path
|
||||
|
||||
|
||||
def invalidate_cache_for_path(user_id: int, path_str: str, parent_id: Optional[int] = None):
|
||||
try:
|
||||
from .enterprise.dal import get_dal
|
||||
dal = get_dal()
|
||||
dal.invalidate_path(user_id, path_str)
|
||||
if parent_id is not None:
|
||||
dal.invalidate_folder(user_id, parent_id)
|
||||
else:
|
||||
dal.invalidate_folder(user_id, None)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def invalidate_cache_for_file(user_id: int, file_id: int, parent_id: Optional[int] = None):
|
||||
try:
|
||||
from .enterprise.dal import get_dal
|
||||
dal = get_dal()
|
||||
dal.invalidate_file(user_id, file_id, parent_id)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def invalidate_cache_for_folder(user_id: int, folder_id: Optional[int] = None):
|
||||
try:
|
||||
from .enterprise.dal import get_dal
|
||||
dal = get_dal()
|
||||
dal.invalidate_folder(user_id, folder_id)
|
||||
dal.invalidate_user_paths(user_id)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
async def get_custom_properties(resource_type: str, resource_id: int):
|
||||
props = await WebDAVProperty.filter(
|
||||
resource_type=resource_type, resource_id=resource_id
|
||||
@@ -358,17 +297,12 @@ async def handle_propfind(
|
||||
res_href = base_href if isinstance(resource, File) else (base_href if base_href.endswith('/') else base_href + '/')
|
||||
await add_resource_to_response(resource, res_href)
|
||||
|
||||
# If depth is 1 or infinity, add children
|
||||
if depth in ["1", "infinity"]:
|
||||
target_folder = resource if isinstance(resource, Folder) else (None if full_path_str == "" else parent_folder)
|
||||
target_folder_id = target_folder.id if target_folder else None
|
||||
|
||||
try:
|
||||
from .enterprise.dal import get_dal
|
||||
dal = get_dal()
|
||||
folders, files = await dal.get_folder_contents(current_user.id, target_folder_id)
|
||||
except RuntimeError:
|
||||
folders = await Folder.filter(owner=current_user, parent=target_folder, is_deleted=False)
|
||||
files = await File.filter(owner=current_user, parent=target_folder, is_deleted=False)
|
||||
|
||||
folders = await Folder.filter(owner=current_user, parent=target_folder, is_deleted=False)
|
||||
files = await File.filter(owner=current_user, parent=target_folder, is_deleted=False)
|
||||
|
||||
for folder in folders:
|
||||
child_href = build_href(base_href, folder.name, True)
|
||||
@@ -459,7 +393,6 @@ async def handle_put(
|
||||
name=file_name, parent=parent_folder, owner=current_user, is_deleted=False
|
||||
)
|
||||
|
||||
parent_id = parent_folder.id if parent_folder else None
|
||||
if existing_file:
|
||||
old_size = existing_file.size
|
||||
existing_file.path, existing_file.size, existing_file.mime_type, existing_file.file_hash = \
|
||||
@@ -468,7 +401,6 @@ async def handle_put(
|
||||
await existing_file.save()
|
||||
current_user.used_storage_bytes += (file_size - old_size)
|
||||
await current_user.save()
|
||||
invalidate_cache_for_file(current_user.id, existing_file.id, parent_id)
|
||||
await log_activity(current_user, "file_updated", "file", existing_file.id)
|
||||
return Response(status_code=204)
|
||||
else:
|
||||
@@ -478,7 +410,6 @@ async def handle_put(
|
||||
)
|
||||
current_user.used_storage_bytes += file_size
|
||||
await current_user.save()
|
||||
invalidate_cache_for_path(current_user.id, full_path, parent_id)
|
||||
await log_activity(current_user, "file_created", "file", db_file.id)
|
||||
return Response(status_code=201)
|
||||
|
||||
@@ -497,23 +428,19 @@ async def handle_delete(
|
||||
raise HTTPException(status_code=404, detail="Resource not found")
|
||||
|
||||
if isinstance(resource, File):
|
||||
parent_id = resource.parent_id
|
||||
resource.is_deleted = True
|
||||
resource.deleted_at = datetime.now()
|
||||
await resource.save()
|
||||
invalidate_cache_for_file(current_user.id, resource.id, parent_id)
|
||||
await log_activity(current_user, "file_deleted", "file", resource.id)
|
||||
elif isinstance(resource, Folder):
|
||||
child_files = await File.filter(parent=resource, is_deleted=False).count()
|
||||
child_folders = await Folder.filter(parent=resource, is_deleted=False).count()
|
||||
if child_files > 0 or child_folders > 0:
|
||||
raise HTTPException(status_code=409, detail="Folder is not empty")
|
||||
|
||||
parent_id = resource.parent_id
|
||||
|
||||
resource.is_deleted = True
|
||||
resource.deleted_at = datetime.now()
|
||||
await resource.save()
|
||||
invalidate_cache_for_folder(current_user.id, parent_id)
|
||||
await log_activity(current_user, "folder_deleted", "folder", resource.id)
|
||||
|
||||
return Response(status_code=204)
|
||||
@@ -541,10 +468,8 @@ async def handle_mkcol(
|
||||
raise HTTPException(status_code=409, detail="Parent collection does not exist")
|
||||
|
||||
parent_folder = parent_resource if isinstance(parent_resource, Folder) else None
|
||||
parent_id = parent_folder.id if parent_folder else None
|
||||
|
||||
|
||||
folder = await Folder.create(name=folder_name, parent=parent_folder, owner=current_user)
|
||||
invalidate_cache_for_folder(current_user.id, parent_id)
|
||||
await log_activity(current_user, "folder_created", "folder", folder.id)
|
||||
return Response(status_code=201)
|
||||
|
||||
@@ -583,15 +508,14 @@ async def handle_copy(
|
||||
if existing_dest_exists and overwrite == "F":
|
||||
raise HTTPException(status_code=412, detail="Destination exists and Overwrite is 'F'")
|
||||
|
||||
dest_parent_id = dest_parent_folder.id if dest_parent_folder else None
|
||||
if existing_dest_exists:
|
||||
# Update existing_dest instead of deleting and recreating
|
||||
existing_dest.path = source_resource.path
|
||||
existing_dest.size = source_resource.size
|
||||
existing_dest.mime_type = source_resource.mime_type
|
||||
existing_dest.file_hash = source_resource.file_hash
|
||||
existing_dest.updated_at = datetime.now()
|
||||
await existing_dest.save()
|
||||
invalidate_cache_for_file(current_user.id, existing_dest.id, dest_parent_id)
|
||||
await log_activity(current_user, "file_copied", "file", existing_dest.id)
|
||||
return Response(status_code=204)
|
||||
else:
|
||||
@@ -600,7 +524,6 @@ async def handle_copy(
|
||||
mime_type=source_resource.mime_type, file_hash=source_resource.file_hash,
|
||||
owner=current_user, parent=dest_parent_folder
|
||||
)
|
||||
invalidate_cache_for_path(current_user.id, dest_path, dest_parent_id)
|
||||
await log_activity(current_user, "file_copied", "file", new_file.id)
|
||||
return Response(status_code=201)
|
||||
|
||||
@@ -637,11 +560,10 @@ async def handle_move(
|
||||
if existing_dest_exists and overwrite == "F":
|
||||
raise HTTPException(status_code=412, detail="Destination exists and Overwrite is 'F'")
|
||||
|
||||
dest_parent_id = dest_parent_folder.id if dest_parent_folder else None
|
||||
source_parent_id = source_resource.parent_id if hasattr(source_resource, 'parent_id') else None
|
||||
|
||||
# Handle overwrite scenario
|
||||
if existing_dest_exists:
|
||||
if isinstance(source_resource, File):
|
||||
# Update existing_dest with source_resource's properties
|
||||
existing_dest.name = dest_name
|
||||
existing_dest.path = source_resource.path
|
||||
existing_dest.size = source_resource.size
|
||||
@@ -650,19 +572,19 @@ async def handle_move(
|
||||
existing_dest.parent = dest_parent_folder
|
||||
existing_dest.updated_at = datetime.now()
|
||||
await existing_dest.save()
|
||||
invalidate_cache_for_file(current_user.id, existing_dest.id, dest_parent_id)
|
||||
await log_activity(current_user, "file_moved_overwrite", "file", existing_dest.id)
|
||||
elif isinstance(source_resource, Folder):
|
||||
raise HTTPException(status_code=501, detail="Folder move overwrite not implemented")
|
||||
|
||||
|
||||
# Mark source as deleted
|
||||
source_resource.is_deleted = True
|
||||
source_resource.deleted_at = datetime.now()
|
||||
await source_resource.save()
|
||||
invalidate_cache_for_file(current_user.id, source_resource.id, source_parent_id)
|
||||
await log_activity(current_user, "file_deleted_after_move", "file", source_resource.id)
|
||||
|
||||
return Response(status_code=204)
|
||||
return Response(status_code=204) # Overwrite means 204 No Content
|
||||
|
||||
# Handle non-overwrite scenario (create new resource at destination)
|
||||
else:
|
||||
if isinstance(source_resource, File):
|
||||
new_file = await File.create(
|
||||
@@ -670,18 +592,17 @@ async def handle_move(
|
||||
mime_type=source_resource.mime_type, file_hash=source_resource.file_hash,
|
||||
owner=current_user, parent=dest_parent_folder
|
||||
)
|
||||
invalidate_cache_for_path(current_user.id, dest_path, dest_parent_id)
|
||||
await log_activity(current_user, "file_moved_created", "file", new_file.id)
|
||||
elif isinstance(source_resource, Folder):
|
||||
raise HTTPException(status_code=501, detail="Folder move not implemented")
|
||||
|
||||
# Mark source as deleted
|
||||
source_resource.is_deleted = True
|
||||
source_resource.deleted_at = datetime.now()
|
||||
await source_resource.save()
|
||||
invalidate_cache_for_file(current_user.id, source_resource.id, source_parent_id)
|
||||
await log_activity(current_user, "file_deleted_after_move", "file", source_resource.id)
|
||||
|
||||
return Response(status_code=201)
|
||||
return Response(status_code=201) # New resource created means 201 Created
|
||||
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["LOCK"])
|
||||
@@ -698,9 +619,7 @@ async def handle_lock(
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
lock_token = await WebDAVLock.create_lock(full_path, current_user.id, current_user.username, timeout)
|
||||
if not lock_token:
|
||||
raise HTTPException(status_code=423, detail="Resource is locked")
|
||||
lock_token = WebDAVLock.create_lock(full_path, current_user.id, timeout)
|
||||
|
||||
lockinfo = ET.Element("D:prop", {"xmlns:D": "DAV:"})
|
||||
lockdiscovery = ET.SubElement(lockinfo, "D:lockdiscovery")
|
||||
@@ -747,23 +666,15 @@ async def handle_unlock(
|
||||
raise HTTPException(status_code=400, detail="Lock-Token header required")
|
||||
|
||||
lock_token = lock_token_header.strip("<>")
|
||||
existing_lock = await WebDAVLock.get_lock(full_path)
|
||||
existing_lock = WebDAVLock.get_lock(full_path)
|
||||
|
||||
if not existing_lock:
|
||||
raise HTTPException(status_code=409, detail="No lock exists for this resource")
|
||||
if not existing_lock or existing_lock["token"] != lock_token:
|
||||
raise HTTPException(status_code=409, detail="Invalid lock token")
|
||||
|
||||
if hasattr(existing_lock, 'token'):
|
||||
if existing_lock.token != lock_token:
|
||||
raise HTTPException(status_code=409, detail="Invalid lock token")
|
||||
if existing_lock.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not lock owner")
|
||||
else:
|
||||
if existing_lock.get("token") != lock_token:
|
||||
raise HTTPException(status_code=409, detail="Invalid lock token")
|
||||
if existing_lock.get("user_id") != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not lock owner")
|
||||
if existing_lock["user_id"] != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not lock owner")
|
||||
|
||||
await WebDAVLock.remove_lock(full_path, lock_token)
|
||||
WebDAVLock.remove_lock(full_path)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .queue import TaskQueue, get_task_queue
|
||||
|
||||
__all__ = ["TaskQueue", "get_task_queue"]
|
||||
@@ -1,251 +0,0 @@
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from typing import Dict, List, Any, Optional, Callable, Awaitable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from collections import deque
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class TaskPriority(Enum):
|
||||
LOW = 0
|
||||
NORMAL = 1
|
||||
HIGH = 2
|
||||
CRITICAL = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
id: str
|
||||
queue_name: str
|
||||
handler_name: str
|
||||
payload: Dict[str, Any]
|
||||
priority: TaskPriority = TaskPriority.NORMAL
|
||||
status: TaskStatus = TaskStatus.PENDING
|
||||
created_at: float = field(default_factory=time.time)
|
||||
started_at: Optional[float] = None
|
||||
completed_at: Optional[float] = None
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
retry_count: int = 0
|
||||
max_retries: int = 3
|
||||
|
||||
|
||||
class TaskQueue:
|
||||
QUEUES = [
|
||||
"thumbnails",
|
||||
"cleanup",
|
||||
"billing",
|
||||
"notifications",
|
||||
"default",
|
||||
]
|
||||
|
||||
def __init__(self, max_workers: int = 4):
|
||||
self.max_workers = max_workers
|
||||
self.queues: Dict[str, deque] = {q: deque() for q in self.QUEUES}
|
||||
self.handlers: Dict[str, Callable] = {}
|
||||
self.tasks: Dict[str, Task] = {}
|
||||
self.workers: List[asyncio.Task] = []
|
||||
self._lock = asyncio.Lock()
|
||||
self._running = False
|
||||
self._stats = {
|
||||
"enqueued": 0,
|
||||
"completed": 0,
|
||||
"failed": 0,
|
||||
"retried": 0,
|
||||
}
|
||||
|
||||
async def start(self):
|
||||
self._running = True
|
||||
for i in range(self.max_workers):
|
||||
worker = asyncio.create_task(self._worker_loop(i))
|
||||
self.workers.append(worker)
|
||||
logger.info(f"TaskQueue started with {self.max_workers} workers")
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
for worker in self.workers:
|
||||
worker.cancel()
|
||||
await asyncio.gather(*self.workers, return_exceptions=True)
|
||||
self.workers.clear()
|
||||
logger.info("TaskQueue stopped")
|
||||
|
||||
def register_handler(self, name: str, handler: Callable[..., Awaitable[Any]]):
|
||||
self.handlers[name] = handler
|
||||
logger.debug(f"Registered handler: {name}")
|
||||
|
||||
async def enqueue(
|
||||
self,
|
||||
queue_name: str,
|
||||
handler_name: str,
|
||||
payload: Dict[str, Any],
|
||||
priority: TaskPriority = TaskPriority.NORMAL,
|
||||
max_retries: int = 3
|
||||
) -> str:
|
||||
if queue_name not in self.queues:
|
||||
queue_name = "default"
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
task = Task(
|
||||
id=task_id,
|
||||
queue_name=queue_name,
|
||||
handler_name=handler_name,
|
||||
payload=payload,
|
||||
priority=priority,
|
||||
max_retries=max_retries
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
self.tasks[task_id] = task
|
||||
if priority == TaskPriority.HIGH or priority == TaskPriority.CRITICAL:
|
||||
self.queues[queue_name].appendleft(task_id)
|
||||
else:
|
||||
self.queues[queue_name].append(task_id)
|
||||
self._stats["enqueued"] += 1
|
||||
|
||||
logger.debug(f"Enqueued task {task_id} to {queue_name}")
|
||||
return task_id
|
||||
|
||||
async def get_task_status(self, task_id: str) -> Optional[Task]:
|
||||
async with self._lock:
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
async def cancel_task(self, task_id: str) -> bool:
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
task = self.tasks[task_id]
|
||||
if task.status == TaskStatus.PENDING:
|
||||
task.status = TaskStatus.CANCELLED
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _worker_loop(self, worker_id: int):
|
||||
logger.debug(f"Worker {worker_id} started")
|
||||
while self._running:
|
||||
try:
|
||||
task = await self._get_next_task()
|
||||
if task:
|
||||
await self._process_task(task, worker_id)
|
||||
else:
|
||||
await asyncio.sleep(0.1)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Worker {worker_id} error: {e}")
|
||||
await asyncio.sleep(1)
|
||||
logger.debug(f"Worker {worker_id} stopped")
|
||||
|
||||
async def _get_next_task(self) -> Optional[Task]:
|
||||
async with self._lock:
|
||||
for priority in [TaskPriority.CRITICAL, TaskPriority.HIGH, TaskPriority.NORMAL, TaskPriority.LOW]:
|
||||
for queue_name in self.QUEUES:
|
||||
if self.queues[queue_name]:
|
||||
task_id = None
|
||||
for tid in list(self.queues[queue_name]):
|
||||
if tid in self.tasks and self.tasks[tid].priority == priority:
|
||||
task_id = tid
|
||||
break
|
||||
if task_id:
|
||||
self.queues[queue_name].remove(task_id)
|
||||
task = self.tasks.get(task_id)
|
||||
if task and task.status == TaskStatus.PENDING:
|
||||
task.status = TaskStatus.RUNNING
|
||||
task.started_at = time.time()
|
||||
return task
|
||||
return None
|
||||
|
||||
async def _process_task(self, task: Task, worker_id: int):
|
||||
handler = self.handlers.get(task.handler_name)
|
||||
if not handler:
|
||||
logger.error(f"No handler found for {task.handler_name}")
|
||||
task.status = TaskStatus.FAILED
|
||||
task.error = f"Handler not found: {task.handler_name}"
|
||||
async with self._lock:
|
||||
self._stats["failed"] += 1
|
||||
return
|
||||
|
||||
try:
|
||||
result = await handler(**task.payload)
|
||||
task.status = TaskStatus.COMPLETED
|
||||
task.completed_at = time.time()
|
||||
task.result = result
|
||||
async with self._lock:
|
||||
self._stats["completed"] += 1
|
||||
logger.debug(f"Task {task.id} completed by worker {worker_id}")
|
||||
except Exception as e:
|
||||
task.error = str(e)
|
||||
task.retry_count += 1
|
||||
|
||||
if task.retry_count < task.max_retries:
|
||||
task.status = TaskStatus.PENDING
|
||||
task.started_at = None
|
||||
async with self._lock:
|
||||
self.queues[task.queue_name].append(task.id)
|
||||
self._stats["retried"] += 1
|
||||
logger.warning(f"Task {task.id} failed, retrying ({task.retry_count}/{task.max_retries})")
|
||||
else:
|
||||
task.status = TaskStatus.FAILED
|
||||
task.completed_at = time.time()
|
||||
async with self._lock:
|
||||
self._stats["failed"] += 1
|
||||
logger.error(f"Task {task.id} failed permanently: {e}")
|
||||
|
||||
async def cleanup_completed_tasks(self, max_age: float = 3600):
|
||||
now = time.time()
|
||||
async with self._lock:
|
||||
to_remove = [
|
||||
tid for tid, task in self.tasks.items()
|
||||
if task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]
|
||||
and task.completed_at and now - task.completed_at > max_age
|
||||
]
|
||||
for tid in to_remove:
|
||||
del self.tasks[tid]
|
||||
if to_remove:
|
||||
logger.debug(f"Cleaned up {len(to_remove)} completed tasks")
|
||||
|
||||
async def get_stats(self) -> Dict:
|
||||
async with self._lock:
|
||||
queue_sizes = {name: len(queue) for name, queue in self.queues.items()}
|
||||
pending = sum(1 for t in self.tasks.values() if t.status == TaskStatus.PENDING)
|
||||
running = sum(1 for t in self.tasks.values() if t.status == TaskStatus.RUNNING)
|
||||
return {
|
||||
**self._stats,
|
||||
"queue_sizes": queue_sizes,
|
||||
"pending_tasks": pending,
|
||||
"running_tasks": running,
|
||||
"total_tasks": len(self.tasks),
|
||||
}
|
||||
|
||||
|
||||
_task_queue: Optional[TaskQueue] = None
|
||||
|
||||
|
||||
async def init_task_queue(max_workers: int = 4) -> TaskQueue:
|
||||
global _task_queue
|
||||
_task_queue = TaskQueue(max_workers=max_workers)
|
||||
await _task_queue.start()
|
||||
return _task_queue
|
||||
|
||||
|
||||
async def shutdown_task_queue():
|
||||
global _task_queue
|
||||
if _task_queue:
|
||||
await _task_queue.stop()
|
||||
_task_queue = None
|
||||
|
||||
|
||||
def get_task_queue() -> TaskQueue:
|
||||
if not _task_queue:
|
||||
raise RuntimeError("Task queue not initialized")
|
||||
return _task_queue
|
||||
@@ -1,924 +0,0 @@
|
||||
/* 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,87 +384,3 @@
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.billing-dashboard,
|
||||
.admin-billing {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.billing-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.billing-cards {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.estimated-cost {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.invoices-section,
|
||||
.payment-methods-section,
|
||||
.pricing-config-section,
|
||||
.invoice-generation-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.invoices-table {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.invoices-table table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.pricing-table {
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
.invoice-gen-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.invoice-gen-form label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invoice-gen-form input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-actions .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.payment-methods-section .button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,57 +88,3 @@
|
||||
.code-editor-body textarea {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.code-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.code-editor-header {
|
||||
padding: 12px 16px;
|
||||
padding-top: calc(12px + env(safe-area-inset-top, 0));
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.code-editor-header .header-left {
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code-editor-header .preview-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.code-editor-header .button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.editor-filename {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.code-editor-body {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
.code-editor-body .CodeMirror {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.code-editor-body .CodeMirror-linenumber {
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,64 +143,3 @@
|
||||
color: #dc3545;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.file-upload-view {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.file-upload-header {
|
||||
padding: 12px 16px;
|
||||
padding-top: calc(12px + env(safe-area-inset-top, 0));
|
||||
}
|
||||
|
||||
.file-upload-header .header-left {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-upload-header h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.file-upload-header .button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.file-upload-body {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0));
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.drop-zone-icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.drop-zone h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.upload-item-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.upload-item-name {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+3
-105
@@ -66,37 +66,6 @@ body {
|
||||
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;
|
||||
@@ -220,49 +189,9 @@ body {
|
||||
}
|
||||
|
||||
@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 {
|
||||
@@ -273,48 +202,17 @@ body {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 1.5rem 1rem;
|
||||
.nav-menu {
|
||||
gap: 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%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -579,6 +579,53 @@ body {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.header-center {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: var(--spacing-unit) 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.file-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark-mode {
|
||||
--background-color: #222222;
|
||||
@@ -946,7 +993,7 @@ body.dark-mode {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(0, 51, 153, 0.05);
|
||||
}
|
||||
|
||||
-e
|
||||
.shared-items-container {
|
||||
background-color: var(--accent-color);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
<link rel="stylesheet" href="/static/lib/codemirror/codemirror.min.css">
|
||||
<link rel="stylesheet" href="/static/css/code-editor-view.css">
|
||||
<link rel="stylesheet" href="/static/css/file-upload-view.css">
|
||||
<link rel="stylesheet" href="/static/css/mobile.css">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script src="/static/lib/codemirror/codemirror.min.js"></script>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { api } from '../api.js';
|
||||
import { GestureHandler, PullToRefreshIndicator, ContextMenu, isMobile } from '../gesture-handler.js';
|
||||
|
||||
export class FileList extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -13,9 +12,6 @@ export class FileList extends HTMLElement {
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
this.boundHandleDblClick = this.handleDblClick.bind(this);
|
||||
this.boundHandleChange = this.handleChange.bind(this);
|
||||
this.gestureHandler = null;
|
||||
this.pullIndicator = null;
|
||||
this.contextMenu = new ContextMenu();
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
@@ -31,12 +27,6 @@ export class FileList extends HTMLElement {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
this.removeEventListener('dblclick', this.boundHandleDblClick);
|
||||
this.removeEventListener('change', this.boundHandleChange);
|
||||
if (this.gestureHandler) {
|
||||
this.gestureHandler.destroy();
|
||||
}
|
||||
if (this.pullIndicator) {
|
||||
this.pullIndicator.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async loadContents(folderId) {
|
||||
@@ -138,7 +128,7 @@ export class FileList extends HTMLElement {
|
||||
|
||||
renderFolder(folder) {
|
||||
const isSelected = this.selectedFolders.has(folder.id);
|
||||
const starIcon = folder.is_starred ? '★' : '☆';
|
||||
const starIcon = folder.is_starred ? '★' : '☆'; // Filled star or empty star
|
||||
const starAction = folder.is_starred ? 'unstar-folder' : 'star-folder';
|
||||
return `
|
||||
<div class="file-item folder-item" data-folder-id="${folder.id}">
|
||||
@@ -149,7 +139,6 @@ export class FileList extends HTMLElement {
|
||||
<button class="action-btn" data-action="delete-folder" data-id="${folder.id}">Delete</button>
|
||||
<button class="action-btn star-btn" data-action="${starAction}" data-id="${folder.id}">${starIcon}</button>
|
||||
</div>
|
||||
<button class="mobile-more-btn" data-folder-id="${folder.id}" aria-label="More actions">⋮</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -158,7 +147,7 @@ export class FileList extends HTMLElement {
|
||||
const isSelected = this.selectedFiles.has(file.id);
|
||||
const icon = this.getFileIcon(file.mime_type);
|
||||
const size = this.formatFileSize(file.size);
|
||||
const starIcon = file.is_starred ? '★' : '☆';
|
||||
const starIcon = file.is_starred ? '★' : '☆'; // Filled star or empty star
|
||||
const starAction = file.is_starred ? 'unstar-file' : 'star-file';
|
||||
|
||||
return `
|
||||
@@ -174,7 +163,6 @@ export class FileList extends HTMLElement {
|
||||
<button class="action-btn" data-action="share" data-id="${file.id}">Share</button>
|
||||
<button class="action-btn star-btn" data-action="${starAction}" data-id="${file.id}">${starIcon}</button>
|
||||
</div>
|
||||
<button class="mobile-more-btn" data-file-id="${file.id}" aria-label="More actions">⋮</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -262,26 +250,6 @@ export class FileList extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('mobile-more-btn')) {
|
||||
e.stopPropagation();
|
||||
const fileId = target.dataset.fileId;
|
||||
const folderId = target.dataset.folderId;
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
if (fileId) {
|
||||
const file = this.files.find(f => f.id === parseInt(fileId));
|
||||
if (file) {
|
||||
this.showFileContextMenu(rect.left, rect.bottom, file);
|
||||
}
|
||||
} else if (folderId) {
|
||||
const folder = this.folders.find(f => f.id === parseInt(folderId));
|
||||
if (folder) {
|
||||
this.showFolderContextMenu(rect.left, rect.bottom, folder);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('select-item')) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
@@ -337,117 +305,6 @@ export class FileList extends HTMLElement {
|
||||
|
||||
attachListeners() {
|
||||
this.updateBatchActionVisibility();
|
||||
this.initGestures();
|
||||
}
|
||||
|
||||
initGestures() {
|
||||
if (this.gestureHandler) {
|
||||
this.gestureHandler.destroy();
|
||||
}
|
||||
if (this.pullIndicator) {
|
||||
this.pullIndicator.destroy();
|
||||
}
|
||||
|
||||
const container = this.querySelector('.file-list-container');
|
||||
if (!container) return;
|
||||
|
||||
this.pullIndicator = new PullToRefreshIndicator(container);
|
||||
this.gestureHandler = new GestureHandler(container);
|
||||
|
||||
this.gestureHandler.on('pullToRefresh', async () => {
|
||||
this.pullIndicator.showRefreshing();
|
||||
await this.loadContents(this.currentFolderId);
|
||||
this.pullIndicator.hide();
|
||||
});
|
||||
|
||||
this.gestureHandler.on('longPress', (data) => {
|
||||
if (!isMobile()) return;
|
||||
|
||||
const fileItem = data.target?.closest('.file-item');
|
||||
if (!fileItem) return;
|
||||
|
||||
const folderId = fileItem.dataset.folderId;
|
||||
const fileId = fileItem.dataset.fileId;
|
||||
|
||||
if (folderId) {
|
||||
const folder = this.folders.find(f => f.id === parseInt(folderId));
|
||||
if (folder) {
|
||||
this.showFolderContextMenu(data.x, data.y, folder);
|
||||
}
|
||||
} else if (fileId) {
|
||||
const file = this.files.find(f => f.id === parseInt(fileId));
|
||||
if (file) {
|
||||
this.showFileContextMenu(data.x, data.y, file);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
container.addEventListener('pull-progress', (e) => {
|
||||
this.pullIndicator.setProgress(e.detail.progress);
|
||||
});
|
||||
|
||||
container.addEventListener('pull-end', () => {
|
||||
if (this.pullIndicator) {
|
||||
this.pullIndicator.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showFileContextMenu(x, y, file) {
|
||||
const items = [
|
||||
{
|
||||
label: 'Download',
|
||||
icon: '⬇',
|
||||
action: () => this.handleAction('download', file.id)
|
||||
},
|
||||
{
|
||||
label: 'Rename',
|
||||
icon: '✏',
|
||||
action: () => this.handleAction('rename', file.id)
|
||||
},
|
||||
{
|
||||
label: 'Share',
|
||||
icon: '🔗',
|
||||
action: () => this.handleAction('share', file.id)
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: file.is_starred ? 'Unstar' : 'Star',
|
||||
icon: file.is_starred ? '★' : '☆',
|
||||
action: () => this.handleAction(file.is_starred ? 'unstar-file' : 'star-file', file.id)
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: '🗑',
|
||||
destructive: true,
|
||||
action: () => this.handleAction('delete', file.id)
|
||||
}
|
||||
];
|
||||
this.contextMenu.show(x, y, items);
|
||||
}
|
||||
|
||||
showFolderContextMenu(x, y, folder) {
|
||||
const items = [
|
||||
{
|
||||
label: 'Open',
|
||||
icon: '📂',
|
||||
action: () => this.loadContents(folder.id)
|
||||
},
|
||||
{
|
||||
label: folder.is_starred ? 'Unstar' : 'Star',
|
||||
icon: folder.is_starred ? '★' : '☆',
|
||||
action: () => this.handleAction(folder.is_starred ? 'unstar-folder' : 'star-folder', folder.id)
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: '🗑',
|
||||
destructive: true,
|
||||
action: () => this.handleAction('delete-folder', folder.id)
|
||||
}
|
||||
];
|
||||
this.contextMenu.show(x, y, items);
|
||||
}
|
||||
|
||||
toggleSelectItem(type, id, checked) {
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
import { api } from '../api.js';
|
||||
import { GestureHandler, isMobile } from '../gesture-handler.js';
|
||||
|
||||
class FilePreview extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.file = null;
|
||||
this.handleEscape = this.handleEscape.bind(this);
|
||||
this.gestureHandler = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
this.initGestures();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.gestureHandler) {
|
||||
this.gestureHandler.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
initGestures() {
|
||||
const overlay = this.querySelector('.file-preview-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
this.gestureHandler = new GestureHandler(overlay);
|
||||
this.gestureHandler.on('swipeDown', () => {
|
||||
if (isMobile()) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { api } from '../api.js';
|
||||
import { GestureHandler, isMobile } from '../gesture-handler.js';
|
||||
|
||||
export class FileUploadView extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -7,7 +6,6 @@ export class FileUploadView extends HTMLElement {
|
||||
this.folderId = null;
|
||||
this.handleEscape = this.handleEscape.bind(this);
|
||||
this.uploadItems = new Map();
|
||||
this.gestureHandler = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -16,21 +14,6 @@ export class FileUploadView extends HTMLElement {
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('keydown', this.handleEscape);
|
||||
if (this.gestureHandler) {
|
||||
this.gestureHandler.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
initGestures() {
|
||||
const view = this.querySelector('.file-upload-view');
|
||||
if (!view) return;
|
||||
|
||||
this.gestureHandler = new GestureHandler(view);
|
||||
this.gestureHandler.on('swipeDown', () => {
|
||||
if (isMobile()) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setFolder(folderId) {
|
||||
@@ -74,8 +57,6 @@ export class FileUploadView extends HTMLElement {
|
||||
backBtn.addEventListener('click', () => this.close());
|
||||
}
|
||||
|
||||
this.initGestures();
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
|
||||
@@ -15,9 +15,8 @@ import './billing-dashboard.js';
|
||||
import './admin-billing.js';
|
||||
import './code-editor-view.js';
|
||||
import './cookie-consent.js';
|
||||
import './user-settings.js';
|
||||
import './user-settings.js'; // Import the new user settings component
|
||||
import { shortcuts } from '../shortcuts.js';
|
||||
import { GestureHandler, isMobile } from '../gesture-handler.js';
|
||||
|
||||
const api = app.getAPI();
|
||||
const logger = app.getLogger();
|
||||
@@ -32,8 +31,6 @@ export class MyWebdavApp extends HTMLElement {
|
||||
this.boundHandlePopState = this.handlePopState.bind(this);
|
||||
this.popstateAttached = false;
|
||||
this.currentSearchId = 0;
|
||||
this.gestureHandler = null;
|
||||
this.sidebarOpen = false;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
@@ -100,7 +97,6 @@ export class MyWebdavApp extends HTMLElement {
|
||||
}
|
||||
|
||||
showLogin() {
|
||||
document.body.classList.remove('logged-in');
|
||||
this.innerHTML = `
|
||||
<div class="login-container">
|
||||
<login-view></login-view>
|
||||
@@ -127,20 +123,14 @@ export class MyWebdavApp extends HTMLElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
document.body.classList.add('logged-in');
|
||||
this.innerHTML = `
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle navigation">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<h1 class="app-title">MyWebdav</h1>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<input type="search" placeholder="Search..." class="search-input" id="search-input" inputmode="search">
|
||||
<input type="search" placeholder="Search..." class="search-input" id="search-input">
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="user-info">${this.user.username}</span>
|
||||
@@ -149,8 +139,7 @@ export class MyWebdavApp extends HTMLElement {
|
||||
</header>
|
||||
|
||||
<div class="app-body">
|
||||
<div class="sidebar-overlay" id="sidebar-overlay"></div>
|
||||
<aside class="app-sidebar" id="app-sidebar">
|
||||
<aside class="app-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<h3 class="nav-title">Navigation</h3>
|
||||
<ul class="nav-list">
|
||||
@@ -171,7 +160,7 @@ export class MyWebdavApp extends HTMLElement {
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="app-main" id="app-main">
|
||||
<main class="app-main">
|
||||
<div id="main-content">
|
||||
<file-list></file-list>
|
||||
</div>
|
||||
@@ -202,55 +191,6 @@ export class MyWebdavApp extends HTMLElement {
|
||||
this.initializeNavigation();
|
||||
this.attachListeners();
|
||||
this.registerShortcuts();
|
||||
this.initGestures();
|
||||
}
|
||||
|
||||
initGestures() {
|
||||
const appMain = this.querySelector('#app-main');
|
||||
if (!appMain) return;
|
||||
|
||||
this.gestureHandler = new GestureHandler(appMain);
|
||||
this.gestureHandler.on('edgeSwipeRight', () => {
|
||||
if (isMobile()) {
|
||||
this.openSidebar();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleSidebar() {
|
||||
if (this.sidebarOpen) {
|
||||
this.closeSidebar();
|
||||
} else {
|
||||
this.openSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
openSidebar() {
|
||||
const sidebar = this.querySelector('#app-sidebar');
|
||||
const overlay = this.querySelector('#sidebar-overlay');
|
||||
const hamburger = this.querySelector('#hamburger-btn');
|
||||
|
||||
if (sidebar && overlay) {
|
||||
sidebar.classList.add('open');
|
||||
overlay.classList.add('visible');
|
||||
hamburger?.classList.add('active');
|
||||
this.sidebarOpen = true;
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
closeSidebar() {
|
||||
const sidebar = this.querySelector('#app-sidebar');
|
||||
const overlay = this.querySelector('#sidebar-overlay');
|
||||
const hamburger = this.querySelector('#hamburger-btn');
|
||||
|
||||
if (sidebar && overlay) {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('visible');
|
||||
hamburger?.classList.remove('active');
|
||||
this.sidebarOpen = false;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
initializeNavigation() {
|
||||
@@ -462,22 +402,11 @@ export class MyWebdavApp extends HTMLElement {
|
||||
api.logout();
|
||||
});
|
||||
|
||||
this.querySelector('#hamburger-btn')?.addEventListener('click', () => {
|
||||
this.toggleSidebar();
|
||||
});
|
||||
|
||||
this.querySelector('#sidebar-overlay')?.addEventListener('click', () => {
|
||||
this.closeSidebar();
|
||||
});
|
||||
|
||||
this.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const view = link.dataset.view;
|
||||
this.switchView(view);
|
||||
if (isMobile()) {
|
||||
this.closeSidebar();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,54 +1,20 @@
|
||||
import { api } from '../api.js';
|
||||
import { GestureHandler, PullToRefreshIndicator } from '../gesture-handler.js';
|
||||
|
||||
class PhotoGallery extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.photos = [];
|
||||
this.boundHandleClick = this.handleClick.bind(this);
|
||||
this.gestureHandler = null;
|
||||
this.pullIndicator = null;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener('click', this.boundHandleClick);
|
||||
this.render();
|
||||
this.loadPhotos();
|
||||
this.initGestures();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener('click', this.boundHandleClick);
|
||||
if (this.gestureHandler) {
|
||||
this.gestureHandler.destroy();
|
||||
}
|
||||
if (this.pullIndicator) {
|
||||
this.pullIndicator.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
initGestures() {
|
||||
const container = this.querySelector('.photo-gallery');
|
||||
if (!container) return;
|
||||
|
||||
this.pullIndicator = new PullToRefreshIndicator(container);
|
||||
this.gestureHandler = new GestureHandler(container);
|
||||
|
||||
this.gestureHandler.on('pullToRefresh', async () => {
|
||||
this.pullIndicator.showRefreshing();
|
||||
await this.loadPhotos();
|
||||
this.pullIndicator.hide();
|
||||
});
|
||||
|
||||
container.addEventListener('pull-progress', (e) => {
|
||||
this.pullIndicator.setProgress(e.detail.progress);
|
||||
});
|
||||
|
||||
container.addEventListener('pull-end', () => {
|
||||
if (this.pullIndicator) {
|
||||
this.pullIndicator.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadPhotos() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { api } from '../api.js';
|
||||
import { GestureHandler, isMobile } from '../gesture-handler.js';
|
||||
|
||||
export class ShareModal extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -7,29 +6,10 @@ export class ShareModal extends HTMLElement {
|
||||
this.fileId = null;
|
||||
this.folderId = null;
|
||||
this.handleEscape = this.handleEscape.bind(this);
|
||||
this.gestureHandler = null;
|
||||
this.render();
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this.gestureHandler) {
|
||||
this.gestureHandler.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
initGestures() {
|
||||
const modal = this.querySelector('.share-modal-content');
|
||||
if (!modal || this.gestureHandler) return;
|
||||
|
||||
this.gestureHandler = new GestureHandler(modal);
|
||||
this.gestureHandler.on('swipeDown', () => {
|
||||
if (isMobile()) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="share-modal" id="share-modal" style="display: none;">
|
||||
@@ -110,7 +90,6 @@ export class ShareModal extends HTMLElement {
|
||||
this.querySelector('#share-result').style.display = 'none';
|
||||
this.querySelector('#share-form').reset();
|
||||
document.addEventListener('keydown', this.handleEscape);
|
||||
this.initGestures();
|
||||
}
|
||||
|
||||
hide() {
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -59,7 +59,7 @@ async def pricing_config():
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="storage_per_gb_month",
|
||||
config_value=Decimal("0.005"),
|
||||
config_value=Decimal("0.0045"),
|
||||
description="Storage cost per GB per month",
|
||||
unit="per_gb_month",
|
||||
)
|
||||
@@ -67,11 +67,27 @@ async def pricing_config():
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="bandwidth_egress_per_gb",
|
||||
config_value=Decimal("0.008"),
|
||||
config_value=Decimal("0.009"),
|
||||
description="Bandwidth egress cost per GB",
|
||||
unit="per_gb",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_storage_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier storage in GB",
|
||||
unit="gb",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_bandwidth_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier bandwidth in GB per month",
|
||||
unit="gb",
|
||||
)
|
||||
)
|
||||
yield configs
|
||||
for config in configs:
|
||||
await config.delete()
|
||||
|
||||
@@ -31,7 +31,7 @@ async def pricing_config():
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="storage_per_gb_month",
|
||||
config_value=Decimal("0.005"),
|
||||
config_value=Decimal("0.0045"),
|
||||
description="Storage cost per GB per month",
|
||||
unit="per_gb_month",
|
||||
)
|
||||
@@ -39,11 +39,27 @@ async def pricing_config():
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="bandwidth_egress_per_gb",
|
||||
config_value=Decimal("0.008"),
|
||||
config_value=Decimal("0.009"),
|
||||
description="Bandwidth egress cost per GB",
|
||||
unit="per_gb",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_storage_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier storage in GB",
|
||||
unit="gb",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="free_tier_bandwidth_gb",
|
||||
config_value=Decimal("15"),
|
||||
description="Free tier bandwidth in GB per month",
|
||||
unit="gb",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
await PricingConfig.create(
|
||||
config_key="tax_rate_default",
|
||||
@@ -87,7 +103,7 @@ async def test_generate_monthly_invoice_with_usage(test_user, pricing_config):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_monthly_invoice_with_small_usage(test_user, pricing_config):
|
||||
async def test_generate_monthly_invoice_below_free_tier(test_user, pricing_config):
|
||||
today = date.today()
|
||||
|
||||
await UsageAggregate.create(
|
||||
@@ -103,11 +119,8 @@ async def test_generate_monthly_invoice_with_small_usage(test_user, pricing_conf
|
||||
test_user, today.year, today.month
|
||||
)
|
||||
|
||||
# Should always generate invoice now (no free tier)
|
||||
assert invoice is not None
|
||||
assert invoice.total > 0
|
||||
assert invoice is None
|
||||
|
||||
await invoice.delete()
|
||||
await UsageAggregate.filter(user=test_user).delete()
|
||||
|
||||
|
||||
|
||||
@@ -143,14 +143,14 @@ async def test_invoice_line_item_creation(test_user):
|
||||
async def test_pricing_config_creation(test_user):
|
||||
config = await PricingConfig.create(
|
||||
config_key="storage_per_gb_month",
|
||||
config_value=Decimal("0.005"),
|
||||
config_value=Decimal("0.0045"),
|
||||
description="Storage cost per GB per month",
|
||||
unit="per_gb_month",
|
||||
updated_by=test_user,
|
||||
)
|
||||
|
||||
assert config.config_key == "storage_per_gb_month"
|
||||
assert config.config_value == Decimal("0.005")
|
||||
assert config.config_value == Decimal("0.0045")
|
||||
await config.delete()
|
||||
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import asyncio
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def temp_db_path():
|
||||
path = tempfile.mkdtemp()
|
||||
yield path
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_manager(temp_db_path):
|
||||
from mywebdav.database.manager import UserDatabaseManager
|
||||
manager = UserDatabaseManager(Path(temp_db_path), cache_size=10, flush_interval=1)
|
||||
await manager.start()
|
||||
yield manager
|
||||
await manager.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def cache():
|
||||
from mywebdav.cache.layer import CacheLayer
|
||||
cache = CacheLayer(maxsize=100, flush_interval=60)
|
||||
await cache.start()
|
||||
yield cache
|
||||
await cache.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def lock_manager():
|
||||
from mywebdav.concurrency.locks import LockManager
|
||||
manager = LockManager(default_timeout=5.0, cleanup_interval=60.0)
|
||||
await manager.start()
|
||||
yield manager
|
||||
await manager.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def webdav_locks():
|
||||
from mywebdav.concurrency.webdav_locks import PersistentWebDAVLocks
|
||||
lock_manager = PersistentWebDAVLocks()
|
||||
await lock_manager.start()
|
||||
yield lock_manager
|
||||
await lock_manager.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def token_manager():
|
||||
from mywebdav.auth_tokens import TokenManager
|
||||
manager = TokenManager()
|
||||
await manager.start()
|
||||
yield manager
|
||||
await manager.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def rate_limiter():
|
||||
from mywebdav.middleware.rate_limit import RateLimiter
|
||||
limiter = RateLimiter()
|
||||
await limiter.start()
|
||||
yield limiter
|
||||
await limiter.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def task_queue():
|
||||
from mywebdav.workers.queue import TaskQueue
|
||||
queue = TaskQueue(max_workers=2)
|
||||
await queue.start()
|
||||
yield queue
|
||||
await queue.stop()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def atomic_ops():
|
||||
from mywebdav.concurrency.locks import init_lock_manager, shutdown_lock_manager
|
||||
from mywebdav.concurrency.atomic import AtomicOperations, init_atomic_ops
|
||||
|
||||
await init_lock_manager(default_timeout=5.0)
|
||||
ops = init_atomic_ops()
|
||||
yield ops
|
||||
await shutdown_lock_manager()
|
||||
@@ -1,185 +0,0 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockUser:
|
||||
id: int
|
||||
used_storage_bytes: int
|
||||
storage_quota_bytes: int
|
||||
|
||||
|
||||
class TestAtomicOperations:
|
||||
@pytest.mark.asyncio
|
||||
async def test_quota_check_allowed(self, atomic_ops):
|
||||
user = MockUser(id=1, used_storage_bytes=1000, storage_quota_bytes=10000)
|
||||
save_called = False
|
||||
|
||||
async def save_callback(u):
|
||||
nonlocal save_called
|
||||
save_called = True
|
||||
|
||||
result = await atomic_ops.atomic_quota_check_and_update(user, 500, save_callback)
|
||||
|
||||
assert result.allowed is True
|
||||
assert result.current_usage == 1000
|
||||
assert result.quota == 10000
|
||||
assert result.requested == 500
|
||||
assert result.remaining == 9000
|
||||
assert save_called is True
|
||||
assert user.used_storage_bytes == 1500
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quota_check_denied(self, atomic_ops):
|
||||
user = MockUser(id=1, used_storage_bytes=9500, storage_quota_bytes=10000)
|
||||
save_called = False
|
||||
|
||||
async def save_callback(u):
|
||||
nonlocal save_called
|
||||
save_called = True
|
||||
|
||||
result = await atomic_ops.atomic_quota_check_and_update(user, 1000, save_callback)
|
||||
|
||||
assert result.allowed is False
|
||||
assert result.remaining == 500
|
||||
assert save_called is False
|
||||
assert user.used_storage_bytes == 9500
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quota_check_concurrent_requests(self, atomic_ops):
|
||||
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=1000)
|
||||
|
||||
async def save_callback(u):
|
||||
pass
|
||||
|
||||
async def request_quota(amount):
|
||||
return await atomic_ops.atomic_quota_check_and_update(user, amount, save_callback)
|
||||
|
||||
results = await asyncio.gather(*[request_quota(200) for _ in range(10)])
|
||||
|
||||
allowed_count = sum(1 for r in results if r.allowed)
|
||||
assert allowed_count == 5
|
||||
assert user.used_storage_bytes == 1000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_create_success(self, atomic_ops):
|
||||
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
|
||||
|
||||
async def check_exists():
|
||||
return None
|
||||
|
||||
async def create_file():
|
||||
return {"id": 1, "name": "test.txt"}
|
||||
|
||||
result = await atomic_ops.atomic_file_create(
|
||||
user, None, "test.txt", check_exists, create_file
|
||||
)
|
||||
|
||||
assert result["name"] == "test.txt"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_create_exists(self, atomic_ops):
|
||||
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
|
||||
|
||||
async def check_exists():
|
||||
return {"id": 1, "name": "test.txt"}
|
||||
|
||||
async def create_file():
|
||||
return {"id": 2, "name": "test.txt"}
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
await atomic_ops.atomic_file_create(
|
||||
user, None, "test.txt", check_exists, create_file
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_create_concurrent_same_name(self, atomic_ops):
|
||||
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
|
||||
created_files = []
|
||||
|
||||
async def check_exists():
|
||||
return len(created_files) > 0
|
||||
|
||||
async def create_file():
|
||||
file = {"id": len(created_files) + 1, "name": "test.txt"}
|
||||
created_files.append(file)
|
||||
return file
|
||||
|
||||
async def try_create():
|
||||
try:
|
||||
return await atomic_ops.atomic_file_create(
|
||||
user, 1, "test.txt", check_exists, create_file
|
||||
)
|
||||
except FileExistsError:
|
||||
return None
|
||||
|
||||
results = await asyncio.gather(*[try_create() for _ in range(5)])
|
||||
|
||||
successful = [r for r in results if r is not None]
|
||||
assert len(successful) == 1
|
||||
assert len(created_files) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_create_success(self, atomic_ops):
|
||||
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
|
||||
|
||||
async def check_exists():
|
||||
return None
|
||||
|
||||
async def create_folder():
|
||||
return {"id": 1, "name": "Documents"}
|
||||
|
||||
result = await atomic_ops.atomic_folder_create(
|
||||
user, None, "Documents", check_exists, create_folder
|
||||
)
|
||||
|
||||
assert result["name"] == "Documents"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_folder_create_exists(self, atomic_ops):
|
||||
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
|
||||
|
||||
async def check_exists():
|
||||
return {"id": 1, "name": "Documents"}
|
||||
|
||||
async def create_folder():
|
||||
return {"id": 2, "name": "Documents"}
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
await atomic_ops.atomic_folder_create(
|
||||
user, None, "Documents", check_exists, create_folder
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_update(self, atomic_ops):
|
||||
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
|
||||
update_called = False
|
||||
|
||||
async def update_callback():
|
||||
nonlocal update_called
|
||||
update_called = True
|
||||
return {"id": 1, "updated": True}
|
||||
|
||||
result = await atomic_ops.atomic_file_update(user, 1, update_callback)
|
||||
|
||||
assert update_called is True
|
||||
assert result["updated"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_operation(self, atomic_ops):
|
||||
user = MockUser(id=1, used_storage_bytes=0, storage_quota_bytes=10000)
|
||||
|
||||
async def process_item(item):
|
||||
if item == "fail":
|
||||
raise ValueError("Failed item")
|
||||
return f"processed_{item}"
|
||||
|
||||
result = await atomic_ops.atomic_batch_operation(
|
||||
user, "test_batch", ["a", "b", "fail", "c"], process_item
|
||||
)
|
||||
|
||||
assert len(result["results"]) == 3
|
||||
assert len(result["errors"]) == 1
|
||||
assert "processed_a" in result["results"]
|
||||
assert result["errors"][0]["item"] == "fail"
|
||||
@@ -1,167 +0,0 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from mywebdav.cache.layer import LRUCache
|
||||
|
||||
|
||||
class TestLRUCache:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_and_get(self):
|
||||
cache = LRUCache(maxsize=100)
|
||||
await cache.set("key1", "value1")
|
||||
entry = await cache.get("key1")
|
||||
assert entry is not None
|
||||
assert entry.value == "value1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_missing_key(self):
|
||||
cache = LRUCache(maxsize=100)
|
||||
entry = await cache.get("nonexistent")
|
||||
assert entry is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttl_expiration(self):
|
||||
cache = LRUCache(maxsize=100)
|
||||
await cache.set("key1", "value1", ttl=0.1)
|
||||
await asyncio.sleep(0.2)
|
||||
entry = await cache.get("key1")
|
||||
assert entry is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lru_eviction(self):
|
||||
cache = LRUCache(maxsize=3)
|
||||
await cache.set("key1", "value1")
|
||||
await cache.set("key2", "value2")
|
||||
await cache.set("key3", "value3")
|
||||
await cache.set("key4", "value4")
|
||||
|
||||
entry1 = await cache.get("key1")
|
||||
assert entry1 is None
|
||||
|
||||
entry4 = await cache.get("key4")
|
||||
assert entry4 is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete(self):
|
||||
cache = LRUCache(maxsize=100)
|
||||
await cache.set("key1", "value1")
|
||||
result = await cache.delete("key1")
|
||||
assert result is True
|
||||
entry = await cache.get("key1")
|
||||
assert entry is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dirty_tracking(self):
|
||||
cache = LRUCache(maxsize=100)
|
||||
await cache.set("key1", "value1", dirty=True)
|
||||
await cache.set("key2", "value2", dirty=False)
|
||||
|
||||
dirty_keys = await cache.get_dirty_keys()
|
||||
assert "key1" in dirty_keys
|
||||
assert "key2" not in dirty_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_clean(self):
|
||||
cache = LRUCache(maxsize=100)
|
||||
await cache.set("key1", "value1", dirty=True)
|
||||
await cache.mark_clean("key1")
|
||||
|
||||
dirty_keys = await cache.get_dirty_keys()
|
||||
assert "key1" not in dirty_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_pattern(self):
|
||||
cache = LRUCache(maxsize=100)
|
||||
await cache.set("user:1:profile", "data1")
|
||||
await cache.set("user:1:files", "data2")
|
||||
await cache.set("user:2:profile", "data3")
|
||||
|
||||
count = await cache.invalidate_pattern("user:1:")
|
||||
assert count == 2
|
||||
|
||||
entry1 = await cache.get("user:1:profile")
|
||||
assert entry1 is None
|
||||
|
||||
entry2 = await cache.get("user:2:profile")
|
||||
assert entry2 is not None
|
||||
|
||||
|
||||
class TestCacheLayer:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_with_loader(self, cache):
|
||||
call_count = 0
|
||||
|
||||
async def loader():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return "loaded_value"
|
||||
|
||||
result1 = await cache.get("test_key", loader)
|
||||
assert result1 == "loaded_value"
|
||||
assert call_count == 1
|
||||
|
||||
result2 = await cache.get("test_key", loader)
|
||||
assert result2 == "loaded_value"
|
||||
assert call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_and_get(self, cache):
|
||||
await cache.set("key1", {"data": "value"})
|
||||
result = await cache.get("key1")
|
||||
assert result == {"data": "value"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete(self, cache):
|
||||
await cache.set("key1", "value1")
|
||||
await cache.delete("key1")
|
||||
result = await cache.get("key1")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_user_cache(self, cache):
|
||||
await cache.set("user:1:profile", "data1")
|
||||
await cache.set("user:1:files", "data2")
|
||||
await cache.set("user:2:profile", "data3")
|
||||
|
||||
await cache.invalidate_user_cache(1)
|
||||
|
||||
result1 = await cache.get("user:1:profile")
|
||||
assert result1 is None
|
||||
|
||||
result2 = await cache.get("user:2:profile")
|
||||
assert result2 == "data3"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats(self, cache):
|
||||
await cache.get("miss1")
|
||||
await cache.set("hit1", "value")
|
||||
await cache.get("hit1")
|
||||
await cache.get("hit1")
|
||||
|
||||
stats = cache.get_stats()
|
||||
assert stats["hits"] == 2
|
||||
assert stats["misses"] == 1
|
||||
assert stats["sets"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_key(self, cache):
|
||||
key = cache.build_key("user_profile", user_id=123)
|
||||
assert key == "user:123:profile"
|
||||
|
||||
key = cache.build_key("folder_contents", user_id=1, folder_id=5)
|
||||
assert key == "user:1:folder:5:contents"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttl_by_key_type(self, cache):
|
||||
assert cache._get_ttl_for_key("user:1:profile") == 300.0
|
||||
assert cache._get_ttl_for_key("folder_contents:1") == 30.0
|
||||
assert cache._get_ttl_for_key("unknown_key") == 300.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_flag(self, cache):
|
||||
await cache.set("persistent_key", "value", persist=True)
|
||||
assert "persistent_key" in cache.dirty_keys
|
||||
|
||||
await cache.set("non_persistent_key", "value", persist=False)
|
||||
assert "non_persistent_key" not in cache.dirty_keys
|
||||
@@ -1,131 +0,0 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestUserDatabaseManager:
|
||||
@pytest.mark.asyncio
|
||||
async def test_master_db_initialization(self, db_manager, temp_db_path):
|
||||
assert db_manager.master_db is not None
|
||||
assert db_manager.master_db.connection is not None
|
||||
master_path = Path(temp_db_path) / "master.db"
|
||||
assert master_path.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_master_tables_created(self, db_manager):
|
||||
async with db_manager.get_master_connection() as conn:
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
tables = [row[0] for row in await cursor.fetchall()]
|
||||
assert "users" in tables
|
||||
assert "revoked_tokens" in tables
|
||||
assert "rate_limits" in tables
|
||||
assert "webdav_locks" in tables
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_database_creation(self, db_manager, temp_db_path):
|
||||
user_id = 1
|
||||
async with db_manager.get_user_connection(user_id) as conn:
|
||||
assert conn is not None
|
||||
|
||||
user_db_path = Path(temp_db_path) / "users" / "1" / "database.db"
|
||||
assert user_db_path.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_tables_created(self, db_manager):
|
||||
user_id = 1
|
||||
async with db_manager.get_user_connection(user_id) as conn:
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
tables = [row[0] for row in await cursor.fetchall()]
|
||||
assert "files" in tables
|
||||
assert "folders" in tables
|
||||
assert "shares" in tables
|
||||
assert "activities" in tables
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_caching(self, db_manager):
|
||||
user_id = 1
|
||||
async with db_manager.get_user_connection(user_id):
|
||||
pass
|
||||
assert user_id in db_manager.databases
|
||||
|
||||
async with db_manager.get_user_connection(user_id):
|
||||
pass
|
||||
assert len(db_manager.databases) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_eviction(self, db_manager):
|
||||
for i in range(15):
|
||||
async with db_manager.get_user_connection(i):
|
||||
pass
|
||||
|
||||
assert len(db_manager.databases) <= db_manager.cache_size
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_buffered_write(self, db_manager):
|
||||
user_id = 1
|
||||
async with db_manager.get_user_connection(user_id):
|
||||
pass
|
||||
|
||||
await db_manager.execute_buffered(
|
||||
user_id,
|
||||
"INSERT INTO folders (name, owner_id) VALUES (?, ?)",
|
||||
("test_folder", user_id)
|
||||
)
|
||||
|
||||
user_db = db_manager.databases[user_id]
|
||||
assert user_db.dirty is True
|
||||
assert len(user_db.write_buffer) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_user(self, db_manager):
|
||||
user_id = 1
|
||||
async with db_manager.get_user_connection(user_id):
|
||||
pass
|
||||
|
||||
await db_manager.execute_buffered(
|
||||
user_id,
|
||||
"INSERT INTO folders (name, owner_id) VALUES (?, ?)",
|
||||
("test_folder", user_id)
|
||||
)
|
||||
|
||||
await db_manager.flush_user(user_id)
|
||||
|
||||
user_db = db_manager.databases[user_id]
|
||||
assert user_db.dirty is False
|
||||
assert len(user_db.write_buffer) == 0
|
||||
|
||||
async with db_manager.get_user_connection(user_id) as conn:
|
||||
cursor = await conn.execute("SELECT name FROM folders WHERE owner_id = ?", (user_id,))
|
||||
rows = await cursor.fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0][0] == "test_folder"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_master_buffered_write(self, db_manager):
|
||||
await db_manager.execute_master_buffered(
|
||||
"INSERT INTO users (username, email, hashed_password) VALUES (?, ?, ?)",
|
||||
("testuser", "test@test.com", "hash123")
|
||||
)
|
||||
|
||||
assert db_manager.master_db.dirty is True
|
||||
await db_manager.flush_master()
|
||||
assert db_manager.master_db.dirty is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_users_isolated(self, db_manager):
|
||||
for user_id in [1, 2, 3]:
|
||||
async with db_manager.get_user_connection(user_id) as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO folders (name, owner_id) VALUES (?, ?)",
|
||||
(f"folder_user_{user_id}", user_id)
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
for user_id in [1, 2, 3]:
|
||||
async with db_manager.get_user_connection(user_id) as conn:
|
||||
cursor = await conn.execute("SELECT COUNT(*) FROM folders")
|
||||
count = (await cursor.fetchone())[0]
|
||||
assert count == 1
|
||||
@@ -1,158 +0,0 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
from mywebdav.monitoring.health import router, check_database, check_cache, check_locks, check_task_queue, check_storage
|
||||
|
||||
|
||||
class TestHealthChecks:
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_database_success(self):
|
||||
with patch('mywebdav.database.get_user_db_manager') as mock_db:
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value=(1,))
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_master_connection.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_manager.get_master_connection.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_db.return_value = mock_manager
|
||||
|
||||
result = await check_database()
|
||||
assert result["ok"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_database_failure(self):
|
||||
with patch('mywebdav.database.get_user_db_manager') as mock_db:
|
||||
mock_db.side_effect = Exception("Connection failed")
|
||||
|
||||
result = await check_database()
|
||||
assert result["ok"] is False
|
||||
assert "Connection failed" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_cache_success(self):
|
||||
with patch('mywebdav.cache.get_cache') as mock_cache:
|
||||
mock_cache_instance = MagicMock()
|
||||
mock_cache_instance.get_stats.return_value = {"hits": 100, "misses": 10}
|
||||
mock_cache.return_value = mock_cache_instance
|
||||
|
||||
result = await check_cache()
|
||||
assert result["ok"] is True
|
||||
assert "stats" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_cache_failure(self):
|
||||
with patch('mywebdav.cache.get_cache') as mock_cache:
|
||||
mock_cache.side_effect = RuntimeError("Cache not initialized")
|
||||
|
||||
result = await check_cache()
|
||||
assert result["ok"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_locks_success(self):
|
||||
with patch('mywebdav.concurrency.get_lock_manager') as mock_locks:
|
||||
mock_lock_manager = MagicMock()
|
||||
mock_lock_manager.get_stats = AsyncMock(return_value={"total_locks": 5, "active_locks": 2})
|
||||
mock_locks.return_value = mock_lock_manager
|
||||
|
||||
result = await check_locks()
|
||||
assert result["ok"] is True
|
||||
assert result["stats"]["total_locks"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_task_queue_success(self):
|
||||
with patch('mywebdav.workers.get_task_queue') as mock_queue:
|
||||
mock_queue_instance = MagicMock()
|
||||
mock_queue_instance.get_stats = AsyncMock(return_value={"pending_tasks": 3})
|
||||
mock_queue.return_value = mock_queue_instance
|
||||
|
||||
result = await check_task_queue()
|
||||
assert result["ok"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_storage_success(self):
|
||||
with patch('mywebdav.settings.settings') as mock_settings:
|
||||
mock_settings.STORAGE_PATH = "/tmp"
|
||||
|
||||
with patch('os.path.exists', return_value=True):
|
||||
with patch('os.statvfs') as mock_statvfs:
|
||||
mock_stat = type('obj', (object,), {
|
||||
'f_bavail': 1000000,
|
||||
'f_blocks': 2000000,
|
||||
'f_frsize': 4096
|
||||
})()
|
||||
mock_statvfs.return_value = mock_stat
|
||||
|
||||
result = await check_storage()
|
||||
assert result["ok"] is True
|
||||
assert "free_gb" in result
|
||||
assert "used_percent" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_storage_path_not_exists(self):
|
||||
with patch('mywebdav.settings.settings') as mock_settings:
|
||||
mock_settings.STORAGE_PATH = "/nonexistent/path"
|
||||
|
||||
with patch('os.path.exists', return_value=False):
|
||||
result = await check_storage()
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
class TestHealthEndpoints:
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
from fastapi import FastAPI
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
def test_liveness_check(self, client):
|
||||
response = client.get("/health/live")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["alive"] is True
|
||||
|
||||
def test_readiness_check_success(self, client):
|
||||
with patch('mywebdav.monitoring.health.check_database') as mock_check:
|
||||
mock_check.return_value = {"ok": True}
|
||||
|
||||
response = client.get("/health/ready")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_health_check_all_healthy(self, client):
|
||||
with patch('mywebdav.monitoring.health.check_database') as mock_db, \
|
||||
patch('mywebdav.monitoring.health.check_cache') as mock_cache, \
|
||||
patch('mywebdav.monitoring.health.check_locks') as mock_locks, \
|
||||
patch('mywebdav.monitoring.health.check_task_queue') as mock_queue, \
|
||||
patch('mywebdav.monitoring.health.check_storage') as mock_storage:
|
||||
|
||||
mock_db.return_value = {"ok": True}
|
||||
mock_cache.return_value = {"ok": True}
|
||||
mock_locks.return_value = {"ok": True}
|
||||
mock_queue.return_value = {"ok": True}
|
||||
mock_storage.return_value = {"ok": True}
|
||||
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
|
||||
def test_health_check_degraded(self, client):
|
||||
with patch('mywebdav.monitoring.health.check_database') as mock_db, \
|
||||
patch('mywebdav.monitoring.health.check_cache') as mock_cache, \
|
||||
patch('mywebdav.monitoring.health.check_locks') as mock_locks, \
|
||||
patch('mywebdav.monitoring.health.check_task_queue') as mock_queue, \
|
||||
patch('mywebdav.monitoring.health.check_storage') as mock_storage:
|
||||
|
||||
mock_db.return_value = {"ok": True}
|
||||
mock_cache.return_value = {"ok": False, "message": "Cache error"}
|
||||
mock_locks.return_value = {"ok": True}
|
||||
mock_queue.return_value = {"ok": True}
|
||||
mock_storage.return_value = {"ok": True}
|
||||
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "degraded"
|
||||
@@ -1,140 +0,0 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
|
||||
class TestLockManager:
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_and_release(self, lock_manager):
|
||||
async with lock_manager.acquire("resource1", owner="test", user_id=1) as token:
|
||||
assert token is not None
|
||||
assert await lock_manager.is_locked("resource1")
|
||||
|
||||
assert not await lock_manager.is_locked("resource1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_prevents_concurrent_access(self, lock_manager):
|
||||
results = []
|
||||
|
||||
async def task(task_id):
|
||||
async with lock_manager.acquire("shared_resource", timeout=10.0, owner=f"task{task_id}", user_id=task_id):
|
||||
results.append(f"start_{task_id}")
|
||||
await asyncio.sleep(0.1)
|
||||
results.append(f"end_{task_id}")
|
||||
|
||||
await asyncio.gather(task(1), task(2), task(3))
|
||||
|
||||
for i in range(3):
|
||||
start_idx = results.index(f"start_{i+1}")
|
||||
end_idx = results.index(f"end_{i+1}")
|
||||
assert end_idx == start_idx + 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_timeout(self, lock_manager):
|
||||
async with lock_manager.acquire("resource1", owner="holder", user_id=1):
|
||||
with pytest.raises(TimeoutError):
|
||||
async with lock_manager.acquire("resource1", timeout=0.1, owner="waiter", user_id=2):
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_try_acquire_success(self, lock_manager):
|
||||
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
|
||||
assert token is not None
|
||||
|
||||
released = await lock_manager.release("resource1", token)
|
||||
assert released is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_try_acquire_fails_when_locked(self, lock_manager):
|
||||
token1 = await lock_manager.try_acquire("resource1", owner="holder", user_id=1)
|
||||
assert token1 is not None
|
||||
|
||||
token2 = await lock_manager.try_acquire("resource1", owner="waiter", user_id=2)
|
||||
assert token2 is None
|
||||
|
||||
await lock_manager.release("resource1", token1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extend_lock(self, lock_manager):
|
||||
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
|
||||
assert token is not None
|
||||
|
||||
extended = await lock_manager.extend("resource1", token, extension=60.0)
|
||||
assert extended is True
|
||||
|
||||
info = await lock_manager.get_lock_info("resource1")
|
||||
assert info is not None
|
||||
assert info.extend_count == 1
|
||||
|
||||
await lock_manager.release("resource1", token)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_lock_info(self, lock_manager):
|
||||
async with lock_manager.acquire("resource1", owner="testowner", user_id=42):
|
||||
info = await lock_manager.get_lock_info("resource1")
|
||||
assert info is not None
|
||||
assert info.owner == "testowner"
|
||||
assert info.user_id == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_with_wrong_token(self, lock_manager):
|
||||
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
|
||||
assert token is not None
|
||||
|
||||
released = await lock_manager.release("resource1", "wrong_token")
|
||||
assert released is False
|
||||
|
||||
assert await lock_manager.is_locked("resource1")
|
||||
|
||||
await lock_manager.release("resource1", token)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_force_release(self, lock_manager):
|
||||
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
|
||||
assert token is not None
|
||||
|
||||
released = await lock_manager.force_release("resource1", user_id=1)
|
||||
assert released is True
|
||||
assert not await lock_manager.is_locked("resource1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_force_release_wrong_user(self, lock_manager):
|
||||
token = await lock_manager.try_acquire("resource1", owner="test", user_id=1)
|
||||
assert token is not None
|
||||
|
||||
released = await lock_manager.force_release("resource1", user_id=2)
|
||||
assert released is False
|
||||
assert await lock_manager.is_locked("resource1")
|
||||
|
||||
await lock_manager.release("resource1", token)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_lock_key(self, lock_manager):
|
||||
key = lock_manager.build_lock_key("quota_update", user_id=123)
|
||||
assert "123" in key
|
||||
assert "quota" in key
|
||||
|
||||
key = lock_manager.build_lock_key("file_create", user_id=1, parent_id=5, name_hash="abc")
|
||||
assert "1" in key
|
||||
assert "5" in key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats(self, lock_manager):
|
||||
token1 = await lock_manager.try_acquire("resource1", owner="test1", user_id=1)
|
||||
token2 = await lock_manager.try_acquire("resource2", owner="test2", user_id=2)
|
||||
|
||||
stats = await lock_manager.get_stats()
|
||||
assert stats["total_locks"] == 2
|
||||
assert stats["active_locks"] == 2
|
||||
|
||||
await lock_manager.release("resource1", token1)
|
||||
await lock_manager.release("resource2", token2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_different_resources(self, lock_manager):
|
||||
async def acquire_resource(resource_id):
|
||||
async with lock_manager.acquire(f"resource_{resource_id}", owner=f"owner{resource_id}", user_id=resource_id):
|
||||
await asyncio.sleep(0.05)
|
||||
return resource_id
|
||||
|
||||
results = await asyncio.gather(*[acquire_resource(i) for i in range(10)])
|
||||
assert sorted(results) == list(range(10))
|
||||
@@ -1,115 +0,0 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
from mywebdav.middleware.rate_limit import RateLimitMiddleware
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_request_allowed(self, rate_limiter):
|
||||
allowed, remaining, retry_after = await rate_limiter.check_rate_limit(
|
||||
"192.168.1.1", "api"
|
||||
)
|
||||
assert allowed is True
|
||||
assert remaining == 99
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_exhausted(self, rate_limiter):
|
||||
for i in range(100):
|
||||
await rate_limiter.check_rate_limit("192.168.1.1", "api")
|
||||
|
||||
allowed, remaining, retry_after = await rate_limiter.check_rate_limit(
|
||||
"192.168.1.1", "api"
|
||||
)
|
||||
assert allowed is False
|
||||
assert remaining == 0
|
||||
assert retry_after > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_keys_independent(self, rate_limiter):
|
||||
for i in range(100):
|
||||
await rate_limiter.check_rate_limit("192.168.1.1", "api")
|
||||
|
||||
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
|
||||
assert allowed is False
|
||||
|
||||
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.2", "api")
|
||||
assert allowed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_limit_types(self, rate_limiter):
|
||||
for i in range(5):
|
||||
await rate_limiter.check_rate_limit("192.168.1.1", "login")
|
||||
|
||||
allowed_login, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "login")
|
||||
assert allowed_login is False
|
||||
|
||||
allowed_api, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
|
||||
assert allowed_api is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_rate_limit(self, rate_limiter):
|
||||
for i in range(100):
|
||||
await rate_limiter.check_rate_limit("192.168.1.1", "api")
|
||||
|
||||
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
|
||||
assert allowed is False
|
||||
|
||||
await rate_limiter.reset_rate_limit("192.168.1.1", "api")
|
||||
|
||||
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "api")
|
||||
assert allowed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_limit(self, rate_limiter):
|
||||
for i in range(5):
|
||||
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "login")
|
||||
assert allowed is True
|
||||
|
||||
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "login")
|
||||
assert allowed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_limit(self, rate_limiter):
|
||||
for i in range(20):
|
||||
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "upload")
|
||||
assert allowed is True
|
||||
|
||||
allowed, _, _ = await rate_limiter.check_rate_limit("192.168.1.1", "upload")
|
||||
assert allowed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_requests(self, rate_limiter):
|
||||
async def make_request():
|
||||
return await rate_limiter.check_rate_limit("192.168.1.1", "api")
|
||||
|
||||
results = await asyncio.gather(*[make_request() for _ in range(150)])
|
||||
|
||||
allowed_count = sum(1 for allowed, _, _ in results if allowed)
|
||||
assert allowed_count == 100
|
||||
|
||||
|
||||
class TestRateLimitMiddleware:
|
||||
def test_get_limit_type_login(self):
|
||||
middleware = RateLimitMiddleware(app=None)
|
||||
assert middleware._get_limit_type("/api/auth/login") == "login"
|
||||
|
||||
def test_get_limit_type_register(self):
|
||||
middleware = RateLimitMiddleware(app=None)
|
||||
assert middleware._get_limit_type("/api/auth/register") == "register"
|
||||
|
||||
def test_get_limit_type_upload(self):
|
||||
middleware = RateLimitMiddleware(app=None)
|
||||
assert middleware._get_limit_type("/files/upload") == "upload"
|
||||
|
||||
def test_get_limit_type_download(self):
|
||||
middleware = RateLimitMiddleware(app=None)
|
||||
assert middleware._get_limit_type("/files/download/123") == "download"
|
||||
|
||||
def test_get_limit_type_webdav(self):
|
||||
middleware = RateLimitMiddleware(app=None)
|
||||
assert middleware._get_limit_type("/webdav/folder/file.txt") == "webdav"
|
||||
|
||||
def test_get_limit_type_default(self):
|
||||
middleware = RateLimitMiddleware(app=None)
|
||||
assert middleware._get_limit_type("/api/users/me") == "api"
|
||||
@@ -1,231 +0,0 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
|
||||
from mywebdav.workers.queue import TaskQueue, TaskStatus, TaskPriority, Task
|
||||
|
||||
|
||||
class TestTaskQueue:
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_task(self, task_queue):
|
||||
async def handler(**kwargs):
|
||||
return "success"
|
||||
|
||||
task_queue.register_handler("test_handler", handler)
|
||||
|
||||
task_id = await task_queue.enqueue(
|
||||
"default",
|
||||
"test_handler",
|
||||
{"key": "value"}
|
||||
)
|
||||
|
||||
assert task_id is not None
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
task = await task_queue.get_task_status(task_id)
|
||||
assert task.status == TaskStatus.COMPLETED
|
||||
assert task.result == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_with_payload(self, task_queue):
|
||||
received_payload = {}
|
||||
|
||||
async def handler(**kwargs):
|
||||
received_payload.update(kwargs)
|
||||
return kwargs
|
||||
|
||||
task_queue.register_handler("payload_handler", handler)
|
||||
|
||||
task_id = await task_queue.enqueue(
|
||||
"default",
|
||||
"payload_handler",
|
||||
{"user_id": 1, "file_id": 123}
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
task = await task_queue.get_task_status(task_id)
|
||||
assert task.status == TaskStatus.COMPLETED
|
||||
assert received_payload["user_id"] == 1
|
||||
assert received_payload["file_id"] == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_task_retry(self, task_queue):
|
||||
attempt_count = 0
|
||||
|
||||
async def failing_handler(**kwargs):
|
||||
nonlocal attempt_count
|
||||
attempt_count += 1
|
||||
if attempt_count < 3:
|
||||
raise ValueError("Temporary failure")
|
||||
return "success after retries"
|
||||
|
||||
task_queue.register_handler("retry_handler", failing_handler)
|
||||
|
||||
task_id = await task_queue.enqueue(
|
||||
"default",
|
||||
"retry_handler",
|
||||
{},
|
||||
max_retries=3
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
task = await task_queue.get_task_status(task_id)
|
||||
assert task.status == TaskStatus.COMPLETED
|
||||
assert attempt_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permanently_failed_task(self, task_queue):
|
||||
async def always_failing_handler(**kwargs):
|
||||
raise ValueError("Permanent failure")
|
||||
|
||||
task_queue.register_handler("failing_handler", always_failing_handler)
|
||||
|
||||
task_id = await task_queue.enqueue(
|
||||
"default",
|
||||
"failing_handler",
|
||||
{},
|
||||
max_retries=2
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
task = await task_queue.get_task_status(task_id)
|
||||
assert task.status == TaskStatus.FAILED
|
||||
assert task.retry_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_task(self, task_queue):
|
||||
async def slow_handler(**kwargs):
|
||||
await asyncio.sleep(10)
|
||||
return "done"
|
||||
|
||||
task_queue.register_handler("slow_handler", slow_handler)
|
||||
|
||||
task_id = await task_queue.enqueue(
|
||||
"default",
|
||||
"slow_handler",
|
||||
{}
|
||||
)
|
||||
|
||||
cancelled = await task_queue.cancel_task(task_id)
|
||||
task = await task_queue.get_task_status(task_id)
|
||||
|
||||
if task.status == TaskStatus.PENDING:
|
||||
assert cancelled is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_priority_ordering(self, task_queue):
|
||||
execution_order = []
|
||||
|
||||
async def order_handler(**kwargs):
|
||||
execution_order.append(kwargs["priority"])
|
||||
return kwargs["priority"]
|
||||
|
||||
task_queue.register_handler("order_handler", order_handler)
|
||||
|
||||
await task_queue.stop()
|
||||
task_queue = TaskQueue(max_workers=1)
|
||||
task_queue.register_handler("order_handler", order_handler)
|
||||
|
||||
await task_queue.enqueue("default", "order_handler", {"priority": "low"}, priority=TaskPriority.LOW)
|
||||
await task_queue.enqueue("default", "order_handler", {"priority": "normal"}, priority=TaskPriority.NORMAL)
|
||||
await task_queue.enqueue("default", "order_handler", {"priority": "high"}, priority=TaskPriority.HIGH)
|
||||
await task_queue.enqueue("default", "order_handler", {"priority": "critical"}, priority=TaskPriority.CRITICAL)
|
||||
|
||||
await task_queue.start()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert execution_order[0] == "critical"
|
||||
assert execution_order[1] == "high"
|
||||
|
||||
await task_queue.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_queues(self, task_queue):
|
||||
results = {"thumbnails": False, "cleanup": False}
|
||||
|
||||
async def thumbnail_handler(**kwargs):
|
||||
results["thumbnails"] = True
|
||||
|
||||
async def cleanup_handler(**kwargs):
|
||||
results["cleanup"] = True
|
||||
|
||||
task_queue.register_handler("thumbnail_handler", thumbnail_handler)
|
||||
task_queue.register_handler("cleanup_handler", cleanup_handler)
|
||||
|
||||
await task_queue.enqueue("thumbnails", "thumbnail_handler", {})
|
||||
await task_queue.enqueue("cleanup", "cleanup_handler", {})
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
assert results["thumbnails"] is True
|
||||
assert results["cleanup"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats(self, task_queue):
|
||||
async def handler(**kwargs):
|
||||
return "done"
|
||||
|
||||
task_queue.register_handler("stats_handler", handler)
|
||||
|
||||
for _ in range(5):
|
||||
await task_queue.enqueue("default", "stats_handler", {})
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
stats = await task_queue.get_stats()
|
||||
assert stats["enqueued"] == 5
|
||||
assert stats["completed"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_handler(self, task_queue):
|
||||
task_id = await task_queue.enqueue(
|
||||
"default",
|
||||
"unknown_handler",
|
||||
{}
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
task = await task_queue.get_task_status(task_id)
|
||||
assert task.status == TaskStatus.FAILED
|
||||
assert "Handler not found" in task.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_completed_tasks(self, task_queue):
|
||||
async def handler(**kwargs):
|
||||
return "done"
|
||||
|
||||
task_queue.register_handler("cleanup_test", handler)
|
||||
|
||||
task_ids = []
|
||||
for _ in range(5):
|
||||
task_id = await task_queue.enqueue("default", "cleanup_test", {})
|
||||
task_ids.append(task_id)
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
for task_id in task_ids:
|
||||
task = await task_queue.get_task_status(task_id)
|
||||
task.completed_at = 1.0
|
||||
|
||||
await task_queue.cleanup_completed_tasks(max_age=0)
|
||||
|
||||
for task_id in task_ids:
|
||||
task = await task_queue.get_task_status(task_id)
|
||||
assert task is None
|
||||
|
||||
|
||||
class TestTask:
|
||||
def test_task_creation(self):
|
||||
task = Task(
|
||||
id="task_123",
|
||||
queue_name="default",
|
||||
handler_name="test_handler",
|
||||
payload={"key": "value"}
|
||||
)
|
||||
assert task.id == "task_123"
|
||||
assert task.status == TaskStatus.PENDING
|
||||
assert task.retry_count == 0
|
||||
@@ -1,138 +0,0 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
import time
|
||||
from jose import jwt
|
||||
|
||||
from mywebdav.auth_tokens import TokenInfo
|
||||
from mywebdav.settings import settings
|
||||
|
||||
|
||||
class TestTokenManager:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_access_token(self, token_manager):
|
||||
token, jti = token_manager.create_access_token(
|
||||
user_id=1,
|
||||
username="testuser",
|
||||
two_factor_verified=False
|
||||
)
|
||||
|
||||
assert token is not None
|
||||
assert jti is not None
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
assert payload["sub"] == "testuser"
|
||||
assert payload["user_id"] == 1
|
||||
assert payload["jti"] == jti
|
||||
assert payload["type"] == "access"
|
||||
assert payload["2fa_verified"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_access_token_with_2fa(self, token_manager):
|
||||
token, jti = token_manager.create_access_token(
|
||||
user_id=1,
|
||||
username="testuser",
|
||||
two_factor_verified=True
|
||||
)
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
assert payload["2fa_verified"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_refresh_token(self, token_manager):
|
||||
token, jti = token_manager.create_refresh_token(
|
||||
user_id=1,
|
||||
username="testuser"
|
||||
)
|
||||
|
||||
assert token is not None
|
||||
assert jti is not None
|
||||
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
assert payload["sub"] == "testuser"
|
||||
assert payload["type"] == "refresh"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_token(self, token_manager):
|
||||
token, jti = token_manager.create_access_token(
|
||||
user_id=1,
|
||||
username="testuser"
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
is_revoked_before = await token_manager.is_revoked(jti)
|
||||
assert is_revoked_before is False
|
||||
|
||||
await token_manager.revoke_token(jti, user_id=1)
|
||||
|
||||
is_revoked_after = await token_manager.is_revoked(jti)
|
||||
assert is_revoked_after is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_all_user_tokens(self, token_manager):
|
||||
jtis = []
|
||||
for i in range(5):
|
||||
_, jti = token_manager.create_access_token(
|
||||
user_id=1,
|
||||
username="testuser"
|
||||
)
|
||||
jtis.append(jti)
|
||||
|
||||
_, other_jti = token_manager.create_access_token(
|
||||
user_id=2,
|
||||
username="otheruser"
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
revoked_count = await token_manager.revoke_all_user_tokens(1)
|
||||
assert revoked_count == 5
|
||||
|
||||
for jti in jtis:
|
||||
assert await token_manager.is_revoked(jti) is True
|
||||
|
||||
assert await token_manager.is_revoked(other_jti) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_revoked_unknown_token(self, token_manager):
|
||||
is_revoked = await token_manager.is_revoked("unknown_jti")
|
||||
assert is_revoked is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats(self, token_manager):
|
||||
token_manager.create_access_token(user_id=1, username="user1")
|
||||
token_manager.create_access_token(user_id=2, username="user2")
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
stats = await token_manager.get_stats()
|
||||
assert stats["active_tokens"] >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_tracking(self, token_manager):
|
||||
_, jti = token_manager.create_access_token(
|
||||
user_id=1,
|
||||
username="testuser"
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert jti in token_manager.active_tokens
|
||||
|
||||
await token_manager.revoke_token(jti)
|
||||
|
||||
assert jti not in token_manager.active_tokens
|
||||
assert jti in token_manager.blacklist
|
||||
|
||||
|
||||
class TestTokenInfo:
|
||||
def test_token_info_creation(self):
|
||||
info = TokenInfo(
|
||||
jti="test_jti",
|
||||
user_id=1,
|
||||
token_type="access",
|
||||
expires_at=time.time() + 3600
|
||||
)
|
||||
assert info.jti == "test_jti"
|
||||
assert info.user_id == 1
|
||||
assert info.token_type == "access"
|
||||
@@ -1,212 +0,0 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from mywebdav.concurrency.webdav_locks import WebDAVLockInfo
|
||||
|
||||
|
||||
class TestWebDAVLockInfo:
|
||||
def test_is_expired_false(self):
|
||||
lock = WebDAVLockInfo(
|
||||
token="token123",
|
||||
path="/test/file.txt",
|
||||
path_hash="abc123",
|
||||
owner="user1",
|
||||
user_id=1,
|
||||
timeout=3600
|
||||
)
|
||||
assert lock.is_expired is False
|
||||
|
||||
def test_is_expired_true(self):
|
||||
lock = WebDAVLockInfo(
|
||||
token="token123",
|
||||
path="/test/file.txt",
|
||||
path_hash="abc123",
|
||||
owner="user1",
|
||||
user_id=1,
|
||||
timeout=0,
|
||||
created_at=time.time() - 1
|
||||
)
|
||||
assert lock.is_expired is True
|
||||
|
||||
def test_remaining_seconds(self):
|
||||
lock = WebDAVLockInfo(
|
||||
token="token123",
|
||||
path="/test/file.txt",
|
||||
path_hash="abc123",
|
||||
owner="user1",
|
||||
user_id=1,
|
||||
timeout=100
|
||||
)
|
||||
assert 99 <= lock.remaining_seconds <= 100
|
||||
|
||||
def test_to_dict(self):
|
||||
lock = WebDAVLockInfo(
|
||||
token="token123",
|
||||
path="/test/file.txt",
|
||||
path_hash="abc123",
|
||||
owner="user1",
|
||||
user_id=1
|
||||
)
|
||||
d = lock.to_dict()
|
||||
assert d["token"] == "token123"
|
||||
assert d["path"] == "/test/file.txt"
|
||||
assert d["owner"] == "user1"
|
||||
|
||||
def test_from_dict(self):
|
||||
data = {
|
||||
"token": "token123",
|
||||
"path": "/test/file.txt",
|
||||
"path_hash": "abc123",
|
||||
"owner": "user1",
|
||||
"user_id": 1,
|
||||
"scope": "exclusive",
|
||||
"depth": "0",
|
||||
"timeout": 3600,
|
||||
"created_at": time.time()
|
||||
}
|
||||
lock = WebDAVLockInfo.from_dict(data)
|
||||
assert lock.token == "token123"
|
||||
assert lock.user_id == 1
|
||||
|
||||
|
||||
class TestPersistentWebDAVLocks:
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_lock(self, webdav_locks):
|
||||
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
assert token is not None
|
||||
assert token.startswith("opaquelocktoken:")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_same_path_same_user(self, webdav_locks):
|
||||
token1 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
token2 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
assert token1 == token2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquire_same_path_different_user(self, webdav_locks):
|
||||
token1 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
token2 = await webdav_locks.acquire_lock("/test/file.txt", "user2", user_id=2)
|
||||
assert token1 is not None
|
||||
assert token2 is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_lock(self, webdav_locks):
|
||||
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
released = await webdav_locks.release_lock("/test/file.txt", token)
|
||||
assert released is True
|
||||
|
||||
lock_info = await webdav_locks.check_lock("/test/file.txt")
|
||||
assert lock_info is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_wrong_token(self, webdav_locks):
|
||||
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
released = await webdav_locks.release_lock("/test/file.txt", "wrong_token")
|
||||
assert released is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_lock(self, webdav_locks):
|
||||
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
lock_info = await webdav_locks.check_lock("/test/file.txt")
|
||||
|
||||
assert lock_info is not None
|
||||
assert lock_info.owner == "user1"
|
||||
assert lock_info.user_id == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_lock_nonexistent(self, webdav_locks):
|
||||
lock_info = await webdav_locks.check_lock("/nonexistent/file.txt")
|
||||
assert lock_info is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_locked(self, webdav_locks):
|
||||
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
|
||||
assert await webdav_locks.is_locked("/test/file.txt") is True
|
||||
assert await webdav_locks.is_locked("/test/file.txt", user_id=1) is False
|
||||
assert await webdav_locks.is_locked("/test/file.txt", user_id=2) is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_lock(self, webdav_locks):
|
||||
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1, timeout=10)
|
||||
|
||||
lock_before = await webdav_locks.check_lock("/test/file.txt")
|
||||
created_at_before = lock_before.created_at
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
refreshed = await webdav_locks.refresh_lock("/test/file.txt", token, timeout=100)
|
||||
assert refreshed is True
|
||||
|
||||
lock_after = await webdav_locks.check_lock("/test/file.txt")
|
||||
assert lock_after.timeout == 100
|
||||
assert lock_after.created_at > created_at_before
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_wrong_token(self, webdav_locks):
|
||||
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
refreshed = await webdav_locks.refresh_lock("/test/file.txt", "wrong_token")
|
||||
assert refreshed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_force_unlock(self, webdav_locks):
|
||||
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
|
||||
unlocked = await webdav_locks.force_unlock("/test/file.txt", user_id=1)
|
||||
assert unlocked is True
|
||||
|
||||
lock_info = await webdav_locks.check_lock("/test/file.txt")
|
||||
assert lock_info is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_force_unlock_wrong_user(self, webdav_locks):
|
||||
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
|
||||
unlocked = await webdav_locks.force_unlock("/test/file.txt", user_id=2)
|
||||
assert unlocked is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_locks(self, webdav_locks):
|
||||
await webdav_locks.acquire_lock("/file1.txt", "user1", user_id=1)
|
||||
await webdav_locks.acquire_lock("/file2.txt", "user1", user_id=1)
|
||||
await webdav_locks.acquire_lock("/file3.txt", "user2", user_id=2)
|
||||
|
||||
user1_locks = await webdav_locks.get_user_locks(1)
|
||||
assert len(user1_locks) == 2
|
||||
|
||||
user2_locks = await webdav_locks.get_user_locks(2)
|
||||
assert len(user2_locks) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_lock_by_token(self, webdav_locks):
|
||||
token = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
|
||||
lock_info = await webdav_locks.get_lock_by_token(token)
|
||||
assert lock_info is not None
|
||||
assert lock_info.path == "test/file.txt"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_lock_cleanup(self, webdav_locks):
|
||||
await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1, timeout=0)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
lock_info = await webdav_locks.check_lock("/test/file.txt")
|
||||
assert lock_info is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stats(self, webdav_locks):
|
||||
await webdav_locks.acquire_lock("/file1.txt", "user1", user_id=1)
|
||||
await webdav_locks.acquire_lock("/file2.txt", "user1", user_id=1)
|
||||
|
||||
stats = await webdav_locks.get_stats()
|
||||
assert stats["total_locks"] == 2
|
||||
assert stats["active_locks"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_normalization(self, webdav_locks):
|
||||
token1 = await webdav_locks.acquire_lock("/test/file.txt", "user1", user_id=1)
|
||||
token2 = await webdav_locks.acquire_lock("test/file.txt", "user1", user_id=1)
|
||||
token3 = await webdav_locks.acquire_lock("/test/file.txt/", "user1", user_id=1)
|
||||
|
||||
assert token1 == token2 == token3
|
||||
@@ -1,72 +0,0 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from fastapi import status
|
||||
from mywebdav.main import app
|
||||
from mywebdav.models import User, Folder, File, Share
|
||||
from mywebdav.auth import get_password_hash
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_share_subfolder_navigation():
|
||||
# 1. Setup: User, Parent Folder, Subfolder, File in Subfolder
|
||||
user = await User.create(
|
||||
username="shareuser",
|
||||
email="share@example.com",
|
||||
hashed_password=get_password_hash("testpass"),
|
||||
is_active=True
|
||||
)
|
||||
|
||||
parent_folder = await Folder.create(name="parent", owner=user)
|
||||
subfolder = await Folder.create(name="subfolder", parent=parent_folder, owner=user)
|
||||
file_in_sub = await File.create(
|
||||
name="deep_file.txt",
|
||||
path="parent/subfolder/deep_file.txt",
|
||||
size=10,
|
||||
mime_type="text/plain",
|
||||
file_hash="hash",
|
||||
owner=user,
|
||||
parent=subfolder
|
||||
)
|
||||
|
||||
# 2. Create Share for Parent Folder
|
||||
token = secrets.token_urlsafe(16)
|
||||
share = await Share.create(
|
||||
token=token,
|
||||
folder=parent_folder,
|
||||
owner=user,
|
||||
permission_level="viewer"
|
||||
)
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
# 3. Access Share Root
|
||||
resp = await client.post(f"/shares/{token}/access")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "folder"
|
||||
assert data["folder"]["id"] == parent_folder.id
|
||||
# Should see subfolder
|
||||
assert any(f["id"] == subfolder.id for f in data["folders"])
|
||||
|
||||
# 4. Try to Access Subfolder (Expecting this to fail or return root currently)
|
||||
# We'll try passing subfolder_id as a query param, which is a common pattern
|
||||
resp_sub = await client.post(f"/shares/{token}/access?subfolder_id={subfolder.id}")
|
||||
|
||||
# If the feature is missing, this might just ignore the param and return root,
|
||||
# or fail if the param isn't expected.
|
||||
# We WANT it to return the subfolder content.
|
||||
|
||||
assert resp_sub.status_code == 200
|
||||
sub_data = resp_sub.json()
|
||||
|
||||
# This assertion will fail if the feature is not implemented (it will likely return parent folder again)
|
||||
assert sub_data["folder"]["id"] == subfolder.id
|
||||
# Should see the file inside
|
||||
assert any(f["id"] == file_in_sub.id for f in sub_data["files"])
|
||||
|
||||
# Cleanup
|
||||
await file_in_sub.delete()
|
||||
await subfolder.delete()
|
||||
await share.delete()
|
||||
await parent_folder.delete()
|
||||
await user.delete()
|
||||
Reference in New Issue
Block a user