This commit is contained in:
lelo 2025-04-01 19:28:02 +02:00
commit 1a55d187fc
12 changed files with 505 additions and 253 deletions

9
.gitignore vendored
View File

@ -1,5 +1,12 @@
/venv /venv
/filecache /filecache
/filecache_audio
/filecache_image
/filecache_video
/filecache_other
/postgres_data
/instance
/__pycache__ /__pycache__
/access_log.db /access_log.db
/folder_config.json /folder_config.json
/.env

View File

@ -1,13 +1,37 @@
from flask import render_template, request, session
import sqlite3 import sqlite3
from datetime import datetime, date, timedelta from flask import render_template, request
from datetime import datetime, timedelta
import geoip2.database import geoip2.database
from urllib.parse import urlparse, unquote
from auth import require_secret from auth import require_secret
import os
file_access_temp = [] file_access_temp = []
# Example database name; you can change to whatever you want:
DB_NAME = 'access_log.db'
# Create a single global connection to SQLite
log_db = sqlite3.connect(DB_NAME, check_same_thread=False)
def init_log_db():
"""Create the file_access_log table if it doesn't already exist."""
with log_db:
log_db.execute('''
CREATE TABLE IF NOT EXISTS file_access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT,
rel_path TEXT,
filesize INTEGER,
mime TEXT,
ip_address TEXT,
user_agent TEXT,
device_id TEXT,
cached BOOLEAN
)
''')
init_log_db()
def lookup_location(ip, reader): def lookup_location(ip, reader):
try: try:
response = reader.city(ip) response = reader.city(ip)
@ -18,7 +42,7 @@ def lookup_location(ip, reader):
return "Unknown", "Unknown" return "Unknown", "Unknown"
def get_device_type(user_agent): def get_device_type(user_agent):
"classify device type based on user agent string" """Classify device type based on user agent string."""
if 'Android' in user_agent: if 'Android' in user_agent:
return 'Android' return 'Android'
elif 'iPhone' in user_agent or 'iPad' in user_agent: elif 'iPhone' in user_agent or 'iPad' in user_agent:
@ -32,54 +56,32 @@ def get_device_type(user_agent):
else: else:
return 'Other' return 'Other'
def shorten_referrer(url): def log_file_access(rel_path, filesize, mime, ip_address, user_agent, device_id, cached):
segments = [seg for seg in url.split('/') if seg] """Insert a file access record into the database."""
segment = segments[-1]
# Decode all percent-encoded characters (like %20, %2F, etc.)
segment_decoded = unquote(segment)
return segment_decoded
def log_file_access(full_path, ip_address, user_agent, referrer):
"""
Log file access details to a SQLite database.
Records the timestamp, full file path, client IP, user agent, and referrer.
"""
global file_access_temp global file_access_temp
# Connect to the database (this will create the file if it doesn't exist) timestamp = datetime.now() # a datetime object
conn = sqlite3.connect('access_log.db')
cursor = conn.cursor() # Store the ISO timestamp in the database for easy lexical comparison
# Create the table if it doesn't exist iso_ts = timestamp.isoformat()
cursor.execute('''
CREATE TABLE IF NOT EXISTS file_access_log ( with log_db:
id INTEGER PRIMARY KEY AUTOINCREMENT, log_db.execute('''
timestamp TEXT, INSERT INTO file_access_log
full_path TEXT, (timestamp, rel_path, filesize, mime, ip_address, user_agent, device_id, cached)
ip_address TEXT, VALUES (?, ?, ?, ?, ?, ?, ?, ?)
user_agent TEXT, ''', (iso_ts, rel_path, filesize, mime, ip_address, user_agent, device_id, cached))
referrer TEXT file_access_temp.insert(0, [iso_ts, rel_path, filesize, mime, ip_address, user_agent, device_id, cached])
)
''') return iso_ts
# Gather information from the request
timestamp = datetime.now().isoformat()
# Insert the access record into the database
cursor.execute('''
INSERT INTO file_access_log (timestamp, full_path, ip_address, user_agent, referrer)
VALUES (?, ?, ?, ?, ?)
''', (timestamp, full_path, ip_address, user_agent, referrer))
conn.commit()
conn.close()
file_access_temp.insert(0, [timestamp, full_path, ip_address, user_agent, referrer])
return return_file_access()
def return_file_access(): def return_file_access():
"""Return recent file access logs from memory (the last 10 minutes)."""
global file_access_temp global file_access_temp
if len(file_access_temp) > 0: if file_access_temp:
# Compute the cutoff time (10 minutes ago from now)
cutoff_time = datetime.now() - timedelta(minutes=10) cutoff_time = datetime.now() - timedelta(minutes=10)
# Update the list in-place to keep only entries newer than 10 minutes # Convert each stored timestamp (ISO string) back to datetime
file_access_temp[:] = [ file_access_temp[:] = [
entry for entry in file_access_temp entry for entry in file_access_temp
if datetime.fromisoformat(entry[0]) >= cutoff_time if datetime.fromisoformat(entry[0]) >= cutoff_time
] ]
return file_access_temp return file_access_temp
@ -92,140 +94,240 @@ def connections():
@require_secret @require_secret
def dashboard(): def dashboard():
filetype_arg = request.args.get('filetype', 'audio')
timeframe = request.args.get('timeframe', 'today') timeframe = request.args.get('timeframe', 'today')
now = datetime.now() now = datetime.now()
# Determine which file type we're filtering by.
filetype = 'other'
# Some simplistic sets to decide how we match the MIME type
audio_list = ['mp3', 'wav', 'audio']
image_list = ['jpg', 'jpeg', 'image', 'photo']
video_list = ['mp4', 'mov', 'wmv', 'avi']
if filetype_arg.lower() in audio_list:
filetype = 'audio/'
elif filetype_arg.lower() in image_list:
filetype = 'image/'
elif filetype_arg.lower() in video_list:
filetype = 'video/'
# Determine start time based on timeframe
if timeframe == 'today': if timeframe == 'today':
start = now.replace(hour=0, minute=0, second=0, microsecond=0) start_dt = now.replace(hour=0, minute=0, second=0, microsecond=0)
elif timeframe == '7days': elif timeframe == '7days':
start = now - timedelta(days=7) start_dt = now - timedelta(days=7)
elif timeframe == '30days': elif timeframe == '30days':
start = now - timedelta(days=30) start_dt = now - timedelta(days=30)
elif timeframe == '365days': elif timeframe == '365days':
start = now - timedelta(days=365) start_dt = now - timedelta(days=365)
else: else:
start = now.replace(hour=0, minute=0, second=0, microsecond=0) start_dt = now.replace(hour=0, minute=0, second=0, microsecond=0)
conn = sqlite3.connect('access_log.db') # We'll compare the textual timestamp (ISO 8601).
cursor = conn.cursor() start_str = start_dt.isoformat()
# Raw file access counts for the table (top files) # Build the SQL filter
cursor.execute(''' if filetype == 'other':
SELECT full_path, COUNT(*) as access_count # Exclude audio, image, video
filetype_filter_sql = (
"AND mime NOT LIKE 'audio/%' "
"AND mime NOT LIKE 'image/%' "
"AND mime NOT LIKE 'video/%' "
)
params_for_filter = (start_str,)
else:
# Filter for mimes that start with the given type
filetype_filter_sql = "AND mime LIKE ?"
params_for_filter = (start_str, filetype + '%')
# 1. Top files by access count
query = f'''
SELECT rel_path, COUNT(*) as access_count
FROM file_access_log FROM file_access_log
WHERE timestamp >= ? WHERE timestamp >= ? {filetype_filter_sql}
GROUP BY full_path GROUP BY rel_path
ORDER BY access_count DESC ORDER BY access_count DESC
LIMIT 20 LIMIT 20
''', (start.isoformat(),)) '''
rows = cursor.fetchall() with log_db:
cursor = log_db.execute(query, params_for_filter)
rows = cursor.fetchall()
# Daily access trend for a line chart # 2. Daily access trend (line chart)
cursor.execute(''' # We'll group by day using substr(timestamp, 1, 10) -> YYYY-MM-DD
SELECT date(timestamp) as date, COUNT(*) as count query = f'''
SELECT substr(timestamp, 1, 10) AS date, COUNT(*) AS count
FROM file_access_log FROM file_access_log
WHERE timestamp >= ? WHERE timestamp >= ? {filetype_filter_sql}
GROUP BY date GROUP BY date
ORDER BY date ORDER BY date
''', (start.isoformat(),)) '''
daily_access_data = [dict(date=row[0], count=row[1]) for row in cursor.fetchall()] with log_db:
cursor = log_db.execute(query, params_for_filter)
daily_rows = cursor.fetchall()
daily_access_data = [
dict(date=r[0], count=r[1]) for r in daily_rows
]
# Top files for bar chart # 3. Timeframe-based aggregation
cursor.execute(''' # We'll group by hour if "today", by day if "7days"/"30days", by month if "365days".
SELECT full_path, COUNT(*) as access_count if timeframe == 'today':
FROM file_access_log # Hour: substr(timestamp, 12, 2) -> HH
WHERE timestamp >= ? query = f'''
GROUP BY full_path SELECT substr(timestamp, 12, 2) AS bucket, COUNT(*) AS count
ORDER BY access_count DESC FROM file_access_log
LIMIT 10 WHERE timestamp >= ? {filetype_filter_sql}
''', (start.isoformat(),)) GROUP BY bucket
top_files_data = [dict(full_path=row[0], access_count=row[1]) for row in cursor.fetchall()] ORDER BY bucket
'''
elif timeframe in ('7days', '30days'):
# Day: substr(timestamp, 1, 10) -> YYYY-MM-DD
query = f'''
SELECT substr(timestamp, 1, 10) AS bucket, COUNT(*) AS count
FROM file_access_log
WHERE timestamp >= ? {filetype_filter_sql}
GROUP BY bucket
ORDER BY bucket
'''
elif timeframe == '365days':
# Month: substr(timestamp, 1, 7) -> YYYY-MM
query = f'''
SELECT substr(timestamp, 1, 7) AS bucket, COUNT(*) AS count
FROM file_access_log
WHERE timestamp >= ? {filetype_filter_sql}
GROUP BY bucket
ORDER BY bucket
'''
else:
# Default: group by day
query = f'''
SELECT substr(timestamp, 1, 10) AS bucket, COUNT(*) AS count
FROM file_access_log
WHERE timestamp >= ? {filetype_filter_sql}
GROUP BY bucket
ORDER BY bucket
'''
with log_db:
cursor = log_db.execute(query, params_for_filter)
timeframe_data_rows = cursor.fetchall()
timeframe_data = [
dict(bucket=r[0], count=r[1]) for r in timeframe_data_rows
]
# User agent distribution (aggregate by device type) # 4. User agent distribution
cursor.execute(''' query = f'''
SELECT user_agent, COUNT(*) as count SELECT user_agent, COUNT(*) AS count
FROM file_access_log FROM file_access_log
WHERE timestamp >= ? WHERE timestamp >= ? {filetype_filter_sql}
GROUP BY user_agent GROUP BY user_agent
ORDER BY count DESC ORDER BY count DESC
''', (start.isoformat(),)) '''
raw_user_agents = [dict(user_agent=row[0], count=row[1]) for row in cursor.fetchall()] with log_db:
cursor = log_db.execute(query, params_for_filter)
raw_user_agents = cursor.fetchall()
device_counts = {} device_counts = {}
for entry in raw_user_agents: for (ua, cnt) in raw_user_agents:
device = get_device_type(entry['user_agent']) device = get_device_type(ua)
device_counts[device] = device_counts.get(device, 0) + entry['count'] device_counts[device] = device_counts.get(device, 0) + cnt
# Rename to user_agent_data for compatibility with the frontend user_agent_data = [
user_agent_data = [dict(device=device, count=count) for device, count in device_counts.items()] dict(device=d, count=c) for d, c in device_counts.items()
]
# Referrer distribution (shorten links) # 5. Parent folder distribution
cursor.execute(''' query = f'''
SELECT referrer, COUNT(*) as count SELECT rel_path, COUNT(*) AS count
FROM file_access_log FROM file_access_log
WHERE timestamp >= ? WHERE timestamp >= ? {filetype_filter_sql}
GROUP BY referrer GROUP BY rel_path
ORDER BY count DESC ORDER BY count DESC
LIMIT 10 '''
''', (start.isoformat(),)) folder_data_dict = {}
referrer_data = [] with log_db:
for row in cursor.fetchall(): cursor = log_db.execute(query, params_for_filter)
raw_ref = row[0] for (rp, c) in cursor.fetchall():
shortened = shorten_referrer(raw_ref) if raw_ref else "Direct/None" if '/' in rp:
referrer_data.append(dict(referrer=shortened, count=row[1])) parent_folder = rp.rsplit('/', 1)[0]
else:
parent_folder = "Root"
folder_data_dict[parent_folder] = folder_data_dict.get(parent_folder, 0) + c
folder_data = [dict(folder=f, count=cnt) for f, cnt in folder_data_dict.items()]
folder_data.sort(key=lambda x: x['count'], reverse=True)
folder_data = folder_data[:10]
# Aggregate IP addresses with counts # 6. Aggregate IP addresses with counts
cursor.execute(''' query = f'''
SELECT ip_address, COUNT(*) as count SELECT ip_address, COUNT(*) as count
FROM file_access_log FROM file_access_log
WHERE timestamp >= ? WHERE timestamp >= ? {filetype_filter_sql}
GROUP BY ip_address GROUP BY ip_address
ORDER BY count DESC ORDER BY count DESC
''', (start.isoformat(),)) '''
ip_rows = cursor.fetchall() with log_db:
cursor = log_db.execute(query, params_for_filter)
ip_rows = cursor.fetchall()
# Initialize GeoIP2 reader once for efficiency # 7. Summary stats
# total_accesses
query = f'''
SELECT COUNT(*)
FROM file_access_log
WHERE timestamp >= ? {filetype_filter_sql}
'''
with log_db:
cursor = log_db.execute(query, params_for_filter)
total_accesses = cursor.fetchone()[0]
# unique_files
query = f'''
SELECT COUNT(DISTINCT rel_path)
FROM file_access_log
WHERE timestamp >= ? {filetype_filter_sql}
'''
with log_db:
cursor = log_db.execute(query, params_for_filter)
unique_files = cursor.fetchone()[0]
# unique_user
query = f'''
SELECT COUNT(DISTINCT device_id)
FROM file_access_log
WHERE timestamp >= ? {filetype_filter_sql}
'''
with log_db:
cursor = log_db.execute(query, params_for_filter)
unique_user = cursor.fetchone()[0]
# 8. Process location data with GeoIP2
reader = geoip2.database.Reader('GeoLite2-City.mmdb') reader = geoip2.database.Reader('GeoLite2-City.mmdb')
location_data = {} location_data_dict = {}
for ip, count in ip_rows: for (ip_addr, cnt) in ip_rows:
country, city = lookup_location(ip, reader) country, city = lookup_location(ip_addr, reader)
key = (country, city) key = (country, city)
if key in location_data: location_data_dict[key] = location_data_dict.get(key, 0) + cnt
location_data[key] += count
else:
location_data[key] = count
reader.close() reader.close()
# Convert the dictionary to a list of dictionaries
location_data = [ location_data = [
dict(country=key[0], city=key[1], count=value) dict(country=k[0], city=k[1], count=v)
for key, value in location_data.items() for k, v in location_data_dict.items()
] ]
# Sort by count in descending order and take the top 20
location_data.sort(key=lambda x: x['count'], reverse=True) location_data.sort(key=lambda x: x['count'], reverse=True)
location_data = location_data[:20] location_data = location_data[:20]
# Summary stats using separate SQL queries # Convert the top-files rows to a list of dictionaries
cursor.execute('SELECT COUNT(*) FROM file_access_log WHERE timestamp >= ?', (start.isoformat(),)) rows = [dict(rel_path=r[0], access_count=r[1]) for r in rows]
total_accesses = cursor.fetchone()[0]
# Use a separate query to count unique files (distinct full_path values)
cursor.execute('SELECT COUNT(DISTINCT full_path) FROM file_access_log WHERE timestamp >= ?', (start.isoformat(),))
unique_files = cursor.fetchone()[0]
# Use a separate query to count unique IP addresses return render_template(
cursor.execute('SELECT COUNT(DISTINCT ip_address) FROM file_access_log WHERE timestamp >= ?', (start.isoformat(),)) "dashboard.html",
unique_ips = cursor.fetchone()[0] timeframe=timeframe,
rows=rows,
conn.close() daily_access_data=daily_access_data,
user_agent_data=user_agent_data,
return render_template("dashboard.html", folder_data=folder_data,
timeframe=timeframe, location_data=location_data,
rows=rows, total_accesses=total_accesses,
daily_access_data=daily_access_data, unique_files=unique_files,
top_files_data=top_files_data, unique_user=unique_user,
user_agent_data=user_agent_data, timeframe_data=timeframe_data
referrer_data=referrer_data, )
location_data=location_data,
total_accesses=total_accesses,
unique_files=unique_files,
unique_ips=unique_ips)

157
app.py
View File

@ -4,7 +4,6 @@ from PIL import Image
import io import io
from functools import wraps from functools import wraps
import mimetypes import mimetypes
import sqlite3
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
import diskcache import diskcache
import threading import threading
@ -15,12 +14,15 @@ import geoip2.database
from functools import lru_cache from functools import lru_cache
from urllib.parse import urlparse, unquote from urllib.parse import urlparse, unquote
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
import re
import auth import auth
import analytics as a import analytics as a
cache = diskcache.Cache('./filecache', size_limit= 48 * 1024**3) # 48 GB limit cache_audio = diskcache.Cache('./filecache_audio', size_limit= 48 * 1024**3) # 48 GB limit
cache_image = diskcache.Cache('./filecache_image', size_limit= 48 * 1024**3) # 48 GB limit
cache_video = diskcache.Cache('./filecache_video', size_limit= 48 * 1024**3) # 48 GB limit
cache_other = diskcache.Cache('./filecache_other', size_limit= 48 * 1024**3) # 48 GB limit
app = Flask(__name__) app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
@ -36,7 +38,17 @@ app.add_url_rule('/connections', view_func=a.connections)
app.add_url_rule('/mylinks', view_func=auth.mylinks) app.add_url_rule('/mylinks', view_func=auth.mylinks)
app.add_url_rule('/remove_secret', view_func=auth.remove_secret, methods=['POST']) app.add_url_rule('/remove_secret', view_func=auth.remove_secret, methods=['POST'])
socketio = SocketIO(app, async_mode='eventlet') # Grab the HOST_RULE environment variable
host_rule = os.getenv("HOST_RULE", "")
# Use a regex to extract domain names between backticks in patterns like Host(`something`)
pattern = r"Host\(`([^`]+)`\)"
allowed_domains = re.findall(pattern, host_rule)
socketio = SocketIO(
app,
async_mode='eventlet',
cors_allowed_origins=allowed_domains
)
background_thread_running = False background_thread_running = False
# Global variables to track the number of connected clients and the background thread # Global variables to track the number of connected clients and the background thread
@ -76,8 +88,8 @@ def list_directory_contents(directory, subpath):
transcription_exists = os.path.isdir(transcription_dir) transcription_exists = os.path.isdir(transcription_dir)
# Define allowed file extensions. # Define allowed file extensions.
allowed_music_exts = ('.mp3',) music_exts = ('.mp3',)
allowed_image_exts = ('.jpg', '.jpeg', '.png', '.gif', '.bmp') image_exts = ('.jpg', '.jpeg', '.png', '.gif', '.bmp')
try: try:
with os.scandir(directory) as it: with os.scandir(directory) as it:
@ -94,27 +106,31 @@ def list_directory_contents(directory, subpath):
directories.append({'name': entry.name, 'path': rel_path.replace(os.sep, '/')}) directories.append({'name': entry.name, 'path': rel_path.replace(os.sep, '/')})
elif entry.is_file(follow_symlinks=False): elif entry.is_file(follow_symlinks=False):
lower_name = entry.name.lower() lower_name = entry.name.lower()
if lower_name.endswith(allowed_music_exts) or lower_name.endswith(allowed_image_exts):
rel_path = os.path.join(subpath, entry.name) if subpath else entry.name # implement file type filtering here !!!
if lower_name.endswith(allowed_music_exts): #if lower_name.endswith(music_exts) or lower_name.endswith(image_exts):
file_type = 'music' rel_path = os.path.join(subpath, entry.name) if subpath else entry.name
else: if lower_name.endswith(music_exts):
file_type = 'image' file_type = 'music'
file_entry = {'name': entry.name, 'path': rel_path.replace(os.sep, '/'), 'file_type': file_type} elif lower_name.endswith(image_exts):
# Only check for transcription if it's a audio file. file_type = 'image'
if file_type == 'music' and transcription_exists: else:
base_name = os.path.splitext(entry.name)[0] file_type = 'other'
transcript_filename = base_name + '.md' file_entry = {'name': entry.name, 'path': rel_path.replace(os.sep, '/'), 'file_type': file_type}
transcript_path = os.path.join(transcription_dir, transcript_filename) # Only check for transcription if it's a audio file.
if os.path.isfile(transcript_path): if file_type == 'music' and transcription_exists:
file_entry['has_transcript'] = True base_name = os.path.splitext(entry.name)[0]
transcript_rel_path = os.path.join(subpath, "Transkription", transcript_filename) if subpath else os.path.join("Transkription", transcript_filename) transcript_filename = base_name + '.md'
file_entry['transcript_url'] = url_for('get_transcript', subpath=transcript_rel_path.replace(os.sep, '/')) transcript_path = os.path.join(transcription_dir, transcript_filename)
else: if os.path.isfile(transcript_path):
file_entry['has_transcript'] = False file_entry['has_transcript'] = True
transcript_rel_path = os.path.join(subpath, "Transkription", transcript_filename) if subpath else os.path.join("Transkription", transcript_filename)
file_entry['transcript_url'] = url_for('get_transcript', subpath=transcript_rel_path.replace(os.sep, '/'))
else: else:
file_entry['has_transcript'] = False file_entry['has_transcript'] = False
files.append(file_entry) else:
file_entry['has_transcript'] = False
files.append(file_entry)
except PermissionError: except PermissionError:
pass pass
@ -179,7 +195,7 @@ def api_browse(subpath):
@app.route("/media/<path:subpath>") @app.route("/media/<path:subpath>")
@auth.require_secret @auth.require_secret
def serve_file(subpath): def serve_file(subpath):
root, *relative_parts = subpath.split('/') root, *relative_parts = subpath.split('/')
base_path = session['folders'][root] base_path = session['folders'][root]
full_path = os.path.join(base_path, *relative_parts) full_path = os.path.join(base_path, *relative_parts)
@ -190,43 +206,48 @@ def serve_file(subpath):
mime, _ = mimetypes.guess_type(full_path) mime, _ = mimetypes.guess_type(full_path)
mime = mime or 'application/octet-stream' mime = mime or 'application/octet-stream'
range_header = request.headers.get('Range')
if mime and mime.startswith('image/'): ip_address = request.remote_addr
pass # do not log access to images user_agent = request.headers.get('User-Agent')
else:
# HEAD request are coming in to initiate server caching.
# only log initial hits and not the reload of further file parts
range_header = request.headers.get('Range')
# only request with starting from the beginning of the file will be tracked
# no range -> full file not just the first byte
if request.method == 'GET' and (not range_header or (range_header.startswith("bytes=0-") and range_header != "bytes=0-1")):
ip_address = request.remote_addr
user_agent = request.headers.get('User-Agent')
referrer = request.headers.get('Referer')
threading.Thread(
target=a.log_file_access,
args=(full_path, ip_address, user_agent, referrer)
).start()
# Check cache first (using diskcache) # Check cache first (using diskcache)
response = None response = None
# determine the cache to use based on the file type
if mime and mime.startswith('audio/'):
cache = cache_audio
elif mime and mime.startswith('image/'):
cache = cache_image
elif mime and mime.startswith('video/'):
cache = cache_video
else:
cache = cache_other
# Check if the file is already cached
cached = cache.get(subpath) cached = cache.get(subpath)
if cached: if cached:
cached_file_bytes, mime = cached cached_file_bytes, mime = cached
cached_file = io.BytesIO(cached_file_bytes) cached_file = io.BytesIO(cached_file_bytes)
filesize = len(cached_file.getbuffer())
response = send_file(cached_file, mimetype=mime) response = send_file(cached_file, mimetype=mime)
else: else:
if mime and mime.startswith('image/'): if mime and mime.startswith('image/'):
# Image processing branch (with caching) # Image processing branch (with caching)
try: try:
with Image.open(full_path) as img: with Image.open(full_path) as img:
img.thumbnail((1200, 1200)) img.thumbnail((1920, 1920))
img_bytes = io.BytesIO() if img.mode in ("RGBA", "P"):
img.save(img_bytes, format='PNG', quality=85) img = img.convert("RGB")
img_bytes = img_bytes.getvalue() output_format = 'JPEG'
cache.set(subpath, (img_bytes, mime)) output_mime = 'image/jpeg'
response = send_file(io.BytesIO(img_bytes), mimetype=mime, conditional=True) save_kwargs = {'quality': 85}
img_bytes_io = io.BytesIO()
filesize = len(img_bytes_io.getbuffer())
img.save(img_bytes_io, format=output_format, **save_kwargs)
thumb_bytes = img_bytes_io.getvalue()
cache.set(subpath, (thumb_bytes, output_mime))
response = send_file(io.BytesIO(thumb_bytes), mimetype=output_mime, conditional=True)
except Exception as e: except Exception as e:
app.logger.error(f"Image processing failed for {subpath}: {e}") app.logger.error(f"Image processing failed for {subpath}: {e}")
abort(500) abort(500)
@ -236,13 +257,29 @@ def serve_file(subpath):
with open(full_path, 'rb') as f: with open(full_path, 'rb') as f:
file_bytes = f.read() file_bytes = f.read()
cache.set(subpath, (file_bytes, mime)) cache.set(subpath, (file_bytes, mime))
response = send_file(io.BytesIO(file_bytes), mimetype=mime, conditional=True) file_bytes_io = io.BytesIO(file_bytes)
filesize = len(file_bytes_io.getbuffer())
response = send_file(file_bytes_io, mimetype=mime, conditional=True)
except Exception as e: except Exception as e:
app.logger.error(f"Failed to read file {subpath}: {e}") app.logger.error(f"Failed to read file {subpath}: {e}")
abort(500) abort(500)
# Set Cache-Control header (browser caching for 1 day) # Set Cache-Control header (browser caching for 1 day)
response.headers['Cache-Control'] = 'public, max-age=86400' response.headers['Cache-Control'] = 'public, max-age=86400'
if mime and mime.startswith('audio/mpeg'): # special rules for mp3 files
# HEAD request are coming in to initiate server caching. Ignore HEAD Request. Only log GET request.
# log access if there is no range header. # log access if range request starts from 0 but is larger then only from 0 to 1 (bytes=0-1)
if request.method == 'GET' and (not range_header or (range_header.startswith("bytes=0-") and range_header != "bytes=0-1")):
logging = True
else:
logging = False
else:
logging = True
if logging:
a.log_file_access(subpath, filesize, mime, ip_address, user_agent, session['device_id'], bool(cached))
return response return response
@ -327,8 +364,11 @@ def query_recent_connections():
{ {
'timestamp': datetime.strptime(row[0], '%Y-%m-%dT%H:%M:%S.%f').strftime('%d.%m.%Y %H:%M:%S'), 'timestamp': datetime.strptime(row[0], '%Y-%m-%dT%H:%M:%S.%f').strftime('%d.%m.%Y %H:%M:%S'),
'full_path': row[1], 'full_path': row[1],
'ip_address': row[2], 'filesize' : row[2],
'user_agent': row[3] 'mime_typ' : row[3],
'ip_address': row[4],
'user_agent': row[5],
'cached': row[7]
} }
for row in rows for row in rows
] ]
@ -365,8 +405,11 @@ def handle_request_initial_data():
{ {
'timestamp': datetime.strptime(row[0], '%Y-%m-%dT%H:%M:%S.%f').strftime('%d.%m.%Y %H:%M:%S'), 'timestamp': datetime.strptime(row[0], '%Y-%m-%dT%H:%M:%S.%f').strftime('%d.%m.%Y %H:%M:%S'),
'full_path': row[1], 'full_path': row[1],
'ip_address': row[2], 'filesize' : row[2],
'user_agent': row[3] 'mime_typ' : row[3],
'ip_address': row[4],
'user_agent': row[5],
'cached': row[7]
} }
for row in rows for row in rows
] ]
@ -377,7 +420,9 @@ def handle_request_initial_data():
@app.route('/<path:path>') @app.route('/<path:path>')
@auth.require_secret @auth.require_secret
def index(path): def index(path):
return render_template("app.html") title_short = os.environ.get('TITLE_SHORT', 'Default Title')
title_long = os.environ.get('TITLE_LONG', 'Default Title')
return render_template("app.html", title_short=title_short, title_long=title_long)
if __name__ == '__main__': if __name__ == '__main__':
socketio.run(app, debug=True, host='0.0.0.0') socketio.run(app, debug=True, host='0.0.0.0')

