Initial commit
This commit is contained in:
BIN
.gitignore
vendored
Normal file
BIN
.gitignore
vendored
Normal file
Binary file not shown.
@@ -1,2 +1,8 @@
|
|||||||
# go-fileserver
|
# Go File Server with Dashboard
|
||||||
|
|
||||||
|
A self-contained Go application that:
|
||||||
|
|
||||||
|
- Serves file uploads via web UI
|
||||||
|
- Tracks uploads/downloads with timestamps
|
||||||
|
- Displays an admin dashboard with a Bootstrap UI and Chart.js graphs
|
||||||
|
- Serves all frontend files locally (no CDN)
|
||||||
67
dashboard.html
Normal file
67
dashboard.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Dashboard</title>
|
||||||
|
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-dark text-light">
|
||||||
|
<div class="container py-4">
|
||||||
|
<h1>🧠 Admin Dashboard</h1>
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#uploads">Uploads</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#downloads">Downloads</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#chart">Chart</a></li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content mt-3">
|
||||||
|
<div class="tab-pane fade show active" id="uploads">
|
||||||
|
<table class="table table-dark table-bordered">
|
||||||
|
<thead><tr><th>IP</th><th>File</th><th>Time</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Events}} {{if eq .Action "upload"}}
|
||||||
|
<tr><td>{{.IP}}</td><td>{{.File}}</td><td>{{.Timestamp.Format "2006-01-02 15:04:05"}}</td></tr>
|
||||||
|
{{end}} {{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="downloads">
|
||||||
|
<table class="table table-dark table-bordered">
|
||||||
|
<thead><tr><th>IP</th><th>File</th><th>Time</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Events}} {{if eq .Action "download"}}
|
||||||
|
<tr><td>{{.IP}}</td><td>{{.File}}</td><td>{{.Timestamp.Format "2006-01-02 15:04:05"}}</td></tr>
|
||||||
|
{{end}} {{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="chart">
|
||||||
|
<div class="bg-light p-3 rounded text-dark">
|
||||||
|
<canvas id="downloadChart" height="120"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/static/chart.min.js"></script>
|
||||||
|
<script>
|
||||||
|
fetch("/dashboard-data").then(r => r.json()).then(data => {
|
||||||
|
const ctx = document.getElementById('downloadChart').getContext('2d')
|
||||||
|
const labels = data.map(e => e.ip + ": " + e.file)
|
||||||
|
const values = data.map(e => e.count)
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{ label: "Downloads", data: values, backgroundColor: '#00d1b2' }]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: "white" } },
|
||||||
|
y: { ticks: { color: "white" }, beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
debug.log
Normal file
14
debug.log
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
2025/07/16 15:21:33 Starting server...
|
||||||
|
2025/07/16 15:21:33 Server running at http://localhost:8080
|
||||||
|
2025/07/16 15:23:03 Starting server...
|
||||||
|
2025/07/16 15:23:03 Server running at http://localhost:8080
|
||||||
|
2025/07/16 15:24:35 Starting server...
|
||||||
|
2025/07/16 15:24:35 Server running at http://localhost:8080
|
||||||
|
2025/07/16 15:28:43 Starting server...
|
||||||
|
2025/07/16 15:28:43 Server running at http://localhost:80
|
||||||
|
2025/07/16 15:28:58 File uploaded: caddy_windows_amd64_custom.exe
|
||||||
|
2025/07/16 15:33:08 Server started at 2025-07-16 15:33:08.9867437 +0200 CEST m=+0.003202501
|
||||||
|
2025/07/16 15:33:08 Server ready on http://localhost:8080
|
||||||
|
2025/07/16 15:33:36 UPLOAD by 127.0.0.1: bootstrap-5.3.7-dist.zip
|
||||||
|
2025/07/16 15:33:48 UPLOAD by 127.0.0.1: VCDS 25.5.0 + VIIPlus Loader 08.024.05.rar
|
||||||
|
2025/07/16 15:33:55 DOWNLOAD by 127.0.0.1: bootstrap-5.3.7-dist.zip
|
||||||
33
index.html
Normal file
33
index.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>File Uploader</title>
|
||||||
|
<link rel="stylesheet" href="/static/bootstrap.min.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-dark text-light">
|
||||||
|
<div class="container py-5">
|
||||||
|
<h2>Upload a File</h2>
|
||||||
|
<form action="/upload" method="POST" enctype="multipart/form-data" class="row g-3 mb-4">
|
||||||
|
<div class="col-9">
|
||||||
|
<input type="file" name="file" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<button class="btn btn-primary w-100">Upload</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<h3>Files</h3>
|
||||||
|
<ul class="list-group">
|
||||||
|
{{range .Files}}
|
||||||
|
<li class="list-group-item d-flex justify-content-between">
|
||||||
|
<a href="/files/{{.Name}}" class="text-light">{{.Name}}</a>
|
||||||
|
<span class="badge bg-secondary">{{.Size | formatBytes}}</span>
|
||||||
|
</li>
|
||||||
|
{{else}}
|
||||||
|
<li class="list-group-item">No files yet</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<p class="mt-4 text-center"><a href="/dashboard" class="btn btn-outline-info">Go to Dashboard</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
204
main.go
Normal file
204
main.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed index.html
|
||||||
|
//go:embed dashboard.html
|
||||||
|
//go:embed static/*
|
||||||
|
var content embed.FS
|
||||||
|
|
||||||
|
const (
|
||||||
|
uploadDir = "./public"
|
||||||
|
logFile = "debug.log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileInfo struct {
|
||||||
|
Name string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccessEvent struct {
|
||||||
|
IP string
|
||||||
|
File string
|
||||||
|
Timestamp time.Time
|
||||||
|
Action string // "upload" or "download"
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatEntry struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var logger *log.Logger
|
||||||
|
var accessLog []AccessEvent
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Logger setup
|
||||||
|
logWriter, err := os.OpenFile(logFile, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Log file error: %v", err)
|
||||||
|
}
|
||||||
|
defer logWriter.Close()
|
||||||
|
logger = log.New(logWriter, "", log.LstdFlags)
|
||||||
|
|
||||||
|
if _, err := os.Stat(uploadDir); os.IsNotExist(err) {
|
||||||
|
_ = os.Mkdir(uploadDir, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed FS for static/ directory
|
||||||
|
fsStatic, err := fs.Sub(content, "static")
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("Failed to load static: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
tmplIndex := template.Must(template.New("index.html").Funcs(template.FuncMap{
|
||||||
|
"formatBytes": formatBytes,
|
||||||
|
}).ParseFS(content, "index.html"))
|
||||||
|
|
||||||
|
tmplDashboard := template.Must(template.ParseFS(content, "dashboard.html"))
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
files, _ := listFiles(uploadDir)
|
||||||
|
data := struct{ Files []FileInfo }{Files: files}
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_ = tmplIndex.Execute(w, data)
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/upload", uploadHandler)
|
||||||
|
http.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
_ = tmplDashboard.Execute(w, struct{ Events []AccessEvent }{accessLog})
|
||||||
|
})
|
||||||
|
|
||||||
|
http.HandleFunc("/dashboard-data", statsHandler)
|
||||||
|
|
||||||
|
http.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
file := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||||
|
ip := getIP(r)
|
||||||
|
accessLog = append(accessLog, AccessEvent{
|
||||||
|
IP: ip, File: file, Action: "download", Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
http.ServeFile(w, r, filepath.Join(uploadDir, file))
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(fsStatic))))
|
||||||
|
|
||||||
|
log.Println("Server started on http://localhost:80/")
|
||||||
|
_ = http.ListenAndServe(":80", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Helpers =====
|
||||||
|
|
||||||
|
func uploadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Only POST supported", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.ParseMultipartForm(10 << 20)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to parse form", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, handler, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "No file found", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
dst, err := os.Create(filepath.Join(uploadDir, handler.Filename))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to save file", 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
_, _ = io.Copy(dst, file)
|
||||||
|
|
||||||
|
ip := getIP(r)
|
||||||
|
accessLog = append(accessLog, AccessEvent{
|
||||||
|
IP: ip, File: handler.Filename, Action: "upload", Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listFiles(dir string) ([]FileInfo, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var files []FileInfo
|
||||||
|
for _, e := range entries {
|
||||||
|
info, err := e.Info()
|
||||||
|
if err == nil && !info.IsDir() {
|
||||||
|
files = append(files, FileInfo{Name: info.Name(), Size: info.Size()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
return files[i].Name < files[j].Name
|
||||||
|
})
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(b int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if b < unit {
|
||||||
|
return fmt.Sprintf("%d B", b)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := b / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIP(r *http.Request) string {
|
||||||
|
ip := r.RemoteAddr
|
||||||
|
if i := strings.LastIndex(ip, ":"); i != -1 {
|
||||||
|
ip = ip[:i]
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
counter := map[string]map[string]int{}
|
||||||
|
for _, e := range accessLog {
|
||||||
|
if e.Action != "download" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if counter[e.IP] == nil {
|
||||||
|
counter[e.IP] = map[string]int{}
|
||||||
|
}
|
||||||
|
counter[e.IP][e.File]++
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []StatEntry
|
||||||
|
for ip, files := range counter {
|
||||||
|
for file, count := range files {
|
||||||
|
result = append(result, StatEntry{IP: ip, File: file, Count: count})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
7
static/bootstrap.bundle.min.js
vendored
Normal file
7
static/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
static/bootstrap.min.css
vendored
Normal file
6
static/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
14
static/chart.min.js
vendored
Normal file
14
static/chart.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user