From 794a30cde70de85abbaa30ba7ddfae92d0028b84 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:31:33 +0400 Subject: [PATCH 1/4] Add browser-based paste submission to extras/lines web app (#1) * Initial plan * Add browser-based paste submission form to extras/lines web app Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/e6cc4389-fa4f-4a25-881e-c1a3dfdfa4bb Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> --- README.md | 29 ++++++++++++++ extras/lines/lines.py | 93 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e3922d5..b423952 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ You can use our beautification service to get any paste colored and numbered. Ju http://l.termbin.com/ydxh ``` +### Browser-based usage + +You can also create and view pastes entirely from a web browser using the bundled `extras/lines` web app (see [Web Interface](#web-interface) below). + ------------------------------------------------------------------------------- ## Useful aliases @@ -331,6 +335,31 @@ server { } ``` +------------------------------------------------------------------------------- + +## Web Interface + +The `extras/lines` directory contains a Flask-based web application (`lines.py`) that turns fiche into a **fully browser-usable pastebin**. It provides: + +* **A paste-submission form** at `/` — type or paste text, click *Submit*, and receive a shareable URL. +* **Syntax-highlighted paste viewing** at `/` — any paste (whether submitted through the web form or via `nc`) is displayed with line numbers and automatic language detection, powered by [Pygments](https://pygments.org/). + +### Requirements + +``` +pip install flask pygments +``` + +### Running + +``` +python extras/lines/lines.py /path/to/output/directory +``` + +Then open `http://localhost:5000` in your browser. + +> **Note:** The paste *storage* directory must be the same directory used by the fiche TCP server (`-o` flag) if you want both interfaces to share pastes. + ## License Fiche is MIT licensed. diff --git a/extras/lines/lines.py b/extras/lines/lines.py index df6b80c..87f86ba 100644 --- a/extras/lines/lines.py +++ b/extras/lines/lines.py @@ -1,19 +1,92 @@ -from flask import Flask, abort, redirect -app = Flask(__name__) +import argparse +import os +import random +import string -import argparse, os, pygments +import pygments +from flask import Flask, abort, redirect, request from pygments import highlight -from pygments.lexers import guess_lexer from pygments.formatters import HtmlFormatter +from pygments.lexers import guess_lexer + +app = Flask(__name__) parser = argparse.ArgumentParser() parser.add_argument("root_dir", help="Path to directory with pastes") args = parser.parse_args() +SLUG_CHARS = string.ascii_lowercase + string.digits +SLUG_LEN = 4 +MAX_SLUG_GENERATION_ATTEMPTS = 256 +MAX_CONTENT_BYTES = 1024 * 1024 # 1 MB + +UPLOAD_FORM = """ + + + + + Fiche Pastebin + + + +

Fiche Pastebin

+

Paste your text below and click Submit to get a shareable URL.
+ You can also use cat file.txt | nc <host> 9999 from the command line.

+
+
+ +
+ +""" + + +def generate_slug(): + """Generate a unique slug that does not collide with an existing directory.""" + root = os.path.abspath(args.root_dir) + for _ in range(MAX_SLUG_GENERATION_ATTEMPTS): + slug = ''.join(random.choices(SLUG_CHARS, k=SLUG_LEN)) + target = os.path.join(root, slug) + if not os.path.exists(target): + return slug + return None + @app.route('/') def main(): - return redirect("http://termbin.com", code=302) + return UPLOAD_FORM + + +@app.route('/submit', methods=['POST']) +def submit(): + content = request.form.get('content', '') + if not content.strip(): + return 'No content provided.', 400 + + if len(content.encode('utf-8')) > MAX_CONTENT_BYTES: + return 'Content exceeds maximum allowed size (1 MB).', 413 + + root = os.path.abspath(args.root_dir) + os.makedirs(root, exist_ok=True) + + slug = generate_slug() + if slug is None: + return 'Could not generate a unique slug. Try again.', 500 + + paste_dir = os.path.join(root, slug) + os.makedirs(paste_dir) + + paste_file = os.path.join(paste_dir, 'index.txt') + with open(paste_file, 'w') as f: + f.write(content) + + return redirect('/' + slug, code=302) @app.route('/') @@ -22,17 +95,19 @@ def beautify(slug): if len(slug) > 64: abort(404) + root = os.path.abspath(args.root_dir) + # Create path for the target dir - target_dir = os.path.join(args.root_dir, slug) + target_dir = os.path.join(root, slug) # Block directory traversal attempts - if not target_dir.startswith(args.root_dir): + if not os.path.abspath(target_dir).startswith(root): abort(404) # Check if directory with requested slug exists if os.path.isdir(target_dir): - target_file = os.path.join(target_dir, "index.txt") - + target_file = os.path.join(target_dir, 'index.txt') + # File index.txt found inside that dir with open(target_file) as f: code = f.read() From b59b66a0c0d35bc02a737dcd51fccaca248e6512 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:36:07 +0400 Subject: [PATCH 2/4] Add Docker support with persistent paste storage via named volume (#2) * Initial plan * Add Docker support with persistent data volume Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/96b3b401-f91b-41f1-a60a-7f50f8a85453 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * Use python:3.13-alpine image for lines service Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/0bf0cf20-8854-4dde-a6a1-85b581c6f046 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * Add .env support for flexible deployment configuration Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/a5e4e09b-0752-45a5-b916-33e269c05700 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * Add .env.example, gitignore .env, update .dockerignore Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/89e41df2-072c-4ee9-bf76-81cf1ee30427 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * Add GitHub Actions Docker build & integration test workflow Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/9500a86b-e6e5-48e6-a442-0f478f21d73c Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * Update docker-compose.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Build lines service from its own Dockerfile; bind Flask to 0.0.0.0 Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/2b2805bb-d6e7-4cae-8d1e-85f1536e4826 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * Add unprivileged fiche user in Dockerfile; fix README .env setup instructions Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/fab8f56a-cc6c-43c1-8497-993146263f9d Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .dockerignore | 16 +++++++ .env.example | 13 ++++++ .github/workflows/docker.yml | 78 +++++++++++++++++++++++++++++++++++ .gitignore | 3 ++ Dockerfile | 31 ++++++++++++++ README.md | 73 ++++++++++++++++++++++++++++++++ docker-compose.yml | 23 +++++++++++ extras/lines/Dockerfile | 12 ++++++ extras/lines/lines.py | 2 +- extras/lines/requirements.txt | 2 + 10 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 extras/lines/Dockerfile create mode 100644 extras/lines/requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f617b8a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# compiled binary +fiche + +# default paste output directory +code/ + +# log files +*.log + +# editor artefacts +*.swp +.DS_Store + +# environment configuration +.env +.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4c46544 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Host port exposed for the fiche TCP server +FICHE_PORT=9999 + +# Host port exposed for the Lines web UI +LINES_PORT=5000 + +# Domain advertised in paste URLs (e.g. yourdomain.com) +FICHE_DOMAIN=localhost + +# Paste storage: named Docker volume (default) or absolute host path for a bind mount +# Named volume example (default): DATA_VOLUME=fiche_data +# Bind-mount example: DATA_VOLUME=/host/path/to/pastes +DATA_VOLUME=fiche_data diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..af0be28 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,78 @@ +name: Docker + +on: + push: + pull_request: + +jobs: + build-and-test: + name: Build & integration test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # ── 1. Build the fiche image standalone so build errors are surfaced early ── + - name: Build fiche image + run: docker build --tag fiche:ci . + + # ── 2. Provide an env file so docker compose can resolve variables ── + - name: Prepare .env + run: cp .env.example .env + + # ── 3. Start the full stack ── + - name: Start services + run: docker compose up -d --build + + # ── 4. Wait for the fiche TCP server (port 9999) ── + - name: Wait for fiche (TCP 9999) + run: | + echo "Waiting for fiche to accept connections on port 9999..." + for i in $(seq 1 30); do + if nc -z localhost 9999 2>/dev/null; then + echo "fiche is up after ${i}s" + exit 0 + fi + sleep 1 + done + echo "ERROR: fiche did not become ready in 30 seconds" >&2 + exit 1 + + # ── 5. Wait for the Lines web UI (port 5000) ── + - name: Wait for lines (HTTP 5000) + run: | + echo "Waiting for lines to respond on port 5000..." + for i in $(seq 1 60); do + status=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/ || true) + if [ "$status" = "200" ]; then + echo "lines returned HTTP 200 after ${i}s" + exit 0 + fi + sleep 1 + done + echo "ERROR: lines did not return HTTP 200 in 60 seconds (last status: $status)" >&2 + exit 1 + + # ── 6. Smoke-test: submit a paste via the web UI ── + - name: Smoke test paste submission + run: | + response=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST http://localhost:5000/submit \ + --data-urlencode "content=hello from CI") + # Expect 200 (rendered paste) or 302 (redirect to paste URL) + if [ "$response" = "200" ] || [ "$response" = "302" ]; then + echo "Paste submission returned HTTP $response – OK" + else + echo "ERROR: unexpected HTTP status $response from /submit" >&2 + exit 1 + fi + + # ── 7. Always dump logs so failures are easy to diagnose ── + - name: Dump container logs + if: always() + run: docker compose logs --no-color + + # ── 8. Tear down ── + - name: Tear down + if: always() + run: docker compose down --volumes diff --git a/.gitignore b/.gitignore index 60faeb7..258377b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ code/ # ignore log files *.log + +# local environment configuration (use .env.example as a template) +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bfc8860 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# ---- Build stage ---- +FROM ubuntu:24.04 AS builder + +RUN apt-get update && \ + apt-get install -y --no-install-recommends gcc make libc6-dev && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY fiche.c fiche.h main.c Makefile ./ + +RUN make + +# ---- Runtime stage ---- +FROM ubuntu:24.04 + +RUN useradd --system --no-create-home --shell /usr/sbin/nologin fiche && \ + mkdir -p /data && \ + chown fiche:fiche /data + +COPY --from=builder /build/fiche /usr/local/bin/fiche + +# /data is the directory where pastes are stored. +# Mount a host directory or a named volume here for persistence. +VOLUME /data + +USER fiche + +EXPOSE 9999 + +ENTRYPOINT ["fiche"] +CMD ["-o", "/data"] diff --git a/README.md b/README.md index b423952..bfdc648 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,79 @@ Then open `http://localhost:5000` in your browser. > **Note:** The paste *storage* directory must be the same directory used by the fiche TCP server (`-o` flag) if you want both interfaces to share pastes. +------------------------------------------------------------------------------- + +## Docker + +A `Dockerfile` and `docker-compose.yml` are provided so you can run fiche (and the optional web interface) in containers with data persisted across restarts. + +### Quick start with Docker Compose + +```bash +# Start both the fiche TCP server (port 9999) and the Lines web UI (port 5000) +docker compose up -d +``` + +> **Note:** `docker compose` (without a hyphen) requires Docker Engine 20.10+ with the Compose V2 plugin. On older installations use `docker-compose` (with a hyphen) instead. + +Paste data is stored in the `fiche_data` named Docker volume and survives container restarts or re-creation. + +### Configuration via `.env` + +All deployment parameters are controlled by the `.env` file in the project root. Copy the provided template before starting: + +```bash +cp .env.example .env +``` + +Then edit `.env` to customise as needed. The available variables and their defaults are: + +| Variable | Default | Description | +|---|---|---| +| `FICHE_PORT` | `9999` | Host port for the fiche TCP server | +| `LINES_PORT` | `5000` | Host port for the Lines web UI | +| `FICHE_DOMAIN` | `localhost` | Domain advertised in paste URLs | +| `DATA_VOLUME` | `fiche_data` | Named Docker volume **or** absolute host path for a bind mount | + +Edit `.env` to customise before starting: + +```bash +# .env +FICHE_PORT=9999 +LINES_PORT=5000 +FICHE_DOMAIN=yourdomain.com +DATA_VOLUME=fiche_data # named volume (default) +# DATA_VOLUME=/host/path/pastes # bind-mount alternative +``` + +Then start: + +```bash +docker compose up -d +``` + +### Accessing pastes + +After starting, send text with netcat: + +```bash +cat file.txt | nc localhost 9999 +``` + +Open the returned URL in a browser, or browse all pastes through the Lines web UI at `http://localhost:5000`. + +### Running with plain Docker + +```bash +docker build -t fiche . +docker run -d \ + -p 9999:9999 \ + -v fiche_data:/data \ + fiche -o /data -d yourdomain.com +``` + +------------------------------------------------------------------------------- + ## License Fiche is MIT licensed. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..de793be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + fiche: + build: . + ports: + - "${FICHE_PORT:-9999}:9999" + volumes: + - ${DATA_VOLUME:-fiche_data}:/data + command: ["-o", "/data", "-d", "${FICHE_DOMAIN:-localhost}"] + restart: unless-stopped + + lines: + build: ./extras/lines + volumes: + - ${DATA_VOLUME:-fiche_data}:/data + ports: + - "${LINES_PORT:-5000}:5000" + command: ["python", "lines.py", "/data"] + depends_on: + - fiche + restart: unless-stopped + +volumes: + fiche_data: diff --git a/extras/lines/Dockerfile b/extras/lines/Dockerfile new file mode 100644 index 0000000..642fda2 --- /dev/null +++ b/extras/lines/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.13-alpine + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["python", "lines.py", "/data"] diff --git a/extras/lines/lines.py b/extras/lines/lines.py index 87f86ba..7b1bff2 100644 --- a/extras/lines/lines.py +++ b/extras/lines/lines.py @@ -123,4 +123,4 @@ def beautify(slug): if __name__ == '__main__': - app.run() + app.run(host='0.0.0.0', port=5000) diff --git a/extras/lines/requirements.txt b/extras/lines/requirements.txt new file mode 100644 index 0000000..cad276e --- /dev/null +++ b/extras/lines/requirements.txt @@ -0,0 +1,2 @@ +flask==3.1.3 +pygments==2.20.0 From 053f32216bc2fac99d40b784f798221d46de6b49 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 22:44:39 +0400 Subject: [PATCH 3/4] feat: add image/binary file upload support to Flask app (#3) * feat: add file upload support to Flask app Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/a4d205a3-be06-4c55-93db-c3bf8b394584 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * fix: address security review feedback on file upload routes Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/a4d205a3-be06-4c55-93db-c3bf8b394584 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * fix: rename base to first_part in allowed_file for clarity Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/a4d205a3-be06-4c55-93db-c3bf8b394584 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * fix: serve uploaded files as attachments with nosniff header to prevent SVG XSS Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/8c538e91-c7e4-4c5c-85cb-365c5900d274 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * fix: enforce 1 MB request size limit early in submit() via content_length check Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/4767a3b1-693b-4743-a99c-c1d16c4ec7e3 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> --- extras/lines/lines.py | 127 ++++++++++++++++++++++++++++++---- extras/lines/requirements.txt | 1 + 2 files changed, 113 insertions(+), 15 deletions(-) diff --git a/extras/lines/lines.py b/extras/lines/lines.py index 7b1bff2..47cdd1d 100644 --- a/extras/lines/lines.py +++ b/extras/lines/lines.py @@ -1,13 +1,15 @@ import argparse import os import random +import re import string import pygments -from flask import Flask, abort, redirect, request +from flask import Flask, abort, redirect, request, send_from_directory, url_for from pygments import highlight from pygments.formatters import HtmlFormatter from pygments.lexers import guess_lexer +from werkzeug.utils import secure_filename app = Flask(__name__) @@ -18,7 +20,16 @@ SLUG_CHARS = string.ascii_lowercase + string.digits SLUG_LEN = 4 MAX_SLUG_GENERATION_ATTEMPTS = 256 -MAX_CONTENT_BYTES = 1024 * 1024 # 1 MB +MAX_CONTENT_BYTES = 1024 * 1024 # 1 MB for text pastes +MAX_UPLOAD_BYTES = 5 * 1024 * 1024 # 5 MB for file uploads +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf'} +# Extensions that should never appear anywhere in an uploaded filename +DANGEROUS_EXTENSIONS = { + 'php', 'php3', 'php4', 'php5', 'phtml', 'asp', 'aspx', 'jsp', + 'cgi', 'pl', 'py', 'rb', 'sh', 'bash', 'exe', 'bat', 'cmd', 'ps1', +} + +app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_BYTES UPLOAD_FORM = """ @@ -29,24 +40,48 @@

Fiche Pastebin

+

Text paste

Paste your text below and click Submit to get a shareable URL.
You can also use cat file.txt | nc <host> 9999 from the command line.


+
+

File upload

+

Upload an image or PDF (max 5 MB). Allowed types: png, jpg, jpeg, gif, webp, bmp, svg, pdf.

+
+
+ +
""" +def allowed_file(filename): + if '.' not in filename: + return False + parts = filename.split('.') + first_part, ext = parts[0], parts[-1].lower() + if not first_part: + return False + # Reject if any component of the filename is a dangerous extension + if any(p.lower() in DANGEROUS_EXTENSIONS for p in parts[1:]): + return False + return ext in ALLOWED_EXTENSIONS + + def generate_slug(): """Generate a unique slug that does not collide with an existing directory.""" root = os.path.abspath(args.root_dir) @@ -65,6 +100,9 @@ def main(): @app.route('/submit', methods=['POST']) def submit(): + if request.content_length and request.content_length > MAX_CONTENT_BYTES: + return 'Content exceeds maximum allowed size (1 MB).', 413 + content = request.form.get('content', '') if not content.strip(): return 'No content provided.', 400 @@ -89,10 +127,47 @@ def submit(): return redirect('/' + slug, code=302) +@app.route('/upload', methods=['POST']) +def upload(): + if 'file' not in request.files: + return 'No file part in the request.', 400 + + file = request.files['file'] + + if file.filename == '': + return 'No selected file.', 400 + + if not allowed_file(file.filename): + return 'File type not allowed.', 400 + + filename = secure_filename(file.filename) + if not filename or not allowed_file(filename): + return 'Invalid filename.', 400 + + root = os.path.abspath(args.root_dir) + os.makedirs(root, exist_ok=True) + + slug = generate_slug() + if slug is None: + return 'Could not generate a unique slug. Try again.', 500 + + paste_dir = os.path.join(root, slug) + os.makedirs(paste_dir) + + file_path = os.path.join(paste_dir, filename) + file.save(file_path) + + meta_path = os.path.join(paste_dir, 'meta.txt') + with open(meta_path, 'w') as f: + f.write(f'original_filename={filename}\n') + + return redirect(url_for('view_paste', slug=slug), code=302) + + @app.route('/') -def beautify(slug): - # Return 404 in case of urls longer than 64 chars - if len(slug) > 64: +def view_paste(slug): + # Validate slug to only contain safe generated characters + if not re.fullmatch(r'[a-z0-9]{1,64}', slug): abort(404) root = os.path.abspath(args.root_dir) @@ -101,22 +176,44 @@ def beautify(slug): target_dir = os.path.join(root, slug) # Block directory traversal attempts - if not os.path.abspath(target_dir).startswith(root): + if not os.path.abspath(target_dir).startswith(root + os.sep): abort(404) # Check if directory with requested slug exists - if os.path.isdir(target_dir): - target_file = os.path.join(target_dir, 'index.txt') + if not os.path.isdir(target_dir): + abort(404) + text_file = os.path.join(target_dir, 'index.txt') + if os.path.isfile(text_file): # File index.txt found inside that dir - with open(target_file) as f: + with open(text_file) as f: code = f.read() - # Identify language - lexer = guess_lexer(code) - # Create formatter with line numbers - formatter = HtmlFormatter(linenos=True, full=True) - # Return parsed code - return highlight(code, lexer, formatter) + # Identify language + lexer = guess_lexer(code) + # Create formatter with line numbers + formatter = HtmlFormatter(linenos=True, full=True) + # Return parsed code + return highlight(code, lexer, formatter) + + # Try to serve an uploaded file + meta_file = os.path.join(target_dir, 'meta.txt') + if os.path.isfile(meta_file): + filename = None + with open(meta_file) as f: + for line in f: + if line.startswith('original_filename='): + filename = line.split('=', 1)[1].strip() + break + # filename was already sanitized with secure_filename at upload time; + # re-validate defensively in case meta.txt is tampered with + if filename: + safe = secure_filename(filename) + if safe and allowed_file(safe) and os.path.isfile(os.path.join(target_dir, safe)): + response = send_from_directory( + target_dir, safe, as_attachment=True + ) + response.headers['X-Content-Type-Options'] = 'nosniff' + return response # Not found abort(404) diff --git a/extras/lines/requirements.txt b/extras/lines/requirements.txt index cad276e..4b47f94 100644 --- a/extras/lines/requirements.txt +++ b/extras/lines/requirements.txt @@ -1,2 +1,3 @@ flask==3.1.3 pygments==2.20.0 +werkzeug==3.1.3 From e0a20631eb832732a82ba45a622d8691cc194386 Mon Sep 17 00:00:00 2001 From: abqareno <46631436+abqareno@users.noreply.github.com> Date: Fri, 15 May 2026 00:00:42 +0400 Subject: [PATCH 4/4] Add GitHub Actions workflow for Docker image build (#4) * Add GitHub Actions workflow for Docker image build * Harden Docker workflow token usage and image tagging Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/2f3b02ff-68da-4db3-aac8-f7d19c840f18 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * Adjust GHCR login user and restore semantic image tag Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/2f3b02ff-68da-4db3-aac8-f7d19c840f18 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: harden docker publish workflow Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/ab43da95-f736-4a67-adaf-8bcfde90ab79 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * refactor: remove redundant docker prebuild step Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/ab43da95-f736-4a67-adaf-8bcfde90ab79 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> * fix: gate docker publish job on tests Agent-Logs-Url: https://github.com/abqareno/fiche_modern/sessions/b08ff42d-a435-4678-9fe1-50e78a94e3e6 Co-authored-by: abqareno <46631436+abqareno@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/docker-image.yml | 123 +++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .github/workflows/docker-image.yml diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..9a24ef6 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,123 @@ +name: Build and Push Docker image to ghcr.io + +on: + push: + branches: + - master + tags: + - '*' + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Prepare .env + run: cp .env.example .env + + - name: Start services + run: docker compose up -d --build + + - name: Wait for fiche (TCP 9999) + run: | + echo "Waiting for fiche to accept connections on port 9999..." + for i in $(seq 1 30); do + if nc -z localhost 9999 2>/dev/null; then + echo "fiche is up after ${i}s" + exit 0 + fi + sleep 1 + done + echo "ERROR: fiche did not become ready in 30 seconds" >&2 + exit 1 + + - name: Wait for lines (HTTP 5000) + run: | + echo "Waiting for lines to respond on port 5000..." + for i in $(seq 1 60); do + status=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/ || true) + if [ "$status" = "200" ]; then + echo "lines returned HTTP 200 after ${i}s" + exit 0 + fi + sleep 1 + done + echo "ERROR: lines did not return HTTP 200 in 60 seconds (last status: $status)" >&2 + exit 1 + + - name: Smoke test paste submission + run: | + response=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST http://localhost:5000/submit \ + --data-urlencode "content=hello from CI") + if [ "$response" = "200" ] || [ "$response" = "302" ]; then + echo "Paste submission returned HTTP $response – OK" + else + echo "ERROR: unexpected HTTP status $response from /submit" >&2 + exit 1 + fi + + - name: Dump container logs + if: always() + run: docker compose logs --no-color + + - name: Tear down + if: always() + run: docker compose down --volumes + + build-and-push: + needs: build-and-test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Compute image tags + id: image-tags + run: | + image="ghcr.io/${{ github.repository }}" + sha_tag="${image}:${GITHUB_SHA}" + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + { + echo "tags<> "$GITHUB_OUTPUT" + else + { + echo "tags<> "$GITHUB_OUTPUT" + fi + + - name: Log in to ghcr.io + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 + with: + context: . + file: Dockerfile + push: true + tags: ${{ steps.image-tags.outputs.tags }}