# -*- coding: utf-8 -*- """ pin-tu 图片浏览蓝图 用户自行指定浏览路径(root 用 URL-safe base64 编码放在 URL 中) """ import os import shutil import base64 from flask import Blueprint, render_template, send_from_directory, request, abort, jsonify from utils.pin_tu_utils import ( find_media_in_folder, get_subfolders, build_breadcrumbs, is_safe_path, ) from utils.stats_db import ( add_search, get_search_history, delete_search_history, clear_search_history, add_recent_path, get_recent_paths, delete_recent_path, clear_recent_paths, ) bp = Blueprint('pin_tu', __name__, url_prefix='/pin-tu') def _decode_root(b64_str): """URL-safe base64 解码为原始路径""" try: # URL-safe → standard base64 padded = b64_str.replace('-', '+').replace('_', '/') # 补齐 = pad = len(padded) % 4 if pad: padded += '=' * (4 - pad) return base64.b64decode(padded).decode('utf-8') except Exception: return None def _encode_root(path_str): """路径编码为 URL-safe base64 字符串""" b = base64.b64encode(path_str.encode('utf-8')).decode('ascii') return b.replace('+', '-').replace('/', '_').rstrip('=') @bp.route('/') def index(): return render_template('pin_tu.html', mode='index', root='', root_b64='') @bp.route('/browse/') def browse(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) # 记录最近访问路径 add_recent_path(root_path) subpath = request.args.get('p', '') mode = request.args.get('mode', 'list') if mode not in ['list', 'grid', 'manga']: mode = 'list' root_abs = os.path.normpath(root_path) if subpath: current_path = os.path.normpath(os.path.join(root_abs, subpath)) else: current_path = root_abs if not os.path.isdir(root_abs): abort(404) if not is_safe_path(root_abs, current_path): abort(403) image_files, video_files, other_files = find_media_in_folder(current_path) subfolders = get_subfolders(current_path) breadcrumbs = build_breadcrumbs(subpath, base_url=f'/pin-tu/browse/{root_b64}') current_path_name = os.path.basename(current_path) if current_path != root_abs else os.path.basename(root_abs) if mode == 'manga': return render_template( 'pin_tu_manga.html', images=image_files, current_path_name=current_path_name, current_subpath=subpath + '/' if subpath else '', root_b64=root_b64, ) return render_template( 'pin_tu.html', mode='browse', images=image_files, videos=video_files, files=other_files, subfolders=subfolders, current_path_name=current_path_name, current_subpath=subpath + '/' if subpath else '', breadcrumbs=breadcrumbs, view_mode=mode, is_manga_path='漫画' in subpath, root_b64=root_b64, root_display=root_path, ) @bp.route('/file-info/') def file_info(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) file_path = request.args.get('p', '') if not file_path: abort(400) root_abs = os.path.normpath(root_path) full_file = os.path.normpath(os.path.join(root_abs, file_path)) if not is_safe_path(root_abs, full_file) or not os.path.isfile(full_file): abort(404) stat = os.stat(full_file) size = stat.st_size if size < 1024: size_str = f'{size} B' elif size < 1024 * 1024: size_str = f'{size / 1024:.1f} KB' elif size < 1024 * 1024 * 1024: size_str = f'{size / (1024 * 1024):.1f} MB' else: size_str = f'{size / (1024 * 1024 * 1024):.2f} GB' from datetime import datetime modified = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S') ext = os.path.splitext(file_path)[1].lstrip('.').upper() return { 'name': os.path.basename(file_path), 'size': size_str, 'size_bytes': size, 'type': ext or '未知', 'modified': modified, 'path': file_path, } @bp.route('/media/') def serve_media(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) file_path = request.args.get('p', '') if not file_path: abort(400) root_abs = os.path.normpath(root_path) folder = os.path.dirname(file_path) filename = os.path.basename(file_path) base_dir = os.path.join(root_abs, folder) full_file = os.path.normpath(os.path.join(base_dir, filename)) if not is_safe_path(root_abs, full_file): abort(403) return send_from_directory(base_dir, filename) @bp.route('/rename/', methods=['POST']) def rename(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) data = request.get_json() or {} old_path = data.get('path', '') new_name = data.get('new_name', '').strip() if not old_path or not new_name: return {'success': False, 'error': '参数不完整'}, 400 # 校验新文件名 invalid = set(r'\/:*?"<>|') if any(c in invalid for c in new_name): return {'success': False, 'error': '文件名包含非法字符'}, 400 root_abs = os.path.normpath(root_path) old_full = os.path.normpath(os.path.join(root_abs, old_path)) if not is_safe_path(root_abs, old_full) or not os.path.exists(old_full): return {'success': False, 'error': '文件不存在'}, 404 new_full = os.path.join(os.path.dirname(old_full), new_name) if os.path.exists(new_full) and os.path.normcase(new_full) != os.path.normcase(old_full): return {'success': False, 'error': '同名文件已存在'}, 400 try: os.rename(old_full, new_full) return {'success': True, 'new_name': new_name} except Exception as e: return {'success': False, 'error': str(e)}, 500 @bp.route('/search-page/') def search_page(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) return render_template('pin_tu_search.html', root_b64=root_b64, root_display=root_path, query=request.args.get('q', '')) @bp.route('/search/') def search(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) query = request.args.get('q', '').strip().lower() if not query or len(query) < 1: return {'results': []} root_abs = os.path.normpath(root_path) if not os.path.isdir(root_abs): abort(404) results = [] max_results = 50 for dirpath, dirnames, filenames in os.walk(root_abs): # 搜索文件夹名 for d in list(dirnames): if query in d.lower(): rel = os.path.relpath(os.path.join(dirpath, d), root_abs).replace('\\', '/') results.append({'name': d, 'path': rel, 'type': 'folder'}) if len(results) >= max_results: return {'results': results} # 搜索文件名 for f in filenames: if query in f.lower(): rel = os.path.relpath(os.path.join(dirpath, f), root_abs).replace('\\', '/') ext = os.path.splitext(f)[1].lstrip('.').upper() results.append({'name': f, 'path': rel, 'type': ext or 'file'}) if len(results) >= max_results: return {'results': results} return {'results': results} # ===== 搜索历史 API ===== @bp.route('/api/search-history/') def api_get_search_history(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) records = get_search_history(root_path, limit=20) return jsonify({'history': records}) @bp.route('/api/search-history/', methods=['POST']) def api_add_search(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) data = request.get_json() or {} keyword = data.get('keyword', '').strip() result_count = data.get('result_count', 0) if keyword: add_search(keyword, root_path, result_count) return jsonify({'success': True}) @bp.route('/api/search-history/', methods=['DELETE']) def api_delete_search_history(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) data = request.get_json() or {} if data.get('clear'): clear_search_history(root_path) else: ids = data.get('ids', []) if ids: delete_search_history(ids) return jsonify({'success': True}) # ===== 最近访问路径 API ===== @bp.route('/api/recent-paths') def api_get_recent_paths(): records = get_recent_paths(limit=8) return jsonify({'paths': records}) @bp.route('/api/recent-paths', methods=['POST']) def api_add_recent_path(): data = request.get_json() or {} path = data.get('path', '').strip() if path: add_recent_path(path) return jsonify({'success': True}) @bp.route('/api/recent-paths', methods=['DELETE']) def api_delete_recent_path(): data = request.get_json() or {} if data.get('clear'): clear_recent_paths() else: path = data.get('path', '') if path: delete_recent_path(path) return jsonify({'success': True}) # ===== 新建文件夹 ===== @bp.route('/create-folder/', methods=['POST']) def create_folder(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) data = request.get_json() or {} subpath = data.get('path', '') name = data.get('name', '').strip() if not name: return {'success': False, 'error': '名称不能为空'}, 400 invalid = set(r'\/:*?"<>|') if any(c in invalid for c in name): return {'success': False, 'error': '名称包含非法字符'}, 400 root_abs = os.path.normpath(root_path) if subpath: parent = os.path.normpath(os.path.join(root_abs, subpath)) else: parent = root_abs if not is_safe_path(root_abs, parent) or not os.path.isdir(parent): return {'success': False, 'error': '父目录不存在'}, 400 target = os.path.join(parent, name) if os.path.exists(target): return {'success': False, 'error': '同名文件夹已存在'}, 400 try: os.makedirs(target) return {'success': True, 'name': name} except Exception as e: return {'success': False, 'error': str(e)}, 500 # ===== 移动文件/文件夹 ===== @bp.route('/move/', methods=['POST']) def move_item(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) data = request.get_json() or {} source = data.get('source', '') dest = data.get('dest', '') # 目标文件夹的相对路径,空字符串表示根目录 if not source: return {'success': False, 'error': '缺少源路径'}, 400 root_abs = os.path.normpath(root_path) src_full = os.path.normpath(os.path.join(root_abs, source)) if not is_safe_path(root_abs, src_full) or not os.path.exists(src_full): return {'success': False, 'error': '源文件不存在'}, 404 if dest: dest_dir = os.path.normpath(os.path.join(root_abs, dest)) else: dest_dir = root_abs if not is_safe_path(root_abs, dest_dir) or not os.path.isdir(dest_dir): return {'success': False, 'error': '目标目录不存在'}, 400 # 不能移动到自身或自身子目录 norm_src = os.path.normcase(src_full) norm_dest = os.path.normcase(dest_dir) if norm_dest.startswith(norm_src + os.sep) or norm_dest == norm_src: return {'success': False, 'error': '不能移动到自身或子目录'}, 400 item_name = os.path.basename(src_full) new_full = os.path.join(dest_dir, item_name) if os.path.exists(new_full) and os.path.normcase(new_full) != norm_src: return {'success': False, 'error': '目标位置已存在同名项目'}, 400 try: shutil.move(src_full, new_full) return {'success': True, 'name': item_name} except Exception as e: return {'success': False, 'error': str(e)}, 500 # ===== 删除文件/文件夹 ===== @bp.route('/delete/', methods=['POST']) def delete_item(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) data = request.get_json() or {} source = data.get('source', '') if not source: return {'success': False, 'error': '缺少路径'}, 400 root_abs = os.path.normpath(root_path) full = os.path.normpath(os.path.join(root_abs, source)) if not is_safe_path(root_abs, full) or not os.path.exists(full): return {'success': False, 'error': '文件不存在'}, 404 name = os.path.basename(full) try: if os.path.isdir(full): shutil.rmtree(full) else: os.remove(full) return {'success': True, 'name': name} except Exception as e: return {'success': False, 'error': str(e)}, 500 # ===== 列出子文件夹(供移动弹窗使用)===== @bp.route('/list-folders/') def list_folders(root_b64): root_path = _decode_root(root_b64) if not root_path: abort(400) subpath = request.args.get('p', '') root_abs = os.path.normpath(root_path) if subpath: current = os.path.normpath(os.path.join(root_abs, subpath)) else: current = root_abs if not is_safe_path(root_abs, current) or not os.path.isdir(current): abort(404) subfolders = get_subfolders(current) # 父目录 parent = '' if subpath: parts = subpath.rstrip('/').split('/') parent = '/'.join(parts[:-1]) if len(parts) > 1 else '' return jsonify({ 'current': subpath, 'parent': parent, 'folders': [{'name': f, 'path': (subpath + '/' + f).strip('/')} for f in subfolders] })