View File

@ -2,6 +2,7 @@ from flask import Flask, render_template, request, redirect, url_for, session
from functools import wraps from functools import wraps
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
import io import io
import os
import json import json
import qrcode import qrcode
import base64 import base64
@ -72,6 +73,10 @@ def require_secret(f):
# 6) If we have folders, proceed; otherwise show index # 6) If we have folders, proceed; otherwise show index
if session['folders']: if session['folders']:
# assume since visitor has a valid secret, they are ok with annonymous tracking
# this is required to track the devices connecting over the same ip address
if 'device_id' not in session:
session['device_id'] = os.urandom(32).hex()
return f(*args, **kwargs) return f(*args, **kwargs)
else: else:
return render_template('index.html') return render_template('index.html')

View File

@ -16,15 +16,13 @@ SERVER1_MOUNT_POINTS=(
"/mnt/Gottesdienste Speyer" "/mnt/Gottesdienste Speyer"
"/mnt/Besondere Gottesdienste" "/mnt/Besondere Gottesdienste"
"/mnt/Liedersammlung" "/mnt/Liedersammlung"
"/mnt/Jungschar" "/mnt/app_share"
"/mnt/Jugend"
) )
SERVER1_NFS_SHARES=( SERVER1_NFS_SHARES=(
"/volume1/Aufnahme-stereo/010 Gottesdienste ARCHIV" "/volume1/Aufnahme-stereo/010 Gottesdienste ARCHIV"
"/volume1/Aufnahme-stereo/013 Besondere Gottesdienste" "/volume1/Aufnahme-stereo/013 Besondere Gottesdienste"
"/volume1/Aufnahme-stereo/014 Liedersammlung" "/volume1/Aufnahme-stereo/014 Liedersammlung"
"/volume1/app.share/Jungschar" "/volume1/app_share"
"/volume1/app.share/Jugend"
) )
# Server 2 Configuration # Server 2 Configuration
@ -124,7 +122,7 @@ for server in "${SERVERS[@]}"; do
# Mount the NFS share if it's not already mounted. # Mount the NFS share if it's not already mounted.
if ! is_nfs_mounted "${MOUNT_POINT}"; then if ! is_nfs_mounted "${MOUNT_POINT}"; then
echo "[INFO] NFS share is not mounted at ${MOUNT_POINT}. Attempting to mount..." echo "[INFO] NFS share is not mounted at ${MOUNT_POINT}. Attempting to mount..."
sudo mount -t nfs -o port="${LOCAL_PORT}",nolock,soft 127.0.0.1:"${NFS_SHARE}" "${MOUNT_POINT}" sudo mount -t nfs -o ro,port="${LOCAL_PORT}",nolock,soft,timeo=5,retrans=3 127.0.0.1:"${NFS_SHARE}" "${MOUNT_POINT}"
if is_nfs_mounted "${MOUNT_POINT}"; then if is_nfs_mounted "${MOUNT_POINT}"; then
echo "[SUCCESS] NFS share mounted successfully at ${MOUNT_POINT}." echo "[SUCCESS] NFS share mounted successfully at ${MOUNT_POINT}."
else else

