Compare commits
12
Commits
1576c4508b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3694225d64 | ||
|
|
8c7ba4dbd9 | ||
|
|
c8786a2a0b | ||
|
|
3115ac64b2 | ||
|
|
ad4b9b9af8 | ||
|
|
a49545796a | ||
|
|
b99636d3c0 | ||
|
|
295a123717 | ||
|
|
178d3f361c | ||
|
|
1e53363271 | ||
|
|
7515431fb5 | ||
|
|
fd493f13e9 |
@@ -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"
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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_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()
|
||||||
|
|||||||
@@ -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
|
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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>© 2025 Finger Web App. Built with love ❤️</p>
|
<p>© 2025 Finger Web App. Built with love ❤️</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+54
-97
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user