diff --git a/retoors/main.py b/retoors/main.py index 581aceb..c8e1af2 100644 --- a/retoors/main.py +++ b/retoors/main.py @@ -1,4 +1,4 @@ - +import os import argparse from aiohttp import web import aiohttp_jinja2 @@ -73,8 +73,8 @@ def create_app(): def main(): parser = argparse.ArgumentParser() - parser.add_argument('--port', type=int, default=8080) - parser.add_argument('--hostname', default='0.0.0.0') + parser.add_argument('--port', type=int, default=os.getenv("PORT", 9001)) + parser.add_argument('--hostname', default=os.getenv("HOSTNAME", "127.0.0.1")) args = parser.parse_args() app = create_app() web.run_app(app, host=args.hostname, port=args.port) diff --git a/retoors/services/sharing_service.py b/retoors/services/sharing_service.py new file mode 100644 index 0000000..c75200a --- /dev/null +++ b/retoors/services/sharing_service.py @@ -0,0 +1,317 @@ +import uuid +import datetime +import hashlib +import logging +from typing import Dict, List, Optional, Any +from .storage_service import StorageService + +logger = logging.getLogger(__name__) + +class SharingService: + + PERMISSION_VIEW = "view" + PERMISSION_EDIT = "edit" + PERMISSION_COMMENT = "comment" + + SCOPE_PUBLIC = "public" + SCOPE_PRIVATE = "private" + SCOPE_ACCOUNT_BASED = "account_based" + + def __init__(self): + self.storage = StorageService() + + async def _load_shares(self, user_email: str) -> Dict: + shares = await self.storage.load(user_email, "shares") + return shares if shares else {} + + async def _save_shares(self, user_email: str, shares: Dict): + await self.storage.save(user_email, "shares", shares) + + async def _load_share_recipients(self, share_id: str) -> Dict: + recipients = await self.storage.load("global_shares", f"recipients_{share_id}") + return recipients if recipients else {} + + async def _save_share_recipients(self, share_id: str, recipients: Dict): + await self.storage.save("global_shares", f"recipients_{share_id}", recipients) + + async def _load_all_shares(self) -> Dict: + all_shares = await self.storage.load("global_shares", "all_shares_index") + return all_shares if all_shares else {} + + async def _save_all_shares(self, all_shares: Dict): + await self.storage.save("global_shares", "all_shares_index", all_shares) + + def _hash_password(self, password: str) -> str: + return hashlib.sha256(password.encode()).hexdigest() + + async def create_share( + self, + owner_email: str, + item_path: str, + permission: str = PERMISSION_VIEW, + scope: str = SCOPE_PUBLIC, + password: Optional[str] = None, + expiration_days: Optional[int] = None, + disable_download: bool = False, + recipient_emails: Optional[List[str]] = None + ) -> str: + + share_id = str(uuid.uuid4()) + now = datetime.datetime.now(datetime.timezone.utc) + + expires_at = None + if expiration_days: + expires_at = (now + datetime.timedelta(days=expiration_days)).isoformat() + + password_hash = None + if password: + password_hash = self._hash_password(password) + + share_data = { + "share_id": share_id, + "owner_email": owner_email, + "item_path": item_path, + "permission": permission, + "scope": scope, + "password_hash": password_hash, + "created_at": now.isoformat(), + "expires_at": expires_at, + "disable_download": disable_download, + "active": True, + "access_count": 0, + "last_accessed": None + } + + user_shares = await self._load_shares(owner_email) + user_shares[share_id] = share_data + await self._save_shares(owner_email, user_shares) + + all_shares = await self._load_all_shares() + all_shares[share_id] = { + "owner_email": owner_email, + "item_path": item_path, + "created_at": now.isoformat() + } + await self._save_all_shares(all_shares) + + if recipient_emails and scope in [self.SCOPE_PRIVATE, self.SCOPE_ACCOUNT_BASED]: + recipients = {} + for email in recipient_emails: + recipients[email] = { + "email": email, + "permission": permission, + "invited_at": now.isoformat(), + "accessed": False + } + await self._save_share_recipients(share_id, recipients) + + logger.info(f"Created share {share_id} for {item_path} by {owner_email}") + return share_id + + async def get_share(self, share_id: str) -> Optional[Dict]: + all_shares = await self._load_all_shares() + if share_id not in all_shares: + return None + + owner_email = all_shares[share_id]["owner_email"] + user_shares = await self._load_shares(owner_email) + + if share_id not in user_shares: + return None + + share = user_shares[share_id] + + if not share.get("active", True): + logger.warning(f"Share {share_id} is deactivated") + return None + + if share.get("expires_at"): + expiry_time = datetime.datetime.fromisoformat(share["expires_at"]) + if expiry_time <= datetime.datetime.now(datetime.timezone.utc): + logger.warning(f"Share {share_id} has expired") + return None + + return share + + async def verify_share_access( + self, + share_id: str, + password: Optional[str] = None, + accessor_email: Optional[str] = None + ) -> bool: + + share = await self.get_share(share_id) + if not share: + return False + + if share.get("password_hash") and password: + if self._hash_password(password) != share["password_hash"]: + logger.warning(f"Invalid password for share {share_id}") + return False + elif share.get("password_hash") and not password: + return False + + if share["scope"] == self.SCOPE_PRIVATE and accessor_email: + recipients = await self._load_share_recipients(share_id) + if accessor_email not in recipients: + logger.warning(f"Email {accessor_email} not in recipients for share {share_id}") + return False + + if share["scope"] == self.SCOPE_ACCOUNT_BASED and not accessor_email: + logger.warning(f"Account-based share {share_id} requires authenticated user") + return False + + return True + + async def record_share_access(self, share_id: str, accessor_email: Optional[str] = None): + all_shares = await self._load_all_shares() + if share_id not in all_shares: + return + + owner_email = all_shares[share_id]["owner_email"] + user_shares = await self._load_shares(owner_email) + + if share_id in user_shares: + user_shares[share_id]["access_count"] = user_shares[share_id].get("access_count", 0) + 1 + user_shares[share_id]["last_accessed"] = datetime.datetime.now(datetime.timezone.utc).isoformat() + await self._save_shares(owner_email, user_shares) + + if accessor_email: + recipients = await self._load_share_recipients(share_id) + if accessor_email in recipients: + recipients[accessor_email]["accessed"] = True + recipients[accessor_email]["last_accessed"] = datetime.datetime.now(datetime.timezone.utc).isoformat() + await self._save_share_recipients(share_id, recipients) + + async def deactivate_share(self, owner_email: str, share_id: str) -> bool: + user_shares = await self._load_shares(owner_email) + + if share_id not in user_shares: + return False + + user_shares[share_id]["active"] = False + await self._save_shares(owner_email, user_shares) + + logger.info(f"Deactivated share {share_id}") + return True + + async def reactivate_share(self, owner_email: str, share_id: str) -> bool: + user_shares = await self._load_shares(owner_email) + + if share_id not in user_shares: + return False + + user_shares[share_id]["active"] = True + await self._save_shares(owner_email, user_shares) + + logger.info(f"Reactivated share {share_id}") + return True + + async def delete_share(self, owner_email: str, share_id: str) -> bool: + user_shares = await self._load_shares(owner_email) + + if share_id not in user_shares: + return False + + del user_shares[share_id] + await self._save_shares(owner_email, user_shares) + + all_shares = await self._load_all_shares() + if share_id in all_shares: + del all_shares[share_id] + await self._save_all_shares(all_shares) + + logger.info(f"Deleted share {share_id}") + return True + + async def update_share_permission(self, owner_email: str, share_id: str, permission: str) -> bool: + user_shares = await self._load_shares(owner_email) + + if share_id not in user_shares: + return False + + user_shares[share_id]["permission"] = permission + await self._save_shares(owner_email, user_shares) + + logger.info(f"Updated permission for share {share_id} to {permission}") + return True + + async def update_share_expiration(self, owner_email: str, share_id: str, expiration_days: Optional[int]) -> bool: + user_shares = await self._load_shares(owner_email) + + if share_id not in user_shares: + return False + + if expiration_days: + expires_at = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=expiration_days)).isoformat() + user_shares[share_id]["expires_at"] = expires_at + else: + user_shares[share_id]["expires_at"] = None + + await self._save_shares(owner_email, user_shares) + + logger.info(f"Updated expiration for share {share_id}") + return True + + async def add_share_recipient(self, owner_email: str, share_id: str, recipient_email: str, permission: str) -> bool: + user_shares = await self._load_shares(owner_email) + + if share_id not in user_shares: + return False + + recipients = await self._load_share_recipients(share_id) + recipients[recipient_email] = { + "email": recipient_email, + "permission": permission, + "invited_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "accessed": False + } + await self._save_share_recipients(share_id, recipients) + + logger.info(f"Added recipient {recipient_email} to share {share_id}") + return True + + async def remove_share_recipient(self, owner_email: str, share_id: str, recipient_email: str) -> bool: + user_shares = await self._load_shares(owner_email) + + if share_id not in user_shares: + return False + + recipients = await self._load_share_recipients(share_id) + if recipient_email in recipients: + del recipients[recipient_email] + await self._save_share_recipients(share_id, recipients) + logger.info(f"Removed recipient {recipient_email} from share {share_id}") + return True + + return False + + async def update_recipient_permission(self, owner_email: str, share_id: str, recipient_email: str, permission: str) -> bool: + user_shares = await self._load_shares(owner_email) + + if share_id not in user_shares: + return False + + recipients = await self._load_share_recipients(share_id) + if recipient_email in recipients: + recipients[recipient_email]["permission"] = permission + await self._save_share_recipients(share_id, recipients) + logger.info(f"Updated permission for {recipient_email} in share {share_id} to {permission}") + return True + + return False + + async def get_share_recipients(self, share_id: str) -> Dict: + return await self._load_share_recipients(share_id) + + async def list_user_shares(self, user_email: str) -> List[Dict]: + user_shares = await self._load_shares(user_email) + return list(user_shares.values()) + + async def get_shares_for_item(self, user_email: str, item_path: str) -> List[Dict]: + user_shares = await self._load_shares(user_email) + item_shares = [ + share for share in user_shares.values() + if share["item_path"] == item_path + ] + return item_shares diff --git a/retoors/static/js/components/pricing.js b/retoors/static/js/components/pricing.js new file mode 100644 index 0000000..56c3dbf --- /dev/null +++ b/retoors/static/js/components/pricing.js @@ -0,0 +1,79 @@ +class PricingCalculator { + constructor() { + this.slider = document.getElementById('storageSlider'); + this.storageValue = document.getElementById('storageValue'); + this.priceValue = document.getElementById('priceValue'); + this.planDescription = document.getElementById('planDescription'); + + this.pricingTiers = [ + { maxGB: 10, price: 0, name: 'Free Plan' }, + { maxGB: 100, price: 9, name: 'Personal Plan' }, + { maxGB: 500, price: 29, name: 'Professional Plan' }, + { maxGB: 1000, price: 49, name: 'Business Plan' }, + { maxGB: 2000, price: 99, name: 'Enterprise Plan' } + ]; + + this.init(); + } + + init() { + if (!this.slider) return; + + this.slider.addEventListener('input', () => this.updatePrice()); + this.updatePrice(); + } + + calculatePrice(storageGB) { + for (let tier of this.pricingTiers) { + if (storageGB <= tier.maxGB) { + return { + price: tier.price, + plan: tier.name + }; + } + } + return { + price: 99, + plan: 'Enterprise Plan' + }; + } + + formatStorage(value) { + if (value >= 1000) { + return `${(value / 1000).toFixed(1)} TB`; + } + return `${value} GB`; + } + + updatePrice() { + const storage = parseInt(this.slider.value); + const pricing = this.calculatePrice(storage); + + if (storage >= 1000) { + this.storageValue.textContent = (storage / 1000).toFixed(1); + this.storageValue.nextElementSibling.textContent = 'TB'; + } else { + this.storageValue.textContent = storage; + this.storageValue.nextElementSibling.textContent = 'GB'; + } + + this.priceValue.textContent = pricing.price; + this.planDescription.textContent = pricing.plan; + } +} + +class Application { + constructor() { + this.pricingCalculator = null; + this.init(); + } + + init() { + document.addEventListener('DOMContentLoaded', () => { + this.pricingCalculator = new PricingCalculator(); + }); + } +} + +const app = new Application(); +export default app; diff --git a/retoors/templates/pages/create_share.html b/retoors/templates/pages/create_share.html new file mode 100644 index 0000000..20690ad --- /dev/null +++ b/retoors/templates/pages/create_share.html @@ -0,0 +1,246 @@ +{% extends "layouts/dashboard.html" %} + +{% block title %}Create Share - Retoor's Cloud Solutions{% endblock %} + +{% block dashboard_head %} + + +{% endblock %} + +{% block page_title %}Create Share{% endblock %} + +{% block dashboard_content %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/retoors/templates/pages/manage_shares.html b/retoors/templates/pages/manage_shares.html new file mode 100644 index 0000000..aa5d242 --- /dev/null +++ b/retoors/templates/pages/manage_shares.html @@ -0,0 +1,381 @@ +{% extends "layouts/dashboard.html" %} + +{% block title %}Manage Shares - Retoor's Cloud Solutions{% endblock %} + +{% block dashboard_head %} + +{% endblock %} + +{% block page_title %}Manage Shares{% endblock %} + +{% block dashboard_content %} + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/retoors/templates/pages/share_error.html b/retoors/templates/pages/share_error.html new file mode 100644 index 0000000..aff5d69 --- /dev/null +++ b/retoors/templates/pages/share_error.html @@ -0,0 +1,34 @@ +{% extends "layouts/base.html" %} + +{% block title %}Share Error{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +This share is password protected. You will need to enter the password to access it.
" + + body = f""" + + +{sender_email} has shared {item_path} with you.
+ +Permission Level: {permission}
+Item: {item_path}
+Click the button below to access the shared item:
+ + + Access Shared Item + + +Or copy and paste this link into your browser:
++ {share_url} +
+ +
+ This invitation was sent by Retoor's Cloud Solutions.
+ If you believe you received this email in error, please disregard it.
+