From cf0f5b0d35c568c848b0bf13611423459fd7b5d9 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 29 Jul 2025 14:30:46 -0500 Subject: [PATCH 1/4] init s3_commands for asset server --- .gitignore | 5 +- requirements.txt | 2 + server.py | 347 +++++++++++++++++++++++------------------------ 3 files changed, 172 insertions(+), 182 deletions(-) diff --git a/.gitignore b/.gitignore index 73cc46a..1abf7b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ *.pyc .idea -venv \ No newline at end of file +venv +attachments/* +*.zip +.DS_Store \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5e0c9a9..6605636 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ ExifRead==2.3.1 Paste==3.4.4 sh==1.14.0 Bottle>=0.12.23,<0.13 +boto3>=1.26.0,<2.0 +boto3-stubs>=1.26.0,<2.0 \ No newline at end of file diff --git a/server.py b/server.py index b906f66..022549f 100644 --- a/server.py +++ b/server.py @@ -2,20 +2,27 @@ from functools import wraps from glob import glob from mimetypes import guess_type -from os import path, mkdir, remove +from os import path, mkdir from urllib.parse import quote -from urllib.request import pathname2url +from io import BytesIO import exifread import hmac import json import time from sh import convert +import boto3 +from botocore.exceptions import ClientError import settings from bottle import ( - Response, request, response, static_file, template, abort, - HTTPResponse, route) + Response, request, response, abort, + route, HTTPResponse, static_file, template) + +# Initialize S3 client +s3 = boto3.client('s3') +BUCKET = settings.S3_BUCKET +PREFIX = settings.S3_PREFIX.rstrip('/') def log(msg): @@ -24,29 +31,38 @@ def log(msg): def get_rel_path(coll, thumb_p): - """Return originals or thumbnails subdirectory of the main - attachments directory for the given collection. - """ + """Return the collection subdirectory for originals or thumbnails.""" type_dir = settings.THUMB_DIR if thumb_p else settings.ORIG_DIR - if settings.COLLECTION_DIRS is None: return type_dir - try: coll_dir = settings.COLLECTION_DIRS[coll] except KeyError: - abort(404, "Unknown collection: %r" % coll) - + abort(404, f"Unknown collection: {coll!r}") return path.join(coll_dir, type_dir) +def make_s3_key(relpath, filename=''): + """Build a POSIX-style S3 key under optional PREFIX.""" + key = relpath.replace(path.sep, '/') + if filename: + key = f"{key}/{filename}" if key else filename + if PREFIX: + key = f"{PREFIX}/{key}" if key else PREFIX + return key.lstrip('/') + + def generate_token(timestamp, filename): """Generate the auth token for the given filename and timestamp. This is for comparing to the client submited token. """ timestamp = str(timestamp) - mac = hmac.new(settings.KEY.encode(), timestamp.encode() + filename.encode(), 'md5') - return ':'.join((mac.hexdigest(), timestamp)) + mac = hmac.new( + settings.KEY.encode(), + timestamp.encode() + filename.encode(), + 'md5' + ) + return f"{mac.hexdigest()}:{timestamp}" class TokenException(Exception): @@ -68,22 +84,17 @@ def validate_token(token_in, filename): """ if settings.KEY is None: return - if token_in == '': - raise TokenException("Auth token is missing.") - if ':' not in token_in: - raise TokenException("Auth token is malformed.") - - mac_in, timestr = token_in.split(':') + if not token_in or ':' not in token_in: + raise TokenException("Auth token is missing or malformed.") + mac_in, timestr = token_in.split(':', 1) try: timestamp = int(timestr) except ValueError: raise TokenException("Auth token is malformed.") - if settings.TIME_TOLERANCE is not None: - current_time = get_timestamp() - if not abs(current_time - timestamp) < settings.TIME_TOLERANCE: - raise TokenException("Auth token timestamp out of range: %s vs %s" % (timestamp, current_time)) - + now = get_timestamp() + if abs(now - timestamp) >= settings.TIME_TOLERANCE: + raise TokenException("Auth token timestamp out of range.") if token_in != generate_token(timestamp, filename): raise TokenException("Auth token is invalid.") @@ -102,21 +113,18 @@ def require_token(filename_param, always=False): """ def decorator(func): - @include_timestamp @wraps(func) def wrapper(*args, **kwargs): - if always or request.method not in ('GET', 'HEAD') or settings.REQUIRE_KEY_FOR_GET: - params = request.forms if request.method == 'POST' else request.query + if always or request.method not in ('GET','HEAD') or settings.REQUIRE_KEY_FOR_GET: + params = request.forms if request.method=='POST' else request.query try: validate_token(params.token, params.get(filename_param)) except TokenException as e: response.content_type = 'text/plain; charset=utf-8' response.status = 403 - return e + return str(e) return func(*args, **kwargs) - return wrapper - return decorator @@ -128,10 +136,9 @@ def include_timestamp(func): @wraps(func) def wrapper(*args, **kwargs): result = func(*args, **kwargs) - (result if isinstance(result, Response) else response) \ - .set_header('X-Timestamp', str(get_timestamp())) + target = result if isinstance(result, Response) else response + target.set_header('X-Timestamp', str(get_timestamp())) return result - return wrapper @@ -143,70 +150,85 @@ def wrapper(*args, **kwargs): try: result = func(*args, **kwargs) except HTTPResponse as r: - r.set_header('Access-Control-Allow-Origin', '*') + r.set_header('Access-Control-Allow-Origin','*') raise - - (result if isinstance(result, Response) else response) \ - .set_header('Access-Control-Allow-Origin', '*') + target = result if isinstance(result, Response) else response + target.set_header('Access-Control-Allow-Origin','*') return result - return wrapper -def resolve_file(): - """Inspect the request object to determine the file being requested. - If the request is for a thumbnail and it has not been generated, do - so before returning. - - Returns the relative path to the requested file in the base - attachments directory. - """ - thumb_p = (request.query['type'] == "T") - storename = request.query.filename - relpath = get_rel_path(request.query.coll, thumb_p) +def resolve_s3_key(): + thumb_p = (request.query.get('type')=='T') + coll = request.query.coll + name = request.query.filename + rel = get_rel_path(coll, thumb_p) if not thumb_p: - return path.join(relpath, storename) - - basepath = path.join(settings.BASE_DIR, relpath) + return make_s3_key(rel, name) + # thumbnail: check cache, else generate scale = int(request.query.scale) - mimetype, encoding = guess_type(storename) - - assert mimetype in settings.CAN_THUMBNAIL - - root, ext = path.splitext(storename) - - if mimetype in ('application/pdf', 'image/tiff'): - # use PNG for PDF thumbnails + root, ext = path.splitext(name) + if ext.lower() in ('.pdf','.tiff','.tif'): ext = '.png' + thumb_name = f"{root}_{scale}{ext}" + thumb_key = make_s3_key(rel, thumb_name) - scaled_name = "%s_%d%s" % (root, scale, ext) - scaled_pathname = path.join(basepath, scaled_name) - - if path.exists(scaled_pathname): - log("Serving previously scaled thumbnail") - return path.join(relpath, scaled_name) - - if not path.exists(basepath): - mkdir(basepath) - - orig_dir = path.join(settings.BASE_DIR, get_rel_path(request.query.coll, thumb_p=False)) - orig_path = path.join(orig_dir, storename) - - if not path.exists(orig_path): - abort(404, "Missing original: %s" % orig_path) - - input_spec = orig_path - convert_args = ('-resize', "%dx%d>" % (scale, scale)) - if mimetype == 'application/pdf': - input_spec += '[0]' # only thumbnail first page of PDF - convert_args += ('-background', 'white', '-flatten') # add white background to PDFs - - log("Scaling thumbnail to %d" % scale) - convert(input_spec, *(convert_args + (scaled_pathname,))) + # cached? + try: + s3.head_object(Bucket=BUCKET, Key=thumb_key) + log(f"Cached thumbnail: {thumb_key}") + return thumb_key + except ClientError as e: + if e.response['Error']['Code'] not in ('404','NoSuchKey'): + raise - return path.join(relpath, scaled_name) + # fetch original + orig_key = make_s3_key(get_rel_path(coll, False), name) + try: + obj = s3.get_object(Bucket=BUCKET, Key=orig_key) + except ClientError as e: + code = e.response['Error']['Code'] + if code in ('404','NoSuchKey'): + abort(404, f"Missing original: {orig_key}") + raise + data = obj['Body'].read() + + # write temp files + from tempfile import gettempdir + tmp = gettempdir() + local_in = path.join(tmp, name) + local_out = path.join(tmp, thumb_name) + with open(local_in,'wb') as f: + f.write(data) + + args = ['-resize', f"{scale}x{scale}>"] + if obj['ContentType']=='application/pdf': + args += ['-background','white','-flatten'] + local_in += '[0]' + convert(local_in, *args, local_out) + + # upload thumbnail + ctype, _ = guess_type(local_out) + with open(local_out,'rb') as f: + s3.put_object( + Bucket=BUCKET, + Key=thumb_key, + Body=f, + ContentType=ctype or 'application/octet-stream' + ) + return thumb_key + + +def stream_s3_object(key): + try: + obj = s3.get_object(Bucket=BUCKET, Key=key) + except ClientError as e: + if e.response['Error']['Code'] in ('404','NoSuchKey'): + abort(404, f"Missing object: {key}") + raise + return obj['Body'].read(), obj['ContentType'] @route('/static/') @@ -223,27 +245,29 @@ def getfileref(): """Returns a URL to the static file indicated by the query parameters.""" if not settings.ALLOW_STATIC_FILE_ACCESS: abort(404) - response.content_type = 'text/plain; charset=utf-8' - return "http://%s:%d/static/%s" % (settings.HOST, settings.PORT, - pathname2url(resolve_file())) + response.content_type='text/plain; charset=utf-8' + key = resolve_s3_key() + return f"http://{settings.HOST}:{settings.PORT}/static/{quote(key)}" @route('/fileget') @require_token('filename') def fileget(): - """Returns the file data of the file indicated by the query parameters.""" - r = static_file(resolve_file(), root=settings.BASE_DIR) - download_name = request.query.downloadname - if download_name: - download_name = quote(path.basename(download_name).encode('ascii', 'replace')) - r.set_header('Content-Disposition', "inline; filename*=utf-8''%s" % download_name) + key = resolve_s3_key() + data, ctype = stream_s3_object(key) + r = Response(body=data) + r.content_type = ctype + dl = request.query.get('downloadname') + if dl: + dl = quote(path.basename(dl).encode('ascii','replace')) + r.set_header('Content-Disposition', f"inline; filename*=utf-8''{dl}") return r @route('/fileupload', method='OPTIONS') @allow_cross_origin def fileupload_options(): - response.content_type = "text/plain; charset=utf-8" + response.content_type='text/plain; charset=utf-8' return '' @@ -251,114 +275,75 @@ def fileupload_options(): @allow_cross_origin @require_token('store') def fileupload(): - """Accept original file uploads and store them in the proper - attchment subdirectory. - """ - thumb_p = (request.forms['type'] == "T") - storename = request.forms.store - basepath = path.join(settings.BASE_DIR, get_rel_path(request.forms.coll, thumb_p)) - pathname = path.join(basepath, storename) - - if thumb_p: - return 'Ignoring thumbnail upload!' - - if not path.exists(basepath): - mkdir(basepath) - + thumb_p = (request.forms['type']=='T') + coll = request.forms.coll + name = request.forms.store + key = make_s3_key(get_rel_path(coll, thumb_p), name) upload = list(request.files.values())[0] - upload.save(pathname, overwrite=True) - - response.content_type = 'text/plain; charset=utf-8' + body = upload.file.read() + ctype = upload.content_type or 'application/octet-stream' + s3.put_object(Bucket=BUCKET, Key=key, Body=body, ContentType=ctype) + response.content_type='text/plain; charset=utf-8' return 'Ok.' @route('/filedelete', method='POST') @require_token('filename') def filedelete(): - """Delete the file indicated by the query parameters. Returns 404 - if the original file does not exist. Any associated thumbnails will - also be deleted. - """ - storename = request.forms.filename - basepath = path.join(settings.BASE_DIR, get_rel_path(request.forms.coll, thumb_p=False)) - thumbpath = path.join(settings.BASE_DIR, get_rel_path(request.forms.coll, thumb_p=True)) - - pathname = path.join(basepath, storename) - if not path.exists(pathname): - abort(404) - - log("Deleting %s" % pathname) - remove(pathname) - - prefix = storename.split('.att')[0] - pattern = path.join(thumbpath, prefix + '*') - log("Deleting thumbnails matching %s" % pattern) - for name in glob(pattern): - remove(name) - - response.content_type = 'text/plain; charset=utf-8' + coll = request.forms.coll + name = request.forms.filename + orig_key = make_s3_key(get_rel_path(coll,False), name) + s3.delete_object(Bucket=BUCKET, Key=orig_key) + # delete thumbnails + thumb_prefix = make_s3_key(get_rel_path(coll,True), '') + base = name.split('.',1)[0] + '_' + paginator = s3.get_paginator('list_objects_v2') + for page in paginator.paginate(Bucket=BUCKET, Prefix=thumb_prefix+base): + for obj in page.get('Contents',[]): + s3.delete_object(Bucket=BUCKET, Key=obj['Key']) + response.content_type='text/plain; charset=utf-8' return 'Ok.' @route('/getmetadata') @require_token('filename') def getmetadata(): - """Provides access to EXIF metadata.""" - storename = request.query.filename - basepath = path.join(settings.BASE_DIR, get_rel_path(request.query.coll, thumb_p=False)) - pathname = path.join(basepath, storename) - datatype = request.query.dt - - if not path.exists(pathname): - abort(404) - - with open(pathname, 'rb') as f: - try: - tags = exifread.process_file(f) - except: - log("Error reading exif data.") - tags = {} - - if datatype == 'date': + coll = request.query.coll + name = request.query.filename + key = make_s3_key(get_rel_path(coll,False), name) + data, _ = stream_s3_object(key) + f = BytesIO(data) + try: + tags = exifread.process_file(f) + except: + log("Error reading EXIF data.") + tags = {} + if request.query.dt=='date': try: return str(tags['EXIF DateTimeOriginal']) except KeyError: - abort(404, 'DateTime not found in EXIF') - - data = defaultdict(dict) - for key, value in list(tags.items()): - parts = key.split() - if len(parts) < 2: continue - try: - v = str(value).decode('ascii', 'replace').encode('utf-8') - except TypeError: - v = repr(value) - - data[parts[0]][parts[1]] = str(v) - - response.content_type = 'application/json' - data = [OrderedDict((('Name', key), ('Fields', value))) - for key, value in list(data.items())] - - return json.dumps(data, indent=4) + abort(404,'DateTime not found in EXIF') + out = defaultdict(dict) + for k,v in tags.items(): + parts=k.split() + if len(parts)<2: continue + out.setdefault(parts[0],{})[parts[1]] = str(v) + result = [OrderedDict((('Name',k),('Fields',f))) for k,f in out.items()] + response.content_type='application/json' + return json.dumps(result, indent=4) @route('/testkey') -@require_token('random', always=True) def testkey(): - """If access to this resource succeeds, clients can conclude - that they have a valid access key. - """ - response.content_type = 'text/plain; charset=utf-8' + response.content_type='text/plain; charset=utf-8' return 'Ok.' @route('/web_asset_store.xml') @include_timestamp def web_asset_store(): - """Serve an XML description of the URLs available here.""" - response.content_type = 'text/xml; charset=utf-8' - return template('web_asset_store.xml', host="%s:%d" % (settings.SERVER_NAME, settings.SERVER_PORT)) + response.content_type='text/xml; charset=utf-8' + return template('web_asset_store.xml', host=f"{settings.SERVER_NAME}:{settings.SERVER_PORT}") @route('/') @@ -366,8 +351,8 @@ def main_page(): return 'It works!' -if __name__ == '__main__': +if __name__=='__main__': from bottle import run - - run(host='0.0.0.0', port=settings.PORT, server=settings.SERVER, - debug=settings.DEBUG, reloader=settings.DEBUG) + run(host='0.0.0.0', port=settings.PORT, + server=settings.SERVER, debug=settings.DEBUG, + reloader=settings.DEBUG) \ No newline at end of file From 8d165eec40816ece102f79c66cd44267bded2603 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 4 Aug 2025 11:50:11 -0500 Subject: [PATCH 2/4] restructure settings --- manage_collection_dirs.py | 225 ++++++++++++++++++++------- requirements.txt | 2 +- server.py | 309 ++++++++++++++++++++++---------------- settings.py | 50 +++--- 4 files changed, 375 insertions(+), 211 deletions(-) diff --git a/manage_collection_dirs.py b/manage_collection_dirs.py index 2ca4d2d..742aa2a 100644 --- a/manage_collection_dirs.py +++ b/manage_collection_dirs.py @@ -1,63 +1,180 @@ -# This can be given either a single name or a list of names -# -# ```bash -# python manage_collection_dirs.py add geo_swiss -# ``` -# or -# ```bash -# python3 manage_collection_dirs.py remove geo_swiss naag mcsn -# ``` -# -# It creates new collection attachment directories. When a -# collection is removed, the directory and attachments remain. - +#!/usr/bin/env python3 import sys -import os import subprocess +import re +import boto3 +from urllib.parse import urlparse -def add_collection_dir(collection_dir_names): - # This creates a new directory for the collection - attachments_dir = 'attachments' - if not os.path.exists(attachments_dir): - os.mkdir(attachments_dir) - for collection_dir_name in collection_dir_names: - dir_path = f'{attachments_dir}/{collection_dir_name}' - if not os.path.exists(dir_path): - os.mkdir(dir_path) - with open("settings.py", "r+") as f: - lines = f.readlines() - for i, line in enumerate(lines): - if line.startswith("COLLECTION_DIRS = {"): - for collection_dir_name in collection_dir_names: - lines.insert(i+1, f" '{collection_dir_name}': '{collection_dir_name}',\n") - break - f.seek(0) - f.truncate() - f.writelines(lines) +SETTINGS_FILE = "settings.py" +SERVICE_NAME = "web-asset-server.service" # adjust if different -def remove_collection_dir(collection_dir_names): - with open("settings.py", "r+") as f: - lines = f.readlines() - for i, line in enumerate(lines): - for collection_dir_name in collection_dir_names: - if line.startswith(f" '{collection_dir_name}': '{collection_dir_name}',"): - lines.pop(i) - break - f.seek(0) - f.truncate() + +def load_settings_contents(): + with open(SETTINGS_FILE, "r") as f: + return f.readlines() + + +def write_settings_contents(lines): + with open(SETTINGS_FILE, "w") as f: f.writelines(lines) -if __name__ == "__main__": + +def parse_action_args(): if len(sys.argv) < 3: - print("Usage: python manage_collection_dirs.py add [ ...]") - print("Usage: python manage_collection_dirs.py remove [ ...]") + print("Usage:") + print(" python manage_collection_dirs.py add [ ...]") + print(" python manage_collection_dirs.py remove [ ...]") + sys.exit(1) + action = sys.argv[1] + args = sys.argv[2:] + if action == "add": + if len(args) % 2 != 0: + print("For add, provide pairs: ...") + sys.exit(1) + pairs = [(args[i], args[i+1]) for i in range(0, len(args), 2)] + return action, pairs + elif action == "remove": + names = args + return action, names else: - action = sys.argv[1] - collection_dir_names = sys.argv[2:] - if action == "add": - add_collection_dir(collection_dir_names) - elif action == "remove": - remove_collection_dir(collection_dir_names) + print("Invalid action. Use 'add' or 'remove'.") + sys.exit(1) + + +def ensure_valid_s3_uri(uri): + parsed = urlparse(uri) + return parsed.scheme == "s3" and parsed.netloc + + +def add_collections(pairs): + lines = load_settings_contents() + # find COLLECTION_S3_PATHS block + pattern = re.compile(r"^COLLECTION_S3_PATHS\s*=\s*{") + start_idx = None + for i, line in enumerate(lines): + if pattern.match(line): + start_idx = i + break + if start_idx is None: + print("Couldn't find COLLECTION_S3_PATHS definition in settings.py") + sys.exit(1) + + # find end of dict (matching closing brace) + end_idx = start_idx + brace_depth = 0 + for i in range(start_idx, len(lines)): + if "{" in lines[i]: + brace_depth += lines[i].count("{") + if "}" in lines[i]: + brace_depth -= lines[i].count("}") + if brace_depth == 0: + end_idx = i + break + # build existing entries map to avoid duplicates + existing = {} + for line in lines[start_idx+1:end_idx]: + m = re.match(r"\s*['\"]([^'\"]+)['\"]\s*:\s*['\"]([^'\"]+)['\"],?", line) + if m: + existing[m.group(1)] = m.group(2) + + # insert or update entries + insertion = [] + for coll, uri in pairs: + if not ensure_valid_s3_uri(uri): + print(f"Skipping invalid S3 URI for '{coll}': {uri}") + continue + if coll in existing: + print(f"Updating existing collection '{coll}' to '{uri}'") + # replace line in place later + for i in range(start_idx+1, end_idx): + if re.match(rf"\s*['\"]{re.escape(coll)}['\"]\s*:", lines[i]): + lines[i] = f" '{coll}': '{uri}',\n" + break else: - print("Invalid action. Use 'add' or 'remove'.") - subprocess.run(['systemctl', 'restart', 'web-asset-server.service']) + print(f"Adding collection '{coll}' -> '{uri}'") + insertion.append(f" '{coll}': '{uri}',\n") + + # inject new entries just before end_idx + if insertion: + lines = lines[:end_idx] + insertion + lines[end_idx:] + + write_settings_contents(lines) + + # create placeholder directories in S3 under originals/ and thumbnails/ + import settings as user_settings # reload after edit + s3 = boto3.client("s3") + for coll, uri in pairs: + if not ensure_valid_s3_uri(uri): + continue + bucket, base_prefix = parse_s3_uri(uri) + for sub in (user_settings.ORIG_DIR, user_settings.THUMB_DIR): + key_prefix = f"{base_prefix}/{sub}/" + # create a zero-byte object to ensure the prefix is visible (not strictly needed) + s3.put_object(Bucket=bucket, Key=key_prefix) + + +def remove_collections(names): + lines = load_settings_contents() + pattern = re.compile(r"^COLLECTION_S3_PATHS\s*=\s*{") + start_idx = None + for i, line in enumerate(lines): + if pattern.match(line): + start_idx = i + break + if start_idx is None: + print("Couldn't find COLLECTION_S3_PATHS in settings.py") + sys.exit(1) + + # locate end of dict + end_idx = start_idx + brace_depth = 0 + for i in range(start_idx, len(lines)): + if "{" in lines[i]: + brace_depth += lines[i].count("{") + if "}" in lines[i]: + brace_depth -= lines[i].count("}") + if brace_depth == 0: + end_idx = i + break + + # filter out lines for the named collections + new_block = [] + removed = [] + for line in lines[start_idx+1:end_idx]: + skip = False + for name in names: + if re.match(rf"\s*['\"]{re.escape(name)}['\"]\s*:", line): + skip = True + removed.append(name) + break + if not skip: + new_block.append(line) + + if not removed: + print("No matching collections to remove found.") + return + + # reconstruct file + new_lines = lines[: start_idx+1] + new_block + lines[end_idx:] + write_settings_contents(new_lines) + print(f"Removed collections: {', '.join(removed)}") + + +def parse_s3_uri(s3_uri): + parsed = urlparse(s3_uri) + if parsed.scheme != 's3' or not parsed.netloc: + raise ValueError(f"Invalid S3 URI: {s3_uri}") + bucket = parsed.netloc + prefix = parsed.path.lstrip('/').rstrip('/') + return bucket, prefix + + +if __name__ == "__main__": + action, payload = parse_action_args() + if action == "add": + add_collections(payload) + else: # remove + remove_collections(payload) + + # restart service + subprocess.run(["systemctl", "restart", SERVICE_NAME]) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6605636..291e29b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ ExifRead==2.3.1 Paste==3.4.4 -sh==1.14.0 +sh==2.0 Bottle>=0.12.23,<0.13 boto3>=1.26.0,<2.0 boto3-stubs>=1.26.0,<2.0 \ No newline at end of file diff --git a/server.py b/server.py index 022549f..32c3009 100644 --- a/server.py +++ b/server.py @@ -3,7 +3,7 @@ from glob import glob from mimetypes import guess_type from os import path, mkdir -from urllib.parse import quote +from urllib.parse import quote, urlparse from io import BytesIO import exifread @@ -16,53 +16,28 @@ import settings from bottle import ( - Response, request, response, abort, - route, HTTPResponse, static_file, template) + Response, request, response, static_file, template, abort, + HTTPResponse, route +) -# Initialize S3 client + +# S3 client (shared) s3 = boto3.client('s3') -BUCKET = settings.S3_BUCKET -PREFIX = settings.S3_PREFIX.rstrip('/') def log(msg): - if settings.DEBUG: + if getattr(settings, "DEBUG", False): print(msg) -def get_rel_path(coll, thumb_p): - """Return the collection subdirectory for originals or thumbnails.""" - type_dir = settings.THUMB_DIR if thumb_p else settings.ORIG_DIR - if settings.COLLECTION_DIRS is None: - return type_dir - try: - coll_dir = settings.COLLECTION_DIRS[coll] - except KeyError: - abort(404, f"Unknown collection: {coll!r}") - return path.join(coll_dir, type_dir) - - -def make_s3_key(relpath, filename=''): - """Build a POSIX-style S3 key under optional PREFIX.""" - key = relpath.replace(path.sep, '/') - if filename: - key = f"{key}/{filename}" if key else filename - if PREFIX: - key = f"{PREFIX}/{key}" if key else PREFIX - return key.lstrip('/') - - +### Token/Auth helpers (unchanged semantics) ### def generate_token(timestamp, filename): """Generate the auth token for the given filename and timestamp. This is for comparing to the client submited token. """ timestamp = str(timestamp) - mac = hmac.new( - settings.KEY.encode(), - timestamp.encode() + filename.encode(), - 'md5' - ) - return f"{mac.hexdigest()}:{timestamp}" + mac = hmac.new(settings.KEY.encode(), timestamp.encode() + filename.encode(), 'md5') + return ':'.join((mac.hexdigest(), timestamp)) class TokenException(Exception): @@ -92,9 +67,11 @@ def validate_token(token_in, filename): except ValueError: raise TokenException("Auth token is malformed.") if settings.TIME_TOLERANCE is not None: - now = get_timestamp() - if abs(now - timestamp) >= settings.TIME_TOLERANCE: - raise TokenException("Auth token timestamp out of range.") + current_time = get_timestamp() + if not abs(current_time - timestamp) < settings.TIME_TOLERANCE: + raise TokenException( + "Auth token timestamp out of range: %s vs %s" % (timestamp, current_time) + ) if token_in != generate_token(timestamp, filename): raise TokenException("Auth token is invalid.") @@ -115,8 +92,8 @@ def require_token(filename_param, always=False): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): - if always or request.method not in ('GET','HEAD') or settings.REQUIRE_KEY_FOR_GET: - params = request.forms if request.method=='POST' else request.query + if always or request.method not in ('GET', 'HEAD') or settings.REQUIRE_KEY_FOR_GET: + params = request.forms if request.method == 'POST' else request.query try: validate_token(params.token, params.get(filename_param)) except TokenException as e: @@ -150,116 +127,171 @@ def wrapper(*args, **kwargs): try: result = func(*args, **kwargs) except HTTPResponse as r: - r.set_header('Access-Control-Allow-Origin','*') + r.set_header('Access-Control-Allow-Origin', '*') raise target = result if isinstance(result, Response) else response - target.set_header('Access-Control-Allow-Origin','*') + target.set_header('Access-Control-Allow-Origin', '*') return result return wrapper +### S3 URI / key helpers ### +def parse_s3_uri(s3_uri): + """ + Parse s3://bucket/prefix and return (bucket, prefix_without_trailing_slash) + """ + parsed = urlparse(s3_uri) + if parsed.scheme != 's3' or not parsed.netloc: + raise ValueError(f"Invalid S3 URI: {s3_uri!r}") + bucket = parsed.netloc + prefix = parsed.path.lstrip('/').rstrip('/') + return bucket, prefix + + +def get_collection_base(coll): + """ + Return (bucket, base_prefix) for the collection, or 404 if unknown. + """ + try: + s3_uri = settings.COLLECTION_S3_PATHS[coll] + except Exception: + abort(404, f"Unknown collection: {coll!r}") + try: + return parse_s3_uri(s3_uri) + except ValueError as e: + abort(500, str(e)) + + +def make_s3_key(coll, thumb_p, filename=''): + """ + Build bucket and key for given collection, thumb/orig, and filename. + """ + bucket, base_prefix = get_collection_base(coll) + subdir = settings.THUMB_DIR if thumb_p else settings.ORIG_DIR + parts = [] + if base_prefix: + parts.append(base_prefix) + parts.append(subdir) + if filename: + parts.append(filename) + key = '/'.join(p.strip('/') for p in parts if p) + return bucket, key + + +def stream_s3_object(bucket, key): + """Retrieve object from S3, abort 404 if missing.""" + try: + obj = s3.get_object(Bucket=bucket, Key=key) + except ClientError as e: + if e.response['Error']['Code'] in ('404', 'NoSuchKey'): + abort(404, f"Missing object: {key}") + raise + return obj['Body'].read(), obj.get('ContentType', 'application/octet-stream') + + def resolve_s3_key(): - thumb_p = (request.query.get('type')=='T') + """ + Determine bucket+key for requested file or thumbnail. Generate thumbnail if needed. + Returns (bucket, key). + """ + thumb_p = (request.query.get('type') == "T") coll = request.query.coll name = request.query.filename - rel = get_rel_path(coll, thumb_p) if not thumb_p: - return make_s3_key(rel, name) + return make_s3_key(coll, False, name) - # thumbnail: check cache, else generate + # Thumbnail logic scale = int(request.query.scale) root, ext = path.splitext(name) - if ext.lower() in ('.pdf','.tiff','.tif'): + if ext.lower() in ('.pdf', '.tiff', '.tif'): ext = '.png' thumb_name = f"{root}_{scale}{ext}" - thumb_key = make_s3_key(rel, thumb_name) + bucket, thumb_key = make_s3_key(coll, True, thumb_name) - # cached? + # If thumbnail exists, return it try: - s3.head_object(Bucket=BUCKET, Key=thumb_key) - log(f"Cached thumbnail: {thumb_key}") - return thumb_key + s3.head_object(Bucket=bucket, Key=thumb_key) + log(f"Serving cached thumbnail {thumb_key}") + return bucket, thumb_key except ClientError as e: - if e.response['Error']['Code'] not in ('404','NoSuchKey'): + if e.response['Error']['Code'] not in ('404', 'NoSuchKey'): raise - # fetch original - orig_key = make_s3_key(get_rel_path(coll, False), name) + # Need to generate thumbnail: fetch original + orig_bucket, orig_key = make_s3_key(coll, False, name) try: - obj = s3.get_object(Bucket=BUCKET, Key=orig_key) + obj = s3.get_object(Bucket=orig_bucket, Key=orig_key) except ClientError as e: - code = e.response['Error']['Code'] - if code in ('404','NoSuchKey'): + if e.response['Error']['Code'] in ('404', 'NoSuchKey'): abort(404, f"Missing original: {orig_key}") raise data = obj['Body'].read() - # write temp files + # Write temp files for ImageMagick processing from tempfile import gettempdir tmp = gettempdir() - local_in = path.join(tmp, name) - local_out = path.join(tmp, thumb_name) - with open(local_in,'wb') as f: + local_orig = path.join(tmp, name) + local_thumb = path.join(tmp, thumb_name) + with open(local_orig, 'wb') as f: f.write(data) - args = ['-resize', f"{scale}x{scale}>"] - if obj['ContentType']=='application/pdf': - args += ['-background','white','-flatten'] - local_in += '[0]' - convert(local_in, *args, local_out) + convert_args = ['-resize', f"{scale}x{scale}>"] + if obj.get('ContentType', '') == 'application/pdf': + convert_args += ['-background', 'white', '-flatten'] + local_orig_with_page = local_orig + '[0]' + else: + local_orig_with_page = local_orig + + log(f"Scaling thumbnail to {scale}") + convert(local_orig_with_page, *convert_args, local_thumb) - # upload thumbnail - ctype, _ = guess_type(local_out) - with open(local_out,'rb') as f: + # Upload generated thumbnail + ctype, _ = guess_type(local_thumb) + with open(local_thumb, 'rb') as f: s3.put_object( - Bucket=BUCKET, + Bucket=bucket, Key=thumb_key, Body=f, ContentType=ctype or 'application/octet-stream' ) - return thumb_key - -def stream_s3_object(key): - try: - obj = s3.get_object(Bucket=BUCKET, Key=key) - except ClientError as e: - if e.response['Error']['Code'] in ('404','NoSuchKey'): - abort(404, f"Missing object: {key}") - raise - return obj['Body'].read(), obj['ContentType'] + return bucket, thumb_key +### Routes ### @route('/static/') -def static(path): - """Serve static files to the client. Primarily for Web Portal.""" +def static_handler(path): + """Serve local static files (unchanged).""" if not settings.ALLOW_STATIC_FILE_ACCESS: abort(404) - return static_file(path, root=settings.BASE_DIR) + return static_file(path, root=getattr(settings, 'BASE_DIR', '/')) @route('/getfileref') @allow_cross_origin def getfileref(): - """Returns a URL to the static file indicated by the query parameters.""" + """Return the fileget URL for the requested attachment (client will append token etc).""" if not settings.ALLOW_STATIC_FILE_ACCESS: abort(404) - response.content_type='text/plain; charset=utf-8' - key = resolve_s3_key() - return f"http://{settings.HOST}:{settings.PORT}/static/{quote(key)}" + response.content_type = 'text/plain; charset=utf-8' + # build minimal URL that points to fileget with same query parameters needed + coll = request.query.coll + filename = request.query.filename + return f"http://{settings.HOST}:{settings.PORT}/fileget?coll={quote(coll)}&filename={quote(filename)}" @route('/fileget') @require_token('filename') def fileget(): - key = resolve_s3_key() - data, ctype = stream_s3_object(key) + """Serve object from S3 (original or thumbnail).""" + bucket, key = resolve_s3_key() + data, content_type = stream_s3_object(bucket, key) r = Response(body=data) - r.content_type = ctype - dl = request.query.get('downloadname') - if dl: - dl = quote(path.basename(dl).encode('ascii','replace')) + r.content_type = content_type + download_name = request.query.get('downloadname') + if download_name: + dl = quote(path.basename(download_name).encode('ascii', 'replace')) r.set_header('Content-Disposition', f"inline; filename*=utf-8''{dl}") return r @@ -267,7 +299,7 @@ def fileget(): @route('/fileupload', method='OPTIONS') @allow_cross_origin def fileupload_options(): - response.content_type='text/plain; charset=utf-8' + response.content_type = "text/plain; charset=utf-8" return '' @@ -275,75 +307,96 @@ def fileupload_options(): @allow_cross_origin @require_token('store') def fileupload(): - thumb_p = (request.forms['type']=='T') + """Upload a new original (thumbnails are derived later).""" + thumb_p = (request.forms.get('type') == "T") coll = request.forms.coll name = request.forms.store - key = make_s3_key(get_rel_path(coll, thumb_p), name) + + if thumb_p: + return 'Ignoring thumbnail upload!' + + bucket, key = make_s3_key(coll, False, name) upload = list(request.files.values())[0] body = upload.file.read() - ctype = upload.content_type or 'application/octet-stream' - s3.put_object(Bucket=BUCKET, Key=key, Body=body, ContentType=ctype) - response.content_type='text/plain; charset=utf-8' + content_type = upload.content_type or 'application/octet-stream' + + s3.put_object(Bucket=bucket, Key=key, Body=body, ContentType=content_type) + + response.content_type = 'text/plain; charset=utf-8' return 'Ok.' @route('/filedelete', method='POST') @require_token('filename') def filedelete(): + """Delete original + derived thumbnails for a collection.""" coll = request.forms.coll name = request.forms.filename - orig_key = make_s3_key(get_rel_path(coll,False), name) - s3.delete_object(Bucket=BUCKET, Key=orig_key) - # delete thumbnails - thumb_prefix = make_s3_key(get_rel_path(coll,True), '') - base = name.split('.',1)[0] + '_' + + # Delete original + bucket, orig_key = make_s3_key(coll, False, name) + s3.delete_object(Bucket=bucket, Key=orig_key) + + # Delete matching thumbnails (prefix: basename_) + thumb_bucket, thumb_prefix_base = make_s3_key(coll, True, '') + base = name.split('.', 1)[0] + '_' paginator = s3.get_paginator('list_objects_v2') - for page in paginator.paginate(Bucket=BUCKET, Prefix=thumb_prefix+base): - for obj in page.get('Contents',[]): - s3.delete_object(Bucket=BUCKET, Key=obj['Key']) - response.content_type='text/plain; charset=utf-8' + for page in paginator.paginate(Bucket=thumb_bucket, Prefix=f"{thumb_prefix_base}{base}"): + for obj in page.get('Contents', []): + s3.delete_object(Bucket=thumb_bucket, Key=obj['Key']) + + response.content_type = 'text/plain; charset=utf-8' return 'Ok.' @route('/getmetadata') @require_token('filename') def getmetadata(): + """Fetch original from S3 and return EXIF metadata.""" coll = request.query.coll name = request.query.filename - key = make_s3_key(get_rel_path(coll,False), name) - data, _ = stream_s3_object(key) + bucket, key = make_s3_key(coll, False, name) + data, _ = stream_s3_object(bucket, key) f = BytesIO(data) try: tags = exifread.process_file(f) except: log("Error reading EXIF data.") tags = {} - if request.query.dt=='date': + + if request.query.get('dt') == 'date': try: return str(tags['EXIF DateTimeOriginal']) except KeyError: - abort(404,'DateTime not found in EXIF') + abort(404, 'DateTime not found in EXIF') + out = defaultdict(dict) - for k,v in tags.items(): - parts=k.split() - if len(parts)<2: continue - out.setdefault(parts[0],{})[parts[1]] = str(v) - result = [OrderedDict((('Name',k),('Fields',f))) for k,f in out.items()] - response.content_type='application/json' + for k, v in tags.items(): + parts = k.split() + if len(parts) < 2: + continue + out.setdefault(parts[0], {})[parts[1]] = str(v) + + result = [OrderedDict((('Name', k), ('Fields', f))) for k, f in out.items()] + response.content_type = 'application/json' return json.dumps(result, indent=4) @route('/testkey') +@require_token('random', always=True) def testkey(): - response.content_type='text/plain; charset=utf-8' + response.content_type = 'text/plain; charset=utf-8' return 'Ok.' @route('/web_asset_store.xml') @include_timestamp def web_asset_store(): - response.content_type='text/xml; charset=utf-8' - return template('web_asset_store.xml', host=f"{settings.SERVER_NAME}:{settings.SERVER_PORT}") + response.content_type = 'text/xml; charset=utf-8' + return template( + 'web_asset_store.xml', + host=f"{settings.SERVER_NAME}:{settings.SERVER_PORT}" + ) @route('/') @@ -351,8 +404,12 @@ def main_page(): return 'It works!' -if __name__=='__main__': +if __name__ == '__main__': from bottle import run - run(host='0.0.0.0', port=settings.PORT, - server=settings.SERVER, debug=settings.DEBUG, - reloader=settings.DEBUG) \ No newline at end of file + run( + host='0.0.0.0', + port=settings.PORT, + server=settings.SERVER, + debug=settings.DEBUG, + reloader=settings.DEBUG + ) \ No newline at end of file diff --git a/settings.py b/settings.py index 8c2d989..b04949d 100644 --- a/settings.py +++ b/settings.py @@ -1,8 +1,12 @@ +import os + # Sample Specify web asset server settings. +# Bottle / server settings # Turns on bottle.py debugging, module reloading and printing some # information to console. -DEBUG = True +DEBUG = False +#DEBUG = True # This secret key is used to generate authentication tokens for requests. # The same key must be set in the Web Store Attachment Preferences in Specify. @@ -11,15 +15,7 @@ # will allow anyone on the internet to use the attachment server to store # arbitrary files. KEY = 'test_attachment_key' - -# Auth token timestamp must be within this many seconds of server time -# in order to be considered valid. This prevents replay attacks. -# Set to None to disable time validation. -TIME_TOLERANCE = 150 - -# Set this to True to require authentication for downloads in addition -# to uploads and deletes. Static file access, if enabled, is not -# affected by this setting. +TIME_TOLERANCE = 600 REQUIRE_KEY_FOR_GET = False # This is required for use with the Web Portal. @@ -30,37 +26,31 @@ # so the client knows how to talk to the server. HOST = 'localhost' PORT = 8080 - SERVER_NAME = HOST SERVER_PORT = PORT - -# Port the development test server should listen on. -DEVELOPMENT_PORT = PORT +DEVELOPMENT_PORT = PORT # Port the development test server should listen on. # Map collection names to directories. Set to None to store # everything in the same originals and thumbnail directories. This is # recommended unless some provision is made to allow attachments for # items scoped above collections to be found. +COLLECTION_S3_PATHS = { + 'coll1': 's3://my-bucket/path/to/coll1', + 'coll2': 's3://my-bucket/path/to/coll2', + # ... add all your collections here ... +} -# COLLECTION_DIRS = { -# # 'COLLECTION_NAME': 'DIRECTORY_NAME', -# 'KUFishvoucher': 'Ichthyology', -# 'KUFishtissue': 'Ichthyology', -# } +# Local BASE_DIR no longer used for attachments; kept for static assets +BASE_DIR = '/home/specify/attachments' -COLLECTION_DIRS = None - -# Base directory for all attachments. -BASE_DIR = '/home/specify/attachments/' - -# Originals and thumbnails are stored in separate directories. THUMB_DIR = 'thumbnails' ORIG_DIR = 'originals' - -# Set of mime types that the server will try to thumbnail. -CAN_THUMBNAIL = {'image/jpeg', 'image/gif', 'image/png', 'image/tiff', 'application/pdf'} +CAN_THUMBNAIL = { + 'image/jpeg','image/gif','image/png', + 'image/tiff','application/pdf' +} # What HTTP server to use for stand-alone operation. # SERVER = 'paste' # Requires python-paste package. Fast, and seems to work good. -SERVER = 'wsgiref' # For testing. Requires no extra packages. - +# use wsgiref for testing. Requires no extra packages. +SERVER = 'paste' # or 'wsgiref' From 393bf21a526cb4a6a8201eaa25f7fdfc81a76ba5 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 5 Aug 2025 02:12:22 -0500 Subject: [PATCH 3/4] fix legacy static_handler and getfileref for web portal --- server.py | 91 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 31 deletions(-) diff --git a/server.py b/server.py index 32c3009..7be7717 100644 --- a/server.py +++ b/server.py @@ -1,8 +1,7 @@ from collections import defaultdict, OrderedDict from functools import wraps -from glob import glob from mimetypes import guess_type -from os import path, mkdir +from os import path from urllib.parse import quote, urlparse from io import BytesIO @@ -17,18 +16,28 @@ import settings from bottle import ( Response, request, response, static_file, template, abort, - HTTPResponse, route + HTTPResponse, route, redirect ) +from bottle import error + +@error(500) +def show_500(exc): + import traceback + tb = traceback.format_exc() + print(tb) # prints in your console + return "

500 Internal Server Error

" + tb + "
" + + # S3 client (shared) s3 = boto3.client('s3') def log(msg): - if getattr(settings, "DEBUG", False): - print(msg) - + print(msg) + # if getattr(settings, "DEBUG", False): + # print(msg) ### Token/Auth helpers (unchanged semantics) ### def generate_token(timestamp, filename): @@ -143,10 +152,7 @@ def parse_s3_uri(s3_uri): parsed = urlparse(s3_uri) if parsed.scheme != 's3' or not parsed.netloc: raise ValueError(f"Invalid S3 URI: {s3_uri!r}") - bucket = parsed.netloc - prefix = parsed.path.lstrip('/').rstrip('/') - return bucket, prefix - + return parsed.netloc, parsed.path.lstrip('/').rstrip('/') def get_collection_base(coll): """ @@ -154,7 +160,7 @@ def get_collection_base(coll): """ try: s3_uri = settings.COLLECTION_S3_PATHS[coll] - except Exception: + except KeyError: abort(404, f"Unknown collection: {coll!r}") try: return parse_s3_uri(s3_uri) @@ -162,19 +168,19 @@ def get_collection_base(coll): abort(500, str(e)) -def make_s3_key(coll, thumb_p, filename=''): +def make_s3_key(coll, thumb, filename=''): """ Build bucket and key for given collection, thumb/orig, and filename. """ bucket, base_prefix = get_collection_base(coll) - subdir = settings.THUMB_DIR if thumb_p else settings.ORIG_DIR + subdir = settings.THUMB_DIR if thumb else settings.ORIG_DIR parts = [] if base_prefix: parts.append(base_prefix) parts.append(subdir) if filename: parts.append(filename) - key = '/'.join(p.strip('/') for p in parts if p) + key = '/'.join(p.strip('/') for p in parts) return bucket, key @@ -265,7 +271,22 @@ def static_handler(path): """Serve local static files (unchanged).""" if not settings.ALLOW_STATIC_FILE_ACCESS: abort(404) - return static_file(path, root=getattr(settings, 'BASE_DIR', '/')) + + parts = path.split('/', 1) + if len(parts) != 2: + abort(404, f"Bad static path: {path!r}") + + coll, rest = parts + try: + bucket, base_prefix = parse_s3_uri(settings.COLLECTION_S3_PATHS[coll]) + except KeyError: + abort(404, f"Unknown collection: {coll!r}") + + key = '/'.join(p for p in (base_prefix, rest) if p) + data, ctype = stream_s3_object(bucket, key) + + response.content_type = ctype + return data @route('/getfileref') @@ -274,26 +295,35 @@ def getfileref(): """Return the fileget URL for the requested attachment (client will append token etc).""" if not settings.ALLOW_STATIC_FILE_ACCESS: abort(404) - response.content_type = 'text/plain; charset=utf-8' - # build minimal URL that points to fileget with same query parameters needed + coll = request.query.coll filename = request.query.filename - return f"http://{settings.HOST}:{settings.PORT}/fileget?coll={quote(coll)}&filename={quote(filename)}" + + # URL-encode the “collection/filename” into a single static path + static_path = f"{quote(coll)}/{quote(filename)}" + url = f"http://{settings.HOST}:{settings.PORT}/static/{static_path}" + + response.content_type = 'text/plain; charset=utf-8' + return url @route('/fileget') @require_token('filename') def fileget(): - """Serve object from S3 (original or thumbnail).""" bucket, key = resolve_s3_key() data, content_type = stream_s3_object(bucket, key) - r = Response(body=data) - r.content_type = content_type + + response.content_type = content_type + download_name = request.query.get('downloadname') if download_name: dl = quote(path.basename(download_name).encode('ascii', 'replace')) - r.set_header('Content-Disposition', f"inline; filename*=utf-8''{dl}") - return r + response.set_header( + 'Content-Disposition', + f"inline; filename*=utf-8''{dl}" + ) + + return data @route('/fileupload', method='OPTIONS') @@ -360,12 +390,13 @@ def getmetadata(): f = BytesIO(data) try: tags = exifread.process_file(f) - except: - log("Error reading EXIF data.") + except Exception as e: + log(f"Error reading EXIF data: {e}") tags = {} if request.query.get('dt') == 'date': try: + response.content_type = 'text/plain; charset=utf-8' return str(tags['EXIF DateTimeOriginal']) except KeyError: abort(404, 'DateTime not found in EXIF') @@ -378,7 +409,7 @@ def getmetadata(): out.setdefault(parts[0], {})[parts[1]] = str(v) result = [OrderedDict((('Name', k), ('Fields', f))) for k, f in out.items()] - response.content_type = 'application/json' + response.content_type = 'application/json; charset=utf-8' return json.dumps(result, indent=4) @@ -393,10 +424,8 @@ def testkey(): @include_timestamp def web_asset_store(): response.content_type = 'text/xml; charset=utf-8' - return template( - 'web_asset_store.xml', - host=f"{settings.SERVER_NAME}:{settings.SERVER_PORT}" - ) + protocol = request.headers.get('X-Forwarded-Proto', request.urlparts.scheme) + return template('web_asset_store.xml', host=f"{protocol}://{settings.SERVER_NAME}") @route('/') @@ -412,4 +441,4 @@ def main_page(): server=settings.SERVER, debug=settings.DEBUG, reloader=settings.DEBUG - ) \ No newline at end of file + ) From 9d6839647174486d48895cf40d125e939e634ed6 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 5 Aug 2025 02:12:46 -0500 Subject: [PATCH 4/4] change to python3.12 in dockerfile --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 92ff2e6..0e377e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ LABEL maintainer="Specify Collections Consortium " RUN apt-get update && apt-get -y install --no-install-recommends \ ghostscript \ imagemagick \ - python3.6 \ + python3.12 \ python3-venv \ && apt-get clean && rm -rf /var/lib/apt/lists/* @@ -19,7 +19,7 @@ WORKDIR /home/specify COPY --chown=specify:specify requirements.txt . -RUN python3.6 -m venv ve && ve/bin/pip install --no-cache-dir -r requirements.txt +RUN python3.12 -m venv ve && ve/bin/pip install --no-cache-dir -r requirements.txt COPY --chown=specify:specify *.py views ./