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-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 }} 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 e3922d5..bfdc648 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,104 @@ 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. + +------------------------------------------------------------------------------- + +## 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 df6b80c..47cdd1d 100644 --- a/extras/lines/lines.py +++ b/extras/lines/lines.py @@ -1,51 +1,223 @@ -from flask import Flask, abort, redirect -app = Flask(__name__) +import argparse +import os +import random +import re +import string -import argparse, os, pygments +import pygments +from flask import Flask, abort, redirect, request, send_from_directory, url_for from pygments import highlight -from pygments.lexers import guess_lexer from pygments.formatters import HtmlFormatter +from pygments.lexers import guess_lexer +from werkzeug.utils import secure_filename + +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 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 = """ + + + + + Fiche Pastebin + + + +

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) + 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(): + 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 + + 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('/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) + # 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 + 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) 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..4b47f94 --- /dev/null +++ b/extras/lines/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.3 +pygments==2.20.0 +werkzeug==3.1.3