Compare commits

..
12 Commits
Author SHA1 Message Date
waffle2k 3694225d64 Migrate CI to Gitea Actions
docker-build-push / build-push (push) Successful in 45s
Adds .gitea/workflows/docker-build-push.yml (build+push to
gitea.blairhaus.net/pmb/finger-web, matching yttrx-welcomebot's pattern
for the same admin.yttrx.com deploy target) now that GitHub is retired.
2026-07-23 22:09:32 -07:00
waffle2k 8c7ba4dbd9 Restyle to Tokyo Night, matching waffles.yttrx.com's palette
Overrides Bootstrap 5.3's native dark color-mode tokens with the exact
Tokyo Night hex values waffles.yttrx.com's Compost/Tailwind theme uses
(same primary blue, neutral backgrounds, etc.), so the two sites share
one visual identity, plus repoints the stale GitHub source link to Gitea.
2026-07-23 22:09:22 -07:00
pmb c8786a2a0b Route finger search through the cacheable /finger/<term> path
Build and Push Docker Image / build-and-push (push) Failing after 2m19s
The search form POSTed to /finger, an uncacheable URL with no search term in
it, so every lookup re-ran the finger command even for repeated searches of the
same user. Redirect any query/POST search to the canonical /finger/<username>
path (and submit the form via GET, with a JS fast-path straight to that URL) so
repeated lookups are served from the nginx response cache. A bare /finger with
no user still lists local system users.
2026-06-17 10:24:16 -07:00
pmb 3115ac64b2 Add per-IP rate limiting to prevent finger-daemon abuse
The app sits behind nginx, which caches 200s for 30s — so repeated lookups
of the same user are cheap. What bypasses the cache is enumeration of distinct
usernames: each is a unique cache key -> miss -> a fresh finger call to the
mammut daemon, all attributed to admin's single IP (so the daemon cannot ban
the real source). The app only ever receives cache misses, so a per-IP limit
here throttles exactly that uncached path without touching the cached hot path.

- Flask-Limiter keyed per client IP: 30/min on the finger lookup endpoints,
  10/min on /api/upload (auth brute-force), 120/min global default. Index and
  the container healthcheck are exempt. All limits env-tunable (RATELIMIT_*).
- ProxyFix(x_for=1): trust nginx's X-Forwarded-For so the real client IP is
  used for keying and logging. Without it the app only saw the Docker bridge
  gateway (172.20.0.1) and every client shared one bucket.
- 429 handler (JSON for /api, HTML 429.html otherwise) and WARNING logging of
  failed/invalid lookups and limit hits, so enumeration is observable.
