Compare commits
8 commits
Author | SHA1 | Date | |
---|---|---|---|
|
bdcc6c531b | ||
|
75e301b0b2 | ||
|
b8e4cd68fa | ||
|
1cc2747629 | ||
|
07909c41fc | ||
|
92f4b5ad69 | ||
|
666d9b27e8 | ||
|
4f5c5fd09d |
13 changed files with 476 additions and 123 deletions
|
@ -21,3 +21,5 @@ coverage.xml
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
instance/
|
instance/
|
||||||
|
repo-images/
|
||||||
|
.forgejo/
|
|
@ -1,3 +1,6 @@
|
||||||
|
## Debugging
|
||||||
|
LOGGING_LEVEL=DEBUG
|
||||||
|
|
||||||
## Database Configuration
|
## Database Configuration
|
||||||
DATABASE_URL=sqlite:///instance/app.db
|
DATABASE_URL=sqlite:///instance/app.db
|
||||||
|
|
||||||
|
|
20
README.md
20
README.md
|
@ -10,6 +10,26 @@ A digital asset management system built with Flask and S3-compatible storage.
|
||||||
- License key management
|
- License key management
|
||||||
- Docker container support
|
- Docker container support
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
<details>
|
||||||
|
<summary>Home Page</summary>
|
||||||
|
<p>
|
||||||
|
<img src="https://git.hack13.dev/hack13/Personal-Digital-Asset-Manager/raw/branch/main/repo-images/list-of-assets.webp" />
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Asset View</summary>
|
||||||
|
<p>
|
||||||
|
<img src="https://git.hack13.dev/hack13/Personal-Digital-Asset-Manager/raw/branch/main/repo-images/asset-view.webp" />
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Edit Page</summary>
|
||||||
|
<p>
|
||||||
|
<img src="https://git.hack13.dev/hack13/Personal-Digital-Asset-Manager/raw/branch/main/repo-images/edit-view.webp" />
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
## Container Registry
|
## Container Registry
|
||||||
|
|
||||||
This project includes automated container builds using Forgejo CI/CD. The container images are published to the project's container registry.
|
This project includes automated container builds using Forgejo CI/CD. The container images are published to the project's container registry.
|
||||||
|
|
261
app.py
261
app.py
|
@ -1,6 +1,7 @@
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from flask import Flask, render_template, request, redirect, url_for, flash
|
import mimetypes
|
||||||
|
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory, send_file, Response, stream_with_context
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from config import Config
|
from config import Config
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
|
@ -20,11 +21,13 @@ def create_app():
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
migrate.init_app(app, db)
|
migrate.init_app(app, db)
|
||||||
|
|
||||||
|
# Initialize storage backend
|
||||||
|
app.storage = StorageBackend(app.config['STORAGE_URL'])
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
storage = StorageBackend(app.config['STORAGE_URL'])
|
|
||||||
|
|
||||||
def generate_unique_filename(original_filename):
|
def generate_unique_filename(original_filename):
|
||||||
# Get the file extension
|
# Get the file extension
|
||||||
|
@ -47,13 +50,22 @@ def index():
|
||||||
@app.route('/asset/add', methods=['GET', 'POST'])
|
@app.route('/asset/add', methods=['GET', 'POST'])
|
||||||
def add_asset():
|
def add_asset():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
title = request.form.get('title')
|
try:
|
||||||
description = request.form.get('description')
|
title = request.form.get('title')
|
||||||
license_key = request.form.get('license_key')
|
description = request.form.get('description')
|
||||||
featured_image = request.files.get('featured_image')
|
license_key = request.form.get('license_key')
|
||||||
additional_files = request.files.getlist('additional_files')
|
featured_image = request.files.get('featured_image')
|
||||||
|
additional_files = request.files.getlist('additional_files')
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
return jsonify({'success': False, 'error': 'Title is required'})
|
||||||
|
|
||||||
|
if not featured_image:
|
||||||
|
return jsonify({'success': False, 'error': 'Featured image is required'})
|
||||||
|
|
||||||
|
if not allowed_file(featured_image.filename, is_featured_image=True):
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid featured image format'})
|
||||||
|
|
||||||
if title and featured_image and allowed_file(featured_image.filename, is_featured_image=True):
|
|
||||||
# Process and convert featured image to WebP
|
# Process and convert featured image to WebP
|
||||||
processed_image, ext = ImageProcessor.process_featured_image(featured_image)
|
processed_image, ext = ImageProcessor.process_featured_image(featured_image)
|
||||||
|
|
||||||
|
@ -69,7 +81,7 @@ def add_asset():
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save featured image with unique filename using storage backend
|
# Save featured image with unique filename using storage backend
|
||||||
storage.save(processed_file, unique_featured_filename)
|
app.storage.save(processed_file, unique_featured_filename)
|
||||||
|
|
||||||
# Create asset with unique filename
|
# Create asset with unique filename
|
||||||
asset = Asset(
|
asset = Asset(
|
||||||
|
@ -87,7 +99,7 @@ def add_asset():
|
||||||
if file and allowed_file(file.filename):
|
if file and allowed_file(file.filename):
|
||||||
original_filename = secure_filename(file.filename)
|
original_filename = secure_filename(file.filename)
|
||||||
unique_filename = generate_unique_filename(original_filename)
|
unique_filename = generate_unique_filename(original_filename)
|
||||||
storage.save(file, unique_filename)
|
app.storage.save(file, unique_filename)
|
||||||
asset_file = AssetFile(
|
asset_file = AssetFile(
|
||||||
filename=unique_filename,
|
filename=unique_filename,
|
||||||
original_filename=original_filename,
|
original_filename=original_filename,
|
||||||
|
@ -96,8 +108,19 @@ def add_asset():
|
||||||
db.session.add(asset_file)
|
db.session.add(asset_file)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Asset added successfully!', 'success')
|
return jsonify({
|
||||||
return redirect(url_for('index'))
|
'success': True,
|
||||||
|
'message': 'Asset added successfully!',
|
||||||
|
'redirect': url_for('index')
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
app.logger.error(f"Error adding asset: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
return render_template('add_asset.html')
|
return render_template('add_asset.html')
|
||||||
|
|
||||||
|
@ -111,90 +134,180 @@ def edit_asset(id):
|
||||||
asset = Asset.query.get_or_404(id)
|
asset = Asset.query.get_or_404(id)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
asset.title = request.form.get('title')
|
try:
|
||||||
asset.set_description(request.form.get('description'))
|
asset.title = request.form.get('title')
|
||||||
license_key = request.form.get('license_key')
|
if not asset.title:
|
||||||
asset.license_key = license_key.strip() if license_key else None
|
return jsonify({'success': False, 'error': 'Title is required'})
|
||||||
|
|
||||||
# Handle featured image update
|
asset.set_description(request.form.get('description'))
|
||||||
featured_image = request.files.get('featured_image')
|
license_key = request.form.get('license_key')
|
||||||
if featured_image and featured_image.filename and allowed_file(featured_image.filename, is_featured_image=True):
|
asset.license_key = license_key.strip() if license_key else None
|
||||||
# Delete old featured image
|
|
||||||
if asset.featured_image:
|
|
||||||
storage.delete(asset.featured_image)
|
|
||||||
|
|
||||||
# Process and convert featured image to WebP
|
# Handle featured image update
|
||||||
processed_image, ext = ImageProcessor.process_featured_image(featured_image)
|
featured_image = request.files.get('featured_image')
|
||||||
|
if featured_image and featured_image.filename:
|
||||||
# Generate unique filename
|
if not allowed_file(featured_image.filename, is_featured_image=True):
|
||||||
original_featured_filename = secure_filename(featured_image.filename)
|
return jsonify({'success': False, 'error': 'Invalid featured image format'})
|
||||||
unique_featured_filename = f"{uuid.uuid4().hex}{ext}"
|
|
||||||
|
|
||||||
# Create a FileStorage object from the processed image
|
# Delete old featured image
|
||||||
processed_file = FileStorage(
|
if asset.featured_image:
|
||||||
stream=processed_image,
|
app.storage.delete(asset.featured_image)
|
||||||
filename=unique_featured_filename,
|
|
||||||
content_type='image/webp'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save the processed image
|
# Process and convert featured image to WebP
|
||||||
storage.save(processed_file, unique_featured_filename)
|
processed_image, ext = ImageProcessor.process_featured_image(featured_image)
|
||||||
asset.featured_image = unique_featured_filename
|
|
||||||
asset.original_featured_image = original_featured_filename
|
# Generate unique filename
|
||||||
|
original_featured_filename = secure_filename(featured_image.filename)
|
||||||
|
unique_featured_filename = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
|
||||||
# Handle additional files
|
# Create a FileStorage object from the processed image
|
||||||
additional_files = request.files.getlist('additional_files')
|
processed_file = FileStorage(
|
||||||
for file in additional_files:
|
stream=processed_image,
|
||||||
if file and allowed_file(file.filename):
|
filename=unique_featured_filename,
|
||||||
original_filename = secure_filename(file.filename)
|
content_type='image/webp'
|
||||||
unique_filename = generate_unique_filename(original_filename)
|
|
||||||
storage.save(file, unique_filename)
|
|
||||||
asset_file = AssetFile(
|
|
||||||
filename=unique_filename,
|
|
||||||
original_filename=original_filename,
|
|
||||||
asset_id=asset.id
|
|
||||||
)
|
)
|
||||||
db.session.add(asset_file)
|
|
||||||
|
|
||||||
db.session.commit()
|
# Save the processed image
|
||||||
flash('Asset updated successfully!', 'success')
|
app.storage.save(processed_file, unique_featured_filename)
|
||||||
return redirect(url_for('asset_detail', id=asset.id))
|
asset.featured_image = unique_featured_filename
|
||||||
|
asset.original_featured_image = original_featured_filename
|
||||||
|
|
||||||
|
# Handle additional files
|
||||||
|
additional_files = request.files.getlist('additional_files')
|
||||||
|
for file in additional_files:
|
||||||
|
if file and allowed_file(file.filename):
|
||||||
|
original_filename = secure_filename(file.filename)
|
||||||
|
unique_filename = generate_unique_filename(original_filename)
|
||||||
|
app.storage.save(file, unique_filename)
|
||||||
|
asset_file = AssetFile(
|
||||||
|
filename=unique_filename,
|
||||||
|
original_filename=original_filename,
|
||||||
|
asset_id=asset.id
|
||||||
|
)
|
||||||
|
db.session.add(asset_file)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Asset updated successfully!',
|
||||||
|
'redirect': url_for('asset_detail', id=asset.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
return render_template('edit_asset.html', asset=asset)
|
return render_template('edit_asset.html', asset=asset)
|
||||||
|
|
||||||
@app.route('/asset/<int:id>/delete', methods=['POST'])
|
@app.route('/asset/<int:id>/delete', methods=['POST'])
|
||||||
def delete_asset(id):
|
def delete_asset(id):
|
||||||
asset = Asset.query.get_or_404(id)
|
try:
|
||||||
|
asset = Asset.query.get_or_404(id)
|
||||||
|
deletion_errors = []
|
||||||
|
|
||||||
# Delete featured image
|
# Delete featured image
|
||||||
if asset.featured_image:
|
if asset.featured_image:
|
||||||
storage.delete(asset.featured_image)
|
if not app.storage.delete(asset.featured_image):
|
||||||
|
deletion_errors.append(f"Failed to delete featured image: {asset.featured_image}")
|
||||||
|
|
||||||
# Delete additional files
|
# Delete additional files
|
||||||
for file in asset.files:
|
for file in asset.files:
|
||||||
storage.delete(file.filename)
|
if not app.storage.delete(file.filename):
|
||||||
db.session.delete(file)
|
deletion_errors.append(f"Failed to delete file: {file.filename}")
|
||||||
|
db.session.delete(file)
|
||||||
|
|
||||||
db.session.delete(asset)
|
db.session.delete(asset)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash('Asset deleted successfully!', 'success')
|
if deletion_errors:
|
||||||
return redirect(url_for('index'))
|
app.logger.error("Asset deletion had errors: %s", deletion_errors)
|
||||||
|
flash('Asset deleted from database, but some files could not be deleted: ' + '; '.join(deletion_errors), 'warning')
|
||||||
|
else:
|
||||||
|
flash('Asset deleted successfully!', 'success')
|
||||||
|
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
app.logger.error("Failed to delete asset: %s", str(e))
|
||||||
|
flash('Failed to delete asset: ' + str(e), 'error')
|
||||||
|
return redirect(url_for('asset_detail', id=id))
|
||||||
|
|
||||||
@app.route('/asset/file/<int:id>/delete', methods=['POST'])
|
@app.route('/asset/file/<int:id>/delete', methods=['POST'])
|
||||||
def delete_asset_file(id):
|
def delete_asset_file(id):
|
||||||
asset_file = AssetFile.query.get_or_404(id)
|
try:
|
||||||
asset_id = asset_file.asset_id
|
asset_file = AssetFile.query.get_or_404(id)
|
||||||
|
asset_id = asset_file.asset_id
|
||||||
|
filename = asset_file.filename
|
||||||
|
display_name = asset_file.original_filename or asset_file.filename
|
||||||
|
|
||||||
# Delete the file using storage backend
|
# Delete the file using storage backend
|
||||||
storage.delete(asset_file.filename)
|
if not app.storage.delete(filename):
|
||||||
|
error_msg = f'Failed to delete file {display_name} from storage'
|
||||||
|
app.logger.error(error_msg)
|
||||||
|
flash(error_msg, 'error')
|
||||||
|
return redirect(url_for('asset_detail', id=asset_id))
|
||||||
|
|
||||||
# Remove from database
|
# Only remove from database if storage deletion was successful
|
||||||
db.session.delete(asset_file)
|
db.session.delete(asset_file)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash('File deleted successfully!', 'success')
|
flash('File deleted successfully!', 'success')
|
||||||
return redirect(url_for('asset_detail', id=asset_id))
|
return redirect(url_for('asset_detail', id=asset_id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
app.logger.error("Failed to delete asset file: %s", str(e))
|
||||||
|
flash('Failed to delete file: ' + str(e), 'error')
|
||||||
|
return redirect(url_for('asset_detail', id=asset_id))
|
||||||
|
|
||||||
|
@app.route('/download/<int:file_id>')
|
||||||
|
def download_file(file_id):
|
||||||
|
"""Download a file with its original filename"""
|
||||||
|
try:
|
||||||
|
asset_file = AssetFile.query.get_or_404(file_id)
|
||||||
|
filename = asset_file.filename
|
||||||
|
download_name = asset_file.original_filename or filename
|
||||||
|
|
||||||
|
# Guess the mime type
|
||||||
|
mime_type, _ = mimetypes.guess_type(download_name)
|
||||||
|
if mime_type is None:
|
||||||
|
mime_type = 'application/octet-stream'
|
||||||
|
|
||||||
|
app.logger.debug(f"Starting download of {filename} as {download_name} with type {mime_type}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_stream = app.storage.get_file_stream(filename)
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = file_stream.read(8192) # Read in 8KB chunks
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
file_stream.close()
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
stream_with_context(generate()),
|
||||||
|
mimetype=mime_type
|
||||||
|
)
|
||||||
|
response.headers['Content-Disposition'] = f'attachment; filename="{download_name}"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error streaming file {filename}: {str(e)}", exc_info=True)
|
||||||
|
flash('Error downloading file. Please try again.', 'error')
|
||||||
|
return redirect(url_for('asset_detail', id=asset_file.asset_id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error in download_file: {str(e)}", exc_info=True)
|
||||||
|
flash('File not found or error occurred.', 'error')
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(host='0.0.0.0', port=5432, debug=True)
|
app.run(host='0.0.0.0', port=5432, debug=True)
|
||||||
|
|
|
@ -22,6 +22,9 @@ class Config:
|
||||||
S3_ENDPOINT_URL = os.environ.get('S3_ENDPOINT_URL')
|
S3_ENDPOINT_URL = os.environ.get('S3_ENDPOINT_URL')
|
||||||
S3_PUBLIC_URL = os.environ.get('S3_PUBLIC_URL')
|
S3_PUBLIC_URL = os.environ.get('S3_PUBLIC_URL')
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
LOGGING_LEVEL = os.environ.get('LOGGING_LEVEL', 'DEBUG' if os.environ.get('FLASK_ENV') != 'production' else 'INFO')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def init_app(app):
|
def init_app(app):
|
||||||
# Create necessary directories
|
# Create necessary directories
|
||||||
|
@ -31,3 +34,7 @@ class Config:
|
||||||
if app.config['STORAGE_URL'].startswith('file://'):
|
if app.config['STORAGE_URL'].startswith('file://'):
|
||||||
storage_path = app.config['STORAGE_URL'].replace('file://', '')
|
storage_path = app.config['STORAGE_URL'].replace('file://', '')
|
||||||
os.makedirs(storage_path, exist_ok=True)
|
os.makedirs(storage_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
import logging
|
||||||
|
logging.basicConfig(level=getattr(logging, app.config['LOGGING_LEVEL']))
|
||||||
|
|
BIN
repo-images/asset-view.webp
Normal file
BIN
repo-images/asset-view.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 129 KiB |
BIN
repo-images/edit-view.webp
Normal file
BIN
repo-images/edit-view.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 94 KiB |
BIN
repo-images/list-of-assets.webp
Normal file
BIN
repo-images/list-of-assets.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 256 KiB |
142
storage.py
142
storage.py
|
@ -1,9 +1,10 @@
|
||||||
import os
|
import os
|
||||||
import fsspec
|
import fsspec
|
||||||
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import BinaryIO, Optional, Union
|
from typing import BinaryIO, Optional, Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from flask import current_app
|
from flask import current_app, url_for
|
||||||
from werkzeug.datastructures import FileStorage
|
from werkzeug.datastructures import FileStorage
|
||||||
|
|
||||||
class StorageBackend:
|
class StorageBackend:
|
||||||
|
@ -18,6 +19,15 @@ class StorageBackend:
|
||||||
self.parsed_url = urlparse(storage_url)
|
self.parsed_url = urlparse(storage_url)
|
||||||
self.protocol = self.parsed_url.scheme or 'file'
|
self.protocol = self.parsed_url.scheme or 'file'
|
||||||
|
|
||||||
|
# Set up logging - use Flask logger if in app context, otherwise use Python logging
|
||||||
|
try:
|
||||||
|
current_app.name # Check if we're in app context
|
||||||
|
self.logger = current_app.logger
|
||||||
|
except RuntimeError:
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
self.logger.info(f"Initializing StorageBackend with URL: {storage_url}, protocol: {self.protocol}")
|
||||||
|
|
||||||
# Configure filesystem
|
# Configure filesystem
|
||||||
if self.protocol == 's3':
|
if self.protocol == 's3':
|
||||||
self.fs = fsspec.filesystem(
|
self.fs = fsspec.filesystem(
|
||||||
|
@ -31,28 +41,70 @@ class StorageBackend:
|
||||||
)
|
)
|
||||||
self.bucket = self.parsed_url.netloc
|
self.bucket = self.parsed_url.netloc
|
||||||
self.base_path = self.parsed_url.path.lstrip('/')
|
self.base_path = self.parsed_url.path.lstrip('/')
|
||||||
|
self.logger.debug(f"Configured S3 storage with bucket: {self.bucket}, base_path: {self.base_path}")
|
||||||
else:
|
else:
|
||||||
self.fs = fsspec.filesystem('file')
|
self.fs = fsspec.filesystem('file')
|
||||||
self.base_path = self.parsed_url.path or '/uploads'
|
self.base_path = self.parsed_url.path or '/uploads'
|
||||||
|
self.logger.debug(f"Configured local storage with base_path: {self.base_path}")
|
||||||
|
|
||||||
def _get_full_path(self, filename: str) -> str:
|
def _get_full_path(self, filename: str) -> str:
|
||||||
"""Get full path for a file"""
|
"""Get full path for a file"""
|
||||||
if self.protocol == 's3':
|
if self.protocol == 's3':
|
||||||
return os.path.join(self.base_path, filename)
|
full_path = os.path.join(self.base_path, filename)
|
||||||
return os.path.join(current_app.root_path, self.base_path, filename)
|
self.logger.debug(f"Generated S3 full path: {full_path}")
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
full_path = os.path.join(current_app.root_path, self.base_path, filename)
|
||||||
|
self.logger.debug(f"Generated local full path: {full_path}")
|
||||||
|
return full_path
|
||||||
|
|
||||||
def save(self, file_storage: FileStorage, filename: str) -> str:
|
def save(self, file_storage: FileStorage, filename: str) -> str:
|
||||||
"""Save a file to storage"""
|
"""Save a file to storage"""
|
||||||
full_path = self._get_full_path(filename)
|
try:
|
||||||
|
full_path = self._get_full_path(filename)
|
||||||
if self.protocol == 's3':
|
self.logger.info(f"Attempting to save file {filename} to {full_path}")
|
||||||
with self.fs.open(f"{self.bucket}/{full_path}", 'wb') as f:
|
|
||||||
file_storage.save(f)
|
if not isinstance(file_storage, FileStorage):
|
||||||
return f"s3://{self.bucket}/{full_path}"
|
self.logger.error(f"Invalid file_storage object type: {type(file_storage)}")
|
||||||
else:
|
raise ValueError("file_storage must be a FileStorage object")
|
||||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
||||||
file_storage.save(full_path)
|
if self.protocol == 's3':
|
||||||
return f"file://{full_path}"
|
s3_path = f"{self.bucket}/{full_path}"
|
||||||
|
self.logger.debug(f"Opening S3 file for writing: {s3_path}")
|
||||||
|
with self.fs.open(s3_path, 'wb') as f:
|
||||||
|
self.logger.debug("Saving file content to S3...")
|
||||||
|
file_storage.save(f)
|
||||||
|
|
||||||
|
# Verify the file was saved
|
||||||
|
if self.fs.exists(s3_path):
|
||||||
|
self.logger.info(f"Successfully saved file to S3: {s3_path}")
|
||||||
|
else:
|
||||||
|
self.logger.error(f"Failed to verify file existence in S3: {s3_path}")
|
||||||
|
raise RuntimeError(f"Failed to verify file existence in S3: {s3_path}")
|
||||||
|
|
||||||
|
return f"s3://{self.bucket}/{full_path}"
|
||||||
|
else:
|
||||||
|
# Create directory structure if it doesn't exist
|
||||||
|
dir_path = os.path.dirname(full_path)
|
||||||
|
self.logger.debug(f"Creating local directory structure: {dir_path}")
|
||||||
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
|
|
||||||
|
self.logger.debug(f"Saving file to local path: {full_path}")
|
||||||
|
file_storage.save(full_path)
|
||||||
|
|
||||||
|
# Verify the file was saved
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
self.logger.info(f"Successfully saved file locally: {full_path}")
|
||||||
|
self.logger.debug(f"File size: {os.path.getsize(full_path)} bytes")
|
||||||
|
else:
|
||||||
|
self.logger.error(f"Failed to verify file existence locally: {full_path}")
|
||||||
|
raise RuntimeError(f"Failed to verify file existence locally: {full_path}")
|
||||||
|
|
||||||
|
return f"file://{full_path}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error saving file {filename}: {str(e)}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
def open(self, filename: str, mode: str = 'rb') -> BinaryIO:
|
def open(self, filename: str, mode: str = 'rb') -> BinaryIO:
|
||||||
"""Open a file from storage"""
|
"""Open a file from storage"""
|
||||||
|
@ -61,13 +113,43 @@ class StorageBackend:
|
||||||
return self.fs.open(f"{self.bucket}/{full_path}", mode)
|
return self.fs.open(f"{self.bucket}/{full_path}", mode)
|
||||||
return self.fs.open(full_path, mode)
|
return self.fs.open(full_path, mode)
|
||||||
|
|
||||||
def delete(self, filename: str) -> None:
|
def delete(self, filename: str) -> bool:
|
||||||
"""Delete a file from storage"""
|
"""
|
||||||
full_path = self._get_full_path(filename)
|
Delete a file from storage
|
||||||
if self.protocol == 's3':
|
Returns True if file was deleted or didn't exist, False if deletion failed
|
||||||
self.fs.delete(f"{self.bucket}/{full_path}")
|
"""
|
||||||
else:
|
try:
|
||||||
self.fs.delete(full_path)
|
full_path = self._get_full_path(filename)
|
||||||
|
if self.protocol == 's3':
|
||||||
|
path = f"{self.bucket}/{full_path}"
|
||||||
|
self.logger.debug(f"Attempting to delete S3 file: {path}")
|
||||||
|
if self.fs.exists(path):
|
||||||
|
self.logger.debug(f"File exists, deleting: {path}")
|
||||||
|
self.fs.delete(path)
|
||||||
|
deleted = not self.fs.exists(path)
|
||||||
|
if deleted:
|
||||||
|
self.logger.debug(f"Successfully deleted file: {path}")
|
||||||
|
else:
|
||||||
|
self.logger.error(f"Failed to delete file: {path}")
|
||||||
|
return deleted
|
||||||
|
self.logger.debug(f"File doesn't exist, skipping delete: {path}")
|
||||||
|
return True # File didn't exist
|
||||||
|
else:
|
||||||
|
self.logger.debug(f"Attempting to delete local file: {full_path}")
|
||||||
|
if self.fs.exists(full_path):
|
||||||
|
self.logger.debug(f"File exists, deleting: {full_path}")
|
||||||
|
self.fs.delete(full_path)
|
||||||
|
deleted = not os.path.exists(full_path)
|
||||||
|
if deleted:
|
||||||
|
self.logger.debug(f"Successfully deleted file: {full_path}")
|
||||||
|
else:
|
||||||
|
self.logger.error(f"Failed to delete file: {full_path}")
|
||||||
|
return deleted
|
||||||
|
self.logger.debug(f"File doesn't exist, skipping delete: {full_path}")
|
||||||
|
return True # File didn't exist
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to delete file {filename}: {str(e)}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
def url_for(self, filename: str) -> str:
|
def url_for(self, filename: str) -> str:
|
||||||
"""Get URL for a file"""
|
"""Get URL for a file"""
|
||||||
|
@ -82,11 +164,27 @@ class StorageBackend:
|
||||||
return f"{endpoint}/{self.bucket}/{full_path}"
|
return f"{endpoint}/{self.bucket}/{full_path}"
|
||||||
return f"s3://{self.bucket}/{full_path}"
|
return f"s3://{self.bucket}/{full_path}"
|
||||||
else:
|
else:
|
||||||
return f"/uploads/{filename}"
|
# For local storage, use static/uploads path
|
||||||
|
return url_for('static', filename=f'uploads/{filename}')
|
||||||
|
|
||||||
def exists(self, filename: str) -> bool:
|
def exists(self, filename: str) -> bool:
|
||||||
"""Check if a file exists"""
|
"""Check if a file exists"""
|
||||||
full_path = self._get_full_path(filename)
|
full_path = self._get_full_path(filename)
|
||||||
if self.protocol == 's3':
|
if self.protocol == 's3':
|
||||||
return self.fs.exists(f"{self.bucket}/{full_path}")
|
return self.fs.exists(f"{self.bucket}/{full_path}")
|
||||||
return self.fs.exists(full_path)
|
return self.fs.exists(full_path)
|
||||||
|
|
||||||
|
def get_file_stream(self, filename: str):
|
||||||
|
"""Get a file stream from storage"""
|
||||||
|
try:
|
||||||
|
if self.protocol == 's3':
|
||||||
|
s3_path = f"{self.bucket}/{self._get_full_path(filename)}"
|
||||||
|
self.logger.debug(f"Opening S3 file stream: {s3_path}")
|
||||||
|
return self.fs.open(s3_path, 'rb')
|
||||||
|
else:
|
||||||
|
full_path = self._get_full_path(filename)
|
||||||
|
self.logger.debug(f"Opening local file stream: {full_path}")
|
||||||
|
return open(full_path, 'rb')
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to get file stream for {filename}: {str(e)}", exc_info=True)
|
||||||
|
raise
|
|
@ -4,7 +4,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form method="POST" enctype="multipart/form-data" class="form">
|
<form method="POST" enctype="multipart/form-data" class="form" id="assetForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title" class="form-label">Title</label>
|
<label for="title" class="form-label">Title</label>
|
||||||
<input
|
<input
|
||||||
|
@ -82,7 +82,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="button button-primary">
|
<button type="submit" class="button button-primary" id="submitBtn">
|
||||||
<i class="fas fa-save"></i> Save Asset
|
<i class="fas fa-save"></i> Save Asset
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('index') }}" class="button button-secondary">
|
<a href="{{ url_for('index') }}" class="button button-secondary">
|
||||||
|
@ -115,8 +115,11 @@
|
||||||
"https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg",
|
"https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg",
|
||||||
});
|
});
|
||||||
|
|
||||||
// File input preview handling
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const form = document.getElementById("assetForm");
|
||||||
|
const loadingOverlay = document.querySelector(".loading-overlay");
|
||||||
|
const loadingText = document.querySelector(".loading-text");
|
||||||
|
|
||||||
// Featured image preview
|
// Featured image preview
|
||||||
const featuredInput = document.getElementById("featured_image");
|
const featuredInput = document.getElementById("featured_image");
|
||||||
const featuredPreview = document.querySelector(".file-input-preview");
|
const featuredPreview = document.querySelector(".file-input-preview");
|
||||||
|
@ -152,6 +155,45 @@
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
loadingOverlay.style.display = "flex";
|
||||||
|
loadingText.textContent = "Processing...";
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", "{{ url_for('add_asset') }}", true);
|
||||||
|
|
||||||
|
xhr.onload = function() {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
if (response.success) {
|
||||||
|
window.location.href = response.redirect;
|
||||||
|
} else {
|
||||||
|
loadingText.textContent = "Failed: " + response.error;
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingOverlay.style.display = "none";
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadingText.textContent = "Upload failed! Please try again.";
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingOverlay.style.display = "none";
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function() {
|
||||||
|
loadingText.textContent = "Network error! Please try again.";
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingOverlay.style.display = "none";
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -64,33 +64,19 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="attached-files content-box">
|
<div class="files-section">
|
||||||
<h2>Attached Files</h2>
|
<h2>Files</h2>
|
||||||
{% if asset.files %}
|
{% if asset.files %}
|
||||||
<ul class="files-list">
|
<ul class="files-list">
|
||||||
{% for file in asset.files %}
|
{% for file in asset.files %}
|
||||||
<li class="file-item">
|
<li class="file-item">
|
||||||
<a
|
<a
|
||||||
href="{{ file.file_url }}"
|
href="{{ url_for('download_file', file_id=file.id) }}"
|
||||||
target="_blank"
|
|
||||||
class="file-link"
|
class="file-link"
|
||||||
>
|
>
|
||||||
<i class="fas fa-file"></i>
|
<i class="fas fa-file"></i>
|
||||||
{{ file.original_filename or file.filename }}
|
{{ file.original_filename or file.filename }}
|
||||||
</a>
|
</a>
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="{{ url_for('delete_asset_file', id=file.id) }}"
|
|
||||||
class="inline-form"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="button button-small button-danger"
|
|
||||||
onclick="return confirm('Are you sure you want to delete this file?')"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -27,11 +27,47 @@
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
|
<style>
|
||||||
|
.loading-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.loading-spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 5px solid #f3f3f3;
|
||||||
|
border-top: 5px solid #3498db;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.loading-text {
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/trumbowyg.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/trumbowyg.min.js"></script>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="loading-overlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="loading-text">Processing...</div>
|
||||||
|
</div>
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
<div class="nav-container">
|
<div class="nav-container">
|
||||||
<div class="nav-brand">Digital Assets Manager</div>
|
<div class="nav-brand">Digital Assets Manager</div>
|
||||||
|
@ -68,6 +104,5 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<form method="POST" enctype="multipart/form-data" class="form">
|
<form method="POST" enctype="multipart/form-data" class="form" id="editAssetForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title" class="form-label">Title</label>
|
<label for="title" class="form-label">Title</label>
|
||||||
<input
|
<input
|
||||||
|
@ -28,7 +28,7 @@
|
||||||
<label class="form-label">Current Featured Image</label>
|
<label class="form-label">Current Featured Image</label>
|
||||||
<div class="current-image">
|
<div class="current-image">
|
||||||
<img
|
<img
|
||||||
src="{{ url_for('static', filename='uploads/' + asset.featured_image) }}"
|
src="{{ asset.featured_image_url }}"
|
||||||
alt="{{ asset.title }}"
|
alt="{{ asset.title }}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -90,7 +90,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="button button-primary">
|
<button type="submit" class="button button-primary" id="submitBtn">
|
||||||
<i class="fas fa-save"></i> Update Asset
|
<i class="fas fa-save"></i> Update Asset
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
|
@ -109,8 +109,7 @@
|
||||||
{% for file in asset.files %}
|
{% for file in asset.files %}
|
||||||
<li class="file-item">
|
<li class="file-item">
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('static', filename='uploads/' + file.filename) }}"
|
href="{{ url_for('download_file', file_id=file.id) }}"
|
||||||
target="_blank"
|
|
||||||
class="file-link"
|
class="file-link"
|
||||||
>
|
>
|
||||||
<i class="fas fa-file"></i>
|
<i class="fas fa-file"></i>
|
||||||
|
@ -159,8 +158,11 @@
|
||||||
"https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg",
|
"https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg",
|
||||||
});
|
});
|
||||||
|
|
||||||
// File input preview handling (keeping your existing code)
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const form = document.getElementById("editAssetForm");
|
||||||
|
const loadingOverlay = document.querySelector(".loading-overlay");
|
||||||
|
const loadingText = document.querySelector(".loading-text");
|
||||||
|
|
||||||
// Featured image preview
|
// Featured image preview
|
||||||
const featuredInput = document.getElementById("featured_image");
|
const featuredInput = document.getElementById("featured_image");
|
||||||
const featuredPreview = document.querySelector(".file-input-preview");
|
const featuredPreview = document.querySelector(".file-input-preview");
|
||||||
|
@ -196,6 +198,51 @@
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
loadingOverlay.style.display = "flex";
|
||||||
|
loadingText.textContent = "Processing...";
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", "{{ url_for('edit_asset', id=asset.id) }}", true);
|
||||||
|
|
||||||
|
xhr.onload = function() {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
if (response.success) {
|
||||||
|
window.location.href = response.redirect;
|
||||||
|
} else {
|
||||||
|
loadingText.textContent = "Failed: " + response.error;
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingOverlay.style.display = "none";
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Handle non-JSON response (redirect)
|
||||||
|
window.location.href = "{{ url_for('asset_detail', id=asset.id) }}";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadingText.textContent = "Update failed! Please try again.";
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingOverlay.style.display = "none";
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function() {
|
||||||
|
loadingText.textContent = "Network error! Please try again.";
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingOverlay.style.display = "none";
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue