chore: add token revocation, caching, concurrency, and WebDAV lock modules

This commit is contained in:
2025-11-29 10:17:47 +00:00
parent acf70b7019
commit 525784aa6f
40 changed files with 4855 additions and 566 deletions
+19 -2
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
import asyncio
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
@@ -9,11 +10,19 @@ import bcrypt
from .schemas import TokenData
from .settings import settings
from .models import User
from .two_factor import verify_totp_code # Import verify_totp_code
from .two_factor import verify_totp_code
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def get_token_manager_safe():
try:
from .auth_tokens import get_token_manager
return get_token_manager()
except RuntimeError:
return None
def verify_password(plain_password, hashed_password):
password_bytes = plain_password[:72].encode("utf-8")
hashed_bytes = (
@@ -77,8 +86,16 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
)
username: str = payload.get("sub")
two_factor_verified: bool = payload.get("2fa_verified", False)
jti: str = payload.get("jti")
if username is None:
raise credentials_exception
token_manager = get_token_manager_safe()
if token_manager and jti:
is_revoked = await token_manager.is_revoked(jti)
if is_revoked:
raise credentials_exception
token_data = TokenData(
username=username, two_factor_verified=two_factor_verified
)
@@ -87,7 +104,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
user = await User.get_or_none(username=token_data.username)
if user is None:
raise credentials_exception
user.token_data = token_data # Attach token_data to user for easy access
user.token_data = token_data
return user
+291
View File
@@ -0,0 +1,291 @@
import asyncio
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional, Set
from dataclasses import dataclass, field
import logging
from jose import jwt
from .settings import settings
logger = logging.getLogger(__name__)
@dataclass
class TokenInfo:
jti: str
user_id: int
token_type: str
created_at: float = field(default_factory=time.time)
expires_at: float = 0
class TokenManager:
def __init__(self, db_manager=None):
self.db_manager = db_manager
self.blacklist: Dict[str, TokenInfo] = {}
self.active_tokens: Dict[str, TokenInfo] = {}
self._lock = asyncio.Lock()
self._cleanup_task: Optional[asyncio.Task] = None
self._running = False
self._persistence_enabled = False
async def start(self, db_manager=None):
if db_manager:
self.db_manager = db_manager
self._persistence_enabled = True
await self._load_blacklist_from_db()
self._running = True
self._cleanup_task = asyncio.create_task(self._background_cleanup())
logger.info("TokenManager started")
async def stop(self):
self._running = False
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("TokenManager stopped")
def create_access_token(
self,
user_id: int,
username: str,
two_factor_verified: bool = False,
expires_delta: Optional[timedelta] = None
) -> tuple:
jti = str(uuid.uuid4())
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
payload = {
"sub": username,
"user_id": user_id,
"jti": jti,
"type": "access",
"2fa_verified": two_factor_verified,
"iat": datetime.now(timezone.utc),
"exp": expire,
}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
token_info = TokenInfo(
jti=jti,
user_id=user_id,
token_type="access",
expires_at=expire.timestamp()
)
asyncio.create_task(self._track_token(token_info))
return token, jti
def create_refresh_token(
self,
user_id: int,
username: str,
expires_delta: Optional[timedelta] = None
) -> tuple:
jti = str(uuid.uuid4())
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
)
payload = {
"sub": username,
"user_id": user_id,
"jti": jti,
"type": "refresh",
"iat": datetime.now(timezone.utc),
"exp": expire,
}
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
token_info = TokenInfo(
jti=jti,
user_id=user_id,
token_type="refresh",
expires_at=expire.timestamp()
)
asyncio.create_task(self._track_token(token_info))
return token, jti
async def _track_token(self, token_info: TokenInfo):
async with self._lock:
self.active_tokens[token_info.jti] = token_info
async def revoke_token(self, jti: str, user_id: Optional[int] = None) -> bool:
async with self._lock:
token_info = self.active_tokens.pop(jti, None)
if not token_info:
token_info = TokenInfo(
jti=jti,
user_id=user_id or 0,
token_type="unknown",
expires_at=time.time() + 86400 * 7
)
self.blacklist[jti] = token_info
await self._persist_revocation(token_info)
logger.info(f"Token revoked: {jti}")
return True
async def revoke_all_user_tokens(self, user_id: int) -> int:
revoked_count = 0
async with self._lock:
tokens_to_revoke = [
(jti, info) for jti, info in self.active_tokens.items()
if info.user_id == user_id
]
for jti, token_info in tokens_to_revoke:
del self.active_tokens[jti]
self.blacklist[jti] = token_info
revoked_count += 1
for jti, token_info in tokens_to_revoke:
await self._persist_revocation(token_info)
logger.info(f"Revoked {revoked_count} tokens for user {user_id}")
return revoked_count
async def is_revoked(self, jti: str) -> bool:
async with self._lock:
if jti in self.blacklist:
return True
if self._persistence_enabled and self.db_manager:
try:
async with self.db_manager.get_master_connection() as conn:
cursor = await conn.execute(
"SELECT 1 FROM revoked_tokens WHERE jti = ?",
(jti,)
)
row = await cursor.fetchone()
if row:
return True
except Exception as e:
logger.error(f"Failed to check token revocation: {e}")
return False
async def _persist_revocation(self, token_info: TokenInfo):
if not self._persistence_enabled or not self.db_manager:
return
try:
async with self.db_manager.get_master_connection() as conn:
await conn.execute("""
INSERT OR IGNORE INTO revoked_tokens (jti, user_id, expires_at)
VALUES (?, ?, ?)
""", (
token_info.jti,
token_info.user_id,
datetime.fromtimestamp(token_info.expires_at)
))
await conn.commit()
except Exception as e:
logger.error(f"Failed to persist token revocation: {e}")
async def _load_blacklist_from_db(self):
if not self.db_manager:
return
try:
async with self.db_manager.get_master_connection() as conn:
cursor = await conn.execute("""
SELECT jti, user_id, expires_at FROM revoked_tokens
WHERE expires_at > ?
""", (datetime.now(),))
rows = await cursor.fetchall()
for row in rows:
token_info = TokenInfo(
jti=row[0],
user_id=row[1],
token_type="revoked",
expires_at=row[2].timestamp() if hasattr(row[2], 'timestamp') else time.time()
)
self.blacklist[token_info.jti] = token_info
logger.info(f"Loaded {len(self.blacklist)} revoked tokens from database")
except Exception as e:
logger.error(f"Failed to load token blacklist: {e}")
async def _background_cleanup(self):
while self._running:
try:
await asyncio.sleep(3600)
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in token cleanup: {e}")
async def _cleanup_expired(self):
now = time.time()
async with self._lock:
expired_active = [
jti for jti, info in self.active_tokens.items()
if info.expires_at < now
]
for jti in expired_active:
del self.active_tokens[jti]
expired_blacklist = [
jti for jti, info in self.blacklist.items()
if info.expires_at < now
]
for jti in expired_blacklist:
del self.blacklist[jti]
if self._persistence_enabled and self.db_manager:
try:
async with self.db_manager.get_master_connection() as conn:
await conn.execute(
"DELETE FROM revoked_tokens WHERE expires_at < ?",
(datetime.now(),)
)
await conn.commit()
except Exception as e:
logger.error(f"Failed to cleanup expired tokens: {e}")
if expired_active or expired_blacklist:
logger.debug(f"Cleaned up {len(expired_active)} active and {len(expired_blacklist)} blacklisted tokens")
async def get_stats(self) -> dict:
async with self._lock:
return {
"active_tokens": len(self.active_tokens),
"blacklisted_tokens": len(self.blacklist),
}
_token_manager: Optional[TokenManager] = None
async def init_token_manager(db_manager=None) -> TokenManager:
global _token_manager
_token_manager = TokenManager()
await _token_manager.start(db_manager)
return _token_manager
async def shutdown_token_manager():
global _token_manager
if _token_manager:
await _token_manager.stop()
_token_manager = None
def get_token_manager() -> TokenManager:
if not _token_manager:
raise RuntimeError("Token manager not initialized")
return _token_manager
+3
View File
@@ -0,0 +1,3 @@
from .layer import CacheLayer, get_cache
__all__ = ["CacheLayer", "get_cache"]
+264
View File
@@ -0,0 +1,264 @@
import asyncio
import time
import hashlib
import json
from typing import Dict, Any, Optional, Callable, Set, TypeVar, Generic
from dataclasses import dataclass, field
from collections import OrderedDict
import logging
logger = logging.getLogger(__name__)
T = TypeVar('T')
@dataclass
class CacheEntry:
value: Any
created_at: float = field(default_factory=time.time)
ttl: float = 300.0
dirty: bool = False
@property
def is_expired(self) -> bool:
return time.time() - self.created_at > self.ttl
class LRUCache:
def __init__(self, maxsize: int = 10000):
self.maxsize = maxsize
self.cache: OrderedDict[str, CacheEntry] = OrderedDict()
self._lock = asyncio.Lock()
async def get(self, key: str) -> Optional[CacheEntry]:
async with self._lock:
if key in self.cache:
entry = self.cache[key]
if entry.is_expired:
del self.cache[key]
return None
self.cache.move_to_end(key)
return entry
return None
async def set(self, key: str, value: Any, ttl: float = 300.0, dirty: bool = False):
async with self._lock:
if key in self.cache:
self.cache.move_to_end(key)
elif len(self.cache) >= self.maxsize:
oldest_key, oldest_entry = self.cache.popitem(last=False)
if oldest_entry.dirty:
self.cache[oldest_key] = oldest_entry
self.cache.move_to_end(oldest_key)
if len(self.cache) >= self.maxsize:
for k in list(self.cache.keys()):
if not self.cache[k].dirty:
del self.cache[k]
break
self.cache[key] = CacheEntry(value=value, ttl=ttl, dirty=dirty)
async def delete(self, key: str) -> bool:
async with self._lock:
if key in self.cache:
del self.cache[key]
return True
return False
async def get_dirty_keys(self) -> Set[str]:
async with self._lock:
return {k for k, v in self.cache.items() if v.dirty}
async def mark_clean(self, key: str):
async with self._lock:
if key in self.cache:
self.cache[key].dirty = False
async def clear(self):
async with self._lock:
self.cache.clear()
async def invalidate_pattern(self, pattern: str) -> int:
async with self._lock:
keys_to_delete = [k for k in self.cache.keys() if pattern in k]
for k in keys_to_delete:
del self.cache[k]
return len(keys_to_delete)
class CacheLayer:
CACHE_KEYS = {
"user_profile": "user:{user_id}:profile",
"folder_contents": "user:{user_id}:folder:{folder_id}:contents",
"file_metadata": "user:{user_id}:file:{file_id}:meta",
"storage_quota": "user:{user_id}:quota",
"folder_tree": "user:{user_id}:tree",
"share_info": "share:{share_id}:info",
"webdav_lock": "webdav:lock:{path_hash}",
"rate_limit": "ratelimit:{limit_type}:{key}",
}
TTL_CONFIG = {
"user_profile": 300.0,
"folder_contents": 30.0,
"file_metadata": 120.0,
"storage_quota": 60.0,
"folder_tree": 60.0,
"share_info": 300.0,
"webdav_lock": 3600.0,
"rate_limit": 60.0,
"default": 300.0,
}
def __init__(self, maxsize: int = 10000, flush_interval: int = 30):
self.l1_cache = LRUCache(maxsize=maxsize)
self.flush_interval = flush_interval
self.dirty_keys: Set[str] = set()
self._flush_callbacks: Dict[str, Callable] = {}
self._flush_task: Optional[asyncio.Task] = None
self._running = False
self._stats = {
"hits": 0,
"misses": 0,
"sets": 0,
"invalidations": 0,
}
async def start(self):
self._running = True
self._flush_task = asyncio.create_task(self._background_flusher())
logger.info("CacheLayer started")
async def stop(self):
self._running = False
if self._flush_task:
self._flush_task.cancel()
try:
await self._flush_task
except asyncio.CancelledError:
pass
await self.flush_dirty()
logger.info("CacheLayer stopped")
def register_flush_callback(self, key_pattern: str, callback: Callable):
self._flush_callbacks[key_pattern] = callback
async def get(self, key: str, loader: Optional[Callable] = None) -> Optional[Any]:
entry = await self.l1_cache.get(key)
if entry:
self._stats["hits"] += 1
return entry.value
self._stats["misses"] += 1
if loader:
try:
value = await loader() if asyncio.iscoroutinefunction(loader) else loader()
if value is not None:
ttl = self._get_ttl_for_key(key)
await self.set(key, value, ttl=ttl)
return value
except Exception as e:
logger.error(f"Cache loader failed for key {key}: {e}")
return None
return None
async def set(self, key: str, value: Any, ttl: Optional[float] = None, persist: bool = False):
if ttl is None:
ttl = self._get_ttl_for_key(key)
await self.l1_cache.set(key, value, ttl=ttl, dirty=persist)
if persist:
self.dirty_keys.add(key)
self._stats["sets"] += 1
async def delete(self, key: str):
await self.l1_cache.delete(key)
self.dirty_keys.discard(key)
async def invalidate(self, key: str, cascade: bool = True):
await self.l1_cache.delete(key)
self.dirty_keys.discard(key)
self._stats["invalidations"] += 1
if cascade:
parts = key.split(":")
if len(parts) >= 2 and parts[0] == "user":
user_id = parts[1]
await self.invalidate_user_cache(int(user_id))
async def invalidate_user_cache(self, user_id: int):
pattern = f"user:{user_id}:"
count = await self.l1_cache.invalidate_pattern(pattern)
logger.debug(f"Invalidated {count} cache entries for user {user_id}")
async def invalidate_pattern(self, pattern: str) -> int:
count = await self.l1_cache.invalidate_pattern(pattern)
self._stats["invalidations"] += count
return count
async def flush_dirty(self):
dirty_keys = await self.l1_cache.get_dirty_keys()
for key in dirty_keys:
entry = await self.l1_cache.get(key)
if entry and entry.dirty:
for pattern, callback in self._flush_callbacks.items():
if pattern in key:
try:
await callback(key, entry.value)
await self.l1_cache.mark_clean(key)
except Exception as e:
logger.error(f"Flush callback failed for {key}: {e}")
break
self.dirty_keys.clear()
def _get_ttl_for_key(self, key: str) -> float:
for key_type, ttl in self.TTL_CONFIG.items():
if key_type in key:
return ttl
return self.TTL_CONFIG["default"]
async def _background_flusher(self):
while self._running:
try:
await asyncio.sleep(self.flush_interval)
await self.flush_dirty()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in cache background flusher: {e}")
def get_stats(self) -> Dict[str, int]:
total = self._stats["hits"] + self._stats["misses"]
hit_rate = (self._stats["hits"] / total * 100) if total > 0 else 0
return {
**self._stats,
"hit_rate_percent": round(hit_rate, 2),
}
def build_key(self, key_type: str, **kwargs) -> str:
template = self.CACHE_KEYS.get(key_type)
if not template:
raise ValueError(f"Unknown cache key type: {key_type}")
return template.format(**kwargs)
_cache: Optional[CacheLayer] = None
async def init_cache(maxsize: int = 10000, flush_interval: int = 30) -> CacheLayer:
global _cache
_cache = CacheLayer(maxsize=maxsize, flush_interval=flush_interval)
await _cache.start()
return _cache
async def shutdown_cache():
global _cache
if _cache:
await _cache.stop()
_cache = None
def get_cache() -> CacheLayer:
if not _cache:
raise RuntimeError("Cache not initialized")
return _cache
+9
View File
@@ -0,0 +1,9 @@
from .locks import LockManager, get_lock_manager
from .atomic import AtomicOperations, get_atomic_ops
from .webdav_locks import PersistentWebDAVLocks, get_webdav_locks
__all__ = [
"LockManager", "get_lock_manager",
"AtomicOperations", "get_atomic_ops",
"PersistentWebDAVLocks", "get_webdav_locks",
]
+151
View File
@@ -0,0 +1,151 @@
import asyncio
import hashlib
from typing import Optional, Tuple, Any
from dataclasses import dataclass
import logging
from .locks import get_lock_manager
logger = logging.getLogger(__name__)
@dataclass
class QuotaCheckResult:
allowed: bool
current_usage: int
quota: int
requested: int
remaining: int
class AtomicOperations:
def __init__(self):
pass
async def atomic_quota_check_and_update(
self,
user,
delta: int,
save_callback=None
) -> QuotaCheckResult:
lock_manager = get_lock_manager()
lock_key = lock_manager.build_lock_key("quota_update", user_id=user.id)
async with lock_manager.acquire(lock_key, timeout=10.0, owner="quota_update", user_id=user.id):
current = user.used_storage_bytes
quota = user.storage_quota_bytes
new_usage = current + delta
result = QuotaCheckResult(
allowed=new_usage <= quota,
current_usage=current,
quota=quota,
requested=delta,
remaining=max(0, quota - current)
)
if result.allowed and save_callback:
user.used_storage_bytes = new_usage
await save_callback(user)
logger.debug(f"Quota updated for user {user.id}: {current} -> {new_usage}")
return result
async def atomic_file_create(
self,
user,
parent_id: Optional[int],
name: str,
check_exists_callback,
create_callback,
):
lock_manager = get_lock_manager()
name_hash = hashlib.md5(name.encode()).hexdigest()[:16]
lock_key = lock_manager.build_lock_key(
"file_create",
user_id=user.id,
parent_id=parent_id or 0,
name_hash=name_hash
)
async with lock_manager.acquire(lock_key, timeout=10.0, owner="file_create", user_id=user.id):
existing = await check_exists_callback()
if existing:
raise FileExistsError(f"File '{name}' already exists in this location")
result = await create_callback()
logger.debug(f"File created atomically: {name} for user {user.id}")
return result
async def atomic_folder_create(
self,
user,
parent_id: Optional[int],
name: str,
check_exists_callback,
create_callback,
):
lock_manager = get_lock_manager()
lock_key = lock_manager.build_lock_key(
"folder_create",
user_id=user.id,
parent_id=parent_id or 0
)
async with lock_manager.acquire(lock_key, timeout=10.0, owner="folder_create", user_id=user.id):
existing = await check_exists_callback()
if existing:
raise FileExistsError(f"Folder '{name}' already exists in this location")
result = await create_callback()
logger.debug(f"Folder created atomically: {name} for user {user.id}")
return result
async def atomic_file_update(
self,
user,
file_id: int,
update_callback,
):
lock_manager = get_lock_manager()
lock_key = f"lock:file:{file_id}:update"
async with lock_manager.acquire(lock_key, timeout=30.0, owner="file_update", user_id=user.id):
result = await update_callback()
return result
async def atomic_batch_operation(
self,
user,
operation_id: str,
items: list,
operation_callback,
):
lock_manager = get_lock_manager()
lock_key = f"lock:batch:{user.id}:{operation_id}"
async with lock_manager.acquire(lock_key, timeout=60.0, owner="batch_op", user_id=user.id):
results = []
errors = []
for item in items:
try:
result = await operation_callback(item)
results.append(result)
except Exception as e:
errors.append({"item": item, "error": str(e)})
return {"results": results, "errors": errors}
_atomic_ops: Optional[AtomicOperations] = None
def init_atomic_ops() -> AtomicOperations:
global _atomic_ops
_atomic_ops = AtomicOperations()
return _atomic_ops
def get_atomic_ops() -> AtomicOperations:
if not _atomic_ops:
return AtomicOperations()
return _atomic_ops
+265
View File
@@ -0,0 +1,265 @@
import asyncio
import time
import hashlib
import uuid
from typing import Dict, Optional, Set
from dataclasses import dataclass, field
from contextlib import asynccontextmanager
import logging
logger = logging.getLogger(__name__)
@dataclass
class LockInfo:
token: str
owner: str
user_id: int
acquired_at: float = field(default_factory=time.time)
timeout: float = 30.0
extend_count: int = 0
@property
def is_expired(self) -> bool:
return time.time() - self.acquired_at > self.timeout
@dataclass
class LockEntry:
lock: asyncio.Lock
info: Optional[LockInfo] = None
waiters: int = 0
class LockManager:
LOCK_PATTERNS = {
"file_upload": "lock:user:{user_id}:upload:{path_hash}",
"quota_update": "lock:user:{user_id}:quota",
"folder_create": "lock:user:{user_id}:folder:{parent_id}:create",
"file_create": "lock:user:{user_id}:file:{parent_id}:create:{name_hash}",
"webdav_lock": "lock:webdav:{path_hash}",
"invoice_gen": "lock:billing:invoice:{user_id}",
"user_update": "lock:user:{user_id}:update",
}
def __init__(self, default_timeout: float = 30.0, cleanup_interval: float = 60.0):
self.default_timeout = default_timeout
self.cleanup_interval = cleanup_interval
self.locks: Dict[str, LockEntry] = {}
self._global_lock = asyncio.Lock()
self._cleanup_task: Optional[asyncio.Task] = None
self._running = False
async def start(self):
self._running = True
self._cleanup_task = asyncio.create_task(self._background_cleanup())
logger.info("LockManager started")
async def stop(self):
self._running = False
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("LockManager stopped")
def build_lock_key(self, lock_type: str, **kwargs) -> str:
template = self.LOCK_PATTERNS.get(lock_type)
if not template:
return f"lock:custom:{lock_type}:{':'.join(str(v) for v in kwargs.values())}"
for key, value in kwargs.items():
if "hash" in key and not isinstance(value, str):
kwargs[key] = hashlib.md5(str(value).encode()).hexdigest()[:16]
return template.format(**kwargs)
async def _get_or_create_lock(self, resource: str) -> LockEntry:
async with self._global_lock:
if resource not in self.locks:
self.locks[resource] = LockEntry(lock=asyncio.Lock())
return self.locks[resource]
@asynccontextmanager
async def acquire(self, resource: str, timeout: Optional[float] = None,
owner: str = "", user_id: int = 0):
if timeout is None:
timeout = self.default_timeout
entry = await self._get_or_create_lock(resource)
async with self._global_lock:
entry.waiters += 1
try:
try:
await asyncio.wait_for(entry.lock.acquire(), timeout=timeout)
except asyncio.TimeoutError:
async with self._global_lock:
entry.waiters -= 1
raise TimeoutError(f"Failed to acquire lock for {resource} within {timeout}s")
token = str(uuid.uuid4())
entry.info = LockInfo(
token=token,
owner=owner,
user_id=user_id,
timeout=timeout
)
logger.debug(f"Lock acquired: {resource} by {owner}")
try:
yield token
finally:
entry.lock.release()
entry.info = None
async with self._global_lock:
entry.waiters -= 1
logger.debug(f"Lock released: {resource}")
except Exception:
async with self._global_lock:
if entry.waiters > 0:
entry.waiters -= 1
raise
async def try_acquire(self, resource: str, owner: str = "",
user_id: int = 0, timeout: float = 0) -> Optional[str]:
entry = await self._get_or_create_lock(resource)
if entry.lock.locked():
if entry.info and entry.info.is_expired:
pass
elif timeout > 0:
try:
await asyncio.wait_for(entry.lock.acquire(), timeout=timeout)
except asyncio.TimeoutError:
return None
else:
return None
else:
await entry.lock.acquire()
token = str(uuid.uuid4())
entry.info = LockInfo(
token=token,
owner=owner,
user_id=user_id,
timeout=self.default_timeout
)
return token
async def release(self, resource: str, token: str) -> bool:
async with self._global_lock:
if resource not in self.locks:
return False
entry = self.locks[resource]
if not entry.info or entry.info.token != token:
return False
entry.lock.release()
entry.info = None
logger.debug(f"Lock released via token: {resource}")
return True
async def extend(self, resource: str, token: str, extension: float = 30.0) -> bool:
async with self._global_lock:
if resource not in self.locks:
return False
entry = self.locks[resource]
if not entry.info or entry.info.token != token:
return False
entry.info.acquired_at = time.time()
entry.info.timeout = extension
entry.info.extend_count += 1
return True
async def get_lock_info(self, resource: str) -> Optional[LockInfo]:
async with self._global_lock:
if resource in self.locks:
return self.locks[resource].info
return None
async def is_locked(self, resource: str) -> bool:
async with self._global_lock:
if resource in self.locks:
entry = self.locks[resource]
if entry.lock.locked():
if entry.info and not entry.info.is_expired:
return True
return False
async def force_release(self, resource: str, user_id: int) -> bool:
async with self._global_lock:
if resource not in self.locks:
return False
entry = self.locks[resource]
if not entry.info:
return False
if entry.info.user_id != user_id:
return False
if entry.lock.locked():
entry.lock.release()
entry.info = None
return True
async def _background_cleanup(self):
while self._running:
try:
await asyncio.sleep(self.cleanup_interval)
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in lock cleanup: {e}")
async def _cleanup_expired(self):
async with self._global_lock:
expired = []
for resource, entry in self.locks.items():
if entry.info and entry.info.is_expired and entry.waiters == 0:
expired.append(resource)
for resource in expired:
async with self._global_lock:
if resource in self.locks:
entry = self.locks[resource]
if entry.lock.locked() and entry.info and entry.info.is_expired:
entry.lock.release()
entry.info = None
logger.debug(f"Cleaned up expired lock: {resource}")
async def get_stats(self) -> Dict:
async with self._global_lock:
total_locks = len(self.locks)
active_locks = sum(1 for e in self.locks.values() if e.lock.locked())
waiting = sum(e.waiters for e in self.locks.values())
return {
"total_locks": total_locks,
"active_locks": active_locks,
"waiting_requests": waiting,
}
_lock_manager: Optional[LockManager] = None
async def init_lock_manager(default_timeout: float = 30.0) -> LockManager:
global _lock_manager
_lock_manager = LockManager(default_timeout=default_timeout)
await _lock_manager.start()
return _lock_manager
async def shutdown_lock_manager():
global _lock_manager
if _lock_manager:
await _lock_manager.stop()
_lock_manager = None
def get_lock_manager() -> LockManager:
if not _lock_manager:
raise RuntimeError("Lock manager not initialized")
return _lock_manager
+319
View File
@@ -0,0 +1,319 @@
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
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:
await self._delete_lock_from_db(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}")
async def _save_all_locks(self):
if not self._persistence_enabled:
return
for lock_info in list(self.locks.values()):
if not lock_info.is_expired:
await self._persist_lock(lock_info)
async def _background_cleanup(self):
while self._running:
try:
await asyncio.sleep(60)
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in WebDAV lock cleanup: {e}")
async def _cleanup_expired(self):
async with self._lock:
expired = [
path_hash for path_hash, lock_info in self.locks.items()
if lock_info.is_expired
]
for path_hash in expired:
del self.locks[path_hash]
await self._delete_lock_from_db(path_hash)
if expired:
logger.debug(f"Cleaned up {len(expired)} expired WebDAV locks")
async def get_stats(self) -> dict:
async with self._lock:
total = len(self.locks)
active = sum(1 for l in self.locks.values() if not l.is_expired)
return {
"total_locks": total,
"active_locks": active,
"expired_locks": total - active,
}
_webdav_locks: Optional[PersistentWebDAVLocks] = None
async def init_webdav_locks(db_manager=None) -> PersistentWebDAVLocks:
global _webdav_locks
_webdav_locks = PersistentWebDAVLocks()
await _webdav_locks.start(db_manager)
return _webdav_locks
async def shutdown_webdav_locks():
global _webdav_locks
if _webdav_locks:
await _webdav_locks.stop()
_webdav_locks = None
def get_webdav_locks() -> PersistentWebDAVLocks:
if not _webdav_locks:
raise RuntimeError("WebDAV locks not initialized")
return _webdav_locks
+3
View File
@@ -0,0 +1,3 @@
from .manager import UserDatabaseManager, get_user_db_manager
__all__ = ["UserDatabaseManager", "get_user_db_manager"]
+388
View File
@@ -0,0 +1,388 @@
import asyncio
import aiosqlite
import time
import json
from pathlib import Path
from typing import Dict, List, Any, Optional, Callable
from dataclasses import dataclass, field
from contextlib import asynccontextmanager
import logging
logger = logging.getLogger(__name__)
@dataclass
class WriteOperation:
sql: str
params: tuple = field(default_factory=tuple)
timestamp: float = field(default_factory=time.time)
@dataclass
class UserDatabase:
connection: Optional[aiosqlite.Connection] = None
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
last_access: float = field(default_factory=time.time)
write_buffer: List[WriteOperation] = field(default_factory=list)
dirty: bool = False
class UserDatabaseManager:
MASTER_TABLES_SQL = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
hashed_password TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
is_superuser INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
storage_quota_bytes INTEGER DEFAULT 10737418240,
used_storage_bytes INTEGER DEFAULT 0,
plan_type TEXT DEFAULT 'free',
two_factor_secret TEXT,
is_2fa_enabled INTEGER DEFAULT 0,
recovery_codes TEXT
);
CREATE TABLE IF NOT EXISTS revoked_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jti TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
revoked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_jti ON revoked_tokens(jti);
CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at);
CREATE TABLE IF NOT EXISTS rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
limit_type TEXT NOT NULL,
count INTEGER DEFAULT 1,
window_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(key, limit_type)
);
CREATE INDEX IF NOT EXISTS idx_rate_limits_key ON rate_limits(key, limit_type);
CREATE TABLE IF NOT EXISTS webdav_locks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path_hash TEXT UNIQUE NOT NULL,
path TEXT NOT NULL,
token TEXT NOT NULL,
owner TEXT NOT NULL,
user_id INTEGER NOT NULL,
scope TEXT DEFAULT 'exclusive',
timeout INTEGER DEFAULT 3600,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_webdav_locks_path ON webdav_locks(path_hash);
CREATE INDEX IF NOT EXISTS idx_webdav_locks_token ON webdav_locks(token);
"""
USER_TABLES_SQL = """
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER,
owner_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted INTEGER DEFAULT 0,
is_starred INTEGER DEFAULT 0,
FOREIGN KEY (parent_id) REFERENCES folders(id),
UNIQUE(name, parent_id, owner_id)
);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
path TEXT NOT NULL,
size INTEGER NOT NULL,
mime_type TEXT NOT NULL,
file_hash TEXT,
thumbnail_path TEXT,
parent_id INTEGER,
owner_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted INTEGER DEFAULT 0,
deleted_at TIMESTAMP,
is_starred INTEGER DEFAULT 0,
last_accessed_at TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES folders(id),
UNIQUE(name, parent_id, owner_id)
);
CREATE TABLE IF NOT EXISTS file_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL,
version_path TEXT NOT NULL,
size INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (file_id) REFERENCES files(id)
);
CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
file_id INTEGER,
folder_id INTEGER,
owner_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
password_protected INTEGER DEFAULT 0,
hashed_password TEXT,
access_count INTEGER DEFAULT 0,
permission_level TEXT DEFAULT 'viewer',
FOREIGN KEY (file_id) REFERENCES files(id),
FOREIGN KEY (folder_id) REFERENCES folders(id)
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
ip_address TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS webdav_properties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_type TEXT NOT NULL,
resource_id INTEGER NOT NULL,
namespace TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(resource_type, resource_id, namespace, name)
);
CREATE INDEX IF NOT EXISTS idx_files_owner_deleted ON files(owner_id, is_deleted);
CREATE INDEX IF NOT EXISTS idx_files_parent_deleted ON files(parent_id, is_deleted);
CREATE INDEX IF NOT EXISTS idx_folders_owner_deleted ON folders(owner_id, is_deleted);
CREATE INDEX IF NOT EXISTS idx_folders_parent_deleted ON folders(parent_id, is_deleted);
CREATE INDEX IF NOT EXISTS idx_activities_user ON activities(user_id);
CREATE INDEX IF NOT EXISTS idx_activities_timestamp ON activities(timestamp);
"""
def __init__(self, base_path: Path, cache_size: int = 100, flush_interval: int = 30):
self.base_path = Path(base_path)
self.cache_size = cache_size
self.flush_interval = flush_interval
self.databases: Dict[int, UserDatabase] = {}
self.master_db: Optional[UserDatabase] = None
self._flush_task: Optional[asyncio.Task] = None
self._cleanup_task: Optional[asyncio.Task] = None
self._running = False
self._global_lock = asyncio.Lock()
async def start(self):
self._running = True
self.base_path.mkdir(parents=True, exist_ok=True)
(self.base_path / "users").mkdir(exist_ok=True)
await self._init_master_db()
self._flush_task = asyncio.create_task(self._background_flusher())
self._cleanup_task = asyncio.create_task(self._background_cleanup())
logger.info("UserDatabaseManager started")
async def stop(self):
self._running = False
if self._flush_task:
self._flush_task.cancel()
try:
await self._flush_task
except asyncio.CancelledError:
pass
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
await self._flush_all()
await self._close_all_connections()
logger.info("UserDatabaseManager stopped")
async def _init_master_db(self):
master_path = self.base_path / "master.db"
conn = await aiosqlite.connect(str(master_path))
await conn.executescript(self.MASTER_TABLES_SQL)
await conn.commit()
self.master_db = UserDatabase(connection=conn)
logger.info(f"Master database initialized at {master_path}")
async def _get_user_db_path(self, user_id: int) -> Path:
user_dir = self.base_path / "users" / str(user_id)
user_dir.mkdir(parents=True, exist_ok=True)
return user_dir / "database.db"
async def _create_user_db(self, user_id: int) -> aiosqlite.Connection:
db_path = await self._get_user_db_path(user_id)
conn = await aiosqlite.connect(str(db_path))
await conn.executescript(self.USER_TABLES_SQL)
await conn.commit()
logger.info(f"User database created for user {user_id}")
return conn
@asynccontextmanager
async def get_master_connection(self):
if not self.master_db or not self.master_db.connection:
raise RuntimeError("Master database not initialized")
async with self.master_db.lock:
self.master_db.last_access = time.time()
yield self.master_db.connection
@asynccontextmanager
async def get_user_connection(self, user_id: int):
async with self._global_lock:
if user_id not in self.databases:
if len(self.databases) >= self.cache_size:
await self._evict_oldest()
db_path = await self._get_user_db_path(user_id)
if db_path.exists():
conn = await aiosqlite.connect(str(db_path))
else:
conn = await self._create_user_db(user_id)
self.databases[user_id] = UserDatabase(connection=conn)
user_db = self.databases[user_id]
async with user_db.lock:
user_db.last_access = time.time()
yield user_db.connection
async def execute_buffered(self, user_id: int, sql: str, params: tuple = ()):
async with self._global_lock:
if user_id not in self.databases:
async with self.get_user_connection(user_id):
pass
user_db = self.databases[user_id]
async with user_db.lock:
user_db.write_buffer.append(WriteOperation(sql=sql, params=params))
user_db.dirty = True
async def execute_master_buffered(self, sql: str, params: tuple = ()):
if not self.master_db:
raise RuntimeError("Master database not initialized")
async with self.master_db.lock:
self.master_db.write_buffer.append(WriteOperation(sql=sql, params=params))
self.master_db.dirty = True
async def flush_user(self, user_id: int):
if user_id not in self.databases:
return
user_db = self.databases[user_id]
async with user_db.lock:
if not user_db.dirty or not user_db.write_buffer:
return
if user_db.connection:
for op in user_db.write_buffer:
await user_db.connection.execute(op.sql, op.params)
await user_db.connection.commit()
user_db.write_buffer.clear()
user_db.dirty = False
logger.debug(f"Flushed user {user_id} database")
async def flush_master(self):
if not self.master_db:
return
async with self.master_db.lock:
if not self.master_db.dirty or not self.master_db.write_buffer:
return
if self.master_db.connection:
for op in self.master_db.write_buffer:
await self.master_db.connection.execute(op.sql, op.params)
await self.master_db.connection.commit()
self.master_db.write_buffer.clear()
self.master_db.dirty = False
logger.debug("Flushed master database")
async def _flush_all(self):
await self.flush_master()
for user_id in list(self.databases.keys()):
await self.flush_user(user_id)
async def _evict_oldest(self):
if not self.databases:
return
oldest_id = min(self.databases.keys(), key=lambda k: self.databases[k].last_access)
await self.flush_user(oldest_id)
user_db = self.databases.pop(oldest_id)
if user_db.connection:
await user_db.connection.close()
logger.debug(f"Evicted user {oldest_id} database from cache")
async def _close_all_connections(self):
if self.master_db and self.master_db.connection:
await self.master_db.connection.close()
for user_id, user_db in self.databases.items():
if user_db.connection:
await user_db.connection.close()
self.databases.clear()
async def _background_flusher(self):
while self._running:
try:
await asyncio.sleep(self.flush_interval)
await self._flush_all()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in background flusher: {e}")
async def _background_cleanup(self):
while self._running:
try:
await asyncio.sleep(300)
now = time.time()
stale_timeout = 600
async with self._global_lock:
stale_users = [
uid for uid, db in self.databases.items()
if now - db.last_access > stale_timeout and not db.dirty
]
for user_id in stale_users:
await self.flush_user(user_id)
async with self._global_lock:
if user_id in self.databases:
user_db = self.databases.pop(user_id)
if user_db.connection:
await user_db.connection.close()
logger.debug(f"Cleaned up stale connection for user {user_id}")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in background cleanup: {e}")
async def create_user_database(self, user_id: int):
await self._create_user_db(user_id)
_db_manager: Optional[UserDatabaseManager] = None
async def init_db_manager(base_path: str = "data"):
global _db_manager
_db_manager = UserDatabaseManager(Path(base_path))
await _db_manager.start()
return _db_manager
async def shutdown_db_manager():
global _db_manager
if _db_manager:
await _db_manager.stop()
_db_manager = None
def get_user_db_manager() -> UserDatabaseManager:
if not _db_manager:
raise RuntimeError("Database manager not initialized")
return _db_manager
+92 -1
View File
@@ -22,17 +22,102 @@ from .routers import (
)
from . import webdav
from .schemas import ErrorResponse
from .middleware.usage_tracking import UsageTrackingMiddleware
from .middleware import UsageTrackingMiddleware, RateLimitMiddleware, SecurityHeadersMiddleware
from .monitoring import health_router
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
enterprise_components = {
"db_manager": None,
"cache": None,
"lock_manager": None,
"token_manager": None,
"rate_limiter": None,
"webdav_locks": None,
"task_queue": None,
}
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
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["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
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_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
@@ -92,6 +177,9 @@ async def lifespan(app: FastAPI):
logger.info("Billing scheduler stopped")
await email_service.stop()
logger.info("Email service stopped")
await shutdown_enterprise_components()
logger.info("All enterprise components shut down")
print("Shutting down...")
@@ -115,7 +203,10 @@ app.include_router(starred.router)
app.include_router(billing.router)
app.include_router(admin_billing.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")
+5
View File
@@ -0,0 +1,5 @@
from .usage_tracking import UsageTrackingMiddleware
from .rate_limit import RateLimitMiddleware
from .security import SecurityHeadersMiddleware
__all__ = ["UsageTrackingMiddleware", "RateLimitMiddleware", "SecurityHeadersMiddleware"]
+190
View File
@@ -0,0 +1,190 @@
import asyncio
import time
from typing import Dict, Tuple, Optional
from dataclasses import dataclass, field
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response, JSONResponse
import logging
from mywebdav.settings import settings
logger = logging.getLogger(__name__)
@dataclass
class RateLimitBucket:
count: int = 0
window_start: float = field(default_factory=time.time)
class RateLimiter:
LIMITS = {
"login": (5, 60),
"register": (3, 60),
"api": (100, 60),
"upload": (20, 60),
"download": (50, 60),
"webdav": (200, 60),
"default": (100, 60),
}
def __init__(self, db_manager=None):
self.db_manager = db_manager
self.buckets: Dict[str, RateLimitBucket] = {}
self._lock = asyncio.Lock()
self._cleanup_task: Optional[asyncio.Task] = None
self._running = False
async def start(self, db_manager=None):
if db_manager:
self.db_manager = db_manager
self._running = True
self._cleanup_task = asyncio.create_task(self._background_cleanup())
logger.info("RateLimiter started")
async def stop(self):
self._running = False
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("RateLimiter stopped")
def _get_bucket_key(self, key: str, limit_type: str) -> str:
return f"{limit_type}:{key}"
async def check_rate_limit(self, key: str, limit_type: str = "default") -> Tuple[bool, int, int]:
max_requests, window_seconds = self.LIMITS.get(limit_type, self.LIMITS["default"])
bucket_key = self._get_bucket_key(key, limit_type)
now = time.time()
async with self._lock:
if bucket_key not in self.buckets:
self.buckets[bucket_key] = RateLimitBucket(count=1, window_start=now)
return True, max_requests - 1, window_seconds
bucket = self.buckets[bucket_key]
if now - bucket.window_start >= window_seconds:
bucket.count = 1
bucket.window_start = now
return True, max_requests - 1, window_seconds
if bucket.count >= max_requests:
retry_after = int(window_seconds - (now - bucket.window_start))
return False, 0, retry_after
bucket.count += 1
remaining = max_requests - bucket.count
return True, remaining, window_seconds
async def reset_rate_limit(self, key: str, limit_type: str = "default"):
bucket_key = self._get_bucket_key(key, limit_type)
async with self._lock:
if bucket_key in self.buckets:
del self.buckets[bucket_key]
async def _background_cleanup(self):
while self._running:
try:
await asyncio.sleep(60)
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in rate limit cleanup: {e}")
async def _cleanup_expired(self):
now = time.time()
async with self._lock:
expired = []
for bucket_key, bucket in self.buckets.items():
limit_type = bucket_key.split(":")[0]
_, window_seconds = self.LIMITS.get(limit_type, self.LIMITS["default"])
if now - bucket.window_start > window_seconds * 2:
expired.append(bucket_key)
for key in expired:
del self.buckets[key]
if expired:
logger.debug(f"Cleaned up {len(expired)} expired rate limit buckets")
_rate_limiter: Optional[RateLimiter] = None
async def init_rate_limiter(db_manager=None) -> RateLimiter:
global _rate_limiter
_rate_limiter = RateLimiter()
await _rate_limiter.start(db_manager)
return _rate_limiter
async def shutdown_rate_limiter():
global _rate_limiter
if _rate_limiter:
await _rate_limiter.stop()
_rate_limiter = None
def get_rate_limiter() -> RateLimiter:
if not _rate_limiter:
raise RuntimeError("Rate limiter not initialized")
return _rate_limiter
class RateLimitMiddleware(BaseHTTPMiddleware):
ROUTE_LIMITS = {
"/api/auth/login": "login",
"/api/auth/register": "register",
"/files/upload": "upload",
"/files/download": "download",
"/webdav": "webdav",
}
async def dispatch(self, request: Request, call_next):
if not settings.RATE_LIMIT_ENABLED:
return await call_next(request)
if not _rate_limiter:
return await call_next(request)
client_ip = self._get_client_ip(request)
limit_type = self._get_limit_type(request.url.path)
allowed, remaining, retry_after = await _rate_limiter.check_rate_limit(
client_ip, limit_type
)
if not allowed:
return JSONResponse(
status_code=429,
content={
"detail": "Too many requests",
"retry_after": retry_after
},
headers={
"Retry-After": str(retry_after),
"X-RateLimit-Remaining": "0",
}
)
response = await call_next(request)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(retry_after)
return response
def _get_client_ip(self, request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _get_limit_type(self, path: str) -> str:
for route_prefix, limit_type in self.ROUTE_LIMITS.items():
if path.startswith(route_prefix):
return limit_type
return "api"
+49
View File
@@ -0,0 +1,49 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "geolocation=(), microphone=(), camera=()",
}
HTTPS_HEADERS = {
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
}
CSP_POLICY = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"font-src 'self'; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
def __init__(self, app, enable_hsts: bool = False, enable_csp: bool = True):
super().__init__(app)
self.enable_hsts = enable_hsts
self.enable_csp = enable_csp
async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request)
for header, value in self.SECURITY_HEADERS.items():
response.headers[header] = value
if self.enable_hsts:
for header, value in self.HTTPS_HEADERS.items():
response.headers[header] = value
if self.enable_csp and not request.url.path.startswith("/webdav"):
response.headers["Content-Security-Policy"] = self.CSP_POLICY
return response
+7 -2
View File
@@ -1,13 +1,18 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from ..billing.usage_tracker import UsageTracker
try:
from ..billing.usage_tracker import UsageTracker
BILLING_AVAILABLE = True
except ImportError:
BILLING_AVAILABLE = False
class UsageTrackingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if hasattr(request.state, "user") and request.state.user:
if BILLING_AVAILABLE and hasattr(request.state, "user") and request.state.user:
user = request.state.user
if (
+3
View File
@@ -0,0 +1,3 @@
from .health import router as health_router
__all__ = ["health_router"]
+179
View File
@@ -0,0 +1,179 @@
import asyncio
import time
import os
from datetime import datetime
from typing import Dict, Any
from fastapi import APIRouter, Response
from pydantic import BaseModel
router = APIRouter(prefix="/health", tags=["health"])
class HealthCheck(BaseModel):
status: str
timestamp: str
checks: Dict[str, Any]
class ReadinessCheck(BaseModel):
ready: bool
class LivenessCheck(BaseModel):
alive: bool
async def check_database() -> Dict[str, Any]:
try:
from ..database import get_user_db_manager
db_manager = get_user_db_manager()
async with db_manager.get_master_connection() as conn:
cursor = await conn.execute("SELECT 1")
await cursor.fetchone()
return {"ok": True, "message": "Connected"}
except Exception as e:
return {"ok": False, "message": str(e)}
async def check_cache() -> Dict[str, Any]:
try:
from ..cache import get_cache
cache = get_cache()
stats = cache.get_stats()
return {"ok": True, "stats": stats}
except Exception as e:
return {"ok": False, "message": str(e)}
async def check_locks() -> Dict[str, Any]:
try:
from ..concurrency import get_lock_manager
lock_manager = get_lock_manager()
stats = await lock_manager.get_stats()
return {"ok": True, "stats": stats}
except Exception as e:
return {"ok": False, "message": str(e)}
async def check_task_queue() -> Dict[str, Any]:
try:
from ..workers import get_task_queue
queue = get_task_queue()
stats = await queue.get_stats()
return {"ok": True, "stats": stats}
except Exception as e:
return {"ok": False, "message": str(e)}
async def check_storage() -> Dict[str, Any]:
try:
from ..settings import settings
storage_path = settings.STORAGE_PATH
if os.path.exists(storage_path):
stat = os.statvfs(storage_path)
free_bytes = stat.f_bavail * stat.f_frsize
total_bytes = stat.f_blocks * stat.f_frsize
used_percent = ((total_bytes - free_bytes) / total_bytes) * 100
return {
"ok": True,
"free_gb": round(free_bytes / (1024**3), 2),
"total_gb": round(total_bytes / (1024**3), 2),
"used_percent": round(used_percent, 2)
}
return {"ok": False, "message": "Storage path does not exist"}
except Exception as e:
return {"ok": False, "message": str(e)}
@router.get("", response_model=HealthCheck)
async def health_check():
checks = {}
check_funcs = {
"database": check_database,
"cache": check_cache,
"locks": check_locks,
"task_queue": check_task_queue,
"storage": check_storage,
}
results = await asyncio.gather(
*[func() for func in check_funcs.values()],
return_exceptions=True
)
for name, result in zip(check_funcs.keys(), results):
if isinstance(result, Exception):
checks[name] = {"ok": False, "message": str(result)}
else:
checks[name] = result
all_ok = all(check.get("ok", False) for check in checks.values())
status = "healthy" if all_ok else "degraded"
return HealthCheck(
status=status,
timestamp=datetime.utcnow().isoformat(),
checks=checks
)
@router.get("/ready", response_model=ReadinessCheck)
async def readiness_check():
try:
db_check = await check_database()
return ReadinessCheck(ready=db_check.get("ok", False))
except Exception:
return ReadinessCheck(ready=False)
@router.get("/live", response_model=LivenessCheck)
async def liveness_check():
return LivenessCheck(alive=True)
@router.get("/metrics")
async def metrics():
metrics_data = []
try:
from ..cache import get_cache
cache = get_cache()
stats = cache.get_stats()
metrics_data.append(f'cache_hits_total {stats.get("hits", 0)}')
metrics_data.append(f'cache_misses_total {stats.get("misses", 0)}')
metrics_data.append(f'cache_hit_rate_percent {stats.get("hit_rate_percent", 0)}')
except Exception:
pass
try:
from ..concurrency import get_lock_manager
lock_manager = get_lock_manager()
stats = await lock_manager.get_stats()
metrics_data.append(f'locks_total {stats.get("total_locks", 0)}')
metrics_data.append(f'locks_active {stats.get("active_locks", 0)}')
except Exception:
pass
try:
from ..workers import get_task_queue
queue = get_task_queue()
stats = await queue.get_stats()
metrics_data.append(f'tasks_enqueued_total {stats.get("enqueued", 0)}')
metrics_data.append(f'tasks_completed_total {stats.get("completed", 0)}')
metrics_data.append(f'tasks_failed_total {stats.get("failed", 0)}')
metrics_data.append(f'tasks_pending {stats.get("pending_tasks", 0)}')
except Exception:
pass
try:
storage_check = await check_storage()
if storage_check.get("ok"):
metrics_data.append(f'storage_free_gb {storage_check.get("free_gb", 0)}')
metrics_data.append(f'storage_used_percent {storage_check.get("used_percent", 0)}')
except Exception:
pass
return Response(
content="\n".join(metrics_data),
media_type="text/plain"
)
+57
View File
@@ -0,0 +1,57 @@
from typing import TypeVar, Generic, List, Optional
from pydantic import BaseModel
from fastapi import Query
T = TypeVar('T')
class PaginationParams:
def __init__(
self,
offset: int = Query(default=0, ge=0, description="Number of items to skip"),
limit: int = Query(default=50, ge=1, le=500, description="Number of items to return"),
sort_by: Optional[str] = Query(default=None, description="Field to sort by"),
sort_order: str = Query(default="asc", regex="^(asc|desc)$", description="Sort order"),
):
self.offset = offset
self.limit = limit
self.sort_by = sort_by
self.sort_order = sort_order
class PaginatedResponse(BaseModel, Generic[T]):
items: List[T]
total: int
offset: int
limit: int
has_more: bool
async def paginate_query(
query,
pagination: PaginationParams,
default_sort: str = "id"
) -> tuple:
sort_field = pagination.sort_by or default_sort
if pagination.sort_order == "desc":
sort_field = f"-{sort_field}"
total = await query.count()
items = await query.offset(pagination.offset).limit(pagination.limit).order_by(sort_field).all()
has_more = pagination.offset + len(items) < total
return items, total, has_more
def create_paginated_response(
items: List,
total: int,
pagination: PaginationParams
) -> dict:
return {
"items": items,
"total": total,
"offset": pagination.offset,
"limit": pagination.limit,
"has_more": pagination.offset + len(items) < total,
}
+97 -39
View File
@@ -22,6 +22,12 @@ from ..storage import storage_manager
from ..activity import log_activity
from ..thumbnails import generate_thumbnail, delete_thumbnail
try:
from ..concurrency.atomic import get_atomic_ops
ATOMIC_OPS_AVAILABLE = True
except ImportError:
ATOMIC_OPS_AVAILABLE = False
router = APIRouter(
prefix="/files",
tags=["files"],
@@ -70,51 +76,103 @@ async def upload_file(
else:
parent_folder = None
existing_file = await File.get_or_none(
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
)
if existing_file:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="File with this name already exists in the current folder",
)
file_content = await file.read()
file_size = len(file_content)
file_hash = hashlib.sha256(file_content).hexdigest()
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail="Storage quota exceeded",
if ATOMIC_OPS_AVAILABLE:
atomic_ops = get_atomic_ops()
async def check_exists():
return await File.get_or_none(
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
)
async def create_file():
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{file_hash}{file_extension}"
storage_path = unique_filename
await storage_manager.save_file(current_user.id, storage_path, file_content)
mime_type, _ = mimetypes.guess_type(file.filename)
if not mime_type:
mime_type = "application/octet-stream"
db_file = await File.create(
name=file.filename,
path=storage_path,
size=file_size,
mime_type=mime_type,
file_hash=file_hash,
owner=current_user,
parent=parent_folder,
)
return db_file, storage_path, mime_type
quota_result = await atomic_ops.atomic_quota_check_and_update(
current_user, file_size, lambda u: u.save()
)
if not quota_result.allowed:
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail=f"Storage quota exceeded. Available: {quota_result.remaining} bytes",
)
try:
db_file, storage_path, mime_type = await atomic_ops.atomic_file_create(
current_user,
parent_folder.id if parent_folder else None,
file.filename,
check_exists,
create_file,
)
except FileExistsError as e:
await atomic_ops.atomic_quota_check_and_update(
current_user, -file_size, lambda u: u.save()
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
else:
existing_file = await File.get_or_none(
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
)
if existing_file:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="File with this name already exists in the current folder",
)
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail="Storage quota exceeded",
)
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{file_hash}{file_extension}"
storage_path = unique_filename
await storage_manager.save_file(current_user.id, storage_path, file_content)
mime_type, _ = mimetypes.guess_type(file.filename)
if not mime_type:
mime_type = "application/octet-stream"
db_file = await File.create(
name=file.filename,
path=storage_path,
size=file_size,
mime_type=mime_type,
file_hash=file_hash,
owner=current_user,
parent=parent_folder,
)
# Generate a unique path for storage
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{file_hash}{file_extension}" # Use hash for unique filename
storage_path = unique_filename
# Save file to storage
await storage_manager.save_file(current_user.id, storage_path, file_content)
# Get mime type
mime_type, _ = mimetypes.guess_type(file.filename)
if not mime_type:
mime_type = "application/octet-stream"
# Create file entry in database
db_file = await File.create(
name=file.filename,
path=storage_path,
size=file_size,
mime_type=mime_type,
file_hash=file_hash,
owner=current_user,
parent=parent_folder,
)
current_user.used_storage_bytes += file_size
await current_user.save()
current_user.used_storage_bytes += file_size
await current_user.save()
thumbnail_path = await generate_thumbnail(storage_path, mime_type, current_user.id)
if thumbnail_path:
+36 -2
View File
@@ -183,7 +183,11 @@ async def update_share(
@router.post("/{share_token}/access")
async def access_shared_content(share_token: str, password: Optional[str] = None):
async def access_shared_content(
share_token: str,
password: Optional[str] = None,
subfolder_id: Optional[int] = None,
):
share = await Share.get_or_none(token=share_token)
if not share:
raise HTTPException(
@@ -212,7 +216,37 @@ async def access_shared_content(share_token: str, password: Optional[str] = None
result["file"] = await FileOut.from_tortoise_orm(file)
result["type"] = "file"
elif share.folder_id:
folder = await Folder.get_or_none(id=share.folder_id, is_deleted=False)
# Start with the shared root folder
target_folder_id = share.folder_id
# If subfolder_id is requested, verify it's a descendant of the shared folder
if subfolder_id:
subfolder = await Folder.get_or_none(id=subfolder_id, is_deleted=False)
if not subfolder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Subfolder not found"
)
# Verify hierarchy
current = subfolder
is_descendant = False
while current.parent_id:
if current.parent_id == share.folder_id:
is_descendant = True
break
current = await Folder.get_or_none(id=current.parent_id)
if not current:
break
if not is_descendant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this folder"
)
target_folder_id = subfolder_id
folder = await Folder.get_or_none(id=target_folder_id, is_deleted=False)
if not folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
+2 -1
View File
@@ -5,7 +5,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
RATE_LIMIT_ENABLED: bool = False
DATABASE_URL: str = "sqlite:///app/mywebdav.db"
REDIS_URL: str = "redis://redis:6379/0"
SECRET_KEY: str = "super_secret_key"
+89 -88
View File
@@ -2,97 +2,98 @@
{% 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: 2rem;
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 {
grid-template-columns: 1fr;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.feature-card {
background: white;
border-radius: 8px;
padding: 2rem;
border: 2px solid #e0e0e0;
transition: all 0.3s;
}
.feature-card:hover {
border-color: #1976d2;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.1);
transform: translateY(-2px);
}
.feature-icon {
font-size: 2.5rem;
color: #d32f2f;
margin-bottom: 1rem;
}
.feature-title {
font-size: 1.5rem;
font-weight: 600;
color: #333;
margin-bottom: 0.75rem;
}
.feature-description {
color: #666;
line-height: 1.6;
}
.feature-list {
list-style: none;
padding: 0;
margin-top: 1rem;
}
.feature-list li {
padding: 0.5rem 0;
color: #555;
}
.feature-list li:before {
content: "✓ ";
color: #d32f2f;
font-weight: bold;
margin-right: 0.5rem;
}
@media (max-width: 768px) {
.page-title {
font-size: 2rem;
}
.features-grid {
grid-template-columns: 1fr;
}
}
}
</style>
{% endblock %}
@@ -107,7 +108,7 @@
<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>AES-256 encryption at rest</li>
<li>Secure file storage</li>
<li>TLS 1.3 encryption in transit</li>
<li>Two-factor authentication (TOTP)</li>
<li>Regular security audits</li>
@@ -157,9 +158,9 @@
<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 with global CDN.</p>
<p class="feature-description">Fast upload and download speeds.</p>
<ul class="feature-list">
<li>Global edge network</li>
<li>High-speed transfer</li>
<li>Parallel upload/download</li>
<li>Resume interrupted transfers</li>
<li>Optimized for large files</li>
@@ -169,10 +170,10 @@
<div class="feature-card">
<div class="feature-icon">🔍</div>
<h2 class="feature-title">Full-Text Search</h2>
<h2 class="feature-title">Instant File Search</h2>
<p class="feature-description">Find your files instantly with powerful search capabilities.</p>
<ul class="feature-list">
<li>Search file names and content</li>
<li>Search file names</li>
<li>Filter by type and date</li>
<li>Advanced query syntax</li>
<li>Instant results</li>
@@ -263,4 +264,4 @@
<a href="/app" class="btn btn-primary" style="font-size: 1.125rem; padding: 1rem 3rem;">Get Started Today</a>
</div>
</div>
{% endblock %}
{% endblock %}
+151 -148
View File
@@ -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-title {
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}
.legal-updated {
color: #666;
font-style: italic;
margin-bottom: 2rem;
}
.legal-content h2 {
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
.legal-content h3 {
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.legal-content p {
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}
.legal-content ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.legal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}
.legal-content strong {
color: #333;
font-weight: 600;
}
.legal-contact {
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.legal-contact h3 {
color: #1565c0;
margin-top: 0;
}
@media (max-width: 768px) {
.legal-title {
font-size: 2rem;
}
.legal-content {
padding: 2rem 1rem;
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
background: white;
border-radius: 8px;
}
.legal-title {
font-size: 2.5rem;
font-weight: 700;
color: #1565c0;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 3px solid #1976d2;
}
.legal-updated {
color: #666;
font-style: italic;
margin-bottom: 2rem;
}
.legal-content h2 {
font-size: 1.75rem;
color: #333;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e0e0e0;
}
.legal-content h3 {
font-size: 1.25rem;
color: #555;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.legal-content p {
line-height: 1.8;
color: #555;
margin-bottom: 1rem;
}
.legal-content ul {
margin-left: 2rem;
margin-bottom: 1rem;
}
.legal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
color: #555;
}
.legal-content strong {
color: #333;
font-weight: 600;
}
.legal-contact {
margin-top: 3rem;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.legal-contact h3 {
color: #1565c0;
margin-top: 0;
}
@media (max-width: 768px) {
.legal-title {
font-size: 2rem;
}
.legal-content {
padding: 2rem 1rem;
}
}
}
</style>
{% endblock %}
@@ -97,104 +97,107 @@
<h1 class="legal-title">Security Policy</h1>
<p class="legal-updated">Last Updated: November 16, 2025</p>
<h2>1. Introduction</h2>
<h3>1.1 Purpose</h3>
<p>This policy establishes the framework for securing our cloud storage platform and ensures all personnel understand their security responsibilities.</p>
<h2>1. Introduction</h2>
<h3>1.2 Scope</h3>
<p>Applies to all employees, contractors, systems, and data managed by MyWebdav Technologies.</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>
<h2>2. Governance and Management</h2>
<h3>1.2 Scope</h3>
<p>Applies to all employees, contractors, systems, and data managed by MyWebdav Technologies.</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>
<h2>2. Governance and Management</h2>
<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.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>
<h2>3. Access Control</h2>
<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>3.1 Access Management</h3>
<p>Access follows the principle of least privilege with multi-factor authentication required for administrative access.</p>
<h2>3. Access Control</h2>
<h3>3.2 User Authentication</h3>
<p>Strong passwords, regular rotation, and account lockout policies are enforced.</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.3 Remote Access</h3>
<p>Secured via VPN with full logging and monitoring.</p>
<h3>3.2 User Authentication</h3>
<p>Strong passwords, regular rotation, and account lockout policies are enforced.</p>
<h2>4. Data Protection and Encryption</h2>
<h3>3.3 Remote Access</h3>
<p>Secured via VPN with full logging and monitoring.</p>
<h3>4.1 Data Classification</h3>
<p>Data classified as Public, Internal, Confidential, or Highly Sensitive with appropriate controls.</p>
<h2>4. Data Protection and Encryption</h2>
<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.1 Data Classification</h3>
<p>Data classified as Public, Internal, Confidential, or Highly Sensitive with appropriate controls.</p>
<h3>4.3 Data Retention and Disposal</h3>
<p>Data retained only as necessary with secure deletion methods.</p>
<h3>4.2 Encryption Standards</h3>
<ul>
<li>TLS 1.3 for data in transit</li>
<li>Secure file storage</li>
<li>Secure key management and rotation</li>
</ul>
<h2>5. Network Security</h2>
<h3>4.3 Data Retention and Disposal</h3>
<p>Data retained only as necessary with secure deletion methods.</p>
<h3>5.1 Network Segmentation</h3>
<p>Isolated networks with firewalls, IDS, and regular monitoring.</p>
<h2>5. Network Security</h2>
<h3>5.2 Secure Configuration</h3>
<p>Hardened systems following CIS Benchmarks.</p>
<h3>5.1 Network Segmentation</h3>
<p>Isolated networks with firewalls, IDS, and regular monitoring.</p>
<h2>6. Physical Security</h2>
<h3>5.2 Secure Configuration</h3>
<p>Hardened systems following CIS Benchmarks.</p>
<h3>6.1 Facility Access</h3>
<p>Controlled access to data centers with biometric authentication.</p>
<h2>6. Physical Security</h2>
<h3>6.2 Equipment Security</h3>
<p>Secure storage in climate-controlled environments.</p>
<h3>6.1 Facility Access</h3>
<p>Controlled access to data centers with biometric authentication.</p>
<h2>7. Incident Response</h2>
<h3>6.2 Equipment Security</h3>
<p>Secure storage in climate-controlled environments.</p>
<h3>7.1 Incident Response Plan</h3>
<p>Comprehensive plan for identification, containment, eradication, recovery, and notification.</p>
<h2>7. Incident Response</h2>
<h3>7.2 Breach Notification</h3>
<p>Incidents reported within 72 hours (GDPR) or 24 hours (NIS2) as applicable.</p>
<h3>7.1 Incident Response Plan</h3>
<p>Comprehensive plan for identification, containment, eradication, recovery, and notification.</p>
<h2>8. Secure Development</h2>
<h3>7.2 Breach Notification</h3>
<p>Incidents reported within 72 hours (GDPR) or 24 hours (NIS2) as applicable.</p>
<h3>8.1 Secure Coding Practices</h3>
<p>Code reviews, static/dynamic analysis, and vulnerability management.</p>
<h2>8. Secure Development</h2>
<h3>8.2 Change Management</h3>
<p>Formal approval processes for production changes.</p>
<h3>8.1 Secure Coding Practices</h3>
<p>Code reviews, static/dynamic analysis, and vulnerability management.</p>
<h2>9. Third-Party Risk Management</h2>
<h3>8.2 Change Management</h3>
<p>Formal approval processes for production changes.</p>
<h3>9.1 Vendor Assessment</h3>
<p>Security assessments and contractual requirements for all vendors.</p>
<h2>9. Third-Party Risk Management</h2>
<h2>10. Compliance and Auditing</h2>
<h3>9.1 Vendor Assessment</h3>
<p>Security assessments and contractual requirements for all vendors.</p>
<h3>10.1 Regulatory Compliance</h3>
<p>Compliance with GDPR, NIS2, and ISO/IEC 27001.</p>
<h2>10. Compliance and Auditing</h2>
<h3>10.2 Audits and Assessments</h3>
<p>Annual audits, quarterly penetration testing, and continuous monitoring.</p>
<h3>10.1 Regulatory Compliance</h3>
<p>Compliance with GDPR, NIS2, and ISO/IEC 27001.</p>
<h3>10.3 Training</h3>
<p>Mandatory annual security training for all personnel.</p>
<h3>10.2 Audits and Assessments</h3>
<p>Annual audits, quarterly penetration testing, and continuous monitoring.</p>
<h2>11. Enforcement</h2>
<p>Compliance is mandatory. Violations may result in disciplinary action up to termination.</p>
<h3>10.3 Training</h3>
<p>Mandatory annual security training for all personnel.</p>
<h2>11. Enforcement</h2>
<p>Compliance is mandatory. Violations may result in disciplinary action up to termination.</p>
<div class="legal-contact">
@@ -207,4 +210,4 @@
</ul>
</div>
</div>
{% endblock %}
{% endblock %}
+246 -237
View File
@@ -2,232 +2,233 @@
{% 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: 2rem;
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: 3.5rem;
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 {
transform: scale(1);
border-color: #d32f2f;
box-shadow: 0 8px 24px rgba(211, 47, 47, 0.2);
transform: scale(1.05);
}
.pricing-card:hover {
border-color: #1976d2;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.1);
}
.pricing-tier {
font-size: 1.75rem;
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
}
.pricing-amount {
font-size: 2.5rem;
font-weight: 700;
color: #d32f2f;
margin: 1rem 0;
}
.pricing-period {
font-size: 1rem;
color: #666;
margin-bottom: 1.5rem;
}
.pricing-features {
list-style: none;
padding: 0;
margin: 1.5rem 0;
}
.pricing-features li {
padding: 0.75rem 0;
color: #555;
border-bottom: 1px solid #f0f0f0;
}
.pricing-features li:last-child {
border-bottom: none;
}
.pricing-features li:before {
content: "✓ ";
color: #d32f2f;
font-weight: bold;
margin-right: 0.5rem;
}
.pricing-cta {
margin-top: 1.5rem;
}
.calculator-section {
background: #f5f5f5;
padding: 3rem;
border-radius: 12px;
margin-top: 3rem;
}
.calculator-title {
font-size: 2rem;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 2rem;
}
.calculator-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.calculator-input-group {
background: white;
padding: 1.5rem;
border-radius: 8px;
}
.calculator-label {
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
display: block;
}
.calculator-input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e0e0e0;
border-radius: 4px;
font-size: 1rem;
}
.calculator-result {
background: #1976d2;
color: white;
padding: 2rem;
border-radius: 8px;
text-align: center;
}
.calculator-result-label {
font-size: 1rem;
opacity: 0.9;
margin-bottom: 0.5rem;
}
.calculator-result-amount {
font-size: 3rem;
font-weight: 700;
}
.faq-section {
margin-top: 4rem;
}
.faq-title {
font-size: 2rem;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 2rem;
}
.faq-item {
background: white;
padding: 1.5rem;
border-radius: 8px;
border: 2px solid #e0e0e0;
margin-bottom: 1rem;
}
.faq-question {
font-weight: 600;
color: #333;
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.faq-answer {
color: #666;
line-height: 1.6;
}
@media (max-width: 768px) {
.page-title {
font-size: 2rem;
}
.pricing-hero-amount {
font-size: 3.5rem;
}
.pricing-card.featured {
transform: scale(1);
}
}
}
</style>
{% endblock %}
@@ -292,7 +293,7 @@
<li>Custom integrations</li>
<li>Training and onboarding</li>
<li>Compliance assistance</li>
<li>Dedicated infrastructure</li>
<li>Secure infrastructure</li>
</ul>
<div class="pricing-cta">
<a href="/support" class="btn btn-secondary" style="width: 100%;">Contact Sales</a>
@@ -305,11 +306,13 @@
<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">
@@ -323,55 +326,61 @@
<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 $0.0045 per GB per month ($5 per TB). The first 15GB is always free.</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 $0.009 per GB. The first 15GB per month is free. 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. 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 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 delete your account at any time and will only be charged for 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 (Visa, Mastercard, American Express) and SEPA direct debit 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. For storage over 10TB or bandwidth over 5TB per month, please contact our sales team for custom 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>
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;
const freeStorage = 15;
const freeBandwidth = 15;
const freeStorage = 15;
const freeBandwidth = 15;
const billableStorage = Math.max(0, storage - freeStorage);
const billableBandwidth = Math.max(0, bandwidth - freeBandwidth);
const billableStorage = Math.max(0, storage - freeStorage);
const billableBandwidth = Math.max(0, bandwidth - freeBandwidth);
const storageCost = billableStorage * 0.0045;
const bandwidthCost = billableBandwidth * 0.009;
const storageCost = billableStorage * 0.0045;
const bandwidthCost = billableBandwidth * 0.009;
const total = storageCost + bandwidthCost;
const total = storageCost + bandwidthCost;
document.getElementById('result').textContent = '$' + total.toFixed(2);
}
document.getElementById('result').textContent = '$' + total.toFixed(2);
}
calculatePrice();
calculatePrice();
</script>
{% endblock %}
{% endblock %}
+3 -26
View File
@@ -243,13 +243,6 @@
<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">
@@ -266,20 +259,12 @@
<div class="contact-grid">
<div class="contact-item">
<div class="contact-item-icon">📧</div>
<div class="contact-item-label">General Support</div>
<div class="contact-item-label">Support Team</div>
<div class="contact-item-value">
<a href="mailto:support@mywebdav.eu">support@mywebdav.eu</a>
</div>
</div>
<div class="contact-item">
<div class="contact-item-icon">🔧</div>
<div class="contact-item-label">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>
@@ -288,17 +273,9 @@
</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 Matters</div>
<div class="contact-item-label">Legal & Privacy</div>
<div class="contact-item-value">
<a href="mailto:legal@mywebdav.eu">legal@mywebdav.eu</a>
</div>
@@ -306,7 +283,7 @@
<div class="contact-item">
<div class="contact-item-icon">🏢</div>
<div class="contact-item-label">Sales & Enterprise</div>
<div class="contact-item-label">Sales</div>
<div class="contact-item-value">
<a href="mailto:sales@mywebdav.eu">sales@mywebdav.eu</a>
</div>
+47 -20
View File
@@ -22,28 +22,45 @@ router = APIRouter(
)
def get_persistent_locks():
try:
from .concurrency.webdav_locks import get_webdav_locks
return get_webdav_locks()
except RuntimeError:
return None
class WebDAVLock:
locks = {}
_fallback_locks: dict = {}
@classmethod
def create_lock(cls, path: str, user_id: int, timeout: int = 3600):
async def create_lock(cls, path: str, user_id: int, owner: str = "", timeout: int = 3600):
locks = get_persistent_locks()
if locks:
return await locks.acquire_lock(path, owner or str(user_id), user_id, timeout)
path = path.strip("/")
lock_token = f"opaquelocktoken:{hashlib.md5(f'{path}{user_id}{datetime.now()}'.encode()).hexdigest()}"
cls.locks[path] = {
"token": lock_token,
"user_id": user_id,
"created_at": datetime.now(),
"timeout": timeout,
}
cls._fallback_locks[path] = {"token": lock_token, "user_id": user_id}
return lock_token
@classmethod
def get_lock(cls, path: str):
return cls.locks.get(path)
async def get_lock(cls, path: str):
locks = get_persistent_locks()
if locks:
return await locks.check_lock(path)
path = path.strip("/")
return cls._fallback_locks.get(path)
@classmethod
def remove_lock(cls, path: str):
if path in cls.locks:
del cls.locks[path]
async def remove_lock(cls, path: str, token: str):
locks = get_persistent_locks()
if locks:
return await locks.release_lock(path, token)
path = path.strip("/")
if path in cls._fallback_locks and cls._fallback_locks[path]["token"] == token:
del cls._fallback_locks[path]
return True
return False
async def basic_auth(authorization: Optional[str] = Header(None)):
@@ -619,7 +636,9 @@ async def handle_lock(
except (ValueError, IndexError):
pass
lock_token = WebDAVLock.create_lock(full_path, current_user.id, timeout)
lock_token = await WebDAVLock.create_lock(full_path, current_user.id, current_user.username, timeout)
if not lock_token:
raise HTTPException(status_code=423, detail="Resource is locked")
lockinfo = ET.Element("D:prop", {"xmlns:D": "DAV:"})
lockdiscovery = ET.SubElement(lockinfo, "D:lockdiscovery")
@@ -666,15 +685,23 @@ async def handle_unlock(
raise HTTPException(status_code=400, detail="Lock-Token header required")
lock_token = lock_token_header.strip("<>")
existing_lock = WebDAVLock.get_lock(full_path)
existing_lock = await WebDAVLock.get_lock(full_path)
if not existing_lock or existing_lock["token"] != lock_token:
raise HTTPException(status_code=409, detail="Invalid lock token")
if not existing_lock:
raise HTTPException(status_code=409, detail="No lock exists for this resource")
if existing_lock["user_id"] != current_user.id:
raise HTTPException(status_code=403, detail="Not lock owner")
if hasattr(existing_lock, 'token'):
if existing_lock.token != lock_token:
raise HTTPException(status_code=409, detail="Invalid lock token")
if existing_lock.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not lock owner")
else:
if existing_lock.get("token") != lock_token:
raise HTTPException(status_code=409, detail="Invalid lock token")
if existing_lock.get("user_id") != current_user.id:
raise HTTPException(status_code=403, detail="Not lock owner")
WebDAVLock.remove_lock(full_path)
await WebDAVLock.remove_lock(full_path, lock_token)
return Response(status_code=204)
+3
View File
@@ -0,0 +1,3 @@
from .queue import TaskQueue, get_task_queue
__all__ = ["TaskQueue", "get_task_queue"]
+251
View File
@@ -0,0 +1,251 @@
import asyncio
import time
import uuid
from typing import Dict, List, Any, Optional, Callable, Awaitable
from dataclasses import dataclass, field
from enum import Enum
from collections import deque
import logging
logger = logging.getLogger(__name__)
class TaskStatus(Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class TaskPriority(Enum):
LOW = 0
NORMAL = 1
HIGH = 2
CRITICAL = 3
@dataclass
class Task:
id: str
queue_name: str
handler_name: str
payload: Dict[str, Any]
priority: TaskPriority = TaskPriority.NORMAL
status: TaskStatus = TaskStatus.PENDING
created_at: float = field(default_factory=time.time)
started_at: Optional[float] = None
completed_at: Optional[float] = None
result: Optional[Any] = None
error: Optional[str] = None
retry_count: int = 0
max_retries: int = 3
class TaskQueue:
QUEUES = [
"thumbnails",
"cleanup",
"billing",
"notifications",
"default",
]
def __init__(self, max_workers: int = 4):
self.max_workers = max_workers
self.queues: Dict[str, deque] = {q: deque() for q in self.QUEUES}
self.handlers: Dict[str, Callable] = {}
self.tasks: Dict[str, Task] = {}
self.workers: List[asyncio.Task] = []
self._lock = asyncio.Lock()
self._running = False
self._stats = {
"enqueued": 0,
"completed": 0,
"failed": 0,
"retried": 0,
}
async def start(self):
self._running = True
for i in range(self.max_workers):
worker = asyncio.create_task(self._worker_loop(i))
self.workers.append(worker)
logger.info(f"TaskQueue started with {self.max_workers} workers")
async def stop(self):
self._running = False
for worker in self.workers:
worker.cancel()
await asyncio.gather(*self.workers, return_exceptions=True)
self.workers.clear()
logger.info("TaskQueue stopped")
def register_handler(self, name: str, handler: Callable[..., Awaitable[Any]]):
self.handlers[name] = handler
logger.debug(f"Registered handler: {name}")
async def enqueue(
self,
queue_name: str,
handler_name: str,
payload: Dict[str, Any],
priority: TaskPriority = TaskPriority.NORMAL,
max_retries: int = 3
) -> str:
if queue_name not in self.queues:
queue_name = "default"
task_id = str(uuid.uuid4())
task = Task(
id=task_id,
queue_name=queue_name,
handler_name=handler_name,
payload=payload,
priority=priority,
max_retries=max_retries
)
async with self._lock:
self.tasks[task_id] = task
if priority == TaskPriority.HIGH or priority == TaskPriority.CRITICAL:
self.queues[queue_name].appendleft(task_id)
else:
self.queues[queue_name].append(task_id)
self._stats["enqueued"] += 1
logger.debug(f"Enqueued task {task_id} to {queue_name}")
return task_id
async def get_task_status(self, task_id: str) -> Optional[Task]:
async with self._lock:
return self.tasks.get(task_id)
async def cancel_task(self, task_id: str) -> bool:
async with self._lock:
if task_id in self.tasks:
task = self.tasks[task_id]
if task.status == TaskStatus.PENDING:
task.status = TaskStatus.CANCELLED
return True
return False
async def _worker_loop(self, worker_id: int):
logger.debug(f"Worker {worker_id} started")
while self._running:
try:
task = await self._get_next_task()
if task:
await self._process_task(task, worker_id)
else:
await asyncio.sleep(0.1)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Worker {worker_id} error: {e}")
await asyncio.sleep(1)
logger.debug(f"Worker {worker_id} stopped")
async def _get_next_task(self) -> Optional[Task]:
async with self._lock:
for priority in [TaskPriority.CRITICAL, TaskPriority.HIGH, TaskPriority.NORMAL, TaskPriority.LOW]:
for queue_name in self.QUEUES:
if self.queues[queue_name]:
task_id = None
for tid in list(self.queues[queue_name]):
if tid in self.tasks and self.tasks[tid].priority == priority:
task_id = tid
break
if task_id:
self.queues[queue_name].remove(task_id)
task = self.tasks.get(task_id)
if task and task.status == TaskStatus.PENDING:
task.status = TaskStatus.RUNNING
task.started_at = time.time()
return task
return None
async def _process_task(self, task: Task, worker_id: int):
handler = self.handlers.get(task.handler_name)
if not handler:
logger.error(f"No handler found for {task.handler_name}")
task.status = TaskStatus.FAILED
task.error = f"Handler not found: {task.handler_name}"
async with self._lock:
self._stats["failed"] += 1
return
try:
result = await handler(**task.payload)
task.status = TaskStatus.COMPLETED
task.completed_at = time.time()
task.result = result
async with self._lock:
self._stats["completed"] += 1
logger.debug(f"Task {task.id} completed by worker {worker_id}")
except Exception as e:
task.error = str(e)
task.retry_count += 1
if task.retry_count < task.max_retries:
task.status = TaskStatus.PENDING
task.started_at = None
async with self._lock:
self.queues[task.queue_name].append(task.id)
self._stats["retried"] += 1
logger.warning(f"Task {task.id} failed, retrying ({task.retry_count}/{task.max_retries})")
else:
task.status = TaskStatus.FAILED
task.completed_at = time.time()
async with self._lock:
self._stats["failed"] += 1
logger.error(f"Task {task.id} failed permanently: {e}")
async def cleanup_completed_tasks(self, max_age: float = 3600):
now = time.time()
async with self._lock:
to_remove = [
tid for tid, task in self.tasks.items()
if task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]
and task.completed_at and now - task.completed_at > max_age
]
for tid in to_remove:
del self.tasks[tid]
if to_remove:
logger.debug(f"Cleaned up {len(to_remove)} completed tasks")
async def get_stats(self) -> Dict:
async with self._lock:
queue_sizes = {name: len(queue) for name, queue in self.queues.items()}
pending = sum(1 for t in self.tasks.values() if t.status == TaskStatus.PENDING)
running = sum(1 for t in self.tasks.values() if t.status == TaskStatus.RUNNING)
return {
**self._stats,
"queue_sizes": queue_sizes,
"pending_tasks": pending,
"running_tasks": running,
"total_tasks": len(self.tasks),
}
_task_queue: Optional[TaskQueue] = None
async def init_task_queue(max_workers: int = 4) -> TaskQueue:
global _task_queue
_task_queue = TaskQueue(max_workers=max_workers)
await _task_queue.start()
return _task_queue
async def shutdown_task_queue():
global _task_queue
if _task_queue:
await _task_queue.stop()
_task_queue = None
def get_task_queue() -> TaskQueue:
if not _task_queue:
raise RuntimeError("Task queue not initialized")
return _task_queue