This commit is contained in:
retoor 2025-11-09 19:12:54 +01:00
parent c281f1e9ea
commit b374f7cd4f
9 changed files with 1636 additions and 3 deletions

View File

@ -1,4 +1,4 @@
import os
import argparse import argparse
from aiohttp import web from aiohttp import web
import aiohttp_jinja2 import aiohttp_jinja2
@ -73,8 +73,8 @@ def create_app():
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=8080) parser.add_argument('--port', type=int, default=os.getenv("PORT", 9001))
parser.add_argument('--hostname', default='0.0.0.0') parser.add_argument('--hostname', default=os.getenv("HOSTNAME", "127.0.0.1"))
args = parser.parse_args() args = parser.parse_args()
app = create_app() app = create_app()
web.run_app(app, host=args.hostname, port=args.port) web.run_app(app, host=args.hostname, port=args.port)

View File

@ -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

View File

@ -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;

View File

@ -0,0 +1,246 @@
{% extends "layouts/dashboard.html" %}
{% block title %}Create Share - Retoor's Cloud Solutions{% endblock %}
{% block dashboard_head %}
<link rel="stylesheet" href="/static/css/components/form.css">
<style>
.share-form-container {
max-width: 800px;
margin: 2rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-section {
margin-bottom: 2rem;
}
.form-section h3 {
margin-bottom: 1rem;
color: #333;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.checkbox-group input {
width: auto;
}
.recipient-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.recipient-item {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-add-recipient {
margin-top: 0.5rem;
}
.share-url-display {
display: none;
margin-top: 2rem;
padding: 1rem;
background: #f0f7ff;
border-radius: 4px;
}
.share-url-display input {
font-family: monospace;
background: white;
}
</style>
{% endblock %}
{% block page_title %}Create Share{% endblock %}
{% block dashboard_content %}
<div class="share-form-container">
<form id="create-share-form">
<div class="form-section">
<h3>Basic Information</h3>
<div class="form-group">
<label for="item_path">Item to Share</label>
<input type="text" id="item_path" name="item_path" value="{{ item_path }}" required readonly>
</div>
<div class="form-group">
<label for="permission">Permission Level</label>
<select id="permission" name="permission">
<option value="view">View Only - Can view and download</option>
<option value="edit">Edit - Can modify, add, and delete</option>
<option value="comment">Comment - Can provide feedback only</option>
</select>
</div>
<div class="form-group">
<label for="scope">Sharing Scope</label>
<select id="scope" name="scope">
<option value="public">Public - Anyone with the link</option>
<option value="private">Private - Specific recipients only</option>
<option value="account_based">Account Based - Requires user account</option>
</select>
</div>
</div>
<div class="form-section" id="recipients-section" style="display: none;">
<h3>Recipients</h3>
<div class="form-group">
<label>Recipient Emails</label>
<div class="recipient-list" id="recipient-list">
<div class="recipient-item">
<input type="email" class="recipient-email" placeholder="email@example.com">
<button type="button" class="btn-small btn-remove-recipient">Remove</button>
</div>
</div>
<button type="button" class="btn-outline btn-add-recipient" id="add-recipient-btn">Add Recipient</button>
</div>
</div>
<div class="form-section">
<h3>Security Options</h3>
<div class="form-group">
<label for="password">Password Protection</label>
<input type="password" id="password" name="password" placeholder="Optional password">
</div>
<div class="form-group">
<label for="expiration_days">Expiration</label>
<select id="expiration_days" name="expiration_days">
<option value="">Never expires</option>
<option value="1">1 day</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="disable_download" name="disable_download">
<label for="disable_download">Disable downloads (view only)</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Create Share Link</button>
<button type="button" class="btn-outline" onclick="history.back()">Cancel</button>
</div>
</form>
<div class="share-url-display" id="share-url-display">
<h3>Share Link Created</h3>
<div class="form-group">
<label>Share URL</label>
<input type="text" id="share-url" readonly>
<button type="button" class="btn-primary" id="copy-url-btn">Copy Link</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="module">
const form = document.getElementById('create-share-form');
const scopeSelect = document.getElementById('scope');
const recipientsSection = document.getElementById('recipients-section');
const addRecipientBtn = document.getElementById('add-recipient-btn');
const recipientList = document.getElementById('recipient-list');
const shareUrlDisplay = document.getElementById('share-url-display');
const shareUrlInput = document.getElementById('share-url');
const copyUrlBtn = document.getElementById('copy-url-btn');
scopeSelect.addEventListener('change', () => {
if (scopeSelect.value === 'private' || scopeSelect.value === 'account_based') {
recipientsSection.style.display = 'block';
} else {
recipientsSection.style.display = 'none';
}
});
addRecipientBtn.addEventListener('click', () => {
const recipientItem = document.createElement('div');
recipientItem.className = 'recipient-item';
recipientItem.innerHTML = `
<input type="email" class="recipient-email" placeholder="email@example.com">
<button type="button" class="btn-small btn-remove-recipient">Remove</button>
`;
recipientList.appendChild(recipientItem);
});
recipientList.addEventListener('click', (e) => {
if (e.target.classList.contains('btn-remove-recipient')) {
e.target.parentElement.remove();
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
item_path: document.getElementById('item_path').value,
permission: document.getElementById('permission').value,
scope: document.getElementById('scope').value,
password: document.getElementById('password').value || null,
expiration_days: parseInt(document.getElementById('expiration_days').value) || null,
disable_download: document.getElementById('disable_download').checked,
recipient_emails: []
};
if (formData.scope === 'private' || formData.scope === 'account_based') {
const emailInputs = document.querySelectorAll('.recipient-email');
formData.recipient_emails = Array.from(emailInputs)
.map(input => input.value.trim())
.filter(email => email);
}
try {
const response = await fetch('/api/sharing/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.success) {
shareUrlInput.value = result.share_url;
shareUrlDisplay.style.display = 'block';
form.style.display = 'none';
} else {
alert('Error: ' + result.error);
}
} catch (error) {
alert('Error creating share: ' + error.message);
}
});
copyUrlBtn.addEventListener('click', () => {
shareUrlInput.select();
document.execCommand('copy');
copyUrlBtn.textContent = 'Copied!';
setTimeout(() => {
copyUrlBtn.textContent = 'Copy Link';
}, 2000);
});
</script>
{% endblock %}

View File

@ -0,0 +1,381 @@
{% extends "layouts/dashboard.html" %}
{% block title %}Manage Shares - Retoor's Cloud Solutions{% endblock %}
{% block dashboard_head %}
<style>
.shares-container {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.shares-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.share-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border-left: 4px solid #0066cc;
transition: transform 0.2s, box-shadow 0.2s;
}
.share-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.share-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.share-item-path {
font-weight: 600;
font-size: 1.1rem;
color: #333;
word-break: break-all;
flex: 1;
margin-right: 1rem;
}
.share-status {
padding: 0.4rem 0.8rem;
border-radius: 16px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.share-status.active {
background: #d4edda;
color: #155724;
}
.share-status.inactive {
background: #f8d7da;
color: #721c24;
}
.share-status.expired {
background: #fff3cd;
color: #856404;
}
.share-card-body {
margin-bottom: 1rem;
}
.share-info-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
font-size: 0.9rem;
}
.share-info-label {
color: #666;
font-weight: 500;
}
.share-info-value {
color: #333;
font-weight: 600;
}
.share-link {
font-family: monospace;
font-size: 0.85rem;
color: #0066cc;
background: #f0f7ff;
padding: 0.5rem;
border-radius: 4px;
margin: 0.75rem 0;
display: block;
word-break: break-all;
}
.share-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.share-actions button {
flex: 1;
min-width: 80px;
padding: 0.6rem 0.8rem;
font-size: 0.85rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state h3 {
margin-bottom: 0.5rem;
color: #333;
}
.empty-state p {
color: #666;
margin-bottom: 1.5rem;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
}
.recipient-list {
margin-top: 1rem;
}
.recipient-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f5f5f5;
margin-bottom: 0.5rem;
border-radius: 4px;
}
@media (max-width: 768px) {
.shares-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block page_title %}Manage Shares{% endblock %}
{% block dashboard_content %}
<div class="shares-container">
{% if shares %}
<div class="shares-grid" id="shares-list">
{% for share in shares %}
<div class="share-card" data-share-id="{{ share.share_id }}">
<div class="share-card-header">
<div class="share-item-path">{{ share.item_path }}</div>
{% if not share.active %}
<span class="share-status inactive">Inactive</span>
{% elif share.expires_at and share.expires_at < now %}
<span class="share-status expired">Expired</span>
{% else %}
<span class="share-status active">Active</span>
{% endif %}
</div>
<div class="share-card-body">
<div class="share-link">/share/{{ share.share_id }}</div>
<div class="share-info-row">
<span class="share-info-label">Permission</span>
<span class="share-info-value">{{ share.permission }}</span>
</div>
<div class="share-info-row">
<span class="share-info-label">Scope</span>
<span class="share-info-value">{{ share.scope }}</span>
</div>
<div class="share-info-row">
<span class="share-info-label">Access Count</span>
<span class="share-info-value">{{ share.access_count or 0 }} views</span>
</div>
<div class="share-info-row">
<span class="share-info-label">Created</span>
<span class="share-info-value">{{ share.created_at[:10] }}</span>
</div>
<div class="share-info-row">
<span class="share-info-label">Expires</span>
<span class="share-info-value">{{ share.expires_at[:10] if share.expires_at else 'Never' }}</span>
</div>
</div>
<div class="share-actions">
<button class="btn-small copy-link-btn" data-share-id="{{ share.share_id }}">Copy Link</button>
<button class="btn-small view-details-btn" data-share-id="{{ share.share_id }}">Details</button>
{% if share.active %}
<button class="btn-small deactivate-btn" data-share-id="{{ share.share_id }}">Deactivate</button>
{% else %}
<button class="btn-small activate-btn" data-share-id="{{ share.share_id }}">Activate</button>
{% endif %}
<button class="btn-small delete-btn btn-danger" data-share-id="{{ share.share_id }}">Delete</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">🔗</div>
<h3>No Shares Yet</h3>
<p>You have not created any shares. Start sharing your files and folders with others.</p>
<a href="/files" class="btn-primary">Go to My Files</a>
</div>
{% endif %}
</div>
<div class="modal" id="share-details-modal">
<div class="modal-content">
<div class="modal-header">
<h2>Share Details</h2>
<button class="modal-close">&times;</button>
</div>
<div id="share-details-content">
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="module">
const sharesList = document.getElementById('shares-list');
const detailsModal = document.getElementById('share-details-modal');
const detailsContent = document.getElementById('share-details-content');
const modalClose = document.querySelector('.modal-close');
if (sharesList) {
sharesList.addEventListener('click', async (e) => {
const shareId = e.target.dataset.shareId;
if (!shareId) return;
if (e.target.classList.contains('view-details-btn')) {
await viewShareDetails(shareId);
} else if (e.target.classList.contains('copy-link-btn')) {
copyShareLink(shareId);
} else if (e.target.classList.contains('deactivate-btn')) {
await updateShare(shareId, 'deactivate');
} else if (e.target.classList.contains('activate-btn')) {
await updateShare(shareId, 'reactivate');
} else if (e.target.classList.contains('delete-btn')) {
if (confirm('Are you sure you want to delete this share?')) {
await updateShare(shareId, 'delete');
}
}
});
}
async function viewShareDetails(shareId) {
try {
const response = await fetch(`/api/sharing/${shareId}`);
const data = await response.json();
let html = `
<div class="form-group">
<label>Item Path</label>
<p>${data.share.item_path}</p>
</div>
<div class="form-group">
<label>Permission</label>
<p>${data.share.permission}</p>
</div>
<div class="form-group">
<label>Scope</label>
<p>${data.share.scope}</p>
</div>
`;
if (data.recipients && data.recipients.length > 0) {
html += `
<div class="form-group">
<label>Recipients</label>
<div class="recipient-list">
${data.recipients.map(r => `
<div class="recipient-item">
<div>
<div>${r.email}</div>
<small>Permission: ${r.permission}</small>
</div>
<div>
${r.accessed ? 'Accessed' : 'Not accessed'}
</div>
</div>
`).join('')}
</div>
</div>
`;
}
detailsContent.innerHTML = html;
detailsModal.classList.add('active');
} catch (error) {
alert('Error loading share details: ' + error.message);
}
}
function copyShareLink(shareId) {
const url = window.location.origin + '/share/' + shareId;
navigator.clipboard.writeText(url).then(() => {
alert('Share link copied to clipboard!');
});
}
async function updateShare(shareId, action) {
try {
const response = await fetch(`/api/sharing/${shareId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action })
});
const result = await response.json();
if (result.success) {
location.reload();
} else {
alert('Error: ' + result.error);
}
} catch (error) {
alert('Error updating share: ' + error.message);
}
}
modalClose.addEventListener('click', () => {
detailsModal.classList.remove('active');
});
detailsModal.addEventListener('click', (e) => {
if (e.target === detailsModal) {
detailsModal.classList.remove('active');
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,34 @@
{% extends "layouts/base.html" %}
{% block title %}Share Error{% endblock %}
{% block head %}
<style>
.error-container {
max-width: 600px;
margin: 4rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.error-message {
color: #666;
margin: 1.5rem 0;
}
</style>
{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">🔒</div>
<h1>Access Denied</h1>
<p class="error-message">{{ error }}</p>
<a href="/" class="btn-primary">Go to Homepage</a>
</div>
{% endblock %}

View File

@ -0,0 +1,81 @@
{% extends "layouts/base.html" %}
{% block title %}Shared File - {{ file_name }}{% endblock %}
{% block head %}
<style>
.share-container {
max-width: 800px;
margin: 4rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.share-header {
border-bottom: 1px solid #eee;
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
}
.file-icon-large {
width: 64px;
height: 64px;
margin: 0 auto 1rem;
display: block;
}
.file-info {
display: grid;
grid-template-columns: 150px 1fr;
gap: 1rem;
margin: 1.5rem 0;
}
.file-info dt {
font-weight: 600;
}
.file-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.permission-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #e3f2fd;
color: #1976d2;
border-radius: 12px;
font-size: 0.875rem;
}
</style>
{% endblock %}
{% block content %}
<div class="share-container">
<div class="share-header">
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon-large">
<h1>{{ file_name }}</h1>
<span class="permission-badge">{{ permission }}</span>
</div>
<dl class="file-info">
<dt>File Name</dt>
<dd>{{ file_name }}</dd>
<dt>Size</dt>
<dd>{{ (file_size / 1024 / 1024)|round(2) }} MB</dd>
<dt>Path</dt>
<dd>{{ item_path }}</dd>
<dt>Permission</dt>
<dd>{{ permission }}</dd>
</dl>
<div class="file-actions">
{% if not disable_download %}
<a href="/share/{{ share_id }}/download" class="btn-primary" download>Download File</a>
{% else %}
<button class="btn-outline" disabled>Download Disabled</button>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,114 @@
{% extends "layouts/base.html" %}
{% block title %}Shared Folder - {{ item_path }}{% endblock %}
{% block head %}
<style>
.share-container {
max-width: 1200px;
margin: 4rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.share-header {
border-bottom: 1px solid #eee;
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
}
.permission-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #e3f2fd;
color: #1976d2;
border-radius: 12px;
font-size: 0.875rem;
margin-left: 1rem;
}
.file-list-table {
width: 100%;
}
.file-list-table table {
width: 100%;
border-collapse: collapse;
}
.file-list-table th {
background: #f5f5f5;
padding: 1rem;
text-align: left;
font-weight: 600;
}
.file-list-table td {
padding: 1rem;
border-top: 1px solid #eee;
}
.file-icon {
width: 20px;
height: 20px;
margin-right: 0.5rem;
vertical-align: middle;
}
</style>
{% endblock %}
{% block content %}
<div class="share-container">
<div class="share-header">
<h1>
{{ item_path.split('/')[-1] if item_path else 'Shared Folder' }}
<span class="permission-badge">{{ permission }}</span>
</h1>
<p>{{ item_path }}</p>
</div>
<div class="file-list-table">
<table>
<thead>
<tr>
<th>Name</th>
<th>Last Modified</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if files %}
{% for item in files %}
<tr>
<td>
{% if item.is_dir %}
<img src="/static/images/icon-families.svg" alt="Folder Icon" class="file-icon">
{{ item.name }}
{% else %}
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon">
{{ item.name }}
{% endif %}
</td>
<td>{{ item.last_modified[:10] if item.last_modified else '' }}</td>
<td>
{% if item.is_dir %}
--
{% else %}
{{ (item.size / 1024 / 1024)|round(2) }} MB
{% endif %}
</td>
<td>
{% if not item.is_dir and not disable_download %}
<a href="/share/{{ share_id }}/download?file_path={{ item.path }}" class="btn-small">Download</a>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" style="text-align: center; padding: 2rem; color: #999;">
This folder is empty
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}

381
retoors/views/sharing.py Normal file
View File

@ -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"<p><strong>This share is password protected.</strong> You will need to enter the password to access it.</p>"
body = f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2>You have been invited to access a shared item</h2>
<p><strong>{sender_email}</strong> has shared <strong>{item_path}</strong> with you.</p>
<div style="background: #f5f5f5; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p><strong>Permission Level:</strong> {permission}</p>
<p><strong>Item:</strong> {item_path}</p>
</div>
{password_note}
<p>Click the button below to access the shared item:</p>
<a href="{share_url}" style="display: inline-block; background: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0;">
Access Shared Item
</a>
<p>Or copy and paste this link into your browser:</p>
<p style="background: #f5f5f5; padding: 10px; border-radius: 4px; word-break: break-all;">
{share_url}
</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
<p style="color: #666; font-size: 12px;">
This invitation was sent by Retoor's Cloud Solutions.<br>
If you believe you received this email in error, please disregard it.
</p>
</body>
</html>
"""
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})