Update.
This commit is contained in:
parent
c281f1e9ea
commit
b374f7cd4f
@ -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)
|
||||
|
||||
317
retoors/services/sharing_service.py
Normal file
317
retoors/services/sharing_service.py
Normal 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
|
||||
79
retoors/static/js/components/pricing.js
Normal file
79
retoors/static/js/components/pricing.js
Normal 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;
|
||||
246
retoors/templates/pages/create_share.html
Normal file
246
retoors/templates/pages/create_share.html
Normal 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 %}
|
||||
381
retoors/templates/pages/manage_shares.html
Normal file
381
retoors/templates/pages/manage_shares.html
Normal 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">×</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 %}
|
||||
34
retoors/templates/pages/share_error.html
Normal file
34
retoors/templates/pages/share_error.html
Normal 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 %}
|
||||
81
retoors/templates/pages/share_file.html
Normal file
81
retoors/templates/pages/share_file.html
Normal 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 %}
|
||||
114
retoors/templates/pages/share_folder.html
Normal file
114
retoors/templates/pages/share_folder.html
Normal 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
381
retoors/views/sharing.py
Normal 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})
|
||||
Loading…
Reference in New Issue
Block a user