Initial UNC file watcher web console

This commit is contained in:
Codex
2026-04-17 17:08:49 +08:00
commit 1d283d540c
10 changed files with 1931 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
func defaultConfig() config {
return config{
interval: defaultInterval,
stableFor: defaultStableFor,
stableScans: 2,
mode: "copy",
generic: genericRule{
dedupeMode: genericDedupeNameSize,
},
}
}
func resolveSettingsPath() string {
executablePath, err := os.Executable()
if err == nil {
return filepath.Join(filepath.Dir(executablePath), settingsFileName)
}
return settingsFileName
}
func loadSettingsErrorMessage(err error) string {
if err == nil {
return ""
}
return fmt.Sprintf("读取本地配置失败: %v", err)
}
func loadSettings(path string) (formValues, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return formValues{}, nil
}
return formValues{}, err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return formValues{}, err
}
if hasAnyLegacyKeys(raw) {
var legacy legacyFormValues
if err := json.Unmarshal(data, &legacy); err != nil {
return formValues{}, err
}
return formValues{
ArchiveEnabled: legacy.SourceDir != "" || legacy.ZipDir != "" || legacy.UnzipDir != "",
ArchiveSourceDir: legacy.SourceDir,
ArchiveZipDir: legacy.ZipDir,
ArchiveUnzipDir: legacy.UnzipDir,
GenericDedupe: genericDedupeNameSize,
Interval: legacy.Interval,
StableFor: legacy.StableFor,
StableScans: legacy.StableScans,
Mode: legacy.Mode,
Recursive: legacy.Recursive,
}, nil
}
var form formValues
if err := json.Unmarshal(data, &form); err != nil {
return formValues{}, err
}
if _, ok := raw["archive_enabled"]; !ok && hasArchiveFields(form) {
form.ArchiveEnabled = true
}
if _, ok := raw["generic_enabled"]; !ok && hasGenericFields(form) {
form.GenericEnabled = true
}
if form.GenericDedupe == "" {
form.GenericDedupe = genericDedupeNameSize
}
return form, nil
}
func hasAnyLegacyKeys(raw map[string]json.RawMessage) bool {
_, sourceOK := raw["source_dir"]
_, zipOK := raw["zip_dir"]
_, unzipOK := raw["unzip_dir"]
return sourceOK || zipOK || unzipOK
}
func saveSettings(path string, form formValues) error {
data, err := json.MarshalIndent(form, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func mergeForm(base, saved formValues) formValues {
if saved.ArchiveSourceDir != "" {
base.ArchiveSourceDir = saved.ArchiveSourceDir
}
if saved.ArchiveZipDir != "" {
base.ArchiveZipDir = saved.ArchiveZipDir
}
if saved.ArchiveUnzipDir != "" {
base.ArchiveUnzipDir = saved.ArchiveUnzipDir
}
if saved.GenericSourceDir != "" {
base.GenericSourceDir = saved.GenericSourceDir
}
if saved.GenericTargetDir != "" {
base.GenericTargetDir = saved.GenericTargetDir
}
if saved.GenericExts != "" {
base.GenericExts = saved.GenericExts
}
if saved.GenericDedupe != "" {
base.GenericDedupe = saved.GenericDedupe
}
if saved.Interval != "" {
base.Interval = saved.Interval
}
if saved.StableFor != "" {
base.StableFor = saved.StableFor
}
if saved.StableScans != "" {
base.StableScans = saved.StableScans
}
if saved.Mode != "" {
base.Mode = saved.Mode
}
base.ArchiveEnabled = saved.ArchiveEnabled
base.GenericEnabled = saved.GenericEnabled
base.Recursive = saved.Recursive
return base
}
func formFromRequest(r *http.Request) formValues {
_ = r.ParseForm()
return formValues{
ArchiveEnabled: r.FormValue("archive_enabled") != "",
ArchiveSourceDir: strings.TrimSpace(r.FormValue("archive_source")),
ArchiveZipDir: strings.TrimSpace(r.FormValue("archive_zip")),
ArchiveUnzipDir: strings.TrimSpace(r.FormValue("archive_unzip")),
GenericEnabled: r.FormValue("generic_enabled") != "",
GenericSourceDir: strings.TrimSpace(r.FormValue("generic_source")),
GenericTargetDir: strings.TrimSpace(r.FormValue("generic_target")),
GenericExts: strings.TrimSpace(r.FormValue("generic_exts")),
GenericDedupe: strings.TrimSpace(r.FormValue("generic_dedupe")),
Interval: strings.TrimSpace(r.FormValue("interval")),
StableFor: strings.TrimSpace(r.FormValue("stable_for")),
StableScans: strings.TrimSpace(r.FormValue("stable_scans")),
Mode: strings.TrimSpace(r.FormValue("mode")),
Recursive: r.FormValue("recursive") != "",
}
}
func formFromConfig(cfg config) formValues {
return formValues{
ArchiveEnabled: cfg.archive.enabled,
ArchiveSourceDir: cfg.archive.sourceDir,
ArchiveZipDir: cfg.archive.zipDir,
ArchiveUnzipDir: cfg.archive.unzipDir,
GenericEnabled: cfg.generic.enabled,
GenericSourceDir: cfg.generic.sourceDir,
GenericTargetDir: cfg.generic.targetDir,
GenericExts: strings.Join(cfg.generic.extensions, ","),
GenericDedupe: cfg.generic.dedupeMode,
Interval: cfg.interval.String(),
StableFor: cfg.stableFor.String(),
StableScans: strconv.Itoa(cfg.stableScans),
Mode: cfg.mode,
Recursive: cfg.recursive,
}
}
func hasFormInput(form formValues) bool {
return form.ArchiveEnabled ||
form.ArchiveSourceDir != "" ||
form.ArchiveZipDir != "" ||
form.ArchiveUnzipDir != "" ||
form.GenericEnabled ||
form.GenericSourceDir != "" ||
form.GenericTargetDir != "" ||
form.GenericExts != "" ||
form.GenericDedupe != "" ||
form.Interval != "" ||
form.StableFor != "" ||
form.StableScans != "" ||
form.Mode != "" ||
form.Recursive
}
func hasArchiveFields(form formValues) bool {
return form.ArchiveSourceDir != "" || form.ArchiveZipDir != "" || form.ArchiveUnzipDir != ""
}
func hasGenericFields(form formValues) bool {
return form.GenericSourceDir != "" || form.GenericTargetDir != "" || form.GenericExts != ""
}
func parseConfig(form formValues) (config, error) {
cfg := defaultConfig()
cfg.archive = archiveRule{
enabled: form.ArchiveEnabled,
sourceDir: normalizePath(form.ArchiveSourceDir),
zipDir: normalizePath(form.ArchiveZipDir),
unzipDir: normalizePath(form.ArchiveUnzipDir),
}
cfg.generic = genericRule{
enabled: form.GenericEnabled,
sourceDir: normalizePath(form.GenericSourceDir),
targetDir: normalizePath(form.GenericTargetDir),
dedupeMode: strings.ToLower(strings.TrimSpace(form.GenericDedupe)),
}
cfg.mode = strings.ToLower(strings.TrimSpace(form.Mode))
cfg.recursive = form.Recursive
if form.Interval != "" {
duration, err := time.ParseDuration(form.Interval)
if err != nil {
return cfg, fmt.Errorf("扫描间隔格式不正确: %w", err)
}
cfg.interval = duration
}
if form.StableFor != "" {
duration, err := time.ParseDuration(form.StableFor)
if err != nil {
return cfg, fmt.Errorf("稳定时长格式不正确: %w", err)
}
cfg.stableFor = duration
}
if form.StableScans != "" {
stableScans, err := strconv.Atoi(form.StableScans)
if err != nil {
return cfg, errors.New("稳定扫描次数必须是整数")
}
cfg.stableScans = stableScans
}
if form.GenericExts != "" {
extensions, err := parseExtensions(form.GenericExts)
if err != nil {
return cfg, err
}
cfg.generic.extensions = extensions
}
return validateConfig(cfg)
}
func validateConfig(cfg config) (config, error) {
if cfg.mode == "" {
cfg.mode = "copy"
}
if cfg.generic.dedupeMode == "" {
cfg.generic.dedupeMode = genericDedupeNameSize
}
switch cfg.mode {
case "copy", "move":
default:
return cfg, fmt.Errorf("不支持的处理方式 %q", cfg.mode)
}
switch cfg.generic.dedupeMode {
case genericDedupeNameSize, genericDedupeNameHash:
default:
return cfg, fmt.Errorf("不支持的普通文件去重方式 %q", cfg.generic.dedupeMode)
}
if cfg.interval <= 0 {
return cfg, errors.New("扫描间隔必须大于 0")
}
if cfg.stableFor < 0 {
return cfg, errors.New("稳定时长不能小于 0")
}
if cfg.stableScans < 1 {
return cfg, errors.New("稳定扫描次数必须至少为 1")
}
if !cfg.archive.enabled && !cfg.generic.enabled {
return cfg, errors.New("至少启用一条监听规则")
}
if cfg.archive.enabled {
if cfg.archive.sourceDir == "" {
return cfg, errors.New("压缩包规则的 UNC 源路径不能为空")
}
if cfg.archive.zipDir == "" {
return cfg, errors.New("压缩包规则的 zip 路径不能为空")
}
if cfg.archive.unzipDir == "" {
return cfg, errors.New("压缩包规则的 unzip 路径不能为空")
}
if cfg.archive.sourceDir == cfg.archive.zipDir {
return cfg, errors.New("压缩包规则的源路径和 zip 路径不能相同")
}
if cfg.archive.sourceDir == cfg.archive.unzipDir {
return cfg, errors.New("压缩包规则的源路径和 unzip 路径不能相同")
}
if cfg.archive.zipDir == cfg.archive.unzipDir {
return cfg, errors.New("压缩包规则的 zip 路径和 unzip 路径不能相同")
}
}
if cfg.generic.enabled {
if cfg.generic.sourceDir == "" {
return cfg, errors.New("普通文件规则的 UNC 源路径不能为空")
}
if cfg.generic.targetDir == "" {
return cfg, errors.New("普通文件规则的目标路径不能为空")
}
if cfg.generic.sourceDir == cfg.generic.targetDir {
return cfg, errors.New("普通文件规则的源路径和目标路径不能相同")
}
if len(cfg.generic.extensions) == 0 {
return cfg, errors.New("普通文件规则的扩展名列表不能为空")
}
}
return cfg, nil
}
func parseExtensions(raw string) ([]string, error) {
parts := strings.FieldsFunc(raw, func(r rune) bool {
switch r {
case ',', ';', '\n', '\r', '\t', ' ':
return true
default:
return false
}
})
seen := make(map[string]struct{})
extensions := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.ToLower(strings.TrimSpace(part))
item = strings.TrimPrefix(item, "*")
if item == "" {
continue
}
if !strings.HasPrefix(item, ".") {
item = "." + item
}
if item == "." {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
extensions = append(extensions, item)
}
if len(extensions) == 0 {
return nil, errors.New("普通文件规则的扩展名列表不能为空")
}
return extensions, nil
}
func matchesExtensions(name string, extensions []string) bool {
lowerName := strings.ToLower(name)
for _, ext := range extensions {
if strings.HasSuffix(lowerName, ext) {
return true
}
}
return false
}
func normalizePath(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
return filepath.Clean(raw)
}
func joinMessages(primary, secondary string) string {
if primary == "" {
return secondary
}
if secondary == "" {
return primary
}
return primary + "" + secondary
}
func saveErrorMessage(err error) string {
if err == nil {
return ""
}
return fmt.Sprintf("配置保存失败: %v", err)
}