68 Commits

Author SHA1 Message Date
7a01525ca5 chore: release v0.3.0
All checks were successful
Build & Publish / build_publish (push) Successful in 46s
2026-01-13 10:52:46 +01:00
6ac669f8c7 wip 2026-01-13 10:48:51 +01:00
48acf723de Add favicon images and web manifest for site branding
- Added favicon-16x16.png, favicon-32x32.png, and favicon.ico to enhance site identity across platforms.
- Introduced site.webmanifest to define application metadata, including icons and color themes for a better user experience on mobile devices.
2026-01-13 09:58:43 +01:00
650352b103 fix: enhance log file rotation to include timestamp and improve error handling 2026-01-12 17:22:06 +01:00
9e567ae760 fix: update log file handling to include timestamp in renamed log files 2026-01-12 16:28:09 +01:00
0685de8ffa fix: enhance code content display with syntax highlighting and improve logging structure 2026-01-12 16:14:47 +01:00
1b295aa843 Release 0.2.0
All checks were successful
Build & Publish / build_publish (push) Successful in 42s
2026-01-11 10:00:04 +01:00
28b7860c6c fix: update asset logging to use serialized values and enhance asset struct with default implementation 2026-01-11 09:54:31 +01:00
62f3c49e8a fix: refactor logging events to use owned asset instances and simplify log event structures 2026-01-11 09:36:40 +01:00
7d02443e67 fix: enhance logging structure by adding missing log event types and improving error handling in API 2026-01-11 08:46:14 +01:00
2ef2b827b7 fix: update Rust installation check to use executable path for cargo 2026-01-11 08:31:01 +01:00
8441dbd74e fix: update Gitea token environment variable for package upload 2026-01-11 08:25:28 +01:00
1fa4c50191 fix: update environment variable names for Gitea package upload 2026-01-11 08:24:29 +01:00
d831bbe85f fix: add python3 to dependencies and improve package name extraction logic 2026-01-11 08:18:42 +01:00
e24630c4a9 refactor: update caching key to use rust-toolchain.toml and improve package name extraction 2026-01-11 08:14:51 +01:00
b8e209bd03 refactor: improve caching and installation steps in build workflow 2026-01-11 08:08:12 +01:00
8145f1c7e4 refactor: separate Rust installation step in build workflow 2026-01-11 08:03:49 +01:00
840cf0ba99 refactor: enhance build workflow with debugging and caching steps 2026-01-11 07:59:39 +01:00
d47e73f47b fix: update build workflow to use Ubuntu for dependency installation 2026-01-11 07:56:34 +01:00
cde83139b1 Refactor statistics page and enhance logging
- Updated the layout and styling of the statistics page for better responsiveness and visual appeal.
- Introduced a new error page for 404 errors with user-friendly messaging and navigation options.
- Enhanced logging functionality to capture detailed events related to asset uploads, deletions, and HTTP requests.
- Implemented an AssetTracker to manage assets in memory, allowing for efficient tracking and retrieval.
- Improved the API for uploading and retrieving assets, ensuring better error handling and response formatting.
- Added auto-refresh functionality to the statistics page to keep data up-to-date.
2026-01-11 07:51:47 +01:00
81656ec0da test
Some checks failed
Build & Publish / check (push) Successful in 3s
Build & Publish / build_publish (push) Failing after 54s
2026-01-09 21:35:07 +01:00
70d7b08b7d refactor: update CI workflow and Dockerfile for improved build process 2026-01-09 21:25:23 +01:00
d6c465466a feat: add statistics API and dashboard for asset metrics
All checks were successful
Rust CI / build-test (push) Successful in 1m22s
- Implemented `/api/stats` endpoint to return JSON metrics including active assets, total uploads, storage usage, and recent activity.
- Created `stats.html` page to display real-time statistics with auto-refresh functionality.
- Enhanced asset logging to include uploader IP and detailed event information for uploads and deletions.
- Updated asset model to store uploader IP for audit purposes.
- Improved logging functionality to ensure log directory exists before writing.
- Refactored asset creation and management to support new features and logging.
2026-01-09 20:59:24 +01:00
954a5be8cb OK
All checks were successful
Rust CI / build-test (push) Successful in 42s
2026-01-08 17:49:12 +01:00
f3b5ae677d okok 2026-01-08 17:48:35 +01:00
099e628418 ok
Some checks failed
Rust CI / build-test (push) Failing after 1m12s
2026-01-08 17:41:38 +01:00
92d3ba1929 ok
Some checks failed
Rust CI / build-test (push) Has been cancelled
2026-01-08 17:40:34 +01:00
747fec0749 test
Some checks failed
Rust CI / build-test (push) Failing after 2m5s
2026-01-08 17:32:02 +01:00
86c96bf9b2 wip
All checks were successful
Rust CI / build-test (push) Successful in 1m26s
2026-01-08 16:31:39 +01:00
d375b233ef wip
Some checks failed
Rust CI / build-test (push) Failing after 2m9s
2026-01-08 15:45:36 +01:00
1d0ba36d85 wip
All checks were successful
Rust CI / build-test (push) Successful in 2m8s
2026-01-08 14:52:33 +01:00
909518cec6 ok
All checks were successful
Rust CI / build-test (push) Successful in 1m13s
2026-01-08 14:48:53 +01:00
a630415818 ok test
Some checks failed
Rust CI / build-test (push) Failing after 1m7s
2026-01-08 14:47:14 +01:00
d2ca118eb8 wip
Some checks failed
Rust CI / build-test (push) Failing after 33s
2026-01-08 14:41:59 +01:00
b90df5bfed test build
Some checks failed
Rust CI / build-test (push) Failing after 33s
2026-01-08 14:35:13 +01:00
d95b4a8fb5 Add Rust CI workflow for build and test processes
Some checks failed
Rust CI / build-test (push) Failing after 1m22s
2026-01-08 14:32:37 +01:00
dd63e94140 Refactor CI workflow to include Rust build and test steps
Some checks failed
Rust CI / build-test (push) Failing after 1m17s
2026-01-08 14:28:56 +01:00
715ae5c971 wip
All checks were successful
runner-test / test (push) Successful in 1s
runner-test / test2 (push) Successful in 1s
2026-01-06 19:21:11 +01:00
ccb38db7f5 wip
All checks were successful
runner-test / test (push) Successful in 1s
runner-test / test2 (push) Successful in 1s
2026-01-06 19:19:51 +01:00
c13960750c wip
All checks were successful
runner-test / test (push) Successful in 1s
2026-01-06 19:18:58 +01:00
c6285f18e8 wip
All checks were successful
runner-test / test (push) Successful in 13m13s
2026-01-06 19:04:21 +01:00
2380417f24 wip
Some checks failed
runner-test / test (push) Has been cancelled
2026-01-06 19:03:32 +01:00
d2b6f80aee wip
Some checks failed
runner-test / test (push) Has been cancelled
2026-01-06 19:02:14 +01:00
2aa2bd2c23 wip 2026-01-06 18:10:35 +01:00
28f2dc7787 wip 2026-01-06 17:35:17 +01:00
37d17dc8b8 Refactor environment variables to use LazyLock for dynamic binding address and port 2026-01-06 17:18:13 +01:00
f5ed10b822 Update docker-compose configuration for improved service management 2026-01-06 17:00:47 +01:00
a84f6209f2 Update upload instructions to include text data in file selection 2026-01-06 16:57:39 +01:00
d7d8e4ebbf ss 2026-01-06 16:50:04 +01:00
7e21dc213a wip 2026-01-06 16:49:20 +01:00
c150d8005f wip 2026-01-06 16:46:30 +01:00
62eea535e4 wip 2026-01-06 16:45:37 +01:00
8f29b335a5 wip 2026-01-06 16:43:18 +01:00
fc46d0952a wip 2026-01-06 16:41:11 +01:00
a288859edb Update WORKDIR in Dockerfile for consistency 2026-01-06 16:35:15 +01:00
0aff6caee7 wip 2026-01-06 16:34:13 +01:00
63b780ac11 Refactor Dockerfile and docker-compose for improved build process and clarity 2026-01-06 16:33:23 +01:00
292a081e9d wip 2026-01-06 15:49:25 +01:00
323e28760b Add Traefik labels for improved routing configuration in docker-compose 2026-01-06 15:24:22 +01:00
abac91df4e Remove git clone command from Dockerfile 2026-01-06 14:43:32 +01:00
7faae610f9 wip 2026-01-06 14:40:13 +01:00
c6eba691a8 Refactor asset and logging directory creation for improved clarity 2026-01-06 14:34:31 +01:00
10384d15e5 Fix directory creation in Asset save method 2026-01-06 13:42:42 +01:00
1147d9b3f0 wip 2026-01-06 13:25:01 +01:00
46cb35e14e wip 2026-01-06 13:24:29 +01:00
b0e7d6a40a wip 2026-01-06 13:22:46 +01:00
4ddf4656a1 WIP 2026-01-06 12:57:53 +01:00
301f6d6202 Update Dockerfile and docker-compose.yaml for build process and volume configuration 2026-01-06 12:55:54 +01:00
26 changed files with 1524 additions and 253 deletions