2026-06-17 10:23:58 -07:00
pmb ad4b9b9af8 Use minimal single-column layout for all finger results
Unify the interactive /finger page on the same results-first layout
introduced for direct /finger/<user> links: drop the two-column view
(About Finger panel, success banner, timestamp, back-to-home button)
and the now-unused direct flag.
2026-06-15 20:36:41 -07:00
pmb a49545796a Minimal single-column layout for direct /finger/<user> links
Direct links now render a results-first single-column view: drop the
About Finger panel, success banner, timestamp and back-to-home button,
and replace the lookup card with a small inline form next to the heading.
The interactive /finger page keeps its original two-column layout.
2026-06-15 20:26:55 -07:00
pmb b99636d3c0 Fix BuildError: nav links to renamed api_info endpoint
base.html referenced url_for('api_hello'), but that endpoint is now
api_info (/api/info). Since every page extends base.html, the dead
reference raised werkzeug BuildError and returned HTTP 500 site-wide --
including /finger/<user>, which federated Mastodon instances fetch for
link previews. The 500s also defeated nginx's 200-only cache, so every
fetch re-ran the finger subprocess against the daemon on mammut.
2026-06-15 17:01:56 -07:00
waffle2k 295a123717 Update README with API, CLI, and MCP documentation 2026-05-07 12:16:39 -07:00
waffle2k 178d3f361c Add JSON API endpoint, CLI tool, and MCP server
- Add /api/finger and /api/finger/<username> JSON endpoints
- Remove hardcoded default credentials from config.py; require BASIC_AUTH_USERS env var
- Add cli/finger.py: query and plan-upload CLI using the JSON API
- Add mcp/server.py: FastMCP server exposing finger_user and upload_plan tools
- All credentials and base URL are read from environment variables
2026-05-07 12:03:23 -07:00
pmb 1e53363271 Handle utf decoding 2025-07-02 17:54:32 -07:00
pmb 7515431fb5 Add missing package to the Dockerfile 2025-06-27 15:30:31 -07:00
pmb fd493f13e9 Support file uploads for setting finger status 2025-06-27 15:20:54 -07:00
16 changed files with 999 additions and 553 deletions
+33
View File
@@ -0,0 +1,33 @@
name: docker-build-push
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# act_runner bind-mounts the admin host's real Docker socket into every
# job container (Docker-outside-of-Docker, not a nested daemon), so
# `docker` here talks straight to the host's daemon — no separate
# dockerd to start. Deploy to admin.yttrx.com stays manual (docker
# compose pull && up -d there), matching yttrx-welcomebot's pattern for
# the same host.
- name: Build and push image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
SHA="${{ github.sha }}"
IMAGE="gitea.blairhaus.net/pmb/finger-web"
docker build -t "$IMAGE:$SHA" -t "$IMAGE:latest" .
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.blairhaus.net -u pmb --password-stdin
docker push "$IMAGE:$SHA"
docker push "$IMAGE:latest"
docker logout gitea.blairhaus.net
echo "pushed $IMAGE:$SHA and $IMAGE:latest"
-50
View File
@@ -1,50 +0,0 @@
name: Build and Push Docker Image
on:
push:
branches: [ main ]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: finger-web
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1
View File
@@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y \
curl \ curl \
gcc \ gcc \
finger \ finger \
openssh-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy requirements first to leverage Docker cache # Copy requirements first to leverage Docker cache
+141 -284
View File
@@ -1,307 +1,164 @@
# Finger Web Flask Application # finger-web
A simple, modern Flask web application demonstrating basic web development concepts with clean code structure, responsive design, and best practices. A Flask web application that fronts a finger daemon, with a JSON API, a CLI client, and an MCP server for Claude integration.
## 🚀 Features ## Components
- **Multiple Routes**: Home, About, Contact pages with clean navigation | Path | Description |
- **Contact Form**: Functional contact form with validation and flash messages |------|-------------|
- **JSON API**: RESTful API endpoints for data exchange | `app.py` | Flask web app and JSON API |
- **Responsive Design**: Mobile-first design using Bootstrap 5 | `cli/finger.py` | Command-line client |
- **Error Handling**: Custom 404 and 500 error pages | `mcp/server.py` | MCP server for Claude |
- **Modern UI**: Clean, professional interface with animations
- **Form Validation**: Client-side and server-side validation
- **Configuration Management**: Environment-based configuration
## 📁 Project Structure ---
``` ## Web App
finger-web/
├── app.py # Main Flask application
├── config.py # Configuration settings
├── requirements.txt # Python dependencies
├── README.md # Project documentation
├── .gitignore # Git ignore file
├── Dockerfile # Docker container configuration
├── .dockerignore # Docker ignore file
├── docker-compose.yml # Docker Compose configuration
├── templates/ # HTML templates
│ ├── base.html # Base template with navigation
│ ├── index.html # Home page
│ ├── about.html # About page
│ ├── contact.html # Contact form
│ ├── 404.html # 404 error page
│ └── 500.html # 500 error page
└── static/ # Static assets
├── css/
│ └── style.css # Custom styles
└── js/
└── main.js # JavaScript functionality
```
## 🛠️ Technologies Used ### Requirements
### Backend - Python 3.9+
- **Python 3.x** - Programming language - A `finger` binary available on the server's PATH
- **Flask 2.3.3** - Web framework
- **Jinja2** - Template engine
- **Werkzeug** - WSGI toolkit
### Frontend ### Installation
- **HTML5** - Markup language
- **CSS3** - Styling with custom animations
- **JavaScript (ES6+)** - Interactive functionality
- **Bootstrap 5.3** - CSS framework for responsive design
## 📋 Prerequisites
- Python 3.7 or higher
- pip (Python package installer)
- Virtual environment (recommended)
## 🚀 Installation & Setup
### 1. Clone or Download the Project
```bash
# If using git
git clone <repository-url>
cd finger-web
# Or download and extract the project files
```
### 2. Create Virtual Environment (Recommended)
```bash
# Create virtual environment
python -m venv venv
# Activate virtual environment
# On Windows:
venv\Scripts\activate
# On macOS/Linux:
source venv/bin/activate
```
### 3. Install Dependencies
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### 4. Run the Application ### Configuration
All settings are read from environment variables.
| Variable | Description | Default |
|----------|-------------|---------|
| `SECRET_KEY` | Flask secret key | `dev-secret-key-change-in-production` |
| `FLASK_DEBUG` | Enable debug mode | `True` |
| `BASIC_AUTH_USERS` | Comma-separated `user:pass` pairs for upload auth | _(none — upload disabled)_ |
| `SCP_ENABLED` | Enable SCP transfer of uploaded plan files | `false` |
| `REMOTE_HOST` | Remote host for SCP | — |
| `REMOTE_USER` | Remote user for SCP | — |
| `REMOTE_PATH` | Remote path for SCP destination | — |
| `REMOTE_PORT` | Remote SSH port | `22` |
| `REMOTE_PRIVATE_KEY` | Path to SSH private key | — |
### Running
```bash ```bash
python app.py python app.py
``` # or with gunicorn
The application will start on `http://localhost:5000`
## 🌐 Available Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/` | GET | Home page |
| `/about` | GET | About page |
| `/contact` | GET, POST | Contact form |
| `/api/hello` | GET | Simple JSON API endpoint |
| `/api/info` | GET | Application information API |
## 🔧 Configuration
The application uses environment variables for configuration. You can set these in your environment or create a `.env` file:
```bash
# Flask Configuration
FLASK_DEBUG=True
SECRET_KEY=your-secret-key-here
# Database (for future use)
DATABASE_URL=sqlite:///app.db
# Mail Configuration (for future use)
MAIL_SERVER=smtp.gmail.com
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_USERNAME=[email protected]
MAIL_PASSWORD=your-app-password
```
## 📱 API Endpoints
### GET /api/hello
Returns a simple greeting message.
**Response:**
```json
{
"message": "Hello from Flask API!",
"status": "success",
"version": "1.0"
}
```
### GET /api/info
Returns application information and available routes.
**Response:**
```json
{
"app_name": "Finger Web Flask App",
"routes": ["/", "/about", "/contact", "/api/hello", "/api/info"],
"framework": "Flask"
}
```
## 🎨 Customization
### Styling
- Edit `static/css/style.css` to customize the appearance
- The app uses Bootstrap 5 classes for responsive design
- Custom CSS variables and animations are included
### JavaScript
- Modify `static/js/main.js` for additional functionality
- Includes form validation, animations, and keyboard shortcuts
- API helper functions are available
### Templates
- All HTML templates extend `templates/base.html`
- Use Jinja2 template syntax for dynamic content
- Bootstrap components are readily available
## 🔍 Features in Detail
### Contact Form
- Client-side validation with real-time feedback
- Server-side validation and sanitization
- Flash messages for user feedback
- Form submission with loading states
### Responsive Design
- Mobile-first approach
- Bootstrap grid system
- Custom breakpoints and animations
- Touch-friendly interface
### Error Handling
- Custom 404 and 500 error pages
- Graceful error handling in routes
- User-friendly error messages
### JavaScript Features
- Form validation and enhancement
- Smooth scrolling navigation
- Card animations on scroll
- Keyboard shortcuts (Alt+H, Alt+A, Alt+C)
- API interaction helpers
## 🚀 Deployment
### Development
```bash
python app.py
```
### Docker Deployment
#### Option 1: Using Docker directly
```bash
# Build the Docker image
docker build -t finger-web .
# Run the container
docker run -d -p 5000:5000 --name finger-web-app finger-web
```
#### Option 2: Using Docker Compose (Recommended)
```bash
# Build and start the application
docker-compose up -d
# View logs
docker-compose logs -f
# Stop the application
docker-compose down
```
#### Docker Commands
```bash
# Build image
docker build -t finger-web .
# Run container with environment variables
docker run -d \
-p 5000:5000 \
-e FLASK_ENV=production \
-e SECRET_KEY=your-secret-key \
--name finger-web-app \
finger-web
# View container logs
docker logs finger-web-app
# Stop and remove container
docker stop finger-web-app
docker rm finger-web-app
```
### Production
For production deployment, consider using:
- **Docker** for containerization (included)
- **Docker Compose** for orchestration (included)
- **Gunicorn** as WSGI server
- **Nginx** as reverse proxy
- **Heroku**, **DigitalOcean**, or **AWS** for hosting
Example with Gunicorn:
```bash
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app gunicorn -w 4 -b 0.0.0.0:5000 app:app
``` ```
## 🔒 Security Considerations ### Docker
- Change the `SECRET_KEY` in production ```bash
- Use environment variables for sensitive data docker-compose up -d
- Enable HTTPS in production ```
- Implement rate limiting for forms
- Validate and sanitize all user inputs
## 🤝 Contributing
1. Fork the project
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
## 📝 License
This project is open source and available under the [MIT License](LICENSE).
## 📞 Support
If you encounter any issues or have questions:
1. Check the existing documentation
2. Review the code comments
3. Test in a clean virtual environment
4. Create an issue with detailed information
## 🎯 Future Enhancements
- Database integration with SQLAlchemy
- User authentication and sessions
- Email functionality for contact form
- Admin dashboard
- API rate limiting
- Unit tests
- CI/CD pipeline
- Kubernetes deployment manifests
- Monitoring and logging integration
--- ---
**Built with ❤️ using Flask and Bootstrap** ## API
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/finger` | GET/POST | — | Web UI finger query |
| `/finger/<username>` | GET | — | Web UI finger query (URL form) |
| `/api/finger` | GET | — | JSON: list logged-in users |
| `/api/finger/<username>` | GET | — | JSON: finger a specific user |
| `/api/upload` | POST | Basic | Upload a plan file |
| `/api/info` | GET | — | API metadata |
### Example
```bash
curl http://localhost:5000/api/finger/[email protected]
```
```json
{
"status": "success",
"username": "[email protected]",
"result": "Login: pete\t\t\tName: Pete Blair\n..."
}
```
---
## CLI
### Installation
```bash
pip install -r cli/requirements.txt
```
### Configuration
```bash
export FINGER_WEB_URL=http://localhost:5000
export FINGER_USER=youruser # only needed for plan uploads
export FINGER_PASS=yourpassword # only needed for plan uploads
```
### Usage
```bash
# Finger a user
python cli/finger.py query [email protected]
# Upload your plan file
python cli/finger.py plan ~/.plan
```
---
## MCP Server
Exposes finger query and plan upload as tools for Claude.
### Installation
```bash
pip install -r mcp/requirements.txt
```
### Configuration
```bash
export FINGER_WEB_URL=http://localhost:5000
export FINGER_USER=youruser # only needed for upload_plan tool
export FINGER_PASS=yourpassword # only needed for upload_plan tool
```
### Running
```bash
python mcp/server.py
```
### Claude Desktop configuration
Add to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"finger": {
"command": "python",
"args": ["/path/to/finger-web/mcp/server.py"],
"env": {
"FINGER_WEB_URL": "http://localhost:5000",
"FINGER_USER": "youruser",
"FINGER_PASS": "yourpassword"
}
}
}
}
```
### Available tools
| Tool | Description |
|------|-------------|
| `finger_user(username)` | Query finger info for a user, or leave empty to list logged-in users |
| `upload_plan(filename, content)` | Upload or update a plan file |
+354 -83
View File
@@ -1,127 +1,398 @@
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash from flask import Flask, render_template, request, jsonify, redirect, url_for, flash
from flask_httpauth import HTTPBasicAuth
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix
import os import os
import subprocess import subprocess
import shlex import shlex
import datetime
from config import Config from config import Config
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) app.config.from_object(Config)
# Trust the front-end nginx's X-Forwarded-For so the real client IP is used for
# logging and rate limiting. Without this the app only ever sees the Docker
# bridge gateway (172.20.0.1), so every client on the internet would share one
# rate-limit bucket and log line.
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=app.config['PROXY_FORWARDED_COUNT'],
x_proto=1,
x_host=1,
)
# Initialize HTTP Basic Auth
auth = HTTPBasicAuth()
# Per-client-IP rate limiting. The app only ever receives nginx cache *misses*
# (upstream caches 200s for 30s), so these limits throttle exactly the uncached
# finger-daemon lookups (e.g. username enumeration) without touching the cached
# hot path that legitimate Fediverse previews ride on.
limiter = Limiter(
key_func=get_remote_address,
app=app,
default_limits=[l.strip() for l in app.config['RATELIMIT_DEFAULT'].split(';') if l.strip()],
storage_uri=app.config['RATELIMIT_STORAGE_URI'],
enabled=app.config['RATELIMIT_ENABLED'],
headers_enabled=True,
)
@auth.verify_password
def verify_password(username, password):
"""Verify basic authentication credentials against multiple users"""
return (username in app.config['BASIC_AUTH_USERS'] and
app.config['BASIC_AUTH_USERS'][username] == password)
def allowed_file(filename):
"""Check if file extension is allowed"""
return ('.' in filename and
filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS'])
def safe_decode_output(raw_output):
"""
Safely decode subprocess output with fallback encoding strategies.
Args:
raw_output (bytes): Raw bytes output from subprocess
Returns:
str: Decoded string output
"""
if isinstance(raw_output, str):
return raw_output
# List of encodings to try in order
encodings = ['utf-8', 'latin-1', 'cp1252', 'iso-8859-1']
for encoding in encodings:
try:
return raw_output.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
# If all encodings fail, use utf-8 with error handling
try:
return raw_output.decode('utf-8', errors='replace')
except Exception:
# Last resort: convert to string representation
return str(raw_output, errors='ignore')
def run_finger_command(cmd):
"""
Execute finger command with robust encoding handling.
Args:
cmd (list): Command list to execute
Returns:
tuple: (success, result_or_error, is_error)
"""
try:
# First try with text=True (UTF-8 decoding)
process = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=10
)
if process.returncode == 0:
result = process.stdout
if not result.strip():
result = "No information available."
return True, result, False
else:
error = process.stderr or "Finger command failed."
return False, error, True
except UnicodeDecodeError:
# Handle UTF-8 decoding error by using binary mode
try:
process = subprocess.run(
cmd,
capture_output=True,
text=False, # Get raw bytes
timeout=10
)
if process.returncode == 0:
result = safe_decode_output(process.stdout)
if not result.strip():
result = "No information available."
return True, result, False
else:
error = safe_decode_output(process.stderr) or "Finger command failed."
return False, error, True
except Exception as e:
return False, f"Encoding error occurred: {str(e)}", True
except subprocess.TimeoutExpired:
return False, "Command timed out. Please try again.", True
except FileNotFoundError:
return False, "Finger command not available on this system.", True
except Exception as e:
return False, f"An error occurred: {str(e)}", True
@app.route('/') @app.route('/')
@limiter.exempt
def index(): def index():
"""Home page route""" """Home page route (exempt from limits; also serves the container healthcheck)"""
return render_template('index.html', title='Home') return render_template('index.html', title='Home')
@app.route('/finger', methods=['GET', 'POST']) @app.route('/finger', methods=['GET', 'POST'])
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
def finger(): def finger():
"""Finger command route""" """Finger search form.
Any submitted username is redirected to the canonical
``/finger/<username>`` path so the lookup happens on a cacheable URL with
the search term in it — repeated searches for the same user are served from
the nginx response cache instead of re-running the finger command. A bare
``/finger`` with no user shows the local system users (no daemon enumeration
risk: it never takes external input).
"""
username = (request.args.get('user', '')
or request.args.get('username', '')
or request.form.get('username', '')).strip()
if username:
# Send the search to the cacheable canonical path.
return redirect(url_for('finger_direct', username=username))
# No user supplied: list logged-in system users.
result = None result = None
error = None error = None
username = request.args.get('user', '') or request.form.get('username', '') success, output, is_error = run_finger_command(['finger'])
if success:
if request.method == 'POST' or username: result = output
try: else:
# Sanitize username input error = output
if username:
# Allow alphanumeric characters, dots, hyphens, underscores, and @ symbol for email-style usernames
if not all(c.isalnum() or c in '.-_@' for c in username):
error = "Invalid username format. Only alphanumeric characters, dots, hyphens, underscores, and @ symbol are allowed."
else:
# Execute finger command with specific user
cmd = ['finger', username]
else:
# Execute finger command without user (show all logged in users)
cmd = ['finger']
if not error:
# Execute the command safely
process = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=10
)
if process.returncode == 0:
result = process.stdout
if not result.strip():
result = "No information available."
else:
error = process.stderr or "Finger command failed."
except subprocess.TimeoutExpired:
error = "Command timed out. Please try again."
except FileNotFoundError:
error = "Finger command not available on this system."
except Exception as e:
error = f"An error occurred: {str(e)}"
return render_template('finger.html', title='Finger', result=result, error=error, username=username) return render_template('finger.html', title='Finger', result=result, error=error, username=username)
@app.route('/finger/<path:username>') @app.route('/finger/<path:username>')
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
def finger_direct(username): def finger_direct(username):
"""Direct finger command route with username in URL""" """Direct finger command route with username in URL"""
result = None result = None
error = None error = None
try: # Sanitize username input
# Sanitize username input if username:
if username: # Allow alphanumeric characters, dots, hyphens, underscores, and @ symbol for email-style usernames
# Allow alphanumeric characters, dots, hyphens, underscores, and @ symbol for email-style usernames if not all(c.isalnum() or c in '.-_@' for c in username):
if not all(c.isalnum() or c in '.-_@' for c in username): error = "Invalid username format. Only alphanumeric characters, dots, hyphens, underscores, and @ symbol are allowed."
error = "Invalid username format. Only alphanumeric characters, dots, hyphens, underscores, and @ symbol are allowed." app.logger.warning("rejected invalid finger username %r from %s",
username, get_remote_address())
else:
# Execute finger command with specific user
cmd = ['finger', username]
# Execute the command with robust encoding handling
success, output, is_error = run_finger_command(cmd)
if success:
result = output
else: else:
# Execute finger command with specific user error = output
cmd = ['finger', username] app.logger.warning("finger lookup failed for %r from %s: %s",
username, get_remote_address(), output.strip()[:200])
# Execute the command safely
process = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=10
)
if process.returncode == 0:
result = process.stdout
if not result.strip():
result = "No information available."
else:
error = process.stderr or "Finger command failed."
except subprocess.TimeoutExpired:
error = "Command timed out. Please try again."
except FileNotFoundError:
error = "Finger command not available on this system."
except Exception as e:
error = f"An error occurred: {str(e)}"
return render_template('finger.html', title=f'Finger - {username}', result=result, error=error, username=username) return render_template('finger.html', title=f'Finger - {username}', result=result, error=error, username=username)
@app.route('/api/hello') @app.route('/api/finger')
def api_hello(): @app.route('/api/finger/<path:username>')
"""Simple JSON API endpoint""" @limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
return jsonify({ def api_finger(username=None):
'message': 'Hello from Flask API!', """JSON API endpoint for finger queries"""
'status': 'success', if username is None:
'version': '1.0' username = request.args.get('user', '')
})
if username:
if not all(c.isalnum() or c in '.-_@' for c in username):
return jsonify({'error': 'Invalid username', 'status': 'error'}), 400
cmd = ['finger', username]
else:
cmd = ['finger']
success, output, is_error = run_finger_command(cmd)
if success:
return jsonify({'result': output, 'username': username, 'status': 'success'})
return jsonify({'error': output, 'status': 'error'}), 500
@app.route('/api/info') @app.route('/api/info')
def api_info(): def api_info():
"""API info endpoint""" """API info endpoint"""
return jsonify({ return jsonify({
'app_name': 'Finger Web Flask App', 'app_name': 'finger-web',
'routes': [ 'routes': [
'/', '/',
'/finger', '/finger',
'/finger/<username>', '/finger/<username>',
'/api/hello', '/api/finger',
'/api/info' '/api/finger/<username>',
'/api/info',
'/api/upload'
], ],
'framework': 'Flask' 'framework': 'Flask'
}) })
@app.route('/api/upload', methods=['POST'])
@limiter.limit(lambda: app.config['RATELIMIT_UPLOAD'])
@auth.login_required
def upload_file():
"""File upload endpoint with basic authentication and SCP transfer"""
try:
# Extract authenticated username
authenticated_user = auth.current_user()
# Check if the post request has the file part
if 'file' not in request.files:
return jsonify({
'error': 'No file part in the request',
'status': 'error'
}), 400
file = request.files['file']
# Check if user selected a file
if file.filename == '':
return jsonify({
'error': 'No file selected',
'status': 'error'
}), 400
# Check if file is allowed
if not allowed_file(file.filename):
return jsonify({
'error': f'File type not allowed. Allowed types: {", ".join(app.config["ALLOWED_EXTENSIONS"])}',
'status': 'error'
}), 400
if file:
# Generate secure filename with timestamp
original_filename = secure_filename(file.filename)
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{timestamp}_{original_filename}"
# Ensure upload directory exists
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.exists(upload_folder):
os.makedirs(upload_folder)
# Save file locally
file_path = os.path.join(upload_folder, filename)
file.save(file_path)
# Get file info
file_size = os.path.getsize(file_path)
# Initialize response data
response_data = {
'message': 'File uploaded successfully',
'status': 'success',
'file_info': {
'original_filename': original_filename,
'saved_filename': filename,
'local_file_path': file_path,
'file_size': file_size,
'upload_time': datetime.datetime.now().isoformat()
}
}
# SCP file to remote server if enabled
if app.config['SCP_ENABLED']:
try:
remote_file_path = os.path.join(app.config['REMOTE_PATH'], authenticated_user).replace('\\', '/')
scp_destination = f"{app.config['REMOTE_USER']}@{app.config['REMOTE_HOST']}:{remote_file_path}"
# Build SCP command
scp_cmd = [
'scp',
'-P', str(app.config['REMOTE_PORT']),
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null'
]
# Add private key if specified
if app.config['REMOTE_PRIVATE_KEY']:
scp_cmd.extend(['-i', app.config['REMOTE_PRIVATE_KEY']])
# Add source and destination
scp_cmd.extend([file_path, scp_destination])
# Execute SCP command
scp_process = subprocess.run(
scp_cmd,
capture_output=True,
text=True,
timeout=30
)
if scp_process.returncode == 0:
response_data['scp_info'] = {
'remote_host': app.config['REMOTE_HOST'],
'remote_path': remote_file_path,
'scp_status': 'success',
'scp_message': 'File successfully transferred to remote server'
}
response_data['message'] = 'File uploaded and transferred to remote server successfully'
else:
response_data['scp_info'] = {
'scp_status': 'failed',
'scp_error': scp_process.stderr or 'SCP transfer failed',
'scp_message': 'File uploaded locally but remote transfer failed'
}
response_data['message'] = 'File uploaded locally but remote transfer failed'
except subprocess.TimeoutExpired:
response_data['scp_info'] = {
'scp_status': 'timeout',
'scp_error': 'SCP transfer timed out',
'scp_message': 'File uploaded locally but remote transfer timed out'
}
response_data['message'] = 'File uploaded locally but remote transfer timed out'
except Exception as scp_error:
response_data['scp_info'] = {
'scp_status': 'error',
'scp_error': str(scp_error),
'scp_message': 'File uploaded locally but SCP failed'
}
response_data['message'] = 'File uploaded locally but SCP failed'
else:
response_data['scp_info'] = {
'scp_status': 'disabled',
'scp_message': 'SCP transfer is disabled'
}
return jsonify(response_data), 200
except Exception as e:
return jsonify({
'error': f'Upload failed: {str(e)}',
'status': 'error'
}), 500
@app.errorhandler(429)
def ratelimit_handler(error):
"""Handle rate-limit (429) responses; JSON for the API, HTML otherwise"""
app.logger.warning(
"Rate limit exceeded for %s on %s (%s)",
get_remote_address(), request.path, error.description,
)
if request.path.startswith('/api/'):
return jsonify({
'error': 'Rate limit exceeded',
'detail': str(error.description),
'status': 'error',
}), 429
return render_template('429.html', title='Too Many Requests',
detail=error.description), 429
@app.errorhandler(404) @app.errorhandler(404)
def not_found_error(error): def not_found_error(error):
"""Handle 404 errors""" """Handle 404 errors"""
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""finger-web CLI — query finger users and manage plan files.
Configuration via environment variables:
FINGER_WEB_URL Base URL of the finger-web instance (default: http://localhost:5000)
FINGER_USER Username for authenticated endpoints (upload)
FINGER_PASS Password for authenticated endpoints (upload)
"""
import argparse
import os
import sys
try:
import requests
except ImportError:
print("requests is required: pip install requests", file=sys.stderr)
sys.exit(1)
BASE_URL = os.environ.get("FINGER_WEB_URL", "http://localhost:5000").rstrip("/")
def _get_auth():
user = os.environ.get("FINGER_USER", "")
passwd = os.environ.get("FINGER_PASS", "")
return (user, passwd) if user and passwd else None
def cmd_query(args):
target = args.username
url = f"{BASE_URL}/api/finger/{target}" if target else f"{BASE_URL}/api/finger"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("status") == "success":
print(data.get("result", "").rstrip())
else:
print(data.get("error", "Unknown error"), file=sys.stderr)
sys.exit(1)
except requests.exceptions.ConnectionError:
print(f"Cannot connect to {BASE_URL}", file=sys.stderr)
sys.exit(1)
except requests.exceptions.HTTPError as e:
print(f"HTTP error: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def cmd_plan(args):
auth = _get_auth()
if not auth:
print(
"Set FINGER_USER and FINGER_PASS environment variables to authenticate.",
file=sys.stderr,
)
sys.exit(1)
plan_file = args.file
if not os.path.exists(plan_file):
print(f"File not found: {plan_file}", file=sys.stderr)
sys.exit(1)
with open(plan_file, "rb") as f:
try:
resp = requests.post(
f"{BASE_URL}/api/upload",
files={"file": (os.path.basename(plan_file), f, "text/plain")},
auth=auth,
timeout=30,
)
resp.raise_for_status()
data = resp.json()
print(data.get("message", "Upload successful."))
except requests.exceptions.ConnectionError:
print(f"Cannot connect to {BASE_URL}", file=sys.stderr)
sys.exit(1)
except requests.exceptions.HTTPError as e:
print(f"HTTP error: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
prog="finger-web",
description="Query finger users and manage plan files via finger-web.",
)
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
q = sub.add_parser("query", aliases=["q"], help="Finger a user")
q.add_argument("username", nargs="?", default="", help="User to finger, e.g. [email protected]")
sub.add_parser("plan", help="Upload your plan file").add_argument(
"file", help="Path to the plan text file"
)
args = parser.parse_args()
if args.command in ("query", "q"):
cmd_query(args)
elif args.command == "plan":
cmd_plan(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
requests>=2.31.0
+43
View File
@@ -15,3 +15,46 @@ class Config:
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1'] MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
# File upload configuration
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_FILE_SIZE', 16 * 1024 * 1024)) # 16MB default
UPLOAD_FOLDER = '/tmp'
ALLOWED_EXTENSIONS = {'txt'}
# Remote server configuration for SCP
REMOTE_HOST = os.environ.get('REMOTE_HOST') or 'example.com'
REMOTE_USER = os.environ.get('REMOTE_USER') or 'user'
REMOTE_PATH = os.environ.get('REMOTE_PATH') or '/home/user/uploads/'
REMOTE_PORT = int(os.environ.get('REMOTE_PORT', 22))
REMOTE_PRIVATE_KEY = os.environ.get('REMOTE_PRIVATE_KEY') # Path to SSH private key file
SCP_ENABLED = os.environ.get('SCP_ENABLED', 'false').lower() == 'true'
# Rate limiting (Flask-Limiter), keyed per client IP.
# The app only ever receives nginx cache *misses* (upstream caches 200s for
# 30s), so these limits throttle exactly the uncached finger-daemon lookups
# (e.g. username enumeration) without affecting the cached hot path.
# Limit strings are ';'-separated, e.g. "30 per minute;600 per hour".
RATELIMIT_ENABLED = os.environ.get('RATELIMIT_ENABLED', 'true').lower() == 'true'
RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI') or 'memory://'
# Global default applied to every route.
RATELIMIT_DEFAULT = os.environ.get('RATELIMIT_DEFAULT') or '120 per minute;2000 per hour'
# Stricter limit for endpoints that shell out to the finger daemon.
RATELIMIT_FINGER = os.environ.get('RATELIMIT_FINGER') or '30 per minute;600 per hour'
# Limit for the authenticated upload endpoint (anti brute-force / spam).
RATELIMIT_UPLOAD = os.environ.get('RATELIMIT_UPLOAD') or '10 per minute;60 per hour'
# Number of trusted reverse-proxy hops in front of the app. nginx on the
# same host appends one X-Forwarded-For entry, so the default is 1. Without
# this the app only sees the Docker bridge gateway and every client would
# share a single rate-limit bucket.
PROXY_FORWARDED_COUNT = int(os.environ.get('PROXY_FORWARDED_COUNT', 1))
# Basic authentication credentials - multiple users support
# Environment variable format: "user1:pass1,user2:pass2"
BASIC_AUTH_USERS_STR = os.environ.get('BASIC_AUTH_USERS', '')
# Parse users string into dictionary
BASIC_AUTH_USERS = {}
for user_pass in BASIC_AUTH_USERS_STR.split(','):
if ':' in user_pass:
username, password = user_pass.strip().split(':', 1)
BASIC_AUTH_USERS[username.strip()] = password.strip()
+2
View File
@@ -0,0 +1,2 @@
mcp[cli]
requests>=2.31.0
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""MCP server for finger-web.
Exposes finger query and plan-upload operations as Claude tools.
Environment variables:
FINGER_WEB_URL Base URL of the finger-web instance (default: http://localhost:5000)
FINGER_USER Username for authenticated endpoints
FINGER_PASS Password for authenticated endpoints
"""
import os
import requests
from mcp.server.fastmcp import FastMCP
BASE_URL = os.environ.get("FINGER_WEB_URL", "http://localhost:5000").rstrip("/")
mcp = FastMCP("finger")
def _auth():
user = os.environ.get("FINGER_USER", "")
passwd = os.environ.get("FINGER_PASS", "")
return (user, passwd) if user and passwd else None
@mcp.tool()
def finger_user(username: str = "") -> str:
"""Query finger information for a user via finger-web.
Pass a bare username (e.g. 'pete') or a user@host address
(e.g. '[email protected]'). Leave username empty to list currently
logged-in users.
"""
if username and not all(c.isalnum() or c in ".-_@" for c in username):
return f"Invalid username: {username!r}"
url = f"{BASE_URL}/api/finger/{username}" if username else f"{BASE_URL}/api/finger"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("status") == "success":
return data.get("result", "No information available.").strip()
return data.get("error", "Unknown error.")
except requests.exceptions.ConnectionError:
return f"Cannot connect to {BASE_URL}"
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def upload_plan(filename: str, content: str) -> str:
"""Upload or update a finger plan file via finger-web.
filename The plan filename (typically your username, e.g. 'pete').
content Plain-text content to publish as your .plan.
Requires FINGER_USER and FINGER_PASS environment variables to be set.
"""
auth = _auth()
if not auth:
return "Set FINGER_USER and FINGER_PASS environment variables to authenticate."
try:
resp = requests.post(
f"{BASE_URL}/api/upload",
files={"file": (filename, content.encode(), "text/plain")},
auth=auth,
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return data.get("message", "Upload successful.")
except requests.exceptions.ConnectionError:
return f"Cannot connect to {BASE_URL}"
except requests.exceptions.HTTPError as e:
return f"HTTP error: {e}"
except Exception as e:
return f"Error: {e}"
if __name__ == "__main__":
mcp.run()
+2
View File
@@ -5,3 +5,5 @@ MarkupSafe==2.1.3
itsdangerous==2.1.2 itsdangerous==2.1.2
click==8.1.7 click==8.1.7
blinker==1.6.3 blinker==1.6.3
Flask-HTTPAuth==4.8.0
Flask-Limiter==3.5.1
+137 -33
View File
@@ -1,43 +1,111 @@
/* Custom styles for Finger Web Flask App */ /* Custom styles for Finger Web Flask App
*
* Palette: Tokyo Night (https://github.com/folke/tokyonight.nvim), matching
* the same anchor colors waffles.yttrx.com's Compost/Tailwind theme uses, so
* both sites share one visual identity. Applied on top of Bootstrap 5.3's
* native [data-bs-theme=dark] mode (set on <html> in base.html) by
* overriding its root-level color tokens rather than fighting individual
* utility classes.
*/
:root {
--tn-bg: #1a1b26;
--tn-bg-dark: #16161e;
--tn-surface: #24283b;
--tn-surface-hi: #292e42;
--tn-border: #3b4261;
--tn-fg: #c0caf5;
--tn-fg-dark: #a9b1d6;
--tn-comment: #565f89;
--tn-blue: #7aa2f7;
--tn-blue-bright: #5d86ef;
--tn-cyan: #7dcfff;
--tn-purple: #bb9af7;
--tn-green: #9ece6a;
--tn-red: #f7768e;
--tn-yellow: #e0af68;
/* Bootstrap root tokens (color-mode independent, used by .bg-primary,
.btn-primary, .text-primary, .border-primary, etc. app-wide). */
--bs-primary: var(--tn-blue);
--bs-primary-rgb: 122, 162, 247;
--bs-danger: var(--tn-red);
--bs-danger-rgb: 247, 118, 142;
--bs-success: var(--tn-green);
--bs-success-rgb: 158, 206, 106;
--bs-warning: var(--tn-yellow);
--bs-warning-rgb: 224, 175, 104;
--bs-info: var(--tn-cyan);
--bs-info-rgb: 125, 207, 255;
--bs-font-sans-serif: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
/* Bootstrap's dark color-mode tokens — override the defaults with the exact
Tokyo Night shades instead of Bootstrap's generic dark grays. */
[data-bs-theme="dark"] {
--bs-body-bg: var(--tn-bg);
--bs-body-color: var(--tn-fg);
--bs-emphasis-color: #ffffff;
--bs-secondary-color: var(--tn-comment);
--bs-secondary-bg: var(--tn-surface);
--bs-tertiary-bg: var(--tn-surface-hi);
--bs-border-color: var(--tn-border);
--bs-link-color: var(--tn-blue);
--bs-link-hover-color: var(--tn-cyan);
--bs-link-color-rgb: 122, 162, 247;
--bs-link-hover-color-rgb: 125, 207, 255;
}
/* Global styles */ /* Global styles */
body { body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: var(--bs-font-sans-serif);
line-height: 1.6; line-height: 1.6;
} }
/* Navigation enhancements */ /* Navigation */
.navbar-tn {
background-color: var(--tn-surface-hi);
border-bottom: 1px solid var(--tn-border);
}
.navbar-brand { .navbar-brand {
font-weight: bold; font-weight: bold;
font-size: 1.5rem; font-size: 1.5rem;
color: var(--tn-blue) !important;
} }
.navbar-nav .nav-link { .navbar-nav .nav-link {
font-weight: 500; font-weight: 500;
color: var(--tn-fg-dark);
transition: color 0.3s ease; transition: color 0.3s ease;
} }
.navbar-nav .nav-link:hover { .navbar-nav .nav-link:hover {
color: #fff !important; color: var(--tn-cyan) !important;
text-decoration: underline; text-decoration: underline;
} }
/* Card enhancements */ /* Card enhancements */
.card { .card {
border: none; border: 1px solid var(--tn-border);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease, box-shadow 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease;
} }
.card:hover { .card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.card-header {
background-color: var(--tn-surface-hi);
border-bottom: 1px solid var(--tn-border);
} }
/* Jumbotron styling */ /* Jumbotron styling */
.jumbotron { .jumbotron-tn {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); background: linear-gradient(135deg, var(--tn-surface) 0%, var(--tn-bg-dark) 100%);
border: 1px solid #dee2e6; border: 1px solid var(--tn-border);
} }
/* Button enhancements */ /* Button enhancements */
@@ -48,29 +116,50 @@ body {
} }
.btn-primary { .btn-primary {
background: linear-gradient(45deg, #007bff, #0056b3); background: linear-gradient(45deg, var(--tn-blue), var(--tn-blue-bright));
border: none; border: none;
color: var(--tn-bg-dark);
} }
.btn-primary:hover { .btn-primary:hover {
background: linear-gradient(45deg, #0056b3, #004085); background: linear-gradient(45deg, var(--tn-blue-bright), var(--tn-blue));
color: var(--tn-bg-dark);
transform: translateY(-1px); transform: translateY(-1px);
} }
.btn-outline-secondary {
color: var(--tn-fg-dark);
border-color: var(--tn-border);
}
.btn-outline-secondary:hover {
background-color: var(--tn-surface-hi);
border-color: var(--tn-comment);
color: var(--tn-fg);
}
.btn-outline-primary:hover { .btn-outline-primary:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Form enhancements */ /* Form enhancements */
.form-control { .form-control {
background-color: var(--tn-surface);
color: var(--tn-fg);
border-radius: 6px; border-radius: 6px;
border: 2px solid #e9ecef; border: 2px solid var(--tn-border);
transition: border-color 0.3s ease, box-shadow 0.3s ease; transition: border-color 0.3s ease, box-shadow 0.3s ease;
} }
.form-control:focus { .form-control:focus {
border-color: #007bff; background-color: var(--tn-surface);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); color: var(--tn-fg);
border-color: var(--tn-blue);
box-shadow: 0 0 0 0.2rem rgba(122, 162, 247, 0.25);
}
.form-control::placeholder {
color: var(--tn-comment);
} }
/* Alert enhancements */ /* Alert enhancements */
@@ -80,19 +169,30 @@ body {
} }
.alert-success { .alert-success {
background: linear-gradient(45deg, #d4edda, #c3e6cb); background: var(--tn-green);
color: #155724; color: var(--tn-bg-dark);
} }
.alert-danger { .alert-danger {
background: linear-gradient(45deg, #f8d7da, #f5c6cb); background: var(--tn-red);
color: #721c24; color: var(--tn-bg-dark);
}
.alert-info {
background: var(--tn-surface-hi);
color: var(--tn-fg);
border: 1px solid var(--tn-cyan);
}
.alert-link {
color: var(--tn-cyan);
} }
/* Footer styling */ /* Footer styling */
footer { .footer-tn {
margin-top: auto; margin-top: auto;
border-top: 1px solid #e9ecef; background-color: var(--tn-bg-dark);
border-top: 1px solid var(--tn-border);
} }
/* Code block styling */ /* Code block styling */
@@ -102,19 +202,19 @@ pre {
} }
pre code { pre code {
color: #495057; color: var(--tn-fg-dark);
} }
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.jumbotron { .jumbotron-tn {
padding: 2rem 1rem; padding: 2rem 1rem;
} }
.display-4 { .display-4 {
font-size: 2rem; font-size: 2rem;
} }
.card-body { .card-body {
padding: 1rem; padding: 1rem;
} }
@@ -138,14 +238,14 @@ main {
/* Custom utility classes */ /* Custom utility classes */
.text-gradient { .text-gradient {
background: linear-gradient(45deg, #007bff, #6610f2); background: linear-gradient(45deg, var(--tn-blue), var(--tn-purple));
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
.shadow-custom { .shadow-custom {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
} }
/* Loading animation for forms */ /* Loading animation for forms */
@@ -163,7 +263,7 @@ main {
left: 50%; left: 50%;
margin-left: -8px; margin-left: -8px;
margin-top: -8px; margin-top: -8px;
border: 2px solid #ffffff; border: 2px solid var(--tn-bg-dark);
border-radius: 50%; border-radius: 50%;
border-top-color: transparent; border-top-color: transparent;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
@@ -177,14 +277,18 @@ main {
/* Finger command specific styles */ /* Finger command specific styles */
.finger-output pre { .finger-output pre {
font-family: 'Courier New', Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
max-height: 500px; max-height: 500px;
overflow-y: auto; overflow-y: auto;
color: white; background-color: var(--tn-surface);
color: var(--tn-fg);
padding: 1rem;
border-radius: 6px;
border: 1px solid var(--tn-border);
} }
.finger-output pre::-webkit-scrollbar { .finger-output pre::-webkit-scrollbar {
@@ -192,14 +296,14 @@ main {
} }
.finger-output pre::-webkit-scrollbar-track { .finger-output pre::-webkit-scrollbar-track {
background: #2d3748; background: var(--tn-surface);
} }
.finger-output pre::-webkit-scrollbar-thumb { .finger-output pre::-webkit-scrollbar-thumb {
background: #4a5568; background: var(--tn-border);
border-radius: 4px; border-radius: 4px;
} }
.finger-output pre::-webkit-scrollbar-thumb:hover { .finger-output pre::-webkit-scrollbar-thumb:hover {
background: #718096; background: var(--tn-comment);
} }
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-lg-6 mx-auto text-center">
<div class="card">
<div class="card-body">
<h1 class="display-1 text-muted">429</h1>
<h2 class="mb-3">Too Many Requests</h2>
<p class="lead">
You've sent too many requests in a short period. Please slow
down and try again in a moment.
</p>
{% if detail %}
<p class="text-muted"><small>Limit: {{ detail }}</small></p>
{% endif %}
<hr class="my-4">
<div class="d-grid gap-2 d-md-block">
<a href="{{ url_for('index') }}" class="btn btn-primary">Go Home</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary">Go Back</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+4 -4
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-bs-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -16,7 +16,7 @@
</head> </head>
<body> <body>
<!-- Navigation --> <!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> <nav class="navbar navbar-expand-lg navbar-dark navbar-tn">
<div class="container"> <div class="container">
<a class="navbar-brand" href="{{ url_for('index') }}">Finger Web</a> <a class="navbar-brand" href="{{ url_for('index') }}">Finger Web</a>
@@ -33,7 +33,7 @@
<a class="nav-link" href="{{ url_for('finger') }}">Finger</a> <a class="nav-link" href="{{ url_for('finger') }}">Finger</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('api_hello') }}" target="_blank">API</a> <a class="nav-link" href="{{ url_for('api_info') }}" target="_blank">API</a>
</li> </li>
</ul> </ul>
</div> </div>
@@ -60,7 +60,7 @@
</main> </main>
<!-- Footer --> <!-- Footer -->
<footer class="bg-light text-center text-muted py-3 mt-5"> <footer class="footer-tn text-center text-muted py-3 mt-5">
<div class="container"> <div class="container">
<p>&copy; 2025 Finger Web App. Built with love ❤️</p> <p>&copy; 2025 Finger Web App. Built with love ❤️</p>
</div> </div>
+54 -97
View File
@@ -3,113 +3,70 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-lg-10 mx-auto"> <div class="col-lg-10 mx-auto">
<h1 class="mb-4">🔍 Finger Command</h1> <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h1 class="h5 mb-0">🔍 Finger Command</h1>
<div class="row"> <!-- GET so the search lands on a cacheable URL; JS sends it straight
<div class="col-md-4"> to /finger/<term>, and without JS the server redirects there. -->
<div class="card"> <form method="GET" action="{{ url_for('finger') }}" class="d-flex" role="search"
<div class="card-body"> onsubmit="return gotoFinger(event)">
<h5 class="card-title">User Lookup</h5> <input type="text" class="form-control form-control-sm me-2" id="username" name="username"
<form method="POST" action="{{ url_for('finger') }}"> value="{{ username or '' }}"
<div class="mb-3"> placeholder="[email protected]"
<label for="username" class="form-label">Username (optional)</label> pattern="[a-zA-Z0-9.\-_@]*"
<input type="text" class="form-control" id="username" name="username" title="Alphanumeric characters, dots, hyphens, underscores, and @ symbol allowed"
value="{{ username or '' }}" style="max-width: 260px;">
placeholder="Enter username or email (e.g., [email protected])" <button type="submit" class="btn btn-sm btn-primary">
pattern="[a-zA-Z0-9.\-_@]*" <i class="fas fa-search"></i> Finger
title="Alphanumeric characters, dots, hyphens, underscores, and @ symbol allowed"> </button>
<div class="form-text"> </form>
Leave empty to show all logged-in users, or enter a username or email address.
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> Run Finger
</button>
<button type="button" class="btn btn-outline-secondary ms-2" onclick="clearForm()">
Clear
</button>
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title">️ About Finger</h6>
<p class="card-text small">
The finger command displays information about users on the system, including:
</p>
<ul class="small">
<li>Login name and real name</li>
<li>Terminal and login time</li>
<li>Idle time</li>
<li>Home directory and shell</li>
<li>Plan and project files (if available)</li>
</ul>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
{% if username %}
Results for "{{ username }}"
{% else %}
System Users
{% endif %}
</h5>
{% if result or error %}
<small class="text-muted">
{{ moment().format('YYYY-MM-DD HH:mm:ss') if moment else '' }}
</small>
{% endif %}
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-triangle"></i>
<strong>Error:</strong> {{ error }}
</div>
{% elif result %}
<div class="alert alert-success mb-3" role="alert">
<i class="fas fa-check-circle"></i>
Command executed successfully
</div>
<div class="finger-output">
<pre class="rounded"><code>{{ result }}</code></pre>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-terminal fa-3x mb-3"></i>
<p>Enter a username and click "Run Finger" to see user information,<br>
or leave username empty to see all logged-in users.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div> </div>
<div class="text-center mt-4"> <div class="card">
<a href="{{ url_for('index') }}" class="btn btn-outline-primary"> <div class="card-header d-flex justify-content-between align-items-center">
<i class="fas fa-home"></i> Back to Home <h5 class="mb-0">
</a> {% if username %}
Results for "{{ username }}"
{% else %}
System Users
{% endif %}
</h5>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-triangle"></i>
<strong>Error:</strong> {{ error }}
</div>
{% elif result %}
<div class="finger-output">
<pre class="rounded mb-0"><code>{{ result }}</code></pre>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-terminal fa-3x mb-3"></i>
<p>No information available.</p>
</div>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
// Navigate straight to the cacheable /finger/<term> path so the nginx response
// cache is hit for repeated lookups (avoids a redirect round-trip).
function gotoFinger(event) {
var term = document.getElementById('username').value.trim();
if (!term) { return true; } // empty -> let GET /finger list system users
event.preventDefault();
window.location.href = "{{ url_for('finger') }}/" + encodeURIComponent(term);
return false;
}
function clearForm() { function clearForm() {
document.getElementById('username').value = ''; document.getElementById('username').value = '';
// Optionally reload the page to clear results // Optionally reload the page to clear results
window.location.href = "{{ url_for('finger') }}"; window.location.href = "{{ url_for('finger') }}";
} }
// Auto-focus on username input when page loads
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('username').focus();
});
</script> </script>
{% endblock %} {% endblock %}
+2 -2
View File
@@ -3,13 +3,13 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-lg-8 mx-auto"> <div class="col-lg-8 mx-auto">
<div class="jumbotron bg-light p-5 rounded"> <div class="jumbotron jumbotron-tn p-5 rounded">
<h1 class="display-4">Welcome to Finger Web!</h1> <h1 class="display-4">Welcome to Finger Web!</h1>
<p class="lead">A simple web interface to the unix finger command.</p> <p class="lead">A simple web interface to the unix finger command.</p>
<hr class="my-4"> <hr class="my-4">
<p><a href="https://en.wikipedia.org/wiki/Finger_(protocol)">Finger</a> is a utility from 1971 written to discover user information on remote unix systems. While its use has fallen off, you can sometimes still find active finger servers where users provide information about themselves.</p><p>See <a href="/finger/[email protected]">[email protected]</a> as an example :)</p> <p><a href="https://en.wikipedia.org/wiki/Finger_(protocol)">Finger</a> is a utility from 1971 written to discover user information on remote unix systems. While its use has fallen off, you can sometimes still find active finger servers where users provide information about themselves.</p><p>See <a href="/finger/[email protected]">[email protected]</a> as an example :)</p>
<hr class="my-4"> <hr class="my-4">
<p>This site was written by <a href="https://yttrx.com/@waffles">waffles</a> (<a href="https://github.com/waffle2k/finger-web/">source code</a>) but inspired by <a href="https://benbrown.com">Ben Brown's</a> <a href="https://happynetbox.com/">happy net box</a> project.</p> <p>This site was written by <a href="https://yttrx.com/@waffles">waffles</a> (<a href="https://gitea.blairhaus.net/pmb/finger-web">source code</a>) but inspired by <a href="https://benbrown.com">Ben Brown's</a> <a href="https://happynetbox.com/">happy net box</a> project.</p>
</div> </div>
</div> </div>
</div> </div>