Init
This commit is contained in:
BIN
downloads.db
Normal file
BIN
downloads.db
Normal file
Binary file not shown.
9
go.mod
Normal file
9
go.mod
Normal file
@@ -0,0 +1,9 @@
|
||||
module image-web
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
)
|
||||
6
go.sum
Normal file
6
go.sum
Normal file
@@ -0,0 +1,6 @@
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
255
main.go
Normal file
255
main.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
)
|
||||
|
||||
// --- Settings (defined as command-line flags) ---
|
||||
var (
|
||||
imageRootPath *string
|
||||
templatesPath *string
|
||||
port *string
|
||||
dbFile *string
|
||||
)
|
||||
|
||||
// FileInfo struct to hold data for the template
|
||||
type FileInfo struct {
|
||||
Board string
|
||||
Release string
|
||||
Variant string
|
||||
DownloadURL string
|
||||
FileName string
|
||||
Checksum string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// AppState struct for thread-safe data storage
|
||||
type AppState struct {
|
||||
sync.RWMutex
|
||||
imagesByBoard map[string][]FileInfo
|
||||
}
|
||||
|
||||
var (
|
||||
appState = AppState{imagesByBoard: make(map[string][]FileInfo)}
|
||||
db *sql.DB // Database connection pool
|
||||
)
|
||||
|
||||
// initDB initializes the database and creates the table if it doesn't exist
|
||||
func initDB() {
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", *dbFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to DB: %v", err)
|
||||
}
|
||||
|
||||
createTableSQL := `
|
||||
CREATE TABLE IF NOT EXISTS downloads (
|
||||
path TEXT PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0
|
||||
);`
|
||||
if _, err := db.Exec(createTableSQL); err != nil {
|
||||
log.Fatalf("Failed to create table: %v", err)
|
||||
}
|
||||
log.Printf("Successfully connected to database '%s'.", *dbFile)
|
||||
}
|
||||
|
||||
// refreshImageList scans the image directory and updates the file list
|
||||
func refreshImageList() {
|
||||
log.Println("Refreshing image list...")
|
||||
newImages := make(map[string][]FileInfo)
|
||||
|
||||
err := filepath.Walk(*imageRootPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".img.xz") {
|
||||
relPath, _ := filepath.Rel(*imageRootPath, path)
|
||||
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
||||
|
||||
if len(parts) >= 4 {
|
||||
file := FileInfo{
|
||||
Board: strings.ToUpper(strings.Replace(parts[0], "odroid", "ODROID-", 1)),
|
||||
Release: fmt.Sprintf("Ubuntu %s (%s)", parseVersion(parts[1]), strings.Title(parts[1])),
|
||||
Variant: strings.Title(strings.Replace(parts[2], "-", " ", -1)),
|
||||
DownloadURL: "/download/" + filepath.ToSlash(relPath),
|
||||
FileName: info.Name(),
|
||||
}
|
||||
|
||||
// Read MD5 checksum file
|
||||
checksumPath := strings.Replace(path, ".img.xz", ".img.xz.md5sum", 1)
|
||||
if content, err := os.ReadFile(checksumPath); err == nil {
|
||||
file.Checksum = strings.Fields(string(content))[0]
|
||||
} else {
|
||||
file.Checksum = "N/A"
|
||||
}
|
||||
|
||||
// Query download count from DB
|
||||
err := db.QueryRow("SELECT count FROM downloads WHERE path = ?", file.DownloadURL).Scan(&file.Count)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
log.Printf("Failed to query count for %s: %v", file.DownloadURL, err)
|
||||
}
|
||||
|
||||
newImages[file.Board] = append(newImages[file.Board], file)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error while scanning directory: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for board := range newImages {
|
||||
sort.Slice(newImages[board], func(i, j int) bool {
|
||||
if newImages[board][i].Release != newImages[board][j].Release {
|
||||
return newImages[board][i].Release < newImages[board][j].Release
|
||||
}
|
||||
return newImages[board][i].Variant < newImages[board][j].Variant
|
||||
})
|
||||
}
|
||||
|
||||
appState.Lock()
|
||||
appState.imagesByBoard = newImages
|
||||
appState.Unlock()
|
||||
log.Println("Image list refresh complete.")
|
||||
}
|
||||
|
||||
// watchFiles detects file system changes and triggers a list refresh
|
||||
func watchFiles() {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create file watcher: %v", err)
|
||||
}
|
||||
defer watcher.Close()
|
||||
|
||||
if err := filepath.Walk(*imageRootPath, func(path string, info os.FileInfo, err error) error {
|
||||
if info.IsDir() {
|
||||
return watcher.Add(path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Fatalf("Failed to add directories to watcher: %v", err)
|
||||
}
|
||||
|
||||
var timer *time.Timer
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Op&(fsnotify.Create|fsnotify.Remove|fsnotify.Rename) != 0 {
|
||||
log.Printf("Detected file system change: %s", event.Name)
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.AfterFunc(2*time.Second, func() {
|
||||
refreshImageList()
|
||||
if err := watcher.Add(event.Name); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("Failed to add new directory to watcher: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Println("File watcher error:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseVersion(release string) string {
|
||||
switch release {
|
||||
case "focal":
|
||||
return "20.04"
|
||||
case "jammy":
|
||||
return "22.04"
|
||||
case "noble":
|
||||
return "24.04"
|
||||
default:
|
||||
return release
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 1. Define command-line flags
|
||||
port = flag.String("port", "8080", "Web server port number")
|
||||
imageRootPath = flag.String("imageDir", "public/image/monthly", "Image file root directory")
|
||||
templatesPath = flag.String("templateDir", "templates", "HTML template directory")
|
||||
dbFile = flag.String("dbFile", "metadata.db", "SQLite database file path")
|
||||
flag.Parse()
|
||||
|
||||
// 2. Initialization
|
||||
initDB()
|
||||
defer db.Close()
|
||||
refreshImageList()
|
||||
|
||||
// 3. Start the file watcher goroutine
|
||||
go watchFiles()
|
||||
|
||||
// 4. Set up web server handlers
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
appState.RLock()
|
||||
boards := make([]string, 0, len(appState.imagesByBoard))
|
||||
for k := range appState.imagesByBoard {
|
||||
boards = append(boards, k)
|
||||
}
|
||||
sort.Strings(boards)
|
||||
|
||||
data := struct {
|
||||
Title, PageHeader string
|
||||
Boards []string
|
||||
Images map[string][]FileInfo
|
||||
}{
|
||||
Title: "ODROID OS Image Downloads",
|
||||
PageHeader: "ODROID OS Image Downloads",
|
||||
Boards: boards,
|
||||
Images: appState.imagesByBoard,
|
||||
}
|
||||
appState.RUnlock()
|
||||
|
||||
tmpl, err := template.ParseFiles(filepath.Join(*templatesPath, "index.html"))
|
||||
if err != nil {
|
||||
http.Error(w, "Could not load template.", http.StatusInternalServerError)
|
||||
log.Printf("Template parsing error: %v", err)
|
||||
return
|
||||
}
|
||||
tmpl.Execute(w, data)
|
||||
})
|
||||
|
||||
http.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Increment download count (UPSERT)
|
||||
upsertSQL := `
|
||||
INSERT INTO downloads (path, count) VALUES (?, 1)
|
||||
ON CONFLICT(path) DO UPDATE SET count = count + 1;`
|
||||
|
||||
if _, err := db.Exec(upsertSQL, r.URL.Path); err != nil {
|
||||
log.Printf("Failed to update DB count for %s: %v", r.URL.Path, err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(*imageRootPath, strings.TrimPrefix(r.URL.Path, "/download/"))
|
||||
http.ServeFile(w, r, filePath)
|
||||
})
|
||||
|
||||
// 5. Start the web server
|
||||
log.Printf("Starting server at http://localhost:%s", *port)
|
||||
if err := http.ListenAndServe(":"+*port, nil); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
BIN
odroid-server
Executable file
BIN
odroid-server
Executable file
Binary file not shown.
54
templates/index.html
Normal file
54
templates/index.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 1200px; margin: 20px auto; padding: 0 15px; background-color: #f9f9f9; }
|
||||
h1, h2 { border-bottom: 2px solid #eee; padding-bottom: 10px; color: #00529B; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 30px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }
|
||||
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #ddd; vertical-align: middle; }
|
||||
th { background-color: #f2f2f2; font-weight: 600; white-space: nowrap; }
|
||||
tr { background-color: #fff; }
|
||||
tr:nth-child(even) { background-color: #f7f7f7; }
|
||||
a { color: #1a0dab; text-decoration: none; font-weight: 500; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.checksum { font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.85em; color: #555; margin-top: 4px; }
|
||||
.filename { font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.95em; }
|
||||
.count { text-align: right; font-weight: bold; color: #d35400; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{.PageHeader}}</h1>
|
||||
<p>Latest monthly build OS images. You can verify file integrity using the MD5 checksum.</p>
|
||||
|
||||
{{range .Boards}}
|
||||
{{$boardName := .}}
|
||||
<h2>{{$boardName}}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>OS Release</th>
|
||||
<th>Variant</th>
|
||||
<th>File (Click to Download)</th>
|
||||
<th style="text-align: right;">Downloads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range index $.Images $boardName}}
|
||||
<tr>
|
||||
<td>{{.Release}}</td>
|
||||
<td>{{.Variant}}</td>
|
||||
<td>
|
||||
<a href="{{.DownloadURL}}" class="filename">{{.FileName}}</a>
|
||||
<div class="checksum">MD5: {{.Checksum}}</div>
|
||||
</td>
|
||||
<td class="count">{{.Count}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user