← 메인 화면프로젝트 자료·코드
← 자료 목록

cc/startup_app/materials.py

프로그램·코드 · 4.6 KB원본 다운로드

원본 내용을 읽기 전용으로 표시합니다.

  1. """허용 목록에 있는 공개용 사본만 읽는 자료실. 로컬 파일 실행/업로드 기능 없음."""
  2. import csv
  3. import json
  4. import re
  5. from functools import lru_cache
  6. from itertools import islice
  7. from pathlib import Path
  8. from flask import Blueprint, abort, current_app, render_template, request, send_file
  9. bp = Blueprint('materials', __name__, url_prefix='/materials')
  10. LABELS = {'code': '프로그램·코드', 'map': '결과 지도', 'image': '그래프', 'csv': 'CSV 데이터', 'text': '문서·자료'}
  11. PREVIEW_LIMIT = 240_000
  12. @lru_cache(maxsize=8)
  13. def load_catalog(directory):
  14. root = Path(directory).resolve()
  15. manifest = root / 'manifest.json'
  16. if not manifest.is_file():
  17. return root, {}, ''
  18. data = json.loads(manifest.read_text(encoding='utf-8'))
  19. return root, {entry['id']: entry for entry in data['files']}, data['created']
  20. def catalog():
  21. return load_catalog(str(current_app.config['MATERIALS_DIR']))
  22. def selected(file_id):
  23. root, entries, _ = catalog()
  24. if not re.fullmatch(r'[0-9a-f]{20}', file_id) or file_id not in entries:
  25. abort(404)
  26. entry = entries[file_id]
  27. base = (root / 'files').resolve()
  28. path = (base / entry['path']).resolve()
  29. if not path.is_relative_to(base) or not path.is_file():
  30. abort(404)
  31. return entry, path
  32. def size_label(value):
  33. return f'{value / 1024 / 1024:.1f} MB' if value >= 1024 * 1024 else f'{value / 1024:.1f} KB'
  34. @bp.get('/')
  35. def index():
  36. _, entries, created = catalog()
  37. files = list(entries.values())
  38. folders = sorted({str(Path(e['path']).parent).replace('\\', '/') for e in files})
  39. folder, query = request.args.get('folder', ''), request.args.get('q', '').strip()[:200]
  40. if folder and folder not in folders:
  41. abort(404)
  42. shown = [e for e in files if (not folder or str(Path(e['path']).parent).replace('\\', '/') == folder) and (not query or query.casefold() in e['path'].casefold())]
  43. return render_template('materials_index.html', files=shown, total=len(files), folders=folders, folder=folder, query=query, labels=LABELS, size_label=size_label, created=created)
  44. @bp.get('/file/<file_id>')
  45. def detail(file_id):
  46. entry, path = selected(file_id)
  47. source = request.args.get('view') == 'source'
  48. lines, rows, header, truncated = [], [], [], False
  49. if entry['kind'] == 'csv' and not source:
  50. with path.open(encoding='utf-8-sig', newline='') as stream:
  51. reader = csv.reader(stream)
  52. header = next(reader, [])
  53. rows = [[value[:500] for value in row] for row in islice(reader, 50)]
  54. elif entry['kind'] not in {'map', 'image'} or source and entry['kind'] == 'map':
  55. with path.open('rb') as stream:
  56. raw = stream.read(PREVIEW_LIMIT + 1)
  57. text = raw[:PREVIEW_LIMIT].decode('utf-8-sig', errors='replace')
  58. all_lines = text.splitlines()
  59. lines = all_lines[:3000]
  60. truncated = len(raw) > PREVIEW_LIMIT or len(all_lines) > 3000
  61. return render_template('materials_detail.html', entry=entry, labels=LABELS, size_label=size_label, source=source, lines=lines, header=header, rows=rows, truncated=truncated)
  62. @bp.get('/download/<file_id>')
  63. def download(file_id):
  64. entry, path = selected(file_id)
  65. return send_file(path, as_attachment=True, download_name=path.name, conditional=True, max_age=3600)
  66. @bp.get('/image/<file_id>')
  67. def image(file_id):
  68. entry, path = selected(file_id)
  69. if entry['kind'] != 'image':
  70. abort(404)
  71. return send_file(path, mimetype='image/png', conditional=True, max_age=3600)
  72. @bp.get('/preview/<file_id>')
  73. def preview(file_id):
  74. entry, path = selected(file_id)
  75. if entry['kind'] != 'map':
  76. abort(404)
  77. response = send_file(path, mimetype='text/html', conditional=True, max_age=3600)
  78. response.headers['Content-Security-Policy'] = (
  79. "sandbox allow-scripts; default-src 'none'; "
  80. "script-src 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://code.jquery.com https://unpkg.com; "
  81. "style-src 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://code.jquery.com https://unpkg.com https://netdna.bootstrapcdn.com; "
  82. "img-src data: https://*.tile.openstreetmap.fr https://*.tile.openstreetmap.org https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; "
  83. "font-src data: https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://netdna.bootstrapcdn.com; "
  84. "connect-src https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://code.jquery.com https://unpkg.com; "
  85. "base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
  86. )
  87. return response
파일 확인 정보

SHA-256: 35ffd8106963694a108baf1313aac96698de62675512760b98143f7f7edced2d