View File

@ -1,7 +1,7 @@
services: services:
flask-app: flask-app:
image: python:3.11-slim image: python:3.11-slim
container_name: bethaus-app container_name: ${CONTAINER_NAME}
restart: always restart: always
working_dir: /app working_dir: /app
volumes: volumes:
@ -17,31 +17,34 @@ services:
environment: environment:
- FLASK_APP=app.py - FLASK_APP=app.py
- FLASK_ENV=production - FLASK_ENV=production
- TITLE_SHORT=${TITLE_SHORT}
- TITLE_LONG=${TITLE_LONG}
networks: networks:
- traefik - traefik
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# HTTP router (port 80), redirecting to HTTPS # HTTP router (port 80), redirecting to HTTPS
- "traefik.http.routers.bethaus-app.rule=Host(`app.bethaus-speyer.de`)" - "traefik.http.routers.${CONTAINER_NAME}.rule=${HOST_RULE}"
- "traefik.http.routers.bethaus-app.entrypoints=web" - "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web"
- "traefik.http.routers.bethaus-app.middlewares=redirect-to-https" - "traefik.http.routers.${CONTAINER_NAME}.middlewares=redirect-to-https"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
# HTTPS router (TLS via Let's Encrypt) # HTTPS router (TLS via Let's Encrypt)
- "traefik.http.routers.bethaus-app-secure.rule=Host(`app.bethaus-speyer.de`)" - "traefik.http.routers.${CONTAINER_NAME}-secure.rule=${HOST_RULE}"
- "traefik.http.routers.bethaus-app-secure.entrypoints=websecure" - "traefik.http.routers.${CONTAINER_NAME}-secure.entrypoints=websecure"
- "traefik.http.routers.bethaus-app-secure.tls=true" - "traefik.http.routers.${CONTAINER_NAME}-secure.tls=true"
- "traefik.http.routers.bethaus-app-secure.tls.certresolver=myresolver" - "traefik.http.routers.${CONTAINER_NAME}-secure.tls.certresolver=myresolver"
# Internal port # Internal port
- "traefik.http.services.bethaus-app.loadbalancer.server.port=5000" - "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=5000"
# Production-ready Gunicorn command with eventlet # Production-ready Gunicorn command with eventlet
command: > command: >
sh -c "pip install -r requirements.txt && sh -c "pip install -r requirements.txt &&
gunicorn --worker-class eventlet -w 1 -b 0.0.0.0:5000 app:app" gunicorn --worker-class eventlet -w 1 -b 0.0.0.0:5000 app:app"
networks: networks:
traefik: traefik:
external: true external: true

