From b374f7cd4f1e8e63b3b364d2320c3ed1f9bbd32f Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 9 Nov 2025 19:12:54 +0100 Subject: [PATCH] Update. --- retoors/main.py | 6 +- retoors/services/sharing_service.py | 317 +++++++++++++++++ retoors/static/js/components/pricing.js | 79 +++++ retoors/templates/pages/create_share.html | 246 +++++++++++++ retoors/templates/pages/manage_shares.html | 381 +++++++++++++++++++++ retoors/templates/pages/share_error.html | 34 ++ retoors/templates/pages/share_file.html | 81 +++++ retoors/templates/pages/share_folder.html | 114 ++++++ retoors/views/sharing.py | 381 +++++++++++++++++++++ 9 files changed, 1636 insertions(+), 3 deletions(-) create mode 100644 retoors/services/sharing_service.py create mode 100644 retoors/static/js/components/pricing.js create mode 100644 retoors/templates/pages/create_share.html create mode 100644 retoors/templates/pages/manage_shares.html create mode 100644 retoors/templates/pages/share_error.html create mode 100644 retoors/templates/pages/share_file.html create mode 100644 retoors/templates/pages/share_folder.html create mode 100644 retoors/views/sharing.py 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 %} +
+
+
+

Basic Information

+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+

Security Options

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+{% 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 %} +
+ {% if shares %} +
+ {% for share in shares %} + + {% endfor %} +
+ {% else %} +
+
🔗
+

No Shares Yet

+

You have not created any shares. Start sharing your files and folders with others.

+ Go to My Files +
+ {% endif %} +
+ + +{% 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 %} +
+
🔒
+

Access Denied

+

{{ error }}

+ Go to Homepage +
+{% endblock %} diff --git a/retoors/templates/pages/share_file.html b/retoors/templates/pages/share_file.html new file mode 100644 index 0000000..3f252f9 --- /dev/null +++ b/retoors/templates/pages/share_file.html @@ -0,0 +1,81 @@ +{% extends "layouts/base.html" %} + +{% block title %}Shared File - {{ file_name }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ + +
+
File Name
+
{{ file_name }}
+ +
Size
+
{{ (file_size / 1024 / 1024)|round(2) }} MB
+ +
Path
+
{{ item_path }}
+ +
Permission
+
{{ permission }}
+
+ +
+ {% if not disable_download %} + Download File + {% else %} + + {% endif %} +
+
+{% endblock %} diff --git a/retoors/templates/pages/share_folder.html b/retoors/templates/pages/share_folder.html new file mode 100644 index 0000000..97d5b29 --- /dev/null +++ b/retoors/templates/pages/share_folder.html @@ -0,0 +1,114 @@ +{% extends "layouts/base.html" %} + +{% block title %}Shared Folder - {{ item_path }}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ + +
+ + + + + + + + + + + {% if files %} + {% for item in files %} + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
NameLast ModifiedSizeActions
+ {% if item.is_dir %} + Folder Icon + {{ item.name }} + {% else %} + File Icon + {{ item.name }} + {% endif %} + {{ item.last_modified[:10] if item.last_modified else '' }} + {% if item.is_dir %} + -- + {% else %} + {{ (item.size / 1024 / 1024)|round(2) }} MB + {% endif %} + + {% if not item.is_dir and not disable_download %} + Download + {% endif %} +
+ This folder is empty +
+
+
+{% endblock %} diff --git a/retoors/views/sharing.py b/retoors/views/sharing.py new file mode 100644 index 0000000..9217993 --- /dev/null +++ b/retoors/views/sharing.py @@ -0,0 +1,381 @@ +import aiohttp_jinja2 +from aiohttp import web +import json +import logging +from ..helpers.email_sender import send_email +from ..helpers.auth import login_required + +logger = logging.getLogger(__name__) + +async def send_share_invitations(app, sender_email, recipient_emails, item_path, share_url, permission, password): + + for recipient in recipient_emails: + subject = f"{sender_email} shared '{item_path}' with you" + + password_note = "" + if password: + password_note = f"

This share is password protected. You will need to enter the password to access it.

" + + body = f""" + + +

You have been invited to access a shared item

+ +

{sender_email} has shared {item_path} with you.

+ +
+

Permission Level: {permission}

+

Item: {item_path}

+
+ + {password_note} + +

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. +

+ + + """ + + try: + await send_email(app, recipient, subject, body) + logger.info(f"Sent share invitation to {recipient} for {item_path}") + except Exception as e: + logger.error(f"Failed to send share invitation to {recipient}: {e}") + +@login_required +@aiohttp_jinja2.template('pages/create_share.html') +async def create_share_page(request: web.Request): + user = request['user'] + user_email = user['email'] + item_path = request.query.get('item_path', '') + + return { + 'request': request, + 'user_email': user_email, + 'item_path': item_path, + 'active_page': 'my_shares', + 'user': user + } + +@login_required +async def create_share_handler(request: web.Request): + user = request['user'] + user_email = user['email'] + + try: + data = await request.json() + except json.JSONDecodeError: + return web.json_response({'error': 'Invalid JSON'}, status=400) + + item_path = data.get('item_path') + if not item_path: + return web.json_response({'error': 'item_path is required'}, status=400) + + permission = data.get('permission', 'view') + scope = data.get('scope', 'public') + password = data.get('password') + expiration_days = data.get('expiration_days') + disable_download = data.get('disable_download', False) + recipient_emails = data.get('recipient_emails', []) + + file_service = request.app['file_service'] + + try: + share_id = await file_service.generate_share_link( + user_email=user_email, + item_path=item_path, + permission=permission, + scope=scope, + password=password, + expiration_days=expiration_days, + disable_download=disable_download, + recipient_emails=recipient_emails + ) + + if share_id: + share_url = str(request.url.origin()) + f'/share/{share_id}' + + if recipient_emails and len(recipient_emails) > 0: + logger.info(f"Sending email invitations to {len(recipient_emails)} recipients: {recipient_emails}") + await send_share_invitations( + request.app, + user_email, + recipient_emails, + item_path, + share_url, + permission, + password + ) + else: + logger.debug(f"No recipients specified for share {share_id}, skipping email invitations") + + return web.json_response({ + 'success': True, + 'share_id': share_id, + 'share_url': share_url + }) + else: + return web.json_response({'error': 'Failed to create share'}, status=400) + + except Exception as e: + logger.error(f"Error creating share: {e}") + return web.json_response({'error': str(e)}, status=500) + +async def view_share(request: web.Request): + share_id = request.match_info['share_id'] + file_service = request.app['file_service'] + + password = request.query.get('password') + accessor_email = request.get('user', {}).get('email') if request.get('user') else None + + shared_item = await file_service.get_shared_item(share_id, password, accessor_email) + + share = await file_service.sharing_service.get_share(share_id) + if not share: + # Check for specific reasons why the share might not be found + # (e.g., expired, deactivated, or truly not found) + # This requires deeper inspection within sharing_service or replicating its logic, + # for now, a generic "not found" is sufficient if get_share returns None. + return aiohttp_jinja2.render_template('pages/share_error.html', request, { + 'request': request, + 'error': 'Share link is invalid, expired, or deactivated.' + }) + + # Now verify access with the retrieved share object + if not await file_service.sharing_service.verify_share_access(share_id, password, accessor_email): + # Determine more specific access denial reasons + error_message = 'Access to this share is denied.' + # Check for password requirement + if share.get("password_hash") and not password: + error_message = 'This share is password protected. Please provide the correct password.' + elif share.get("password_hash") and password and file_service.sharing_service._hash_password(password) != share["password_hash"]: + error_message = 'Incorrect password for this share.' + # Check for private scope and recipient + elif share["scope"] == file_service.sharing_service.SCOPE_PRIVATE and accessor_email: + recipients = await file_service.sharing_service._load_share_recipients(share_id) + if accessor_email not in recipients: + error_message = 'You are not authorized to access this private share.' + # Check for account-based scope and anonymous access + elif share["scope"] == file_service.sharing_service.SCOPE_ACCOUNT_BASED and not accessor_email: + error_message = 'This share requires you to be logged in to an authorized account.' + + return aiohttp_jinja2.render_template('pages/share_error.html', request, { + 'request': request, + 'error': error_message + }) + + # If access is verified, record it + await file_service.sharing_service.record_share_access(share_id, accessor_email) + + # Proceed with getting the shared item details + shared_item = share # Use the already fetched share object + + metadata = await file_service._load_metadata(shared_item['owner_email']) + item_meta = metadata.get(shared_item['item_path']) + + if not item_meta: + return aiohttp_jinja2.render_template('pages/share_error.html', request, { + 'request': request, + 'error': 'The shared item could not be found or has been removed.' + }) + + is_folder = item_meta.get('type') == 'dir' + + if is_folder: + files = await file_service.get_shared_folder_content(share_id, password, accessor_email) + return aiohttp_jinja2.render_template('pages/share_folder.html', request, { + 'request': request, + 'share_id': share_id, + 'item_path': shared_item['item_path'], + 'files': files, + 'permission': shared_item.get('permission', 'view'), + 'disable_download': shared_item.get('disable_download', False) + }) + else: + return aiohttp_jinja2.render_template('pages/share_file.html', request, { + 'request': request, + 'share_id': share_id, + 'item_path': shared_item['item_path'], + 'file_name': item_meta.get('name', shared_item['item_path'].split('/')[-1]), + 'file_size': item_meta.get('size', 0), + 'permission': shared_item.get('permission', 'view'), + 'disable_download': shared_item.get('disable_download', False) + }) + +async def download_shared_file(request: web.Request): + share_id = request.match_info['share_id'] + file_service = request.app['file_service'] + + password = request.query.get('password') + accessor_email = request.get('user', {}).get('email') if request.get('user') else None + requested_file_path = request.query.get('file_path') + + # First, verify access to the share itself + share = await file_service.sharing_service.get_share(share_id) + if not share: + return web.Response(text='Share link is invalid, expired, or deactivated.', status=404) + + if not await file_service.sharing_service.verify_share_access(share_id, password, accessor_email): + error_message = 'Access to this share is denied.' + if share.get("password_hash") and not password: + error_message = 'This share is password protected. Please provide the correct password.' + elif share.get("password_hash") and password and file_service.sharing_service._hash_password(password) != share["password_hash"]: + error_message = 'Incorrect password for this share.' + elif share["scope"] == file_service.sharing_service.SCOPE_PRIVATE and accessor_email: + recipients = await file_service.sharing_service._load_share_recipients(share_id) + if accessor_email not in recipients: + error_message = 'You are not authorized to access this private share.' + elif share["scope"] == file_service.sharing_service.SCOPE_ACCOUNT_BASED and not accessor_email: + error_message = 'This share requires you to be logged in to an authorized account.' + return web.Response(text=error_message, status=403) # Use 403 Forbidden for access denied + + # If access is verified, record it + await file_service.sharing_service.record_share_access(share_id, accessor_email) + + # Then attempt to get the file content + result = await file_service.get_shared_file_content(share_id, password, accessor_email, requested_file_path) + + if not result: + # This means the file itself was not found or download was disabled + if share.get("disable_download", False): + return web.Response(text='Download is disabled for this share.', status=403) + return web.Response(text='The requested file could not be found or has been removed.', status=404) + + content, filename = result + + return web.Response( + body=content, + headers={ + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': f'attachment; filename="{filename}"' + } + ) + +@login_required +@aiohttp_jinja2.template('pages/manage_shares.html') +async def manage_shares(request: web.Request): + import datetime + user = request['user'] + user_email = user['email'] + + file_service = request.app['file_service'] + sharing_service = file_service.sharing_service + + shares = await sharing_service.list_user_shares(user_email) + + return { + 'request': request, + 'user_email': user_email, + 'shares': shares, + 'active_page': 'my_shares', + 'user': user, + 'now': datetime.datetime.now(datetime.timezone.utc).isoformat() + } + +@login_required +async def get_share_details(request: web.Request): + user = request['user'] + user_email = user['email'] + + share_id = request.match_info['share_id'] + file_service = request.app['file_service'] + sharing_service = file_service.sharing_service + + share = await sharing_service.get_share(share_id) + + if not share or share['owner_email'] != user_email: + return web.json_response({'error': 'Share not found'}, status=404) + + recipients = await sharing_service.get_share_recipients(share_id) + + return web.json_response({ + 'share': share, + 'recipients': list(recipients.values()) + }) + +@login_required +async def update_share(request: web.Request): + user = request['user'] + user_email = user['email'] + + share_id = request.match_info['share_id'] + + try: + data = await request.json() + except json.JSONDecodeError: + return web.json_response({'error': 'Invalid JSON'}, status=400) + + file_service = request.app['file_service'] + sharing_service = file_service.sharing_service + + action = data.get('action') + + if action == 'update_permission': + permission = data.get('permission') + success = await sharing_service.update_share_permission(user_email, share_id, permission) + + elif action == 'update_expiration': + expiration_days = data.get('expiration_days') + success = await sharing_service.update_share_expiration(user_email, share_id, expiration_days) + + elif action == 'deactivate': + success = await sharing_service.deactivate_share(user_email, share_id) + + elif action == 'reactivate': + success = await sharing_service.reactivate_share(user_email, share_id) + + elif action == 'delete': + success = await sharing_service.delete_share(user_email, share_id) + + elif action == 'add_recipient': + recipient_email = data.get('recipient_email') + permission = data.get('permission', 'view') + success = await sharing_service.add_share_recipient(user_email, share_id, recipient_email, permission) + + elif action == 'remove_recipient': + recipient_email = data.get('recipient_email') + success = await sharing_service.remove_share_recipient(user_email, share_id, recipient_email) + + elif action == 'update_recipient_permission': + recipient_email = data.get('recipient_email') + permission = data.get('permission') + success = await sharing_service.update_recipient_permission(user_email, share_id, recipient_email, permission) + + else: + return web.json_response({'error': 'Invalid action'}, status=400) + + if success: + return web.json_response({'success': True}) + else: + return web.json_response({'error': 'Operation failed'}, status=400) + +@login_required +async def get_item_shares(request: web.Request): + user = request['user'] + user_email = user['email'] + + item_path = request.query.get('item_path') + if not item_path: + return web.json_response({'error': 'item_path is required'}, status=400) + + file_service = request.app['file_service'] + sharing_service = file_service.sharing_service + + shares = await sharing_service.get_shares_for_item(user_email, item_path) + + return web.json_response({'shares': shares})