198
.gitea/workflows/build.yaml Normal file
View File

@@ -0,0 +1,198 @@
name: Build & Publish
on:
push:
tags: ["v*"]
workflow_dispatch: {}
jobs:
build_publish:
runs-on: ubuntu-latest
steps:
- name: Install deps (Ubuntu)
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential git ca-certificates curl tar gzip python3
- name: Checkout
uses: actions/checkout@v4
- name: Cache Rust toolchain
uses: actions/cache@v4
with:
path: |
~/.cargo/bin
~/.rustup
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-rustup-${{ hashFiles('rust-toolchain.toml') }}
restore-keys: |
${{ runner.os }}-rustup-
- name: Cache Cargo build
uses: actions/cache@v4
with:
path: |
target
key: ${{ runner.os }}-cargo-target-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-target-
- name: Install Rust (stable)
shell: bash
run: |
set -e
if [ ! -x "$HOME/.cargo/bin/cargo" ]; then
curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Debug workspace
shell: bash
run: |
set -e
pwd
ls -la
- name: Read package name
id: pkg_meta
shell: bash
run: |
set -e
if [ -f Cargo.toml ]; then
PKG_NAME="$(cargo metadata --no-deps --format-version=1 2>/dev/null | python3 -c 'import json,sys; data=json.load(sys.stdin); names=[t.get("name","") for p in data.get("packages", []) for t in p.get("targets", []) if "bin" in t.get("kind", [])]; print(names[0] if names else "")')"
if [ -z "${PKG_NAME:-}" ]; then
PKG_NAME="$(sed -n 's/^name = \"\\(.*\\)\"/\\1/p' Cargo.toml | head -n 1)"
fi
fi
if [ -z "${PKG_NAME:-}" ]; then
FULL="${GITHUB_REPOSITORY:-}"
if [ -z "$FULL" ]; then
echo "Could not read Cargo.toml and GITHUB_REPOSITORY is empty"
exit 1
fi
PKG_NAME="${FULL##*/}"
echo "Cargo.toml missing or unreadable. Falling back to repo name: $PKG_NAME"
fi
echo "pkg_name=$PKG_NAME" >> "$GITHUB_OUTPUT"
- name: Compute versions
id: version_meta
shell: bash
run: |
set -euo pipefail
CARGO_VER="$(python3 - << 'PY'
import re
txt = open("Cargo.toml", "r", encoding="utf-8").read()
m = re.search(r'(?m)^\s*version\s*=\s*"([^"]+)"\s*$', txt)
print(m.group(1) if m else "")
PY
)"
if [ -z "$CARGO_VER" ]; then
echo "Could not read version from Cargo.toml"
exit 1
fi
REF="${GITHUB_REF_NAME:-}"
SHA="${GITHUB_SHA:-}"
SHORT_SHA="${SHA:0:8}"
if [[ "$REF" == v* ]]; then
PKG_VERSION="${REF#v}"
else
PKG_VERSION="${CARGO_VER}+g${SHORT_SHA}"
fi
echo "cargo_version=$CARGO_VER" >> "$GITHUB_OUTPUT"
echo "pkg_version=$PKG_VERSION" >> "$GITHUB_OUTPUT"
- name: Create source tarball (code)
shell: bash
run: |
set -e
FULL="${GITHUB_REPOSITORY:-}"
if [ -z "$FULL" ]; then
echo "GITHUB_REPOSITORY is empty. Set it in runner env or switch to explicit OWNER/REPO vars."
exit 1
fi
OWNER="${FULL%%/*}"
REPO="${FULL##*/}"
PKG_VERSION="${{ steps.version_meta.outputs.pkg_version }}"
BIN_NAME="${{ steps.pkg_meta.outputs.pkg_name }}"
mkdir -p dist
# Clean source snapshot of the repository at current commit
git archive --format=tar.gz \
--prefix="${BIN_NAME}-${PKG_VERSION}/" \
-o "dist/${BIN_NAME}-${PKG_VERSION}-source.tar.gz" \
HEAD
ls -lh dist
# OPTIONAL: build binary and package it too
- name: Build (release)
shell: bash
run: |
set -e
cargo build --release
- name: Collect binary
shell: bash
run: |
set -e
FULL="${GITHUB_REPOSITORY:-}"
if [ -z "$FULL" ]; then
echo "GITHUB_REPOSITORY is empty. Set it in runner env or switch to explicit OWNER/REPO vars."
exit 1
fi
REPO="${FULL##*/}"
PKG_VERSION="${{ steps.version_meta.outputs.pkg_version }}"
BIN_NAME="${{ steps.pkg_meta.outputs.pkg_name }}"
mkdir -p dist
cp "target/release/${BIN_NAME}" "dist/${BIN_NAME}-${PKG_VERSION}-linux-x86_64"
chmod +x "dist/${BIN_NAME}-${PKG_VERSION}-linux-x86_64"
ls -lh dist
- name: Upload to Gitea Generic Packages
shell: bash
env:
BASE_URL: ${{ vars.BASE_URL }}
GITEA: ${{ secrets.GITEA }}
run: |
set -e
FULL="${GITHUB_REPOSITORY:-}"
if [ -z "$FULL" ]; then
echo "GITHUB_REPOSITORY is empty. Set it in runner env or switch to explicit OWNER/REPO vars."
exit 1
fi
OWNER="${FULL%%/*}"
REPO="${FULL##*/}"
PKG_VERSION="${{ steps.version_meta.outputs.pkg_version }}"
BIN_NAME="${{ steps.pkg_meta.outputs.pkg_name }}"
if [ -z "${BASE_URL:-}" ]; then
echo "Missing vars.BASE_URL (example: https://gitea.example.com)"
exit 1
fi
if [ -z "${GITEA:-}" ]; then
echo "Missing secrets.GITEA"
exit 1
fi
# Choose a package name (keep stable). Here: cargo package name.
PACKAGE_NAME="$BIN_NAME"
for FILE in dist/*; do
FILENAME="$(basename "$FILE")"
URL="${BASE_URL}/api/packages/${OWNER}/generic/${PACKAGE_NAME}/${PKG_VERSION}/${FILENAME}"
echo "Uploading $FILENAME -> $URL"
curl -fsS -X PUT \
-H "Authorization: token ${GITEA}" \
--upload-file "$FILE" \
"$URL"
done

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.cargo/
.codex/
/target
/data/storage/*
/data/logs/*

68
CHANGELOG.md Normal file
View File

@@ -0,0 +1,68 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.0] - 2026-01-13
### Added
- Favicon set and web manifest for site branding.
- Syntax-highlighted rendering for code-like text content in the viewer.
- Startup log rotation that archives the previous log with a timestamp.
### Changed
- Access logs now write to `data/logs/log.txt` instead of `access.log`.
## [0.2.0] - 2026-01-11
### Added
- Default implementation for the asset model to simplify log parsing fallbacks.
- Basic UI polish for the stats page (background glow and hover highlight on recent activity).
### Changed
- Asset logging now records serialized values without cloning asset content.
- Release workflow uses tag-based versioning and caches Rust/toolchain artifacts.
## [0.1.1] - 2026-01-09
## [0.1.0] - 2026-01-09
### Added
- **Statistics Dashboard** (`/stats.html`) with real-time metrics:
- Active assets count
- Total uploads and deletions
- Storage usage
- Image vs text breakdown
- Average server response time
- Total request count
- Recent activity feed (last 20 events)
- Auto-refresh every 30 seconds
- **Statistics API** (`GET /api/stats`) returning JSON metrics
- **Enhanced logging** for asset events:
- Upload events with uploader IP, MIME type, size, duration, timestamps
- Delete events with full asset metadata
- Request timing (`dur_ms`) in access logs
- **Uploader IP tracking** stored with each asset for audit purposes
- Stats link in index page footer
- Ephemeral image and text sharing with configurable TTL (1-60 minutes)
- Drag/drop, paste, and file picker upload support
- Base64 encoding for images, raw text for plain text
- UUID-based asset storage as JSON files
- Background cleanup task (every 60 seconds)
- Dark theme UI with zoom overlay
- View page for shared content
- Access logging with timing, IPs, and user agent
- Docker and docker-compose support with Traefik labels
- Environment variables for bind address and port
- Access logging with timing, IPs, and user agent
- Docker and docker-compose support with Traefik labels
- Environment variables for bind address and port

2
Cargo.lock generated
View File

@@ -273,7 +273,7 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "black_hole_share"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"actix-files",
"actix-web",

View File

@@ -1,6 +1,6 @@
[package]
name = "black_hole_share"
version = "0.1.0"
version = "0.3.0"
edition = "2024"
[dependencies]

View File

@@ -20,5 +20,14 @@ RUN pacman -Syu --noconfirm --needed \
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /data
CMD ["black_hole_share"]
COPY src /opt/bhs/src
COPY Cargo.toml /opt/bhs/Cargo.toml
COPY Cargo.lock /opt/bhs/Cargo.lock
WORKDIR /opt/bhs
RUN ls -al ./
RUN cargo build --release
RUN cp ./target/release/black_hole_share /usr/local/bin/black_hole_share
WORKDIR /
CMD [ "black_hole_share" ]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Black Hole Share Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

132
README.md
View File

@@ -0,0 +1,132 @@
# Black Hole Share
A lightweight, ephemeral file sharing service built with Rust and Actix-Web. Upload images or text with a configurable TTL (1-60 minutes) and share via a unique link. Content is automatically purged after expiration.
## Features
- **Ephemeral sharing** uploads auto-delete after the specified duration
- **Image & text support** drag/drop, paste, or file picker for images; paste text directly
- **Zero database** assets stored as JSON files on disk
- **Dark theme UI** clean, responsive interface with zoom overlay
- **Statistics dashboard** real-time stats at `/stats.html` (active assets, uploads, response times)
- **Access logging** request and asset events logged to `data/logs/log.txt` with IP, timing, and metadata
- **Code-friendly text view** code-like text content auto-formats with syntax highlighting
- **Site assets** favicon set and web manifest for installable branding
## Quick Start
### Local Development
```bash
# Run from repo root (paths resolve to data/html/, data/logs/, data/storage/)
cargo run --release
```
Server starts at `http://0.0.0.0:8080` by default.
> **Note:** All paths are relative to the repo root: `data/html/`, `data/logs/`, `data/storage/`.
### Toolchain
Rust toolchain is pinned in `rust-toolchain.toml` (current: 1.90.0).
### Docker
```bash
docker-compose up --build
```
Exposes port `8080` mapped to container port `80`. Volume mounts `./data:/data`.
## Configuration
| Environment Variable | Default | Description |
| -------------------- | --------- | --------------- |
| `BIND_ADDR` | `0.0.0.0` | Address to bind |
| `BIND_PORT` | `8080` | Port to bind |
## API
### Upload
```http
POST /api/upload
Content-Type: application/json
{
"duration": 5,
"content_type": "text/plain",
"content": "Hello, world!"
}
```
- `duration` TTL in minutes (1-60)
- `content_type` MIME type (`text/plain` or `image/*`)
- `content` raw text or base64-encoded image data
**Response:**
```json
{ "link": "/bhs/550e8400-e29b-41d4-a716-446655440000" }
```
### Fetch
```http
GET /api/content/{id}
```
Returns the original content with appropriate MIME type, or `404` if expired/missing.
### Statistics
```http
GET /api/stats
```
**Response:**
```json
{
"active_assets": 5,
"total_uploads": 42,
"total_deleted": 37,
"storage_bytes": 1048576,
"image_count": 3,
"text_count": 2,
"total_requests": 150,
"recent_activity": [...]
}
```
## Project Structure
```
├── src/
│ ├── main.rs # HTTP server, routing, background cleanup
│ ├── api.rs # Upload/fetch endpoints
│ ├── data_mgt.rs # Asset model, persistence, expiration
│ └── logs.rs # Request and asset event logging
├── data/
│ ├── html/ # Frontend (index.html, view.html, stats.html, style.css)
│ ├── logs/ # Access logs
│ └── storage/ # Stored assets (auto-created)
├── Dockerfile
├── docker-compose.yaml
└── Cargo.toml
```
## Runtime Layout
The server uses paths relative to the repo root under `data/`:
- `data/html/` frontend assets (index.html, view.html, style.css)
- `data/logs/` access logs (`log.txt`, rotated on startup with timestamps)
- `data/storage/` uploaded assets (auto-created)
- **Local dev:** Run from repo root with `cargo run --release`
- **Docker:** Volume mounts `./data:/data`, container WORKDIR is `/`
## License
MIT

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

40
data/html/error.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Black Hole Share - Error</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body class="view-page error-page">
<h1><a href="/" class="home-link">Black Hole Share</a> - Error</h1>
<div class="view-container">
<div class="content-area">
<div class="error-content">
<div class="error-code">404</div>
<p class="error-message">The page you're looking for vanished into the black hole.</p>
<div class="error-actions">
<a class="upload-btn action-btn" href="/">Go Home</a>
<a class="reset-btn action-btn" href="/stats">View Stats</a>
</div>
</div>
</div>
</div>
<footer class="powered-by" style="display: flex; align-items: center">
<span style="flex: 1"></span>
<span>Powered by: <img src="/logo.png" alt="ICSBox" class="footer-logo" /></span>
<span style="flex: 1; text-align: right">
<a href="/stats" style="
color: var(--text-secondary);
font-size: 0.8em;
text-decoration: none;
">📊 Stats</a>
</span>
</footer>
</body>
</html>

BIN
data/html/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

BIN
data/html/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
data/html/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

View File

@@ -2,10 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Upload</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="style.css" />
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="theme-color" content="#000000">
</head>
<body>
@@ -13,62 +19,80 @@
<div class="upload-container">
<div class="upload-area">
<input type="file" id="fileInput" accept="image/*" style="display: none;">
<input type="file" id="fileInput" accept="image/*" style="display: none" />
<div id="uploadZone" class="upload-zone">
<p>Click to select file, paste image data, or drag & drop</p>
<p>Click to select file, paste image, text data, or drag & drop</p>
</div>
</div>
</div>
<div class="duration-container">
<label for="durationSlider">Duration: <span id="durationValue">5</span> min</label>
<input type="range" id="durationSlider" min="1" max="60" value="5" step="1">
<input type="range" id="durationSlider" min="1" max="60" value="5" step="1" />
<div class="button-row">
<button id="resetBtn" class="reset-btn" style="display: none;">Reset</button>
<button id="uploadBtn" class="upload-btn" style="display: none;">Upload</button>
<button id="resetBtn" class="reset-btn" style="display: none">
Reset
</button>
<button id="uploadBtn" class="upload-btn" style="display: none">
Upload
</button>
</div>
<div id="linkContainer" class="link-container" style="display: none;">
<div id="linkContainer" class="link-container" style="display: none">
<p>Link:</p>
<a id="uploadedLink" href="#" target="_blank"></a>
<p id="clipboardMessage" class="clipboard-message" style="display: none;"></p>
<p id="clipboardMessage" class="clipboard-message" style="display: none"></p>
</div>
</div>
<footer class="powered-by">
<span>Powered by: <img src="logo.png" alt="ICSBox" class="footer-logo"></span>
<footer class="powered-by" style="display: flex; align-items: center">
<span style="flex: 1"></span>
<span>Powered by: <img src="logo.png" alt="ICSBox" class="footer-logo" /></span>
<span style="flex: 1; text-align: right">
<a href="/stats" style="
color: var(--text-secondary);
font-size: 0.8em;
text-decoration: none;
">📊 Stats</a>
</span>
</footer>
<!-- Zoom overlay -->
<div id="zoomOverlay" class="zoom-overlay" style="display: none;">
</div>
<div id="zoomOverlay" class="zoom-overlay" style="display: none"></div>
<script>
let currentContentData = null;
const fileInput = document.getElementById('fileInput');
const uploadZone = document.getElementById('uploadZone');
const uploadContainer = document.querySelector('.upload-container');
const durationSlider = document.getElementById('durationSlider');
const durationValue = document.getElementById('durationValue');
const uploadBtn = document.getElementById('uploadBtn');
const resetBtn = document.getElementById('resetBtn');
const zoomOverlay = document.getElementById('zoomOverlay');
const linkContainer = document.getElementById('linkContainer');
const uploadedLink = document.getElementById('uploadedLink');
const clipboardMessage = document.getElementById('clipboardMessage');
const fileInput = document.getElementById("fileInput");
const uploadZone = document.getElementById("uploadZone");
const uploadContainer = document.querySelector(".upload-container");
const durationSlider = document.getElementById("durationSlider");
const durationValue = document.getElementById("durationValue");
const uploadBtn = document.getElementById("uploadBtn");
const resetBtn = document.getElementById("resetBtn");
const zoomOverlay = document.getElementById("zoomOverlay");
const linkContainer = document.getElementById("linkContainer");
const uploadedLink = document.getElementById("uploadedLink");
const clipboardMessage = document.getElementById("clipboardMessage");
// Update duration display
durationSlider.addEventListener('input', function () {
durationSlider.addEventListener("input", function () {
durationValue.textContent = this.value;
});
uploadBtn.addEventListener('click', async () => {
// fischi20 thanks!!!
durationSlider.addEventListener("wheel", (e) => {
e.preventDefault();
durationSlider.valueAsNumber += e.deltaY < 0 ? -1 : 1;
durationValue.textContent = durationSlider.value;
});
uploadBtn.addEventListener("click", async () => {
const duration = durationSlider.value;
const isText = uploadZone.querySelector('.text-content') !== null;
const mimeType = isText ? 'text/plain' : 'image/png';
const isText = uploadZone.querySelector(".text-content") !== null;
const mimeType = isText ? "text/plain" : "image/png";
const contentData = currentContentData;
if (!contentData) {
console.log('❌ No content to upload!');
console.log("❌ No content to upload!");
return;
}
@@ -76,104 +100,107 @@
});
async function sendUpload(duration, mimeType, contentData) {
const isText = mimeType === 'text/plain';
const isText = mimeType === "text/plain";
let content = contentData;
// For images, remove data URL prefix to send only base64 string
if (!isText && contentData.includes('base64,')) {
content = contentData.split('base64,')[1];
if (!isText && contentData.includes("base64,")) {
content = contentData.split("base64,")[1];
}
const payload = {
duration: parseInt(duration),
content_type: mimeType,
content: content
content: content,
};
try {
const response = await fetch('/api/upload', {
method: 'POST',
const response = await fetch("/api/upload", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(payload)
body: JSON.stringify(payload),
});
const result = await response.json();
console.log(`✅ Upload received!\n${JSON.stringify(result, null, 2)}`);
console.log(
`✅ Upload received!\n${JSON.stringify(result, null, 2)}`
);
// Hide duration controls and buttons
document.querySelector('label[for="durationSlider"]').style.display = 'none';
durationSlider.style.display = 'none';
uploadBtn.style.display = 'none';
resetBtn.style.display = 'none';
document.querySelector('label[for="durationSlider"]').style.display =
"none";
durationSlider.style.display = "none";
uploadBtn.style.display = "none";
resetBtn.style.display = "none";
// Show link
const fullLink = window.location.origin + result.link;
uploadedLink.href = fullLink;
uploadedLink.textContent = fullLink;
linkContainer.style.display = 'block';
linkContainer.style.display = "block";
// Copy to clipboard
try {
await navigator.clipboard.writeText(fullLink);
clipboardMessage.textContent = '✓ Copied to clipboard!';
clipboardMessage.style.color = 'var(--accent-green)';
clipboardMessage.style.cursor = 'default';
clipboardMessage.style.display = 'block';
clipboardMessage.textContent = "✓ Copied to clipboard!";
clipboardMessage.style.color = "var(--accent-green)";
clipboardMessage.style.cursor = "default";
clipboardMessage.style.display = "block";
clipboardMessage.onclick = null;
} catch (error) {
clipboardMessage.textContent = '⚠ Click here to copy link';
clipboardMessage.style.color = 'var(--accent-cyan)';
clipboardMessage.style.cursor = 'pointer';
clipboardMessage.style.display = 'block';
clipboardMessage.textContent = "⚠ Click here to copy link";
clipboardMessage.style.color = "var(--accent-cyan)";
clipboardMessage.style.cursor = "pointer";
clipboardMessage.style.display = "block";
clipboardMessage.onclick = function () {
const textArea = document.createElement('textarea');
const textArea = document.createElement("textarea");
textArea.value = fullLink;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
clipboardMessage.textContent = '✓ Copied to clipboard!';
clipboardMessage.style.color = 'var(--accent-green)';
clipboardMessage.style.cursor = 'default';
document.execCommand("copy");
clipboardMessage.textContent = "✓ Copied to clipboard!";
clipboardMessage.style.color = "var(--accent-green)";
clipboardMessage.style.cursor = "default";
clipboardMessage.onclick = null;
} catch (e) {
clipboardMessage.textContent = '✗ Copy failed';
clipboardMessage.style.color = '#ff6666';
clipboardMessage.textContent = "✗ Copy failed";
clipboardMessage.style.color = "#ff6666";
}
document.body.removeChild(textArea);
};
}
} catch (error) {
console.log(`❌ Error: ${error.message}`);
}
}
// Reset to initial state
resetBtn.addEventListener('click', function () {
resetBtn.addEventListener("click", function () {
currentContentData = null;
uploadZone.innerHTML = '<p>Click to select file, paste image data, or drag & drop</p>';
uploadContainer.style.height = '180px';
uploadContainer.style.pointerEvents = '';
uploadContainer.style.overflow = '';
uploadZone.style.pointerEvents = '';
uploadZone.style.alignItems = '';
uploadZone.style.justifyContent = '';
uploadZone.style.padding = '';
fileInput.value = '';
durationSlider.value = '5';
durationValue.textContent = '5';
document.querySelector('label[for="durationSlider"]').style.display = '';
durationSlider.style.display = '';
uploadBtn.style.display = 'none';
resetBtn.style.display = 'none';
linkContainer.style.display = 'none';
clipboardMessage.style.display = 'none';
uploadZone.innerHTML =
"<p>Click to select file, paste image data, or drag & drop</p>";
uploadContainer.style.height = "180px";
uploadContainer.style.pointerEvents = "";
uploadContainer.style.overflow = "";
uploadZone.style.pointerEvents = "";
uploadZone.style.alignItems = "";
uploadZone.style.justifyContent = "";
uploadZone.style.padding = "";
fileInput.value = "";
durationSlider.value = "5";
durationValue.textContent = "5";
document.querySelector('label[for="durationSlider"]').style.display =
"";
durationSlider.style.display = "";
uploadBtn.style.display = "none";
resetBtn.style.display = "none";
linkContainer.style.display = "none";
clipboardMessage.style.display = "none";
uploadZone.focus();
});
@@ -183,42 +210,47 @@
if (isText) {
// Display text content - ZOOM ENABLED
uploadZone.innerHTML = `<div class="text-content" style="cursor: zoom-in;">${content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>`;
uploadContainer.style.height = '500px';
uploadContainer.style.overflow = 'hidden';
uploadZone.style.pointerEvents = 'auto';
uploadZone.style.alignItems = 'stretch';
uploadZone.style.justifyContent = 'stretch';
uploadZone.style.padding = '0';
uploadBtn.style.display = 'block';
resetBtn.style.display = 'block';
uploadZone.innerHTML = `<div class="text-content" style="cursor: zoom-in;">${content
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")}</div>`;
uploadContainer.style.height = "500px";
uploadContainer.style.overflow = "hidden";
uploadZone.style.pointerEvents = "auto";
uploadZone.style.alignItems = "stretch";
uploadZone.style.justifyContent = "stretch";
uploadZone.style.padding = "0";
uploadBtn.style.display = "block";
resetBtn.style.display = "block";
// ZOOM FOR TEXT
const textContent = uploadZone.querySelector('.text-content');
textContent.addEventListener('click', function (e) {
const textContent = uploadZone.querySelector(".text-content");
textContent.addEventListener("click", function (e) {
e.stopPropagation();
showZoom(content, true);
});
} else {
// Display image
const img = new Image();
img.onload = function () {
const maxWidth = 620;
const maxHeight = 800;
const scale = Math.min(maxWidth / img.width, maxHeight / img.height, 1);
const scale = Math.min(
maxWidth / img.width,
maxHeight / img.height,
1
);
const displayHeight = Math.floor(img.height * scale);
const displayWidth = Math.floor(img.width * scale);
uploadZone.innerHTML = `<img src="${content}" alt="Uploaded Image" style="width: ${displayWidth}px; height: ${displayHeight}px; object-fit: contain; cursor: zoom-in;">`;
uploadContainer.style.height = `${displayHeight + 20}px`;
uploadContainer.style.pointerEvents = 'none';
uploadZone.style.pointerEvents = 'auto';
uploadBtn.style.display = 'block';
resetBtn.style.display = 'block';
uploadContainer.style.pointerEvents = "none";
uploadZone.style.pointerEvents = "auto";
uploadBtn.style.display = "block";
resetBtn.style.display = "block";
const uploadedImg = uploadZone.querySelector('img');
uploadedImg.addEventListener('click', function (e) {
const uploadedImg = uploadZone.querySelector("img");
uploadedImg.addEventListener("click", function (e) {
e.stopPropagation();
showZoom(content, false);
});
@@ -228,13 +260,17 @@
}
// Open file picker on container click (ONLY IF EMPTY)
uploadContainer.addEventListener('click', function (e) {
if (uploadContainer.style.pointerEvents !== 'none' && !uploadZone.querySelector('.text-content') && !uploadZone.querySelector('img')) {
uploadContainer.addEventListener("click", function (e) {
if (
uploadContainer.style.pointerEvents !== "none" &&
!uploadZone.querySelector(".text-content") &&
!uploadZone.querySelector("img")
) {
fileInput.click();
}
});
fileInput.addEventListener('change', function (e) {
fileInput.addEventListener("change", function (e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
@@ -246,12 +282,12 @@
});
// Handle paste from clipboard
uploadZone.addEventListener('paste', function (e) {
uploadZone.addEventListener("paste", function (e) {
e.preventDefault();
const items = e.clipboardData.items;
for (let item of items) {
if (item.type.startsWith('image/')) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
const reader = new FileReader();
reader.onload = function (event) {
@@ -262,20 +298,20 @@
}
}
const text = e.clipboardData.getData('text');
const text = e.clipboardData.getData("text");
if (text) {
displayContent(text, true);
}
});
// Handle drag and drop
uploadZone.addEventListener('drop', handleDrop);
uploadContainer.addEventListener('drop', handleDrop);
uploadZone.addEventListener("drop", handleDrop);
uploadContainer.addEventListener("drop", handleDrop);
function handleDrop(e) {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
if (file && file.type.startsWith("image/")) {
const reader = new FileReader();
reader.onload = function (event) {
displayContent(event.target.result);
@@ -284,22 +320,22 @@
}
}
uploadZone.addEventListener('dragover', function (e) {
uploadZone.addEventListener("dragover", function (e) {
e.preventDefault();
uploadZone.style.borderColor = 'var(--border-hover)';
uploadZone.style.borderColor = "var(--border-hover)";
});
uploadZone.addEventListener('dragleave', function (e) {
uploadZone.style.borderColor = '';
uploadZone.addEventListener("dragleave", function (e) {
uploadZone.style.borderColor = "";
});
uploadContainer.addEventListener('dragover', function (e) {
uploadContainer.addEventListener("dragover", function (e) {
e.preventDefault();
});
uploadZone.setAttribute('tabindex', '0');
uploadZone.setAttribute("tabindex", "0");
window.addEventListener('focus', function () {
window.addEventListener("focus", function () {
uploadZone.focus();
});
@@ -309,28 +345,30 @@
function showZoom(content, isText = false) {
if (isText) {
zoomOverlay.innerHTML = `
<div class="zoom-text-content">${content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
<div class="zoom-text-content">${content
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")}</div>
`;
} else {
zoomOverlay.innerHTML = `<img id="zoomImage" src="${content}" alt="Zoomed Image" style="max-width: 95vw; max-height: 95vh; object-fit: contain; box-shadow: 0 0 50px rgba(51, 204, 255, 0.5);">`;
}
zoomOverlay.style.display = 'flex';
zoomOverlay.style.display = "flex";
}
function hideZoom() {
zoomOverlay.style.display = 'none';
zoomOverlay.style.display = "none";
}
zoomOverlay.addEventListener('click', hideZoom);
zoomOverlay.addEventListener("click", hideZoom);
// ESC TO EXIT ZOOM
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' || e.key === 'Esc') {
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" || e.key === "Esc") {
hideZoom();
}
});
window.addEventListener('resize', function () {
window.addEventListener("resize", function () {
if (currentContentData) {
displayContent(currentContentData);
}

View File

@@ -0,0 +1,19 @@
{
"name": "Black Hole Share",
"short_name": "BHS",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
}

295
data/html/stats.html Normal file
View File

@@ -0,0 +1,295 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Black Hole Share - Statistics</title>
<link rel="stylesheet" href="/style.css" />
<style>
.stats-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(140px, 170px);
gap: 20px;
margin-top: 20px;
align-items: stretch;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(160px, 1fr));
gap: 20px;
}
.stats-request-card {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.stat-card {
background-color: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 12px;
padding: 20px;
text-align: center;
transition: all 0.3s ease;
}
.stat-card:hover {
border-color: var(--border-hover);
box-shadow: 0 4px 15px rgba(0, 255, 153, 0.2);
}
.stat-value {
font-size: 1.5em;
font-weight: bold;
color: var(--accent-cyan);
margin: 10px 0;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
}
.stat-card.highlight .stat-value {
color: var(--accent-green);
}
.recent-activity {
margin-top: 30px;
background-color: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
}
.recent-activity h2 {
color: var(--accent-cyan);
margin: 0 0 15px 0;
font-size: 1.2em;
}
.recent-activity:hover {
border-color: var(--border-hover);
box-shadow: 0 4px 15px rgba(0, 255, 153, 0.2);
}
.activity-list {
max-height: 300px;
overflow-y: auto;
font-family: "JetBrains Mono", monospace;
font-size: 0.85em;
}
.activity-item {
padding: 8px 0;
border-bottom: 1px solid var(--inactive-gray);
display: grid;
grid-template-columns: 90px minmax(120px, 1fr) minmax(90px, 1fr) minmax(180px, 1fr);
align-items: center;
gap: 10px;
white-space: nowrap;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-action {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8em;
font-weight: bold;
}
.activity-action.upload {
background-color: rgba(0, 255, 153, 0.2);
color: var(--accent-green);
}
.activity-action.delete {
background-color: rgba(255, 102, 102, 0.2);
color: #ff6666;
}
.activity-time {
color: var(--text-secondary);
white-space: nowrap;
}
.activity-details {
color: var(--text-primary);
display: contents;
}
.activity-mime {
text-align: left;
}
.activity-duration {
text-align: left;
}
.activity-time {
text-align: left;
}
.refresh-btn {
background-color: var(--border-color);
color: var(--bg-tertiary);
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
margin-top: 20px;
transition: all 0.2s ease;
}
.refresh-btn:hover {
background-color: var(--border-hover);
}
.loading {
text-align: center;
color: var(--text-secondary);
padding: 40px;
}
</style>
</head>
<body class="view-page">
<h1><a href="/" class="home-link">Black Hole Share</a> - Statistics</h1>
<div id="statsContent" class="loading">
<p>Loading statistics...</p>
</div>
<footer class="powered-by" style="display: flex; align-items: center">
<span style="flex: 1"></span>
<span>Powered by: <img src="/logo.png" alt="ICSBox" class="footer-logo" /></span>
<span style="flex: 1; text-align: right">
<a href="/stats" style="
color: var(--text-secondary);
font-size: 0.8em;
text-decoration: none;
">📊 Stats</a>
</span>
</footer>
<script>
async function loadStats() {
try {
const response = await fetch("/api/stats");
if (!response.ok) {
throw new Error("Failed to load stats");
}
const stats = await response.json();
renderStats(stats);
} catch (error) {
document.getElementById("statsContent").innerHTML = `
<p class="error">Failed to load statistics: ${error.message}</p>
`;
}
}
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString("en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
function renderStats(stats) {
const html = `
<div class="stats-layout">
<div class="stats-grid">
<div class="stat-card highlight">
<div class="stat-label">Active Assets</div>
<div class="stat-value">${stats.active_assets}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Uploads</div>
<div class="stat-value">${stats.total_uploads}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Deleted</div>
<div class="stat-value">${stats.total_deleted}</div>
</div>
<div class="stat-card">
<div class="stat-label">Storage Used</div>
<div class="stat-value">${formatBytes(stats.storage_bytes)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Images</div>
<div class="stat-value">${stats.image_count}</div>
</div>
<div class="stat-card">
<div class="stat-label">Text</div>
<div class="stat-value">${stats.text_count}</div>
</div>
</div>
<div class="stat-card stats-request-card">
<div class="stat-label">Total Server Requests</div>
<div class="stat-value">${stats.total_requests}</div>
</div>
</div>
<div class="recent-activity">
<h2>Recent Activity</h2>
<div class="activity-list">
${stats.recent_activity.length === 0
? '<p style="color: var(--text-secondary);">No recent activity</p>'
: stats.recent_activity
.map(
(item) => `
<div class="activity-item">
<span class="activity-action ${item.action}">${item.action
}</span>
<span class="activity-details">
<span class="activity-mime">${item.mime}</span>
<span class="activity-duration">${item.share_duration} min</span>
</span>
<span class="activity-time">${formatTime(item.timestamp)}</span>
</div>
`
)
.join("")
}
</div>
</div>
<button class="refresh-btn" onclick="loadStats()">Refresh</button>
`;
document.getElementById("statsContent").innerHTML = html;
}
loadStats();
// Auto-refresh every 30 seconds
setInterval(loadStats, 30000);
</script>
</body>
</html>

View File

@@ -3,6 +3,8 @@
--bg-primary: #1e1e2e;
--bg-secondary: #1a1a1a;
--bg-tertiary: #1a1a1a;
--bg-glow: rgba(51, 204, 255, 0.08);
--bg-glow-strong: rgba(0, 255, 153, 0.07);
--active-cyan: #33ccff;
--active-green: #00ff99;
--inactive-gray: #595959;
@@ -29,6 +31,11 @@ body {
padding: 20px;
padding-bottom: 140px;
background-color: var(--bg-tertiary);
background-image:
radial-gradient(1200px 800px at 10% -20%, var(--bg-glow), transparent 60%),
radial-gradient(900px 700px at 110% 0%, var(--bg-glow-strong), transparent 55%),
linear-gradient(180deg, rgba(30, 30, 46, 0.35), rgba(26, 26, 26, 0.85));
background-attachment: fixed;
color: var(--text-primary);
display: flex;
flex-direction: column;
@@ -356,6 +363,18 @@ h1 .home-link:hover {
scrollbar-width: thin;
}
.zoom-text-content.code-content {
background-color: var(--bg-primary);
border-color: var(--border-color);
overflow-x: auto;
white-space: pre;
}
.zoom-text-content.code-content code {
display: block;
white-space: pre;
}
.zoom-text-content::-webkit-scrollbar {
width: 8px;
}
@@ -386,7 +405,7 @@ h1 .home-link:hover {
align-items: center;
justify-content: center;
z-index: 9999;
cursor: zoom-out;
cursor: default;
padding: 20px;
box-sizing: border-box;
}
@@ -512,6 +531,55 @@ body.view-page {
text-align: center;
}
/* Error page styles */
.error-page .content-area {
min-height: 320px;
}
.error-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
padding: 10px;
}
.error-code {
font-size: 3.2em;
font-weight: bold;
color: var(--accent-cyan);
text-shadow: 0 0 12px rgba(51, 204, 255, 0.4);
}
.error-message {
color: var(--text-secondary);
font-size: 1.05em;
margin: 0;
}
.error-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
align-items: center;
}
.action-btn {
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
min-width: 140px;
}
.error-actions .upload-btn,
.error-actions .reset-btn {
flex: 0 0 auto;
}
@keyframes pulse {
0%,
@@ -544,6 +612,18 @@ body.view-page {
border: none;
}
.text-content-view.code-content {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
overflow-x: auto;
white-space: pre;
}
.text-content-view.code-content code {
display: block;
white-space: pre;
}
.text-content-view::-webkit-scrollbar {
width: 8px;
}
@@ -560,4 +640,4 @@ body.view-page {
.text-content-view::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
}

View File

@@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Black Hole Share - View</title>
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
</head>
<body class="view-page">
@@ -17,13 +19,22 @@
</div>
</div>
<footer class="powered-by">
<span>Powered by: <img src="/logo.png" alt="ICSBox" class="footer-logo"></span>
<footer class="powered-by" style="display: flex; align-items: center">
<span style="flex: 1"></span>
<span>Powered by: <img src="/logo.png" alt="ICSBox" class="footer-logo" /></span>
<span style="flex: 1; text-align: right">
<a href="/stats" style="
color: var(--text-secondary);
font-size: 0.8em;
text-decoration: none;
">📊 Stats</a>
</span>
</footer>
<!-- Zoom overlay -->
<div id="zoomOverlay" class="zoom-overlay" style="display: none;"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>
const contentArea = document.getElementById('contentArea');
const zoomOverlay = document.getElementById('zoomOverlay');
@@ -32,6 +43,28 @@
const pathParts = window.location.pathname.split('/');
const assetId = pathParts[pathParts.length - 1];
function escapeHtml(text) {
return text.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function isCodeLike(text) {
const lines = text.split('\n');
if (lines.length < 2) {
return false;
}
const indicators = [
/;\s*$/,
/^\s*(fn|function|class|def|public|private|struct|enum|pub\s+struct)\b/,
/^\s*#\[/,
/=>|::|#include|import\s+\w+/,
/\{|\}|\(|\)|\[|\]/,
];
const indicatorHits = indicators.reduce((count, re) => count + (re.test(text) ? 1 : 0), 0);
return indicatorHits >= 2;
}
async function loadContent() {
try {
const response = await fetch(`/api/content/${assetId}`);
@@ -74,12 +107,23 @@
} else if (contentType.startsWith('text/')) {
// Display text
const text = await response.text();
contentArea.innerHTML = `<div class="text-content-view" style="cursor: zoom-in;">${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>`;
const safeText = escapeHtml(text);
const isCode = isCodeLike(text);
const textHtml = isCode
? `<pre class="text-content-view code-content"><code>${safeText}</code></pre>`
: `<div class="text-content-view">${safeText}</div>`;
contentArea.innerHTML = textHtml;
if (isCode && window.hljs) {
contentArea.querySelectorAll('pre code').forEach((block) => {
window.hljs.highlightElement(block);
});
}
const textContent = contentArea.querySelector('.text-content-view');
textContent.addEventListener('click', function (e) {
e.stopPropagation();
showZoom(text, true);
showZoom(text, true, isCode);
});
} else {
contentArea.innerHTML = '<p class="error">Unsupported content type</p>';
@@ -91,11 +135,19 @@
}
}
function showZoom(content, isText = false) {
function showZoom(content, isText = false, isCode = false) {
if (isText) {
zoomOverlay.innerHTML = `
<div class="zoom-text-content">${content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
`;
const safeText = escapeHtml(content);
const zoomClass = isCode ? 'zoom-text-content code-content' : 'zoom-text-content';
const zoomHtml = isCode
? `<pre class="${zoomClass}"><code>${safeText}</code></pre>`
: `<div class="${zoomClass}">${safeText}</div>`;
zoomOverlay.innerHTML = zoomHtml;
if (isCode && window.hljs) {
zoomOverlay.querySelectorAll('pre code').forEach((block) => {
window.hljs.highlightElement(block);
});
}
} else {
zoomOverlay.innerHTML = `<img id="zoomImage" src="${content}" alt="Zoomed Content"
style="max-width: 95vw; max-height: 95vh; object-fit: contain; box-shadow: 0 0 50px rgba(51, 204, 255, 0.5);">`;
@@ -107,8 +159,6 @@
zoomOverlay.style.display = 'none';
}
zoomOverlay.addEventListener('click', hideZoom);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' || e.key === 'Esc') {
hideZoom();
@@ -120,4 +170,4 @@
</script>
</body>
</html>
</html>

View File

@@ -7,9 +7,27 @@ services:
volumes:
- ./data:/data
- /etc/localtime:/etc/localtime:ro
labels:
- "traefik.enable=true"
- "traefik.docker.network=vlan250"
- "traefik.http.routers.bhs.rule=Host(`bhs.qosnet.it`)"
- "traefik.http.routers.bhs.entrypoints=websecure"
- "traefik.http.routers.bhs.tls=true"
- "traefik.http.routers.bhs.tls.certresolver=le"
- "traefik.http.services.bhs.loadbalancer.server.port=80"
environment:
- TZ="Europe/Rome"
- TZ=Europe/Rome
- BIND_ADDR=0.0.0.0
- BIND_PORT=80
tty: true
stdin_open: true
ports:
- "8080:80"
networks:
- vlan250
networks:
vlan250:
external: true

2
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "1.90.0"

View File

@@ -1,9 +1,14 @@
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use base64::{Engine, engine::general_purpose};
use serde::Deserialize;
use serde_json::json;
use crate::{DATA_STORAGE, logs::log_to_file};
use crate::{
LOG_FILE_NAME,
data_mgt::{Asset, AssetTracker},
logs::{LogEvent, LogEventType, log_event},
};
#[derive(Deserialize, Debug)]
pub struct UploadRequest {
@@ -13,38 +18,136 @@ pub struct UploadRequest {
}
#[post("/api/upload")]
async fn api_upload(req: web::Json<UploadRequest>) -> Result<HttpResponse, actix_web::Error> {
async fn api_upload(
req: HttpRequest,
body: web::Json<UploadRequest>,
assets: web::Data<AssetTracker>,
) -> Result<HttpResponse, actix_web::Error> {
// Convert to bytes
let content_bytes = if req.content_type == "text/plain" {
req.content.as_bytes().to_vec() // UTF-8 bytes
let content_bytes = if body.content_type == "text/plain" {
body.content.as_bytes().to_vec()
} else {
// Decode base64 → bytes
general_purpose::STANDARD.decode(&req.content).unwrap()
match general_purpose::STANDARD.decode(&body.content) {
Ok(bytes) => bytes,
Err(_) => return Ok(HttpResponse::BadRequest().body("Invalid base64 payload")),
}
};
let asset = crate::data_mgt::Asset::new(req.duration, req.content_type.clone(), content_bytes);
let connection_info = req.connection_info();
let uploader_ip = connection_info
.realip_remote_addr()
.or_else(|| connection_info.peer_addr())
.unwrap_or("-")
.to_string();
let id = asset
.save()
.map_err(|e| actix_web::error::ErrorInternalServerError(format!("Failed to save asset: {}", e)))?;
let asset = crate::data_mgt::Asset::new(
body.duration,
body.content_type.clone(),
content_bytes,
Some(uploader_ip.clone()),
);
let id = asset.id();
log_event(LogEventType::AssetUploaded(asset.to_value()));
assets.add_asset(asset).await;
let response_body = json!({ "link": format!("/bhs/{}", id) });
Ok(HttpResponse::Ok().json(response_body))
}
#[get("/api/content/{id}")]
async fn api_get_asset(req: HttpRequest, path: web::Path<String>) -> Result<HttpResponse, actix_web::Error> {
let now = std::time::Instant::now();
async fn api_get_asset(
req: HttpRequest,
path: web::Path<String>,
assets: web::Data<AssetTracker>,
) -> Result<HttpResponse, actix_web::Error> {
log_event(LogEventType::HttpRequest(req.into()));
let id = path.into_inner();
let asset_path = format!("{}{}", DATA_STORAGE, id);
let data = std::fs::read(&asset_path).map_err(|_| actix_web::error::ErrorNotFound("Asset not found"))?;
let asset = serde_json::from_slice::<crate::data_mgt::Asset>(&data)
.map_err(|_| actix_web::error::ErrorInternalServerError("Failed to parse asset data"))?;
match assets.get_asset(&path.into_inner()).await {
None => Ok(HttpResponse::NotFound().body("Asset not found")),
Some(asset) => Ok(HttpResponse::Ok()
.content_type(asset.mime())
.body(asset.content().clone())),
}
}
if asset.is_expired() {
return Err(actix_web::error::ErrorNotFound("Asset has expired"));
#[derive(serde::Serialize)]
struct StatsResponse {
active_assets: usize,
total_uploads: usize,
total_deleted: usize,
storage_bytes: u64,
image_count: usize,
text_count: usize,
total_requests: usize,
recent_activity: Vec<ActivityItem>,
}
#[derive(serde::Serialize)]
struct ActivityItem {
action: String,
mime: String,
share_duration: u32,
timestamp: String,
}
#[get("/api/stats")]
async fn api_stats(assets: web::Data<AssetTracker>) -> Result<HttpResponse, actix_web::Error> {
use crate::LOG_DIR;
use std::fs;
let (active_assets, storage_bytes, image_count, text_count) = assets.stats_summary().await;
let mut total_uploads = 0;
let mut total_deleted = 0;
let mut recent_activity: Vec<ActivityItem> = Vec::new();
let mut request_count: usize = 0;
let log_path = format!("{}{}", LOG_DIR, LOG_FILE_NAME);
if let Ok(content) = fs::read_to_string(&log_path) {
for line in content.lines() {
if let Ok(entry) = serde_json::from_str::<LogEvent>(line) {
match entry.event {
LogEventType::HttpRequest(_req) => {
request_count += 1;
}
LogEventType::AssetUploaded(asset) => {
let asset = serde_json::from_value::<Asset>(asset).unwrap_or_default();
total_uploads += 1;
recent_activity.push(ActivityItem {
action: "upload".to_string(),
mime: asset.mime(),
share_duration: asset.share_duration(),
timestamp: entry.time,
});
}
LogEventType::AssetDeleted(asset) => {
let asset = serde_json::from_value::<Asset>(asset).unwrap_or_default();
total_deleted += 1;
recent_activity.push(ActivityItem {
action: "delete".to_string(),
mime: asset.mime(),
share_duration: asset.share_duration(),
timestamp: entry.time,
});
}
}
}
}
}
log_to_file(&req, now);
Ok(HttpResponse::Ok().content_type(asset.mime()).body(asset.content()))
// Keep only last 20, most recent first
recent_activity.reverse();
recent_activity.truncate(20);
let response = StatsResponse {
active_assets,
total_uploads,
total_deleted,
storage_bytes,
image_count,
text_count,
total_requests: request_count,
recent_activity,
};
Ok(HttpResponse::Ok().json(response))
}

View File

@@ -1,21 +1,29 @@
use std::fmt::Debug;
use std::sync::Arc;
use anyhow::Result;
use chrono::{Duration, Utc};
use futures::lock::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::DATA_STORAGE;
use crate::logs::{LogEventType, log_event};
#[derive(Debug, Serialize, Deserialize, Default)]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct Asset {
id: String,
share_duration: u32,
created_at: i64,
expires_at: i64,
mime: String,
#[serde(skip)]
content: Vec<u8>,
uploader_ip: Option<String>,
}
#[allow(dead_code)]
impl Asset {
pub fn new(share_duration: u32, mime: String, content: Vec<u8>) -> Self {
pub fn new(share_duration: u32, mime: String, content: Vec<u8>, uploader_ip: Option<String>) -> Self {
let id = uuid::Uuid::new_v4().to_string();
let created_at = Utc::now().timestamp_millis();
let expires_at = created_at + Duration::minutes(share_duration as i64).num_milliseconds();
@@ -26,51 +34,146 @@ impl Asset {
expires_at,
mime,
content,
uploader_ip,
}
}
pub fn is_expired(&self) -> bool {
Utc::now().timestamp_millis() > self.expires_at
}
pub fn id(&self) -> &str {
&self.id
pub fn id(&self) -> String {
self.id.clone()
}
pub fn mime(&self) -> &str {
&self.mime
pub fn mime(&self) -> String {
self.mime.clone()
}
pub fn content(&self) -> Vec<u8> {
self.content.clone()
}
pub fn share_duration(&self) -> u32 {
self.share_duration
}
pub fn created_at(&self) -> i64 {
self.created_at
}
pub fn expires_at(&self) -> i64 {
self.expires_at
}
pub fn mime_type(&self) -> &str {
&self.mime
}
pub fn size_bytes(&self) -> usize {
self.content.len()
}
pub fn uploader_ip(&self) -> Option<&str> {
self.uploader_ip.as_deref()
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
let bytes = serde_json::to_vec(self)?;
Ok(bytes)
}
pub fn save(&self) -> Result<String> {
let id = self.id.clone();
let path = format!("{}{}", DATA_STORAGE, self.id);
std::fs::create_dir_all(DATA_STORAGE)?;
std::fs::write(&path, self.to_bytes()?)?;
Ok(id)
pub fn to_value(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
// pub fn save(&self) -> Result<String> {
// let id = self.id.clone();
// let path = format!("{}{}", DATA_STORAGE, self.id);
// std::fs::create_dir_all(DATA_STORAGE)?;
// std::fs::write(&path, self.to_bytes()?)?;
// Ok(id)
// }
}
#[derive(Clone)]
pub struct AssetTracker {
assets: Arc<Mutex<Vec<Asset>>>,
}
#[allow(dead_code)]
impl AssetTracker {
pub fn new() -> Self {
AssetTracker {
assets: Arc::new(Mutex::new(Vec::new())),
}
}
pub async fn add_asset(&self, asset: Asset) {
print!("[{}] Adding asset: {}", chrono::Local::now().to_rfc3339(), asset.id());
self.assets.lock().await.push(asset);
self.show_assets().await;
}
pub async fn remove_expired(&self) {
let mut assets = self.assets.lock().await;
let removed_assets = assets.extract_if(.., |asset| asset.is_expired());
for asset in removed_assets {
println!("[{}] Removing asset: {}", chrono::Local::now().to_rfc3339(), asset.id());
log_event(LogEventType::AssetDeleted(asset.to_value()));
}
}
pub async fn active_assets(&self) -> usize {
self.assets.lock().await.len()
}
pub async fn stats_summary(&self) -> (usize, u64, usize, usize) {
let assets = self.assets.lock().await;
let mut active_assets = 0;
let mut storage_bytes: u64 = 0;
let mut image_count = 0;
let mut text_count = 0;
for asset in assets.iter() {
if asset.is_expired() {
continue;
}
active_assets += 1;
storage_bytes += asset.size_bytes() as u64;
if asset.mime().starts_with("image/") {
image_count += 1;
} else if asset.mime().starts_with("text/") {
text_count += 1;
}
}
(active_assets, storage_bytes, image_count, text_count)
}
pub async fn show_assets(&self) {
for asset in self.assets.lock().await.iter() {
println!(
"Asset ID: {}, Expires At: {}, MIME: {}, Size: {} bytes",
asset.id(),
asset.expires_at(),
asset.mime(),
asset.size_bytes()
);
}
}
pub async fn get_asset(&self, id: &str) -> Option<Asset> {
let assets = self.assets.lock().await;
for asset in assets.iter().cloned() {
if asset.id() == id {
return Some(asset.clone());
}
}
None
}
}
pub async fn clear_assets() -> Result<()> {
let entries = std::fs::read_dir(DATA_STORAGE)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let data = std::fs::read(&path)?;
let asset = serde_json::from_slice::<Asset>(&data)?;
if asset.is_expired() {
println!("Removing expired asset: {}", asset.id());
std::fs::remove_file(&path)?;
}
}
}
pub async fn clear_assets(assets: AssetTracker) -> Result<()> {
assets.remove_expired().await;
Ok(())
}

View File

@@ -1,47 +1,82 @@
use std::{
fs::{self, OpenOptions},
io::Write,
time::Instant,
};
use std::{fs::OpenOptions, io::Write};
use actix_web::HttpRequest;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::LOG_DIR;
use crate::{LOG_DIR, LOG_FILE_NAME};
pub fn log_to_file(req: &HttpRequest, start: Instant) {
let delta = start.elapsed().as_nanos();
println!("Request processed in {} ns", delta);
let duration_ms = delta as f64 / 1000_000.0;
let _ = fs::create_dir_all(LOG_DIR);
#[derive(Debug, Serialize, Deserialize)]
pub struct LogHttpRequest {
pub method: String,
pub path: String,
pub query_string: String,
pub scheme: String,
pub ip: String,
pub real_ip: String,
pub user_agent: String,
}
impl From<HttpRequest> for LogHttpRequest {
fn from(req: HttpRequest) -> Self {
let method = req.method().as_str().to_string();
let uri = req.uri();
let path = uri.path().to_string();
let query_string = uri.query().unwrap_or("-").to_string();
let log_path = LOG_DIR.to_string() + "access.log";
let connection_info = req.connection_info();
let scheme = connection_info.scheme().to_string();
let ip = connection_info.peer_addr().unwrap_or("-").to_string();
let real_ip = connection_info.realip_remote_addr().unwrap_or("-").to_string();
let user_agent = req
.headers()
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("-")
.to_string();
LogHttpRequest {
method,
path,
query_string,
scheme,
ip,
real_ip,
user_agent,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum LogEventType {
AssetUploaded(Value),
AssetDeleted(Value),
HttpRequest(LogHttpRequest),
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LogEvent {
pub time: String,
pub event: LogEventType,
}
impl From<LogEventType> for LogEvent {
fn from(event: LogEventType) -> Self {
let time = chrono::Utc::now().to_rfc3339();
LogEvent { time, event }
}
}
pub fn log_event(event: LogEventType) {
let log_path = LOG_DIR.to_string() + LOG_FILE_NAME;
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) else {
eprintln!("failed to open log file");
eprintln!("failed to open log file for asset event");
return;
};
let ts = chrono::Local::now().to_rfc3339();
let log_event: LogEvent = event.into();
let line = serde_json::to_string(&log_event).unwrap_or_else(|e| e.to_string());
let method = req.method();
let uri = req.uri();
let path = uri.path();
let query = uri.query().unwrap_or("-");
let connection_info = req.connection_info();
let scheme = connection_info.scheme();
let ip = connection_info.peer_addr().unwrap_or("-");
let real_ip = connection_info.realip_remote_addr().unwrap_or("-");
let ua = req
.headers()
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
let line = format!(
"{ts} scheme={scheme} ip={ip} real_ip={real_ip} method={method} path={path} qs={query} dur_ms={duration_ms} ua=\"{ua}\"\n"
);
let _ = file.write_all(line.as_bytes());
let _ = writeln!(file, "{}", line);
}

View File

@@ -4,78 +4,137 @@ mod logs;
use actix_files::NamedFile;
use actix_web::{
App, HttpRequest, HttpResponse, HttpServer, get, route,
App, HttpRequest, HttpServer, get, route,
web::{self},
};
use serde_json::Value;
use std::path::PathBuf;
pub static BIND_ADDR: &str = "0.0.0.0";
pub static BIND_PORT: u16 = 80;
pub static STATIC_PAGES: &[&str] = &["index.html", "style.css", "view.html", "logo.png"];
pub static HTML_DIR: &str = "html/";
pub static LOG_DIR: &str = "logs/";
pub static DATA_STORAGE: &str = "storage/";
use serde_json::Value;
use std::{env, fs, path::PathBuf, sync::LazyLock};
pub static HTML_DIR: &str = "data/html/";
pub static LOG_DIR: &str = "data/logs/";
pub static LOG_FILE_NAME: &str = "log.txt";
pub static BIND_ADDR: LazyLock<String> = LazyLock::new(|| match env::var("BIND_ADDR") {
Ok(addr) => {
println!("Binding to address: {}", addr);
addr.parse().unwrap_or("127.0.0.1".to_string())
}
Err(_) => {
println!("Binding to default address: 0.0.0.0");
"0.0.0.0".to_string()
}
});
pub static BIND_PORT: LazyLock<u16> = LazyLock::new(|| match env::var("BIND_PORT") {
Ok(port_str) => {
println!("Binding to port: {}", port_str);
port_str.parse().unwrap_or(8080)
}
Err(_) => {
println!("Binding to default port: 8080");
8080
}
});
pub static STATIC_PAGES: LazyLock<Vec<String>> = LazyLock::new(|| {
fs::read_dir(HTML_DIR)
.unwrap()
.filter_map(|entry| entry.ok().and_then(|e| e.file_name().to_str().map(|s| s.to_string())))
.collect()
});
use crate::{
api::{api_get_asset, api_upload},
logs::log_to_file,
api::{api_get_asset, api_stats, api_upload},
logs::{LogEventType, log_event},
};
#[get("/")]
async fn index(reg: HttpRequest) -> actix_web::Result<NamedFile> {
let now = std::time::Instant::now();
async fn index(req: HttpRequest) -> actix_web::Result<NamedFile> {
let path: PathBuf = PathBuf::from(HTML_DIR.to_string() + "index.html");
log_to_file(&reg, now);
log_event(LogEventType::HttpRequest(req.into()));
Ok(NamedFile::open(path)?)
}
#[get("/stats")]
async fn stats(req: HttpRequest) -> actix_web::Result<NamedFile> {
let path: PathBuf = PathBuf::from(HTML_DIR.to_string() + "stats.html");
log_event(LogEventType::HttpRequest(req.into()));
Ok(NamedFile::open(path)?)
}
#[get("/bhs/{id}")]
async fn view_asset(req: HttpRequest) -> actix_web::Result<NamedFile> {
let now = std::time::Instant::now();
let path: PathBuf = PathBuf::from(HTML_DIR.to_string() + "view.html");
log_to_file(&req, now);
log_event(LogEventType::HttpRequest(req.into()));
Ok(NamedFile::open(path)?)
}
#[route("/{tail:.*}", method = "GET", method = "POST")]
async fn catch_all(req: HttpRequest, _payload: Option<web::Json<Value>>) -> actix_web::Result<HttpResponse> {
let now = std::time::Instant::now();
async fn catch_all(req: HttpRequest, _payload: Option<web::Json<Value>>) -> actix_web::Result<NamedFile> {
let response = match req.uri().path() {
path if STATIC_PAGES.contains(&&path[1..]) => {
path if STATIC_PAGES.contains(&path[1..].into()) => {
let file_path = HTML_DIR.to_string() + path;
Ok(NamedFile::open(file_path)?.into_response(&req))
Ok(NamedFile::open(file_path)?)
}
_ => {
let file_path = PathBuf::from(HTML_DIR.to_string() + "error.html");
Ok(NamedFile::open(file_path)?)
}
_ => Ok(HttpResponse::NotFound().body("Not Found")),
};
log_to_file(&req, now);
log_event(LogEventType::HttpRequest(req.into()));
response
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
println!("Starting server at http://{}:{}/", BIND_ADDR, BIND_PORT);
tokio::spawn(async {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60));
let _ = fs::create_dir_all(LOG_DIR);
let log_filename = format!("{}{}", LOG_DIR, LOG_FILE_NAME);
let log_filename_path = std::path::Path::new(&log_filename);
let time_tag = chrono::Local::now().format("%Y_%m_%d_%H_%M_%S");
if log_filename_path.exists() {
println!("File: {}, exists, rotating.", &log_filename_path.display());
fs::rename(
&log_filename_path,
format!("{}{}_{}", LOG_DIR, time_tag, &LOG_FILE_NAME),
)
.unwrap_or_else(|e| {
println!(
"No existing log file {} to rotate. Error: {}",
log_filename_path.to_string_lossy(),
e
)
});
println!("Rotated log file to: {}_{}", time_tag, &LOG_FILE_NAME);
}
let assets = data_mgt::AssetTracker::new();
println!("Starting server at http://{}:{}/", *BIND_ADDR, *BIND_PORT);
let assets_clone = assets.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
loop {
interval.tick().await;
if let Err(e) = data_mgt::clear_assets().await {
if let Err(e) = data_mgt::clear_assets(assets_clone.clone()).await {
eprintln!("Error clearing assets: {}", e);
}
}
});
HttpServer::new(|| {
HttpServer::new(move || {
App::new()
.app_data(web::JsonConfig::default().limit(1024 * 1024 * 3))
.app_data(web::Data::new(assets.clone()))
.service(index)
.service(stats)
.service(view_asset)
.service(api_get_asset)
.service(api_upload)
.service(api_stats)
.service(catch_all)
})
.bind((BIND_ADDR, BIND_PORT))?
.bind((BIND_ADDR.clone(), *BIND_PORT))?
.run()
.await
}