6 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
19 changed files with 149 additions and 27 deletions

View File

@@ -5,6 +5,18 @@ 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/), 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). 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 ## [0.2.0] - 2026-01-11
### Added ### Added

2
Cargo.lock generated
View File

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

View File

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

View File

@@ -9,7 +9,9 @@ A lightweight, ephemeral file sharing service built with Rust and Actix-Web. Upl
- **Zero database** assets stored as JSON files on disk - **Zero database** assets stored as JSON files on disk
- **Dark theme UI** clean, responsive interface with zoom overlay - **Dark theme UI** clean, responsive interface with zoom overlay
- **Statistics dashboard** real-time stats at `/stats.html` (active assets, uploads, response times) - **Statistics dashboard** real-time stats at `/stats.html` (active assets, uploads, response times)
- **Access logging** request and asset events logged to `data/logs/access.log` with IP, timing, and metadata - **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 ## Quick Start
@@ -119,7 +121,7 @@ GET /api/stats
The server uses paths relative to the repo root under `data/`: The server uses paths relative to the repo root under `data/`:
- `data/html/` frontend assets (index.html, view.html, style.css) - `data/html/` frontend assets (index.html, view.html, style.css)
- `data/logs/` access logs - `data/logs/` access logs (`log.txt`, rotated on startup with timestamps)
- `data/storage/` uploaded assets (auto-created) - `data/storage/` uploaded assets (auto-created)
- **Local dev:** Run from repo root with `cargo run --release` - **Local dev:** Run from repo root with `cargo run --release`

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

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

@@ -6,6 +6,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Upload</title> <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> </head>
<body> <body>

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"
}

View File

