Compare commits
10
Commits
1576c4508b
...
c8786a2a0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8786a2a0b | ||
|
|
3115ac64b2 | ||
|
|
ad4b9b9af8 | ||
|
|
a49545796a | ||
|
|
b99636d3c0 | ||
|
|
295a123717 | ||
|
|
178d3f361c | ||
|
|
1e53363271 | ||
|
|
7515431fb5 | ||
|
|
fd493f13e9 |
@@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
gcc \
|
||||
finger \
|
||||
openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first to leverage Docker cache
|
||||
|
||||
@@ -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
|
||||
- **Contact Form**: Functional contact form with validation and flash messages
|
||||
- **JSON API**: RESTful API endpoints for data exchange
|
||||
- **Responsive Design**: Mobile-first design using Bootstrap 5
|
||||
- **Error Handling**: Custom 404 and 500 error pages
|
||||
- **Modern UI**: Clean, professional interface with animations
|
||||
- **Form Validation**: Client-side and server-side validation
|
||||
- **Configuration Management**: Environment-based configuration
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `app.py` | Flask web app and JSON API |
|
||||
| `cli/finger.py` | Command-line client |
|
||||
| `mcp/server.py` | MCP server for Claude |
|
||||
|
||||
## 📁 Project Structure
|
||||
---
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
## Web App
|
||||
|
||||
## 🛠️ Technologies Used
|
||||
### Requirements
|
||||
|
||||
### Backend
|
||||
- **Python 3.x** - Programming language
|
||||
- **Flask 2.3.3** - Web framework
|
||||
- **Jinja2** - Template engine
|
||||
- **Werkzeug** - WSGI toolkit
|
||||
- Python 3.9+
|
||||
- A `finger` binary available on the server's PATH
|
||||
|
||||
### Frontend
|
||||
- **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
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
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
|
||||
python app.py
|
||||
```
|
||||
|
||||
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
|
||||
# or with gunicorn
|
||||
gunicorn -w 4 -b 0.0.0.0:5000 app:app
|
||||
```
|
||||
|
||||
## 🔒 Security Considerations
|
||||
### Docker
|
||||
|
||||
- Change the `SECRET_KEY` in production
|
||||
- Use environment variables for sensitive data
|
||||
- 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
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**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 |
|
||||
|
||||
@@ -1,40 +1,97 @@
|
||||
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 subprocess
|
||||
import shlex
|
||||
import datetime
|
||||
from config import Config
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Home page route"""
|
||||
return render_template('index.html', title='Home')
|
||||
# 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,
|
||||
)
|
||||
|
||||
@app.route('/finger', methods=['GET', 'POST'])
|
||||
def finger():
|
||||
"""Finger command route"""
|
||||
result = None
|
||||
error = None
|
||||
username = request.args.get('user', '') or request.form.get('username', '')
|
||||
# Initialize HTTP Basic Auth
|
||||
auth = HTTPBasicAuth()
|
||||
|
||||
if request.method == 'POST' or username:
|
||||
# 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:
|
||||
# Sanitize username input
|
||||
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']
|
||||
return raw_output.decode(encoding)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
|
||||
if not error:
|
||||
# Execute the command safely
|
||||
# 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,
|
||||
@@ -46,82 +103,296 @@ def finger():
|
||||
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:
|
||||
error = "Command timed out. Please try again."
|
||||
return False, "Command timed out. Please try again.", True
|
||||
except FileNotFoundError:
|
||||
error = "Finger command not available on this system."
|
||||
return False, "Finger command not available on this system.", True
|
||||
except Exception as e:
|
||||
error = f"An error occurred: {str(e)}"
|
||||
return False, f"An error occurred: {str(e)}", True
|
||||
|
||||
@app.route('/')
|
||||
@limiter.exempt
|
||||
def index():
|
||||
"""Home page route (exempt from limits; also serves the container healthcheck)"""
|
||||
return render_template('index.html', title='Home')
|
||||
|
||||
@app.route('/finger', methods=['GET', 'POST'])
|
||||
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
|
||||
def finger():
|
||||
"""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
|
||||
error = None
|
||||
success, output, is_error = run_finger_command(['finger'])
|
||||
if success:
|
||||
result = output
|
||||
else:
|
||||
error = output
|
||||
|
||||
return render_template('finger.html', title='Finger', result=result, error=error, username=username)
|
||||
|
||||
@app.route('/finger/<path:username>')
|
||||
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
|
||||
def finger_direct(username):
|
||||
"""Direct finger command route with username in URL"""
|
||||
result = None
|
||||
error = None
|
||||
|
||||
try:
|
||||
# Sanitize username input
|
||||
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."
|
||||
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 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."
|
||||
# Execute the command with robust encoding handling
|
||||
success, output, is_error = run_finger_command(cmd)
|
||||
if success:
|
||||
result = output
|
||||
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)}"
|
||||
error = output
|
||||
app.logger.warning("finger lookup failed for %r from %s: %s",
|
||||
username, get_remote_address(), output.strip()[:200])
|
||||
|
||||
return render_template('finger.html', title=f'Finger - {username}', result=result, error=error, username=username)
|
||||
|
||||
@app.route('/api/hello')
|
||||
def api_hello():
|
||||
"""Simple JSON API endpoint"""
|
||||
return jsonify({
|
||||
'message': 'Hello from Flask API!',
|
||||
'status': 'success',
|
||||
'version': '1.0'
|
||||
})
|
||||
@app.route('/api/finger')
|
||||
@app.route('/api/finger/<path:username>')
|
||||
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
|
||||
def api_finger(username=None):
|
||||
"""JSON API endpoint for finger queries"""
|
||||
if username is None:
|
||||
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')
|
||||
def api_info():
|
||||
"""API info endpoint"""
|
||||
return jsonify({
|
||||
'app_name': 'Finger Web Flask App',
|
||||
'app_name': 'finger-web',
|
||||
'routes': [
|
||||
'/',
|
||||
'/finger',
|
||||
'/finger/<username>',
|
||||
'/api/hello',
|
||||
'/api/info'
|
||||
'/api/finger',
|
||||
'/api/finger/<username>',
|
||||
'/api/info',
|
||||
'/api/upload'
|
||||
],
|
||||
'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)
|
||||
def not_found_error(error):
|
||||
"""Handle 404 errors"""
|
||||
|
||||
+114
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
requests>=2.31.0
|
||||
@@ -15,3 +15,46 @@ class Config:
|
||||
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
|
||||
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
mcp[cli]
|
||||
requests>=2.31.0
|
||||
@@ -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()
|
||||
@@ -5,3 +5,5 @@ MarkupSafe==2.1.3
|
||||
itsdangerous==2.1.2
|
||||
click==8.1.7
|
||||
blinker==1.6.3
|
||||
Flask-HTTPAuth==4.8.0
|
||||
Flask-Limiter==3.5.1
|
||||
|
||||
@@ -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 %}
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
<a class="nav-link" href="{{ url_for('finger') }}">Finger</a>
|
||||
</li>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
+24
-67
@@ -3,54 +3,24 @@
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-10 mx-auto">
|
||||
<h1 class="mb-4">🔍 Finger Command</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">User Lookup</h5>
|
||||
<form method="POST" action="{{ url_for('finger') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username (optional)</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||||
<h1 class="h5 mb-0">🔍 Finger Command</h1>
|
||||
<!-- GET so the search lands on a cacheable URL; JS sends it straight
|
||||
to /finger/<term>, and without JS the server redirects there. -->
|
||||
<form method="GET" action="{{ url_for('finger') }}" class="d-flex" role="search"
|
||||
onsubmit="return gotoFinger(event)">
|
||||
<input type="text" class="form-control form-control-sm me-2" id="username" name="username"
|
||||
value="{{ username or '' }}"
|
||||
placeholder="Enter username or email (e.g., [email protected])"
|
||||
placeholder="[email protected]"
|
||||
pattern="[a-zA-Z0-9.\-_@]*"
|
||||
title="Alphanumeric characters, dots, hyphens, underscores, and @ symbol allowed">
|
||||
<div class="form-text">
|
||||
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
|
||||
title="Alphanumeric characters, dots, hyphens, underscores, and @ symbol allowed"
|
||||
style="max-width: 260px;">
|
||||
<button type="submit" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-search"></i> Finger
|
||||
</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">
|
||||
@@ -60,11 +30,6 @@
|
||||
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 %}
|
||||
@@ -73,18 +38,13 @@
|
||||
<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>
|
||||
<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>Enter a username and click "Run Finger" to see user information,<br>
|
||||
or leave username empty to see all logged-in users.</p>
|
||||
<p>No information available.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -92,24 +52,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-home"></i> Back to Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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() {
|
||||
document.getElementById('username').value = '';
|
||||
// Optionally reload the page to clear results
|
||||
window.location.href = "{{ url_for('finger') }}";
|
||||
}
|
||||
|
||||
// Auto-focus on username input when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('username').focus();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user