View File

@ -102,7 +102,7 @@ div.directory-item a, li.directory-item a, li.file-item a {
} }
.directories-grid .directory-item { .directories-grid .directory-item {
background-color: #fff; background-color: #fff;
padding: 15px 10px; padding: 15px;
border-radius: 5px; border-radius: 5px;
text-align: center; text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); box-shadow: 0 1px 3px rgba(0,0,0,0.1);
@ -114,7 +114,7 @@ div.directory-item a, li.directory-item a, li.file-item a {
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
align-items: center; align-items: center;
margin: 10px 0; margin: 10px 0;
padding: 15px 10px; padding: 15px;
background-color: #fff; background-color: #fff;
border-radius: 5px; border-radius: 5px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); box-shadow: 0 1px 3px rgba(0,0,0,0.1);

View File

@ -1,6 +1,7 @@
// Define global variables to track music files and the current index. // Define global variables to track music files and the current index.
let currentMusicFiles = []; // Array of objects with at least { path, index } let currentMusicFiles = []; // Array of objects with at least { path, index }
let currentMusicIndex = -1; // Index of the current music file let currentMusicIndex = -1; // Index of the current music file
let currentTrackPath = "";
// Helper function: decode each segment then re-encode to avoid double encoding. // Helper function: decode each segment then re-encode to avoid double encoding.
function encodeSubpath(subpath) { function encodeSubpath(subpath) {
@ -15,6 +16,19 @@ function encodeSubpath(subpath) {
let currentGalleryImages = []; let currentGalleryImages = [];
function paintFile() {
// Highlight the currently playing file
if (currentTrackPath) {
const currentMusicFile = currentMusicFiles.find(file => file.path === currentTrackPath);
if (currentMusicFile) {
const currentMusicFileElement = document.querySelector(`.play-file[data-url="${currentMusicFile.path}"]`);
if (currentMusicFileElement) {
currentMusicFileElement.closest('.file-item').classList.add('currently-playing');
}
}
}
}
function renderContent(data) { function renderContent(data) {
// Render breadcrumbs, directories (grid view when appropriate), and files. // Render breadcrumbs, directories (grid view when appropriate), and files.
@ -32,8 +46,8 @@ function renderContent(data) {
let contentHTML = ''; let contentHTML = '';
if (data.directories.length > 0) { if (data.directories.length > 0) {
contentHTML += '<ul>'; contentHTML += '<ul>';
// Check if every directory name is short (≤15 characters) // Check if every directory name is short (≤15 characters) and no files are present
const areAllShort = data.directories.every(dir => dir.name.length <= 15); const areAllShort = data.directories.every(dir => dir.name.length <= 15) && data.files.length === 0;
if (areAllShort) { if (areAllShort) {
contentHTML += '<div class="directories-grid">'; contentHTML += '<div class="directories-grid">';
data.directories.forEach(dir => { data.directories.forEach(dir => {
@ -62,9 +76,7 @@ function renderContent(data) {
symbol = '🖼️'; symbol = '🖼️';
} }
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : ''; const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
// preserve currently-playing class during reloads contentHTML += `<li class="file-item">
const isCurrentlyPlaying = file.file_type === 'music' && currentMusicIndex === currentMusicFiles.length - 1 ? ' currently-playing' : '';
contentHTML += `<li class="file-item ${isCurrentlyPlaying}">
<a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`; <a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`;
if (file.has_transcript) { if (file.has_transcript) {
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" title="Show Transcript">&#128196;</a>`; contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" title="Show Transcript">&#128196;</a>`;
@ -132,6 +144,7 @@ function loadDirectory(subpath) {
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
renderContent(data); renderContent(data);
paintFile();
return data; // return data for further chaining return data; // return data for further chaining
}) })
.catch(error => { .catch(error => {
@ -250,6 +263,7 @@ document.querySelectorAll('.play-file').forEach(link => {
audioPlayer.src = mediaUrl; audioPlayer.src = mediaUrl;
audioPlayer.load(); audioPlayer.load();
await audioPlayer.play(); await audioPlayer.play();
currentTrackPath = relUrl;
playerButton.innerHTML = pauseIcon; playerButton.innerHTML = pauseIcon;
// Process file path for display. // Process file path for display.
@ -286,6 +300,9 @@ document.querySelectorAll('.play-file').forEach(link => {
} else if (fileType === 'image') { } else if (fileType === 'image') {
// Open the gallery modal for image files. // Open the gallery modal for image files.
openGalleryModal(relUrl); openGalleryModal(relUrl);
} else {
// serve like a download
window.location.href = `/media/${relUrl}`;
} }
}); });
}); });
@ -412,8 +429,6 @@ if ('mediaSession' in navigator) {
} }
document.getElementById('globalAudio').addEventListener('ended', () => { document.getElementById('globalAudio').addEventListener('ended', () => {
// Save the current track's path (if any)
const currentTrackPath = currentMusicFiles[currentMusicIndex] ? currentMusicFiles[currentMusicIndex].path : null;
reloadDirectory().then(() => { reloadDirectory().then(() => {
@ -436,7 +451,7 @@ document.getElementById('globalAudio').addEventListener('ended', () => {
}); });
}); });
document.addEventListener("DOMContentLoaded", function() { // document.addEventListener("DOMContentLoaded", function() {
// Automatically reload every 5 minutes (300,000 milliseconds) // // Automatically reload every 5 minutes (300,000 milliseconds)
setInterval(reloadDirectory, 300000); // setInterval(reloadDirectory, 300000);
}); // });

View File

@ -8,7 +8,7 @@
<meta property="og:image" content="https://app.bethaus-speyer.de/static/icons/logo-200x200.png" /> <meta property="og:image" content="https://app.bethaus-speyer.de/static/icons/logo-200x200.png" />
<meta property="og:url" content="https://app.bethaus-speyer.de" /> <meta property="og:url" content="https://app.bethaus-speyer.de" />
<title>Gottesdienste</title> <title>{{ title_short }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft."> <meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft.">
<meta name="author" content="Bethaus Speyer"> <meta name="author" content="Bethaus Speyer">
@ -38,7 +38,7 @@
<a href="#"> <a href="#">
<img src="/static/logoW.png" alt="Logo" class="logo"> <img src="/static/logoW.png" alt="Logo" class="logo">
</a> </a>
<h1>Gottesdienste Speyer und Schwegenheim</h1> <h1>{{ title_long }}</h1>
</header> </header>
<div class="wrapper"> <div class="wrapper">
<div class="container"> <div class="container">

View File

@ -41,6 +41,9 @@
<th>IP Address</th> <th>IP Address</th>
<th>User Agent</th> <th>User Agent</th>
<th>File Path</th> <th>File Path</th>
<td>File Size</td>
<td>MIME-Typ</td>
<td>Cached</td>
</tr> </tr>
</thead> </thead>
<tbody id="connectionsTableBody"> <tbody id="connectionsTableBody">
@ -71,6 +74,9 @@
<td>${record.ip_address}</td> <td>${record.ip_address}</td>
<td>${record.user_agent}</td> <td>${record.user_agent}</td>
<td>${record.full_path}</td> <td>${record.full_path}</td>
<td>${record.filesize}</td>
<td>${record.mime_typ}</td>
<td>${record.cached}</td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); });

View File

@ -58,7 +58,7 @@
<div class="card text-white bg-warning"> <div class="card text-white bg-warning">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">eindeutige Nutzer</h5> <h5 class="card-title">eindeutige Nutzer</h5>
<p class="card-text">{{ unique_ips }}</p> <p class="card-text">{{ unique_user }}</p>
</div> </div>
</div> </div>
</div> </div>
@ -75,12 +75,12 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Top Files Accessed Chart --> <!-- Timeframe Breakdown Chart (Bar Chart) -->
<div class="col-md-6 mb-4"> <div class="col-md-6 mb-4">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Häufig geladene Dateien</h5> <h5 class="card-title">Downloads nach Zeit</h5>
<canvas id="topFilesChart"></canvas> <canvas id="timeframeChart"></canvas>
</div> </div>
</div> </div>
</div> </div>
@ -93,12 +93,12 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Referrer Distribution Chart --> <!-- Folder Distribution Chart -->
<div class="col-md-6 mb-4"> <div class="col-md-6 mb-4">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Verteilung auf Ordner</h5> <h5 class="card-title">Verteilung auf Ordner</h5>
<canvas id="referrerChart"></canvas> <canvas id="folderChart"></canvas>
</div> </div>
</div> </div>
</div> </div>
@ -151,8 +151,8 @@
<tbody> <tbody>
{% for row in rows %} {% for row in rows %}
<tr> <tr>
<td>{{ row[0] }}</td> <td>{{ row.rel_path }}</td>
<td>{{ row[1] }}</td> <td>{{ row.access_count }}</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
@ -171,11 +171,41 @@
<script> <script>
// Data passed from the backend as JSON // Data passed from the backend as JSON
const dailyAccessData = {{ daily_access_data|tojson }}; const dailyAccessData = {{ daily_access_data|tojson }};
const topFilesData = {{ top_files_data|tojson }}; // Replace topFilesData usage with timeframeData for this chart
// Note: user_agent_data now contains 'device' and 'count' const timeframeData = {{ timeframe_data|tojson }};
const userAgentData = {{ user_agent_data|tojson }}; const userAgentData = {{ user_agent_data|tojson }};
const referrerData = {{ referrer_data|tojson }}; const folderData = {{ folder_data|tojson }};
// shift the labels to local time zone
const timeframe = "{{ timeframe }}"; // e.g., 'today', '7days', '30days', or '365days'
const shiftedLabels = timeframeData.map(item => {
if (timeframe === 'today') {
// For "today", the bucket is an hour in UTC (e.g., "14")
const utcHour = parseInt(item.bucket, 10);
const now = new Date();
// Create Date objects for the start and end of the hour in UTC
const utcStart = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), utcHour));
const utcEnd = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), utcHour + 1));
// Convert to local time strings, e.g., "16:00"
const localStart = utcStart.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const localEnd = utcEnd.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return `${localStart} - ${localEnd}`;
} else if (timeframe === '7days' || timeframe === '30days') {
// For these timeframes, the bucket is a date in the format "YYYY-MM-DD"
const utcDate = new Date(item.bucket + 'T00:00:00Z');
return utcDate.toLocaleDateString(); // Adjust formatting as needed
} else if (timeframe === '365days') {
// For this timeframe, the bucket is a month in the format "YYYY-MM"
const [year, month] = item.bucket.split('-');
const dateObj = new Date(year, month - 1, 1);
// Format to something like "Mar 2025"
return dateObj.toLocaleString([], { month: 'short', year: 'numeric' });
} else {
// Fallback: use the bucket value as-is
return item.bucket;
}
});
// Access Trend Chart - Line Chart // Access Trend Chart - Line Chart
const ctxTrend = document.getElementById('accessTrendChart').getContext('2d'); const ctxTrend = document.getElementById('accessTrendChart').getContext('2d');
new Chart(ctxTrend, { new Chart(ctxTrend, {
@ -199,25 +229,24 @@
} }
}); });
// Top Files Chart - Horizontal Bar Chart // Timeframe Breakdown Chart - Bar Chart (for "today" timeframe)
const ctxTopFiles = document.getElementById('topFilesChart').getContext('2d'); const ctxTimeframe = document.getElementById('timeframeChart').getContext('2d');
new Chart(ctxTopFiles, { new Chart(ctxTimeframe, {
type: 'bar', type: 'bar',
data: { data: {
labels: topFilesData.map(item => item.full_path), labels: shiftedLabels,
datasets: [{ datasets: [{
label: 'Download Count', label: 'Download Count',
data: topFilesData.map(item => item.access_count), data: timeframeData.map(item => item.count),
borderWidth: 1 borderWidth: 1
}] }]
}, },
options: { options: {
indexAxis: 'y',
responsive: true, responsive: true,
plugins: { legend: { display: false } }, plugins: { legend: { display: false } },
scales: { scales: {
x: { title: { display: true, text: 'Download Count' } }, x: { title: { display: true, text: 'Local Time Range' } },
y: { title: { display: true, text: '' } } y: { title: { display: true, text: 'Download Count' } }
} }
} }
}); });
@ -235,14 +264,14 @@
options: { responsive: true } options: { responsive: true }
}); });
// Referrer Distribution - Pie Chart (with shortened referrers) // folder Distribution - Pie Chart (with shortened folders)
const ctxReferrer = document.getElementById('referrerChart').getContext('2d'); const ctxfolder = document.getElementById('folderChart').getContext('2d');
new Chart(ctxReferrer, { new Chart(ctxfolder, {
type: 'pie', type: 'pie',
data: { data: {
labels: referrerData.map(item => item.referrer), labels: folderData.map(item => item.folder),
datasets: [{ datasets: [{
data: referrerData.map(item => item.count) data: folderData.map(item => item.count)
}] }]
}, },
options: { responsive: true } options: { responsive: true }

42
transforme_sql.py Normal file
View File

@ -0,0 +1,42 @@
import sqlite3
def transform_database(db_path):
# Connect to the SQLite database
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 1. Create the new table with the desired schema.
cursor.execute('''
CREATE TABLE IF NOT EXISTS file_access_log_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT,
rel_path TEXT,
ip_address TEXT,
user_agent TEXT,
device_id TEXT
)
''')
# 2. Copy data from the old table, applying the following transformations:
# - Rename full_path to rel_path and remove '/mnt/' from its entries.
# - Omit the referrer column.
# - Copy ip_address into the new device_id column.
cursor.execute('''
INSERT INTO file_access_log_new (id, timestamp, rel_path, ip_address, user_agent, device_id)
SELECT id, timestamp, REPLACE(full_path, '/mnt/', ''), ip_address, user_agent, ip_address
FROM file_access_log
''')
# 3. Drop the old table.
cursor.execute('DROP TABLE file_access_log')
# 4. Rename the new table to use the original table's name.
cursor.execute('ALTER TABLE file_access_log_new RENAME TO file_access_log')
# Commit the changes and close the connection.
conn.commit()
conn.close()
if __name__ == "__main__":
# Replace 'your_database.db' with the path to your SQLite database file.
transform_database("access_log.db")