@@ -363,6 +363,18 @@ h1 .home-link:hover {
scrollbar-width: thin; 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 { .zoom-text-content::-webkit-scrollbar {
width: 8px; width: 8px;
} }
@@ -393,7 +405,7 @@ h1 .home-link:hover {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 9999; z-index: 9999;
cursor: zoom-out; cursor: default;
padding: 20px; padding: 20px;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -600,6 +612,18 @@ body.view-page {
border: none; 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 { .text-content-view::-webkit-scrollbar {
width: 8px; width: 8px;
} }

View File

@@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Black Hole Share - View</title> <title>Black Hole Share - View</title>
<link rel="stylesheet" href="/style.css"> <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> </head>
<body class="view-page"> <body class="view-page">
@@ -32,6 +34,7 @@
<!-- Zoom overlay --> <!-- Zoom overlay -->
<div id="zoomOverlay" class="zoom-overlay" style="display: none;"></div> <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> <script>
const contentArea = document.getElementById('contentArea'); const contentArea = document.getElementById('contentArea');
const zoomOverlay = document.getElementById('zoomOverlay'); const zoomOverlay = document.getElementById('zoomOverlay');
@@ -40,6 +43,28 @@
const pathParts = window.location.pathname.split('/'); const pathParts = window.location.pathname.split('/');
const assetId = pathParts[pathParts.length - 1]; 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() { async function loadContent() {
try { try {
const response = await fetch(`/api/content/${assetId}`); const response = await fetch(`/api/content/${assetId}`);
@@ -82,12 +107,23 @@
} else if (contentType.startsWith('text/')) { } else if (contentType.startsWith('text/')) {
// Display text // Display text
const text = await response.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'); const textContent = contentArea.querySelector('.text-content-view');
textContent.addEventListener('click', function (e) { textContent.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
showZoom(text, true); showZoom(text, true, isCode);
}); });
} else { } else {
contentArea.innerHTML = '<p class="error">Unsupported content type</p>'; contentArea.innerHTML = '<p class="error">Unsupported content type</p>';
@@ -99,11 +135,19 @@
} }
} }
function showZoom(content, isText = false) { function showZoom(content, isText = false, isCode = false) {
if (isText) { if (isText) {
zoomOverlay.innerHTML = ` const safeText = escapeHtml(content);
<div class="zoom-text-content">${content.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div> 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 { } else {
zoomOverlay.innerHTML = `<img id="zoomImage" src="${content}" alt="Zoomed Content" 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);">`; style="max-width: 95vw; max-height: 95vh; object-fit: contain; box-shadow: 0 0 50px rgba(51, 204, 255, 0.5);">`;
@@ -115,8 +159,6 @@
zoomOverlay.style.display = 'none'; zoomOverlay.style.display = 'none';
} }
zoomOverlay.addEventListener('click', hideZoom);
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' || e.key === 'Esc') { if (e.key === 'Escape' || e.key === 'Esc') {
hideZoom(); hideZoom();

View File

@@ -5,6 +5,7 @@ use serde::Deserialize;
use serde_json::json; use serde_json::json;
use crate::{ use crate::{
LOG_FILE_NAME,
data_mgt::{Asset, AssetTracker}, data_mgt::{Asset, AssetTracker},
logs::{LogEvent, LogEventType, log_event}, logs::{LogEvent, LogEventType, log_event},
}; };
@@ -100,7 +101,7 @@ async fn api_stats(assets: web::Data<AssetTracker>) -> Result<HttpResponse, acti
let mut recent_activity: Vec<ActivityItem> = Vec::new(); let mut recent_activity: Vec<ActivityItem> = Vec::new();
let mut request_count: usize = 0; let mut request_count: usize = 0;
let log_path = format!("{}access.log", LOG_DIR); let log_path = format!("{}{}", LOG_DIR, LOG_FILE_NAME);
if let Ok(content) = fs::read_to_string(&log_path) { if let Ok(content) = fs::read_to_string(&log_path) {
for line in content.lines() { for line in content.lines() {
if let Ok(entry) = serde_json::from_str::<LogEvent>(line) { if let Ok(entry) = serde_json::from_str::<LogEvent>(line) {

View File

@@ -7,7 +7,6 @@ use futures::lock::Mutex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::DATA_STORAGE;
use crate::logs::{LogEventType, log_event}; use crate::logs::{LogEventType, log_event};
#[derive(Debug, Serialize, Deserialize, Clone, Default)] #[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -87,13 +86,13 @@ impl Asset {
serde_json::to_value(self).unwrap_or(Value::Null) serde_json::to_value(self).unwrap_or(Value::Null)
} }
pub fn save(&self) -> Result<String> { // pub fn save(&self) -> Result<String> {
let id = self.id.clone(); // let id = self.id.clone();
let path = format!("{}{}", DATA_STORAGE, self.id); // let path = format!("{}{}", DATA_STORAGE, self.id);
std::fs::create_dir_all(DATA_STORAGE)?; // std::fs::create_dir_all(DATA_STORAGE)?;
std::fs::write(&path, self.to_bytes()?)?; // std::fs::write(&path, self.to_bytes()?)?;
Ok(id) // Ok(id)
} // }
} }
#[derive(Clone)] #[derive(Clone)]

View File

@@ -4,7 +4,7 @@ use actix_web::HttpRequest;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::LOG_DIR; use crate::{LOG_DIR, LOG_FILE_NAME};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct LogHttpRequest { pub struct LogHttpRequest {
@@ -68,7 +68,7 @@ impl From<LogEventType> for LogEvent {
} }
pub fn log_event(event: LogEventType) { pub fn log_event(event: LogEventType) {
let log_path = LOG_DIR.to_string() + "access.log"; let log_path = LOG_DIR.to_string() + LOG_FILE_NAME;
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) else { let Ok(mut file) = OpenOptions::new().create(true).append(true).open(log_path) else {
eprintln!("failed to open log file for asset event"); eprintln!("failed to open log file for asset event");

View File

@@ -13,7 +13,7 @@ use std::{env, fs, path::PathBuf, sync::LazyLock};
pub static HTML_DIR: &str = "data/html/"; pub static HTML_DIR: &str = "data/html/";
pub static LOG_DIR: &str = "data/logs/"; pub static LOG_DIR: &str = "data/logs/";
pub static DATA_STORAGE: &str = "data/storage/"; pub static LOG_FILE_NAME: &str = "log.txt";
pub static BIND_ADDR: LazyLock<String> = LazyLock::new(|| match env::var("BIND_ADDR") { pub static BIND_ADDR: LazyLock<String> = LazyLock::new(|| match env::var("BIND_ADDR") {
Ok(addr) => { Ok(addr) => {
@@ -88,9 +88,26 @@ async fn catch_all(req: HttpRequest, _payload: Option<web::Json<Value>>) -> acti
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
let _ = fs::create_dir_all(DATA_STORAGE);
let _ = fs::create_dir_all(LOG_DIR); 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(); let assets = data_mgt::AssetTracker::new();
println!("Starting server at http://{}:{}/", *BIND_ADDR, *BIND_PORT); println!("Starting server at http://{}:{}/", *BIND_ADDR, *BIND_PORT);