├── config ├── README_cn.md ├── README_jp.md ├── dockerfile ├── README.md ├── config.py ├── utils.py ├── app.py ├── LICENSE ├── index.html └── processors.py /config: -------------------------------------------------------------------------------- 1 | nsfw_threshold = 0.95 2 | ffmpeg_max_frames = 30 3 | ffmpeg_max_timeout = 1800 -------------------------------------------------------------------------------- /README_cn.md: -------------------------------------------------------------------------------- 1 | # NSFW Detector 2 | 3 | ## 简介 4 | 5 | 这是一个 NSFW 内容检测器,它基于 [Falconsai/nsfw_image_detection](https://huggingface.co/Falconsai/nsfw_image_detection) 。 6 | 模型: google/vit-base-patch16-224-in21k 7 | 8 | 相比其它常见的 NSFW 检测器,这个检测器的优势在于: 9 | 10 | * 基于 AI ,准确度更好。 11 | * 支持纯 CPU 推理,可以运行在大部分服务器上。 12 | * 自动调度多个 CPU 加速推理。 13 | * 简单判断,只有两个类别:nsfw 和 normal。 14 | * 以 API 的方式提供服务,更方便集成到其它应用中。 15 | * 基于 Docker 部署,便于分布式部署。 16 | * 纯本地,保护您的数据安全。 17 | 18 | ### 性能需求 19 | 20 | 运行这个模型最多需要 2GB 的内存。不需要显卡的支持。 21 | 在同时处理大量请求时,可能需要更多的内存。 22 | 23 | ### 支持的文件类型 24 | 25 | 这个检测器支持检查的文件类型: 26 | 27 | * ✅ 图片(已支持) 28 | * ✅ PDF 文件(已支持) 29 | * ✅ 视频(已支持) 30 | * ✅ 压缩包中的文件(已支持) 31 | * ✅ Doc,Docx(已支持) 32 | 33 | ## 快速开始 34 | 35 | ### 启动 API 服务器 36 | 37 | ```bash 38 | docker run -d -p 3333:3333 --name nsfw-detector vxlink/nsfw_detector:latest 39 | ``` 40 | 41 | 如果要直接检查服务器本地路径的文件,请将路径挂载到容器中。 42 | 通常推荐挂载路径与容器内路径一致,以避免混淆。 43 | 44 | ```bash 45 | docker run -d -p 3333:3333 -v /path/to/files:/path/to/files --name nsfw-detector vxlink/nsfw_detector:latest 46 | ``` 47 | 48 | 支持的系统架构:`x86_64`、`ARM64`。 49 | 50 | ### 使用 API 进行内容检查 51 | 52 | ```bash 53 | # 检测 54 | curl -X POST -F "file=@/path/to/image.jpg" http://localhost:3333/check 55 | 56 | # 检查本地的文件 57 | curl -X POST -F "path=/path/to/image.jpg" http://localhost:3333/check 58 | ``` 59 | 60 | ### 使用内置的 Web 界面进行检测 61 | 62 | 访问地址:[http://localhost:3333](http://localhost:3333) 63 | 64 | ## 编辑配置文件 65 | 66 | 现在,您可以通过挂载 /tmp 目录,并在该目录下创建一个名为 config 的文件,来配置检测器的行为。 67 | 您可以查看 [config](config) 文件作为参考。 68 | 69 | * `nsfw_threshold` 当目标文件的 NSFW 值超过多少时设定为匹配项目并作为结果返回。 70 | * `ffmpeg_max_frames` 处理视频时最多处理多少帧。 71 | * `ffmpeg_max_timeout` 处理视频时的超时限制。 72 | 73 | 此外, /tmp 目录作为容器中的临时目录,配置到一个高性能的存储设备上会提高性能。 74 | 75 | ## 许可证 76 | 77 | 本项目基于 Apache 2.0 许可证开源。 78 | -------------------------------------------------------------------------------- /README_jp.md: -------------------------------------------------------------------------------- 1 | # NSFW 検出器 2 | 3 | ## はじめに 4 | 5 | これは [Falconsai/nsfw_image_detection](https://huggingface.co/Falconsai/nsfw_image_detection) に基づいた NSFW コンテンツ検出器です。 6 | モデル:google/vit-base-patch16-224-in21k 7 | 8 | 他の一般的な NSFW 検出器と比較して、本検出器には以下の利点がございます: 9 | 10 | * AI ベースで、より高い精度を実現しています。 11 | * CPU のみでの推論に対応し、ほとんどのサーバーで実行可能です。 12 | * 複数の CPU を自動的に活用し、推論を高速化します。 13 | * nsfw と normal の2カテゴリーのみの簡潔な判定を行います。 14 | * API として提供されるため、他のアプリケーションとの統合が容易です。 15 | * Docker ベースの展開により、分散配置が容易です。 16 | * 完全にローカルで動作し、データの安全性を保護します。 17 | 18 | ### パフォーマンス要件 19 | 20 | 本モデルの実行には最大 2GB のメモリが必要です。GPU は不要です。 21 | 22 | ### 対応ファイル形式 23 | 24 | 本検出器は以下のファイル形式の確認に対応しております: 25 | 26 | * ✅ 画像(対応済み) 27 | * ✅ PDF(対応済み) 28 | * ✅ 動画(対応済み) 29 | * ✅ 圧縮ファイル内のファイル(対応済み) 30 | * ✅ Doc,Docx(対応済み) 31 | 32 | ## クイックスタート 33 | 34 | ### API サーバーの起動 35 | 36 | ```bash 37 | docker run -d -p 3333:3333 --name nsfw-detector vxlink/nsfw_detector:latest 38 | ``` 39 | 40 | サーバーのローカルパスにあるファイルを直接チェックする場合は、パスをコンテナにマウントしてください。 41 | 混乱を避けるため、通常はマウントパスをコンテナ内のパスと同じにすることをお勧めします。 42 | 43 | ```bash 44 | docker run -d -p 3333:3333 -v /path/to/files:/path/to/files --name nsfw-detector vxlink/nsfw_detector:latest 45 | ``` 46 | 47 | 48 | 49 | 対応システムアーキテクチャ:`x86_64`、`ARM64`。 50 | 51 | ### API を使用したコンテンツ確認 52 | 53 | ```bash 54 | # 検出 55 | curl -X POST -F "file=@/path/to/image.jpg" http://localhost:3333/check 56 | 57 | 58 | # ファイルパスを指定して検出 59 | curl -X POST -F "path=/path/to/image.jpg" http://localhost:3333/check 60 | ``` 61 | 62 | ### Web インターフェースを使用した検出 63 | 64 | アクセス先:[http://localhost:3333](http://localhost:3333) 65 | 66 | ## 設定ファイルの編集 67 | 68 | 現在、/tmpディレクトリをマウントし、そのディレクトリ内にconfigという名前のファイルを作成することで、検出器の動作を設定することができます。 69 | [config](config)ファイルを参考にしてください。 70 | 71 | * `nsfw_threshold` 対象ファイルのNSFW値がこの値を超えた場合に、一致項目として検出され、結果として返されます。 72 | * `ffmpeg_max_frames` 動画処理時に処理する最大フレーム数を設定します。 73 | * `ffmpeg_max_timeout` 動画処理時のタイムアウト制限を設定します。 74 | 75 | なお、/tmpディレクトリはコンテナ内の一時ディレクトリとして機能し、高性能なストレージデバイスに設定することでパフォーマンスが向上いたします。 76 | 77 | ## ライセンス 78 | 79 | 本プロジェクトは Apache 2.0 ライセンスのもとでオープンソース化されております。 80 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stable-slim 2 | 3 | WORKDIR /app 4 | 5 | ENV DEBIAN_FRONTEND=noninteractive 6 | 7 | # 添加 non-free 仓库 8 | RUN echo "deb http://deb.debian.org/debian stable main contrib non-free" > /etc/apt/sources.list && \ 9 | echo "deb http://security.debian.org/debian-security stable-security main contrib non-free" >> /etc/apt/sources.list && \ 10 | echo "deb http://deb.debian.org/debian stable-updates main contrib non-free" >> /etc/apt/sources.list 11 | 12 | # 更新包列表 13 | RUN apt-get update 14 | 15 | # 基础 Python 环境 16 | RUN apt-get install -y python3 17 | RUN apt-get install -y python3-pip 18 | RUN apt-get install -y python3-venv 19 | 20 | # 压缩工具 21 | RUN apt-get install -y unrar 22 | RUN apt-get install -y p7zip-full 23 | RUN apt-get install -y p7zip-rar 24 | 25 | # 系统工具 26 | RUN apt-get install -y curl 27 | RUN apt-get install -y poppler-utils 28 | RUN apt-get install -y ffmpeg 29 | 30 | # OpenCV 相关依赖 31 | RUN apt-get install -y python3-opencv 32 | RUN apt-get install -y libgl1-mesa-glx 33 | RUN apt-get install -y libglib2.0-0 34 | RUN apt-get install -y libsm6 35 | RUN apt-get install -y libxext6 36 | RUN apt-get install -y libxrender-dev 37 | 38 | # Python magic 依赖 39 | RUN apt-get install -y python3-magic 40 | RUN apt-get install -y libmagic1 41 | 42 | # 文档解析工具 43 | RUN apt-get install -y antiword 44 | 45 | # 创建并激活虚拟环境 46 | RUN python3 -m venv /venv 47 | ENV PATH="/venv/bin:$PATH" 48 | 49 | # Python 包安装 - 每个包单独安装以便调试 50 | RUN pip3 install --no-cache-dir opencv-python-headless 51 | RUN pip3 install --no-cache-dir rarfile 52 | RUN pip3 install --no-cache-dir py7zr 53 | RUN pip3 install --no-cache-dir flask==2.0.1 54 | RUN pip3 install --no-cache-dir werkzeug==2.0.3 55 | RUN pip3 install --no-cache-dir Pillow 56 | RUN pip3 install --no-cache-dir transformers 57 | RUN pip3 install --no-cache-dir pdf2image 58 | RUN pip3 install --no-cache-dir python-docx 59 | RUN pip3 install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu 60 | RUN pip3 install --no-cache-dir python-magic 61 | 62 | # 预下载模型 63 | RUN python3 -c "from transformers import pipeline; pipe = pipeline('image-classification', model='Falconsai/nsfw_image_detection', device=-1)" 64 | 65 | # 设置权限 66 | RUN chmod -R 755 /root/.cache 67 | 68 | # 源代码复制 69 | COPY app.py config.py processors.py utils.py index.html /app/ 70 | 71 | CMD ["python3", "app.py"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NSFW Detector 2 | 3 | [中文指南](README_cn.md) | [日本語ガイド](README_jp.md) 4 | 5 | ## Introduction 6 | 7 | This is an NSFW content detector based on [Falconsai/nsfw_image_detection](https://huggingface.co/Falconsai/nsfw_image_detection). 8 | Model: google/vit-base-patch16-224-in21k 9 | 10 | 11 | Compared to other common NSFW detectors, this detector has the following advantages: 12 | 13 | * AI-based, providing better accuracy. 14 | * Supports CPU-only inference, can run on most servers. 15 | * Automatically utilizes multiple CPUs to accelerate inference. 16 | * Simple classification with only two categories: nsfw and normal. 17 | * Provides service via API, making it easier to integrate with other applications. 18 | * Docker-based deployment, suitable for distributed deployment. 19 | * Purely local, protecting your data security. 20 | 21 | ### Performance Requirements 22 | 23 | Running this model requires up to 2GB of memory. No GPU support is needed. 24 | When handling a large number of requests simultaneously, more memory may be required. 25 | 26 | ### Supported File Types 27 | 28 | This detector supports checking the following file types: 29 | 30 | * ✅ Images (supported) 31 | * ✅ PDF files (supported) 32 | * ✅ Videos (supported) 33 | * ✅ Files in compressed packages (supported) 34 | * ✅ Doc,Docx (supported) 35 | 36 | ## Quick Start 37 | 38 | ### Start the API Server 39 | 40 | ```bash 41 | docker run -d -p 3333:3333 --name nsfw-detector vxlink/nsfw_detector:latest 42 | ``` 43 | 44 | To check files in local paths on the server, mount the path to the container. 45 | It is recommended to keep the mounted path consistent with the path inside the container to avoid confusion. 46 | 47 | ```bash 48 | docker run -d -p 3333:3333 -v /path/to/files:/path/to/files --name nsfw-detector vxlink/nsfw_detector:latest 49 | ``` 50 | 51 | Supported architectures: `x86_64`, `ARM64`. 52 | 53 | ### Use the API for Content Checking 54 | 55 | ```bash 56 | # Detection 57 | curl -X POST -F "file=@/path/to/image.jpg" http://localhost:3333/check 58 | 59 | # Check Local Files 60 | curl -X POST -F "path=/path/to/image.jpg" http://localhost:3333/check 61 | ``` 62 | 63 | ### Use the Built-in Web Interface for Detection 64 | 65 | Visit: [http://localhost:3333](http://localhost:3333) 66 | 67 | ## Edit Configuration File 68 | 69 | Now, you can configure the detector's behavior by mounting the /tmp directory and creating a file named config in that directory. 70 | You can refer to the [config](config) file as a reference. 71 | 72 | * `nsfw_threshold` Sets what NSFW value threshold must be exceeded for a target file to be considered a match and returned as a result. 73 | * `ffmpeg_max_frames` Maximum number of frames to process when handling videos. 74 | * `ffmpeg_max_timeout` Timeout limit when processing videos. 75 | 76 | Additionally, since the /tmp directory serves as a temporary directory in the container, configuring it on a high-performance storage device will improve performance. 77 | 78 | ## License 79 | 80 | This project is open-source under the Apache 2.0 license. 81 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | # config.py 2 | import os 3 | import rarfile 4 | import logging 5 | from pathlib import Path 6 | 7 | # 配置日志 8 | logging.basicConfig( 9 | level=logging.INFO, 10 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 11 | datefmt='%Y-%m-%d %H:%M:%S', 12 | encoding='utf-8' 13 | ) 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | def load_config_from_file(): 18 | """从/tmp/config加载配置并智能记录日志""" 19 | config_path = '/tmp/config' 20 | config_values = {} 21 | loaded_config = {} 22 | 23 | try: 24 | if os.path.exists(config_path): 25 | with open(config_path, 'r') as f: 26 | for line in f: 27 | line = line.strip() 28 | if line and not line.startswith('#'): 29 | try: 30 | key, value = map(str.strip, line.split('=', 1)) 31 | # 将配置键转换为大写 32 | key = key.upper() 33 | 34 | # 尝试转换为适当的类型 35 | try: 36 | # 首先尝试转换为float 37 | if '.' in value: 38 | loaded_config[key] = float(value) 39 | else: 40 | # 然后尝试转换为int 41 | loaded_config[key] = int(value) 42 | except ValueError: 43 | # 如果转换失败,保持为字符串 44 | loaded_config[key] = value 45 | 46 | config_values[key] = loaded_config[key] 47 | except ValueError: 48 | logger.warning(f"无法解析配置行: {line}") 49 | 50 | # 记录加载的配置 51 | if loaded_config: 52 | logger.info("配置加载详情:") 53 | logger.info("-" * 50) 54 | for key, value in loaded_config.items(): 55 | logger.info(f"{key:25s} = {value:<15}") 56 | 57 | else: 58 | logger.warning(f"配置文件{config_path}不存在,使用默认配置") 59 | 60 | except Exception as e: 61 | logger.error(f"读取配置文件时出错: {str(e)}") 62 | 63 | return config_values 64 | 65 | # 基础配置 66 | rarfile.UNRAR_TOOL = "unrar" 67 | rarfile.PATH_SEP = '/' 68 | os.environ['HF_HOME'] = '/root/.cache/huggingface' 69 | 70 | # MIME类型到文件扩展名的映射 71 | # config.py 中的 MIME_TO_EXT 字典应该这样修改: 72 | 73 | # MIME类型到文件扩展名的映射 74 | MIME_TO_EXT = { 75 | # 图片格式 76 | 'image/jpeg': '.jpg', 77 | 'image/png': '.png', 78 | 'image/gif': '.gif', 79 | 'image/webp': '.webp', 80 | 'image/bmp': '.bmp', 81 | 'image/tiff': '.tiff', 82 | 'image/x-tiff': '.tiff', 83 | 'image/x-tga': '.tga', 84 | 'image/x-portable-pixmap': '.ppm', 85 | 'image/x-portable-graymap': '.pgm', 86 | 'image/x-portable-bitmap': '.pbm', 87 | 'image/x-portable-anymap': '.pnm', 88 | 'image/svg+xml': '.svg', 89 | 'image/x-pcx': '.pcx', 90 | 'image/vnd.adobe.photoshop': '.psd', 91 | 'image/vnd.microsoft.icon': '.ico', 92 | 'image/heif': '.heif', 93 | 'image/heic': '.heic', 94 | 'image/avif': '.avif', 95 | 'image/jxl': '.jxl', 96 | 97 | # PDF格式 98 | 'application/pdf': '.pdf', 99 | 100 | # 文档格式 (新增) 101 | 'application/msword': '.doc', 102 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', 103 | 104 | # 视频和容器格式 105 | 'video/mp4': '.mp4', 106 | 'video/x-msvideo': '.avi', 107 | 'video/x-matroska': '.mkv', 108 | 'video/quicktime': '.mov', 109 | 'video/x-ms-wmv': '.wmv', 110 | 'video/webm': '.webm', 111 | 'video/MP2T': '.ts', 112 | 'video/x-flv': '.flv', 113 | 'video/3gpp': '.3gp', 114 | 'video/3gpp2': '.3g2', 115 | 'video/x-m4v': '.m4v', 116 | 'video/mxf': '.mxf', 117 | 'video/x-ogm': '.ogm', 118 | 'video/vnd.rn-realvideo': '.rv', 119 | 'video/dv': '.dv', 120 | 'video/x-ms-asf': '.asf', 121 | 'video/x-f4v': '.f4v', 122 | 'video/vnd.dlna.mpeg-tts': '.m2ts', 123 | 'video/x-raw': '.yuv', 124 | 'video/mpeg': '.mpg', 125 | 'video/x-mpeg': '.mpeg', 126 | 'video/divx': '.divx', 127 | 'video/x-vob': '.vob', 128 | 'video/x-m2v': '.m2v', 129 | 130 | # 压缩格式 131 | 'application/x-rar-compressed': '.rar', 132 | 'application/x-rar': '.rar', 133 | 'application/vnd.rar': '.rar', 134 | 'application/zip': '.zip', 135 | 'application/x-7z-compressed': '.7z', 136 | 'application/gzip': '.gz', 137 | 'application/x-tar': '.tar', 138 | 'application/x-bzip2': '.bz2', 139 | 'application/x-xz': '.xz', 140 | 'application/x-lzma': '.lzma', 141 | 'application/x-zstd': '.zst', 142 | 'application/vnd.ms-cab-compressed': '.cab' 143 | } 144 | 145 | # 文件扩展名集合 146 | IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tga', 147 | '.ppm', '.pgm', '.pbm', '.pnm', '.svg', '.pcx', '.psd', '.ico', 148 | '.heif', '.heic', '.avif', '.jxl'} 149 | 150 | VIDEO_EXTENSIONS = {'.mp4', '.avi', '.mkv', '.mov', '.wmv', '.webm', '.ts', '.flv', 151 | '.3gp', '.3g2', '.m4v', '.mxf', '.ogm', '.rv', '.dv', '.asf', 152 | '.f4v', '.m2ts', '.yuv', '.mpg', '.mpeg', '.divx', '.vob', '.m2v'} 153 | 154 | ARCHIVE_EXTENSIONS = {'.7z', '.rar', '.zip', '.gz', '.tar', '.bz2', '.xz', 155 | '.lzma', '.zst', '.cab'} 156 | 157 | # 添加新的文档扩展名集合 158 | DOCUMENT_EXTENSIONS = {'.doc', '.docx'} 159 | 160 | # 添加新的 MIME 类型集合 161 | DOCUMENT_MIME_TYPES = { 162 | 'application/msword', 163 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' 164 | } 165 | 166 | # MIME 类型集合 167 | IMAGE_MIME_TYPES = {mime for mime, ext in MIME_TO_EXT.items() if mime.startswith('image/')} 168 | VIDEO_MIME_TYPES = {mime for mime, ext in MIME_TO_EXT.items() if mime.startswith('video/')} 169 | ARCHIVE_MIME_TYPES = {mime for mime, ext in MIME_TO_EXT.items() if 170 | mime.startswith('application/') and 171 | any(keyword in mime for keyword in ['zip', 'rar', '7z', 'gzip', 'tar', 172 | 'bzip2', 'xz', 'lzma', 'zstd', 'cab'])} 173 | PDF_MIME_TYPES = {'application/pdf'} 174 | 175 | # 所有支持的 MIME 类型集合 176 | SUPPORTED_MIME_TYPES = IMAGE_MIME_TYPES | VIDEO_MIME_TYPES | ARCHIVE_MIME_TYPES | PDF_MIME_TYPES | DOCUMENT_MIME_TYPES 177 | 178 | # 默认配置值 179 | MAX_FILE_SIZE = 20 * 1024 * 1024 * 1024 # 20GB 180 | NSFW_THRESHOLD = 0.8 181 | FFMPEG_MAX_FRAMES = 20 182 | FFMPEG_TIMEOUT = 1800 183 | CHECK_ALL_FILES = 0 184 | MAX_INTERVAL_SECONDS = 30 185 | 186 | # 从文件加载配置并更新全局变量 187 | file_config = load_config_from_file() 188 | 189 | # 更新全局变量 190 | globals().update(file_config) 191 | 192 | # 导出所有配置变量 193 | __all__ = [ 194 | 'MIME_TO_EXT', 'IMAGE_EXTENSIONS', 'VIDEO_EXTENSIONS', 'ARCHIVE_EXTENSIONS', 195 | 'DOCUMENT_EXTENSIONS', # 新增 196 | 'IMAGE_MIME_TYPES', 'VIDEO_MIME_TYPES', 'ARCHIVE_MIME_TYPES', 'PDF_MIME_TYPES', 197 | 'DOCUMENT_MIME_TYPES', # 新增 198 | 'SUPPORTED_MIME_TYPES', 'MAX_FILE_SIZE', 'NSFW_THRESHOLD', 'FFMPEG_MAX_FRAMES', 199 | 'FFMPEG_TIMEOUT', 'CHECK_ALL_FILES', 'MAX_INTERVAL_SECONDS' 200 | ] -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import zipfile 2 | import rarfile 3 | import gzip 4 | import io 5 | import os 6 | import logging 7 | import tempfile 8 | import subprocess 9 | import shutil 10 | import uuid 11 | from pathlib import Path 12 | from config import ( 13 | IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, DOCUMENT_EXTENSIONS, # 添加 DOCUMENT_EXTENSIONS 14 | ARCHIVE_EXTENSIONS 15 | ) 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | class ArchiveHandler: 20 | def __init__(self, filepath): 21 | self.filepath = filepath 22 | self.archive = None 23 | self.type = self._determine_type() 24 | self.temp_dir = None 25 | self._extracted_files = {} # 存储解压文件的映射 {原始文件名: 临时文件路径} 26 | 27 | def _determine_type(self): 28 | try: 29 | if zipfile.is_zipfile(self.filepath): 30 | return 'zip' 31 | elif rarfile.is_rarfile(self.filepath): 32 | return 'rar' 33 | elif self._is_7z_file(self.filepath): 34 | return '7z' 35 | elif self._is_valid_gzip(self.filepath): 36 | return 'gz' 37 | return None 38 | except Exception as e: 39 | logger.error(f"文件类型检测失败: {str(e)}") 40 | return None 41 | 42 | def _is_7z_file(self, filepath): 43 | try: 44 | result = subprocess.run( 45 | ['7z', 'l', filepath], 46 | stdout=subprocess.PIPE, 47 | stderr=subprocess.PIPE, 48 | encoding='utf-8' 49 | ) 50 | return result.returncode == 0 51 | except Exception as e: 52 | logger.error(f"7z文件检测失败: {str(e)}") 53 | return False 54 | 55 | def _is_valid_gzip(self, filepath): 56 | try: 57 | with gzip.open(filepath, 'rb') as f: 58 | f.read(1) 59 | return True 60 | except Exception: 61 | return False 62 | 63 | def _generate_temp_filename(self, original_filename): 64 | """生成唯一的临时文件名""" 65 | ext = Path(original_filename).suffix 66 | return f"{str(uuid.uuid4())}{ext}" 67 | 68 | def _extract_rar_all(self): 69 | """使用unrar命令行工具完整解压RAR文件""" 70 | if not self.temp_dir: 71 | self.temp_dir = tempfile.mkdtemp() 72 | 73 | try: 74 | # 使用unrar命令行工具解压 75 | extract_cmd = ['unrar', 'x', '-y', self.filepath, self.temp_dir + os.sep] 76 | result = subprocess.run( 77 | extract_cmd, 78 | stdout=subprocess.PIPE, 79 | stderr=subprocess.PIPE, 80 | encoding='utf-8' 81 | ) 82 | 83 | if result.returncode != 0: 84 | raise Exception(f"RAR解压失败: {result.stderr}") 85 | 86 | # 遍历解压目录,重命名文件并记录映射关系 87 | for root, _, files in os.walk(self.temp_dir): 88 | for filename in files: 89 | original_path = os.path.join(root, filename) 90 | relative_path = os.path.relpath(original_path, self.temp_dir) 91 | 92 | # 生成新的唯一文件名 93 | new_filename = str(uuid.uuid4()) + os.path.splitext(filename)[1] 94 | new_path = os.path.join(self.temp_dir, new_filename) 95 | 96 | # 移动文件并记录映射 97 | os.rename(original_path, new_path) 98 | self._extracted_files[relative_path] = new_path 99 | 100 | logger.info(f"成功解压 {len(self._extracted_files)} 个文件到临时目录") 101 | return True 102 | 103 | except Exception as e: 104 | logger.error(f"RAR完整解压失败: {str(e)}") 105 | return False 106 | 107 | def _extract_7z_all(self): 108 | """完整解压 7z 文件到临时目录""" 109 | if not self.temp_dir: 110 | self.temp_dir = tempfile.mkdtemp() 111 | 112 | try: 113 | # 使用 7z 命令行工具解压所有文件 114 | extract_cmd = ['7z', 'x', '-y', self.filepath, f'-o{self.temp_dir}'] 115 | result = subprocess.run( 116 | extract_cmd, 117 | stdout=subprocess.PIPE, 118 | stderr=subprocess.PIPE, 119 | encoding='utf-8' 120 | ) 121 | 122 | if result.returncode != 0: 123 | raise Exception(f"7z解压失败: {result.stderr}") 124 | 125 | # 遍历解压目录,重命名文件并记录映射关系 126 | for root, _, files in os.walk(self.temp_dir): 127 | for filename in files: 128 | original_path = os.path.join(root, filename) 129 | relative_path = os.path.relpath(original_path, self.temp_dir) 130 | 131 | # 生成新的唯一文件名 132 | new_filename = str(uuid.uuid4()) + os.path.splitext(filename)[1] 133 | new_path = os.path.join(self.temp_dir, new_filename) 134 | 135 | # 移动文件并记录映射 136 | os.rename(original_path, new_path) 137 | self._extracted_files[relative_path] = new_path 138 | 139 | logger.info(f"成功解压 {len(self._extracted_files)} 个文件到临时目录") 140 | return True 141 | 142 | except Exception as e: 143 | logger.error(f"7z完整解压失败: {str(e)}") 144 | return False 145 | 146 | def __enter__(self): 147 | try: 148 | if self.type == 'zip': 149 | self.archive = zipfile.ZipFile(self.filepath) 150 | if self.archive.testzip() is not None: 151 | raise zipfile.BadZipFile("ZIP文件损坏") 152 | elif self.type == 'rar': 153 | self.archive = rarfile.RarFile(self.filepath) 154 | if self.archive.needs_password(): 155 | raise Exception("RAR文件有密码保护") 156 | # 直接解压所有RAR文件 157 | if not self._extract_rar_all(): 158 | raise Exception("RAR文件解压失败") 159 | elif self.type == '7z': 160 | # 直接解压所有7z文件 161 | if not self._extract_7z_all(): 162 | raise Exception("7z文件解压失败") 163 | elif self.type == 'gz': 164 | self.archive = gzip.GzipFile(self.filepath) 165 | return self 166 | except (zipfile.BadZipFile, rarfile.BadRarFile) as e: 167 | raise Exception(f"无效的压缩文件: {str(e)}") 168 | except Exception as e: 169 | raise Exception(f"打开压缩文件失败: {str(e)}") 170 | 171 | def __exit__(self, exc_type, exc_val, exc_tb): 172 | if self.archive: 173 | self.archive.close() 174 | if self.temp_dir and os.path.exists(self.temp_dir): 175 | try: 176 | shutil.rmtree(self.temp_dir) 177 | except Exception as e: 178 | logger.error(f"清理临时目录失败: {str(e)}") 179 | 180 | def list_files(self): 181 | try: 182 | if self.type == 'zip': 183 | files = [f for f in self.archive.namelist() if not f.endswith('/')] 184 | elif self.type == 'rar': 185 | # 对于RAR文件,直接返回已解压的文件列表 186 | files = list(self._extracted_files.keys()) 187 | elif self.type == '7z': 188 | # 直接返回已解压的文件列表 189 | files = list(self._extracted_files.keys()) 190 | elif self.type == 'gz': 191 | base_name = os.path.basename(self.filepath) 192 | if base_name.endswith('.gz'): 193 | files = [base_name[:-3]] 194 | else: 195 | files = ['content'] 196 | else: 197 | files = [] 198 | 199 | processable = [f for f in files if can_process_file(f)] 200 | logger.info(f"找到 {len(processable)} 个可处理文件") 201 | return files 202 | 203 | except Exception as e: 204 | logger.error(f"获取文件列表失败: {str(e)}") 205 | return [] 206 | 207 | def get_file_info(self, filename): 208 | try: 209 | if self.type == 'zip': 210 | return self.archive.getinfo(filename).file_size 211 | elif self.type == 'rar' or self.type == '7z': 212 | # 对于RAR和7z文件,直接获取解压后文件的大小 213 | if filename in self._extracted_files: 214 | return os.path.getsize(self._extracted_files[filename]) 215 | return 0 216 | elif self.type == 'gz': 217 | return self.archive.size 218 | return 0 219 | except Exception as e: 220 | logger.error(f"获取文件信息失败: {str(e)}") 221 | return 0 222 | 223 | def extract_file(self, filename): 224 | try: 225 | base_name = os.path.basename(filename) 226 | logger.info(f"正在检测文件: {base_name}") 227 | 228 | if self.type == 'zip': 229 | return self.archive.read(filename) 230 | elif self.type == 'rar' or self.type == '7z': 231 | # 对于RAR和7z文件,直接返回已解压文件的内容 232 | if filename in self._extracted_files: 233 | with open(self._extracted_files[filename], 'rb') as f: 234 | return f.read() 235 | raise Exception(f"文件 {filename} 未在提取列表中") 236 | elif self.type == 'gz': 237 | return self.archive.read() 238 | raise Exception("不支持的压缩格式") 239 | except Exception as e: 240 | raise Exception(f"提取文件失败: {str(e)}") 241 | 242 | def get_file_extension(filename): 243 | return Path(filename).suffix.lower() 244 | 245 | def can_process_file(filename): 246 | ext = get_file_extension(filename) 247 | return (ext in IMAGE_EXTENSIONS or 248 | ext == '.pdf' or 249 | ext in VIDEO_EXTENSIONS or 250 | ext in DOCUMENT_EXTENSIONS) 251 | 252 | def sort_files_by_priority(handler, files): 253 | def get_priority_and_size(filename): 254 | ext = get_file_extension(filename) 255 | size = handler.get_file_info(filename) 256 | 257 | if ext in IMAGE_EXTENSIONS: 258 | priority = 0 259 | elif ext == '.pdf': 260 | priority = 1 261 | elif ext in VIDEO_EXTENSIONS: 262 | priority = 2 263 | elif ext in DOCUMENT_EXTENSIONS: # 添加文档文件的优先级 264 | priority = 1 # 与 PDF 相同的优先级 265 | else: 266 | priority = 3 267 | 268 | return (priority, size) 269 | 270 | return sorted(files, key=get_priority_and_size) -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | # app.py 2 | from flask import Flask, request, jsonify, send_file, Response, g 3 | import tempfile 4 | import os 5 | import shutil 6 | import logging 7 | import magic 8 | import gc 9 | from pathlib import Path 10 | from werkzeug.utils import secure_filename 11 | from config import MAX_FILE_SIZE, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, MIME_TO_EXT, DOCUMENT_EXTENSIONS 12 | from utils import ArchiveHandler, can_process_file, sort_files_by_priority 13 | from processors import ( 14 | process_image, process_pdf_file, process_video_file, 15 | process_archive, process_doc_file, process_docx_file 16 | ) 17 | 18 | # 配置日志 19 | logger = logging.getLogger(__name__) 20 | 21 | app = Flask(__name__) 22 | 23 | # 文件上传配置 24 | app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE # 从config导入的最大文件大小 25 | app.config['UPLOAD_FOLDER'] = tempfile.gettempdir() # 使用系统临时目录 26 | app.request_class.max_form_memory_size = 128 * 1024 * 1024 # 强制所有上传写入磁盘 27 | 28 | # Load index.html content 29 | CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) 30 | with open(os.path.join(CURRENT_DIR, 'index.html'), 'r', encoding='utf-8') as f: 31 | INDEX_HTML = f.read() 32 | 33 | class TempFileHandler: 34 | """临时文件管理器""" 35 | def __init__(self): 36 | self.temp_files = [] 37 | self.temp_dirs = [] 38 | 39 | def create_temp_file(self, suffix=None): 40 | """创建临时文件""" 41 | temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) 42 | self.temp_files.append(temp_file.name) 43 | return temp_file 44 | 45 | def create_temp_dir(self): 46 | """创建临时目录""" 47 | temp_dir = tempfile.mkdtemp() 48 | self.temp_dirs.append(temp_dir) 49 | return temp_dir 50 | 51 | def cleanup(self): 52 | """清理所有临时文件和目录""" 53 | # 清理文件 54 | for file_path in self.temp_files: 55 | try: 56 | if os.path.exists(file_path): 57 | os.unlink(file_path) 58 | except Exception as e: 59 | logger.error(f"清理临时文件失败 {file_path}: {str(e)}") 60 | self.temp_files.clear() 61 | 62 | # 清理目录 63 | for dir_path in self.temp_dirs: 64 | try: 65 | if os.path.exists(dir_path): 66 | shutil.rmtree(dir_path) 67 | except Exception as e: 68 | logger.error(f"清理临时目录失败 {dir_path}: {str(e)}") 69 | self.temp_dirs.clear() 70 | 71 | # 强制垃圾回收 72 | gc.collect() 73 | 74 | def detect_file_type(file_path): 75 | """检测文件类型,使用文件的前2048字节""" 76 | try: 77 | with open(file_path, 'rb') as f: 78 | header = f.read(2048) 79 | 80 | mime = magic.Magic(mime=True) 81 | mime_type = mime.from_buffer(header) 82 | 83 | # 对于RAR文件的特殊处理 84 | if mime_type not in MIME_TO_EXT: 85 | with open(file_path, 'rb') as f: 86 | if f.read(7).startswith(b'Rar!\x1a\x07'): 87 | return 'application/x-rar', '.rar' 88 | 89 | return mime_type, MIME_TO_EXT.get(mime_type) 90 | 91 | except Exception as e: 92 | logger.error(f"文件类型检测失败: {str(e)}") 93 | raise 94 | 95 | def process_file_by_type(file_path, detected_type, original_filename, temp_handler): 96 | """根据文件类型选择处理方法""" 97 | mime_type, ext = detected_type 98 | 99 | # 如果有原始文件扩展名,优先使用 100 | if original_filename and '.' in original_filename: 101 | original_ext = os.path.splitext(original_filename)[1].lower() 102 | if original_ext in IMAGE_EXTENSIONS or original_ext == '.pdf' or \ 103 | original_ext in VIDEO_EXTENSIONS or original_ext in {'.rar', '.zip', '.7z', '.gz'} or \ 104 | original_ext in DOCUMENT_EXTENSIONS: 105 | ext = original_ext 106 | 107 | if not ext: 108 | logger.error(f"不支持的文件类型: {mime_type}") 109 | return { 110 | 'status': 'error', 111 | 'message': f'Unsupported file type: {mime_type}' 112 | }, 400 113 | 114 | try: 115 | if ext in IMAGE_EXTENSIONS: 116 | with open(file_path, 'rb') as f: 117 | from PIL import Image 118 | # 使用with语句确保Image对象正确关闭 119 | with Image.open(f) as image: 120 | result = process_image(image) 121 | # 处理完图片后强制垃圾回收 122 | gc.collect() 123 | return { 124 | 'status': 'success', 125 | 'filename': original_filename, 126 | 'result': result 127 | } 128 | 129 | elif ext == '.pdf': 130 | with open(file_path, 'rb') as f: 131 | pdf_stream = f.read() 132 | result = process_pdf_file(pdf_stream) 133 | # 处理完PDF后强制垃圾回收 134 | gc.collect() 135 | if result: 136 | return { 137 | 'status': 'success', 138 | 'filename': original_filename, 139 | 'result': result 140 | } 141 | return { 142 | 'status': 'error', 143 | 'message': 'No processable content found in PDF' 144 | }, 400 145 | 146 | elif ext in VIDEO_EXTENSIONS: 147 | result = process_video_file(file_path) 148 | # 处理完视频后强制垃圾回收 149 | gc.collect() 150 | if result: 151 | return { 152 | 'status': 'success', 153 | 'filename': original_filename, 154 | 'result': result 155 | } 156 | return { 157 | 'status': 'error', 158 | 'message': 'No processable content found in video' 159 | }, 400 160 | 161 | elif ext in {'.zip', '.rar', '.7z', '.gz'}: 162 | result = process_archive(file_path, original_filename) 163 | # 处理完压缩包后强制垃圾回收 164 | gc.collect() 165 | return result 166 | 167 | elif ext in DOCUMENT_EXTENSIONS: 168 | with open(file_path, 'rb') as f: 169 | file_content = f.read() 170 | if ext == '.doc': 171 | result = process_doc_file(file_content) 172 | else: # .docx 173 | result = process_docx_file(file_content) 174 | 175 | # 处理完文档后强制垃圾回收 176 | gc.collect() 177 | 178 | if result: 179 | return { 180 | 'status': 'success', 181 | 'filename': original_filename, 182 | 'result': result 183 | } 184 | return { 185 | 'status': 'error', 186 | 'message': 'No processable content found in document' 187 | }, 400 188 | 189 | else: 190 | logger.error(f"不支持的文件扩展名: {ext}") 191 | return { 192 | 'status': 'error', 193 | 'message': f'Unsupported file extension: {ext}' 194 | }, 400 195 | 196 | except Exception as e: 197 | logger.error(f"处理文件时出错: {str(e)}") 198 | return { 199 | 'status': 'error', 200 | 'message': str(e) 201 | }, 500 202 | 203 | @app.route('/') 204 | def index(): 205 | """Serve the index.html file""" 206 | return Response(INDEX_HTML, mimetype='text/html') 207 | 208 | @app.route('/check', methods=['POST']) 209 | def check_file(): 210 | """统一的文件检查入口点""" 211 | temp_handler = TempFileHandler() 212 | try: 213 | # 获取请求中的 path 参数 214 | path = request.form.get('path') 215 | 216 | if path: 217 | # 处理文件路径 218 | abs_path = os.path.abspath(path) 219 | app_dir = os.path.abspath(os.path.dirname(__file__)) 220 | 221 | # 安全检查:确保路径不指向程序目录 222 | if abs_path.startswith(app_dir): 223 | return jsonify({ 224 | 'status': 'error', 225 | 'message': 'Invalid path: cannot access program directory' 226 | }), 400 227 | 228 | # 检查文件是否存在 229 | if not os.path.exists(abs_path): 230 | return jsonify({ 231 | 'status': 'error', 232 | 'message': 'File not found' 233 | }), 404 234 | 235 | # 检查是否是文件 236 | if not os.path.isfile(abs_path): 237 | return jsonify({ 238 | 'status': 'error', 239 | 'message': 'Path is not a file' 240 | }), 400 241 | 242 | # 检查文件大小 243 | file_size = os.path.getsize(abs_path) 244 | if file_size > MAX_FILE_SIZE: 245 | return jsonify({ 246 | 'status': 'error', 247 | 'message': 'File too large' 248 | }), 400 249 | 250 | # 获取原始文件名 251 | filename = os.path.basename(abs_path) 252 | 253 | # 检测文件类型 254 | detected_type = detect_file_type(abs_path) 255 | logger.info(f"检测到文件类型: {detected_type}") 256 | 257 | # 处理文件 258 | result = process_file_by_type(abs_path, detected_type, filename, temp_handler) 259 | return jsonify(result) if isinstance(result, dict) else jsonify(result[0]), result[1] if isinstance(result, tuple) else 200 260 | 261 | # 文件上传处理逻辑 262 | elif 'file' not in request.files: 263 | return jsonify({ 264 | 'status': 'error', 265 | 'message': 'No file found' 266 | }), 400 267 | 268 | file = request.files['file'] 269 | if file.filename == '': 270 | return jsonify({ 271 | 'status': 'error', 272 | 'message': 'No file selected' 273 | }), 400 274 | 275 | filename = secure_filename(file.filename) 276 | logger.info(f"接收到文件: {filename}") 277 | 278 | temp_file = temp_handler.create_temp_file() 279 | file.save(temp_file.name) 280 | 281 | file_size = os.path.getsize(temp_file.name) 282 | if file_size > MAX_FILE_SIZE: 283 | return jsonify({ 284 | 'status': 'error', 285 | 'message': 'File too large' 286 | }), 400 287 | 288 | detected_type = detect_file_type(temp_file.name) 289 | logger.info(f"检测到文件类型: {detected_type}") 290 | 291 | result = process_file_by_type(temp_file.name, detected_type, filename, temp_handler) 292 | return jsonify(result) if isinstance(result, dict) else jsonify(result[0]), result[1] if isinstance(result, tuple) else 200 293 | 294 | except Exception as e: 295 | logger.error(f"处理过程发生错误: {str(e)}") 296 | return jsonify({ 297 | 'status': 'error', 298 | 'message': str(e) 299 | }), 500 300 | 301 | finally: 302 | # 清理所有临时文件 303 | temp_handler.cleanup() 304 | 305 | if __name__ == '__main__': 306 | app.run(host='0.0.0.0', port=3333) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 |