Files
2026-06-16 03:30:57 +08:00

448 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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/<string:root_b64>')
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/<string:root_b64>')
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/<string:root_b64>')
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/<string:root_b64>', 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/<string:root_b64>')
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/<string:root_b64>')
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/<string:root_b64>')
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/<string:root_b64>', 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/<string:root_b64>', 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/<string:root_b64>', 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/<string:root_b64>', 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/<string:root_b64>', 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/<string:root_b64>')
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]
})