This commit is contained in:
2025-09-23 00:10:45 +09:00
commit 6d7aa61e36
6 changed files with 324 additions and 0 deletions

BIN
downloads.db Normal file

Binary file not shown.

9
go.mod Normal file
View 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
View 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
View 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

Binary file not shown.

54
templates/index.html Normal file
View 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>