adding multi filter support

This commit is contained in:
2025-12-07 09:44:02 +00:00
parent 72b4fdbe7c
commit 7717350b94
13 changed files with 1674 additions and 116 deletions

View File

@@ -30,7 +30,7 @@ func updateBlocklist(force int) {
func checkIfUpdateNeeded(delayDays, force int) bool {
configMu.RLock()
lastUpdateFile := config.LastUpdateFile
lastUpdateFile := config.LastUpdateFolder + "blocklist_Default_lastupdate.txt"
configMu.RUnlock()
data, err := os.ReadFile(lastUpdateFile)
@@ -50,8 +50,11 @@ func checkIfUpdateNeeded(delayDays, force int) bool {
func handleSkippedUpdate(delayDays int) {
configMu.RLock()
blocklistFile := config.BlocklistFile
tmpFile := config.TmpFile
blocklistFile := ""
if len(config.Filters) > 0 {
blocklistFile = config.DomainListFolder + config.Filters[0].BlocklistFile
}
tmpFile := config.TmpFolder + "blocklist_Default.tmp"
configMu.RUnlock()
log.Printf("%s: Full Update skipped — last update was less than %d days ago. Use force to override.", time.Now(), delayDays)
@@ -76,22 +79,28 @@ func handleSkippedUpdate(delayDays int) {
func createDefaultURLList() {
configMu.RLock()
urlFileList := config.URLFileList
defaultURLs := config.DefaultURLs
var blocklistURLs []URLListItem
if len(config.Filters) > 0 {
blocklistURLs = config.Filters[0].BlocklistURLs
}
configMu.RUnlock()
urlFileList := "blocklist_Default_urls.csv"
_, err := os.Stat(urlFileList)
if os.IsNotExist(err) {
log.Printf("%s: Using Default url list: %s", time.Now(), urlFileList)
writeURLListCSV(urlFileList, defaultURLs)
writeURLListCSV(urlFileList, blocklistURLs)
}
}
func fetchAndMergeSources() {
configMu.RLock()
tmpFile := config.TmpFile
blocklistFile := config.BlocklistFile
urlFileList := config.URLFileList
tmpFile := config.TmpFolder + "blocklist_Default.tmp"
blocklistFile := ""
if len(config.Filters) > 0 {
blocklistFile = config.DomainListFolder + config.Filters[0].BlocklistFile
}
urlFileList := "blocklist_Default_urls.csv"
configMu.RUnlock()
os.Create(tmpFile)
@@ -147,8 +156,11 @@ func fetchAndMergeSources() {
func applyRemovalRules() {
configMu.RLock()
removeFile := config.RemoveFile
tmpFile := config.TmpFile
removeFile := ""
if len(config.Filters) > 0 {
removeFile = config.DomainListFolder + config.Filters[0].WhitelistFile
}
tmpFile := config.TmpFolder + "blocklist_Default.tmp"
configMu.RUnlock()
removeLines, _ := readLines(removeFile)
@@ -185,9 +197,12 @@ func applyRemovalRules() {
func finalizeBlocklist() {
configMu.RLock()
tmpFile := config.TmpFile
blocklistFile := config.BlocklistFile
lastUpdateFile := config.LastUpdateFile
tmpFile := config.TmpFolder + "blocklist_Default.tmp"
blocklistFile := ""
if len(config.Filters) > 0 {
blocklistFile = config.DomainListFolder + config.Filters[0].BlocklistFile
}
lastUpdateFile := config.LastUpdateFolder + "blocklist_Default_lastupdate.txt"
configMu.RUnlock()
os.Rename(tmpFile+".filtered", tmpFile)

179
config.go
View File

@@ -11,6 +11,15 @@ import (
"golang.org/x/crypto/bcrypt"
)
type Filter struct {
Name string `json:"name"`
Description string `json:"description"`
BlocklistFile string `json:"blocklist_file"` // Just filename, path constructed from domain_list_folder
WhitelistFile string `json:"whitelist_file"` // Just filename, path constructed from domain_list_folder
BlocklistURLs []URLListItem `json:"blocklist_urls"`
WhitelistURLs []URLListItem `json:"whitelist_urls"`
}
type Config struct {
// Application
AppName string `json:"app_name"`
@@ -29,19 +38,16 @@ type Config struct {
UpdateDelay int `json:"update_delay_days"`
CheckInterval int `json:"check_interval_seconds"`
// File paths
PidFile string `json:"pid_file"`
ProcessName string `json:"process_name"`
TmpFile string `json:"tmp_file"`
LastUpdateFile string `json:"last_update_file"`
URLFileList string `json:"url_file_list"`
BlocklistFile string `json:"blocklist_file"`
RemoveFile string `json:"remove_file"`
WhitelistFile string `json:"whitelist_file"`
MergedListTmp string `json:"merged_list_tmp"`
// File paths - folders
PidFile string `json:"pid_file"`
ProcessName string `json:"process_name"`
DomainListFolder string `json:"domain_list_folder"` // Base folder for blocklist/whitelist files
TmpFolder string `json:"tmp_folder"` // Folder for temporary blocklist files
LastUpdateFolder string `json:"last_update_folder"` // Folder for last update timestamp files
MergedListTmpFolder string `json:"merged_list_tmp_folder"` // Folder for merged list temporary files
// Default blocklist URLs
DefaultURLs []URLListItem `json:"default_urls"`
// Filters
Filters []Filter `json:"filters"`
}
var (
@@ -51,6 +57,23 @@ var (
)
func getDefaultConfig() Config {
blocklistURLs := []URLListItem{
{Name: "AdGuard DNS filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_27.txt"},
{Name: "AdGuard Russian filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_49.txt"},
{Name: "AdGuard Base filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt"},
{Name: "AdGuard Tracking Protection", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt"},
{Name: "AdGuard Social Media filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_18.txt"},
{Name: "AdGuard Annoyance filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_23.txt"},
{Name: "AdGuard Mobile Ads filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_11.txt"},
{Name: "AdGuard Search Ads filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt"},
{Name: "AdGuard Adult filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_50.txt"},
{Name: "AntiMalware Hosts", Enabled: true, Group: "Default", URL: "https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt"},
{Name: "DigitalSide Threat Intel", Enabled: true, Group: "Default", URL: "https://osint.digitalside.it/Threat-Intel/lists/latestdomains.txt"},
{Name: "Firebog Crypto", Enabled: true, Group: "Default", URL: "https://v.firebog.net/hosts/Prigent-Crypto.txt"},
{Name: "Phishing Army", Enabled: true, Group: "Default", URL: "https://phishing.army/download/phishing_army_blocklist_extended.txt"},
{Name: "W3kbl", Enabled: true, Group: "Default", URL: "https://v.firebog.net/hosts/static/w3kbl.txt"},
}
return Config{
AppName: "Unifi Blocklist Manager",
Username: "admin",
@@ -59,32 +82,23 @@ func getDefaultConfig() Config {
SessionTimeoutMins: 60, // 1 hour default
Port: "8080",
BindAddr: "0.0.0.0",
UpdateDelay: 3,
CheckInterval: 5,
PidFile: "/tmp/coredns_last.pid",
ProcessName: "coredns",
TmpFile: "/sdcard1/combined-blocklist.txt",
LastUpdateFile: "/sdcard1/last_update.txt",
URLFileList: "/sdcard1/urllist.csv",
BlocklistFile: "/run/utm/domain_list/domainlist_0.list",
RemoveFile: "/run/utm/domain_list/domainlist_1.list",
WhitelistFile: "custom_whitelist.txt",
MergedListTmp: "/tmp/mergedlist.txt",
DefaultURLs: []URLListItem{
{Name: "AdGuard DNS filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_27.txt"},
{Name: "AdGuard Russian filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_49.txt"},
{Name: "AdGuard Base filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt"},
{Name: "AdGuard Tracking Protection", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt"},
{Name: "AdGuard Social Media filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_18.txt"},
{Name: "AdGuard Annoyance filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_23.txt"},
{Name: "AdGuard Mobile Ads filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_11.txt"},
{Name: "AdGuard Search Ads filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt"},
{Name: "AdGuard Adult filter", Enabled: true, Group: "Default", URL: "https://adguardteam.github.io/HostlistsRegistry/assets/filter_50.txt"},
{Name: "AntiMalware Hosts", Enabled: true, Group: "Default", URL: "https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt"},
{Name: "DigitalSide Threat Intel", Enabled: true, Group: "Default", URL: "https://osint.digitalside.it/Threat-Intel/lists/latestdomains.txt"},
{Name: "Firebog Crypto", Enabled: true, Group: "Default", URL: "https://v.firebog.net/hosts/Prigent-Crypto.txt"},
{Name: "Phishing Army", Enabled: true, Group: "Default", URL: "https://phishing.army/download/phishing_army_blocklist_extended.txt"},
{Name: "W3kbl", Enabled: true, Group: "Default", URL: "https://v.firebog.net/hosts/static/w3kbl.txt"},
UpdateDelay: 3,
CheckInterval: 5,
PidFile: "/tmp/coredns_last.pid",
ProcessName: "coredns",
DomainListFolder: "/run/utm/domain_list/",
TmpFolder: "/tmp/",
LastUpdateFolder: "/sdcard1/",
MergedListTmpFolder: "/tmp/",
Filters: []Filter{
{
Name: "Default",
Description: "Default blocklist filter",
BlocklistFile: "domainlist_0.list",
WhitelistFile: "domainlist_1.list",
BlocklistURLs: blocklistURLs,
WhitelistURLs: []URLListItem{},
},
},
}
}
@@ -196,3 +210,92 @@ func handleMFACommand(cmd string) {
os.Exit(1)
}
}
func getFilter(filterName string) *Filter {
configMu.RLock()
defer configMu.RUnlock()
for i := range config.Filters {
if config.Filters[i].Name == filterName {
return &config.Filters[i]
}
}
return nil
}
func updateFilterBlocklistURLs(filterName string, urls []URLListItem) error {
configMu.Lock()
defer configMu.Unlock()
for i := range config.Filters {
if config.Filters[i].Name == filterName {
config.Filters[i].BlocklistURLs = urls
saveConfigLocked()
return nil
}
}
return fmt.Errorf("filter not found")
}
func updateFilterWhitelistURLs(filterName string, urls []URLListItem) error {
configMu.Lock()
defer configMu.Unlock()
for i := range config.Filters {
if config.Filters[i].Name == filterName {
config.Filters[i].WhitelistURLs = urls
saveConfigLocked()
return nil
}
}
return fmt.Errorf("filter not found")
}
func getFilterBlocklistPath(filterName string) string {
configMu.RLock()
defer configMu.RUnlock()
for i := range config.Filters {
if config.Filters[i].Name == filterName {
return config.DomainListFolder + config.Filters[i].BlocklistFile
}
}
return ""
}
func getFilterWhitelistPath(filterName string) string {
configMu.RLock()
defer configMu.RUnlock()
for i := range config.Filters {
if config.Filters[i].Name == filterName {
return config.DomainListFolder + config.Filters[i].WhitelistFile
}
}
return ""
}
func getFilterTmpPath(filterName string) string {
configMu.RLock()
tmpFolder := config.TmpFolder
configMu.RUnlock()
return tmpFolder + "blocklist_" + filterName + ".tmp"
}
func getFilterLastUpdatePath(filterName string) string {
configMu.RLock()
lastUpdateFolder := config.LastUpdateFolder
configMu.RUnlock()
return lastUpdateFolder + "blocklist_" + filterName + "_lastupdate.txt"
}
func getFilterMergedListTmpPath(filterName string) string {
configMu.RLock()
mergedFolder := config.MergedListTmpFolder
configMu.RUnlock()
return mergedFolder + "blocklist_" + filterName + "_merged.tmp"
}

View File

@@ -157,22 +157,20 @@ func mfaSetupHandler(w http.ResponseWriter, r *http.Request) {
}
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
pageData := map[string]interface{}{
"Domains": []string{},
"Query": "",
"Notification": "",
"NotificationType": "",
}
renderTemplate(w, r, "domains.html", pageData)
// Redirect to filters page
http.Redirect(w, r, "/filters", http.StatusSeeOther)
}
func urlListsHandler(w http.ResponseWriter, r *http.Request) {
configMu.RLock()
urlFileList := config.URLFileList
defaultURLs := config.DefaultURLs
var blocklistURLs []URLListItem
if len(config.Filters) > 0 {
blocklistURLs = config.Filters[0].BlocklistURLs
}
configMu.RUnlock()
urlFileList := "blocklist_urls.csv"
if r.Method == "POST" {
action := r.FormValue("action")
@@ -297,7 +295,7 @@ func urlListsHandler(w http.ResponseWriter, r *http.Request) {
items, err := readURLListCSV(urlFileList)
if err != nil || len(items) == 0 {
// Use default blocklists
defaultItems := defaultURLs
defaultItems := blocklistURLs
writeURLListCSV(urlFileList, defaultItems)
items = defaultItems
}
@@ -337,7 +335,11 @@ func whitelistHandler(w http.ResponseWriter, r *http.Request) {
pageData["NotificationType"] = "error"
} else {
configMu.RLock()
whitelistFile := config.WhitelistFile
whitelistFile := ""
domainListFolder := config.DomainListFolder
if len(config.Filters) > 0 {
whitelistFile = domainListFolder + config.Filters[0].WhitelistFile
}
configMu.RUnlock()
// Read current whitelist
@@ -373,7 +375,11 @@ func whitelistHandler(w http.ResponseWriter, r *http.Request) {
func searchDomainsHandler(w http.ResponseWriter, r *http.Request) {
configMu.RLock()
blocklistFile := config.BlocklistFile
blocklistFile := ""
domainListFolder := config.DomainListFolder
if len(config.Filters) > 0 {
blocklistFile = domainListFolder + config.Filters[0].BlocklistFile
}
configMu.RUnlock()
query := strings.TrimSpace(r.FormValue("query"))
@@ -423,8 +429,13 @@ func searchDomainsHandler(w http.ResponseWriter, r *http.Request) {
func searchWhitelistHandler(w http.ResponseWriter, r *http.Request) {
configMu.RLock()
removeFile := config.RemoveFile
whitelistFile := config.WhitelistFile
domainListFolder := config.DomainListFolder
removeFile := ""
whitelistFile := ""
if len(config.Filters) > 0 {
removeFile = domainListFolder + config.Filters[0].BlocklistFile
whitelistFile = domainListFolder + config.Filters[0].WhitelistFile
}
configMu.RUnlock()
query := strings.TrimSpace(r.FormValue("query"))
@@ -522,3 +533,485 @@ func logsHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Logs placeholder"))
}
func filtersHandler(w http.ResponseWriter, r *http.Request) {
configMu.RLock()
filters := config.Filters
configMu.RUnlock()
pageData := map[string]interface{}{
"Filters": filters,
}
renderTemplate(w, r, "filters.html", pageData)
}
func createFilterHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Redirect(w, r, "/filters", http.StatusSeeOther)
return
}
// Verify CSRF token
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
description := strings.TrimSpace(r.FormValue("description"))
blocklistFile := strings.TrimSpace(r.FormValue("blocklist_file"))
whitelistFile := strings.TrimSpace(r.FormValue("whitelist_file"))
// Validation
if name == "" || blocklistFile == "" || whitelistFile == "" {
pageData := map[string]interface{}{
"Notification": "All fields are required",
"NotificationType": "error",
}
renderTemplate(w, r, "filters.html", pageData)
return
}
// Check if filter already exists and add to config
configMu.Lock()
for _, f := range config.Filters {
if f.Name == name {
configMu.Unlock()
pageData := map[string]interface{}{
"Notification": "Filter with this name already exists",
"NotificationType": "error",
}
renderTemplate(w, r, "filters.html", pageData)
return
}
}
// Create new filter
newFilter := Filter{
Name: name,
Description: description,
BlocklistFile: blocklistFile,
WhitelistFile: whitelistFile,
}
// Add to config and save
config.Filters = append(config.Filters, newFilter)
saveConfigLocked()
configMu.Unlock()
// Redirect back to filters with success message
http.Redirect(w, r, "/filters?success=1", http.StatusSeeOther)
}
func editFilterHandler(w http.ResponseWriter, r *http.Request) {
filterName := r.URL.Query().Get("filter")
if filterName == "" {
http.Redirect(w, r, "/filters", http.StatusSeeOther)
return
}
filter := getFilter(filterName)
if filter == nil {
http.Redirect(w, r, "/filters", http.StatusSeeOther)
return
}
if r.Method == "POST" {
// Handle edit form submission
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest)
return
}
newName := strings.TrimSpace(r.FormValue("name"))
description := strings.TrimSpace(r.FormValue("description"))
blocklistFile := strings.TrimSpace(r.FormValue("blocklist_file"))
whitelistFile := strings.TrimSpace(r.FormValue("whitelist_file"))
// Validation
if newName == "" || blocklistFile == "" || whitelistFile == "" {
pageData := map[string]interface{}{
"Filter": filter,
"Notification": "All fields are required",
"NotificationType": "error",
}
renderTemplate(w, r, "edit-filter.html", pageData)
return
}
// Check if new name conflicts with existing filter and update it
configMu.Lock()
// Check for name conflict
nameConflict := false
for _, f := range config.Filters {
if f.Name == newName && f.Name != filterName {
nameConflict = true
break
}
}
if nameConflict {
configMu.Unlock()
pageData := map[string]interface{}{
"Filter": filter,
"Notification": "Filter with this name already exists",
"NotificationType": "error",
}
renderTemplate(w, r, "edit-filter.html", pageData)
return
}
// Update filter
for i, f := range config.Filters {
if f.Name == filterName {
config.Filters[i].Name = newName
config.Filters[i].Description = description
config.Filters[i].BlocklistFile = blocklistFile
config.Filters[i].WhitelistFile = whitelistFile
break
}
}
// Save config using saveConfigLocked since we already hold the lock
saveConfigLocked()
configMu.Unlock()
// Redirect back to filters (after unlock)
http.Redirect(w, r, "/filters?success=1", http.StatusSeeOther)
return
}
// Display edit form (GET request)
pageData := map[string]interface{}{
"Filter": filter,
}
renderTemplate(w, r, "edit-filter.html", pageData)
}
func filterDetailHandler(w http.ResponseWriter, r *http.Request) {
filterName := r.URL.Query().Get("filter")
if filterName == "" {
http.Redirect(w, r, "/filters", http.StatusSeeOther)
return
}
filter := getFilter(filterName)
if filter == nil {
http.Redirect(w, r, "/filters", http.StatusSeeOther)
return
}
pageData := map[string]interface{}{
"Filter": filter,
"Notification": "",
"NotificationType": "",
}
renderTemplate(w, r, "filter-detail.html", pageData)
}
func filterSearchHandler(w http.ResponseWriter, r *http.Request) {
filterName := strings.TrimSpace(r.FormValue("filter"))
query := strings.TrimSpace(r.FormValue("query"))
if filterName == "" || query == "" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"results": []map[string]string{}})
return
}
filter := getFilter(filterName)
if filter == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"results": []map[string]string{}})
return
}
configMu.RLock()
domainListFolder := config.DomainListFolder
configMu.RUnlock()
blocklistPath := domainListFolder + filter.BlocklistFile
whitelistPath := domainListFolder + filter.WhitelistFile
// Read both files
blocklist, _ := readLines(blocklistPath)
whitelist, _ := readLines(whitelistPath)
// Create maps for quick lookup
blocklistMap := make(map[string]bool)
for _, d := range blocklist {
blocklistMap[strings.ToLower(d)] = true
}
whitelistMap := make(map[string]bool)
for _, d := range whitelist {
whitelistMap[strings.ToLower(d)] = true
}
// Combine unique domains
domainMap := make(map[string]bool)
for d := range blocklistMap {
domainMap[d] = true
}
for d := range whitelistMap {
domainMap[d] = true
}
var filtered []map[string]string
for d := range domainMap {
var source string
inBlocklist := blocklistMap[d]
inWhitelist := whitelistMap[d]
if inBlocklist && inWhitelist {
source = "Both"
} else if inBlocklist {
source = "Blocklist"
} else {
source = "Whitelist"
}
match := false
if strings.Contains(query, "*") {
// Wildcard search
pattern := strings.ReplaceAll(regexp.QuoteMeta(query), "\\*", ".*")
re, err := regexp.Compile("(?i)^" + pattern + "$")
if err == nil && re.MatchString(d) {
match = true
}
} else {
// Exact match, case insensitive
if strings.EqualFold(d, query) {
match = true
}
}
if match {
filtered = append(filtered, map[string]string{"domain": d, "source": source})
if len(filtered) >= 100 {
break
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"results": filtered})
}
func filterBlocklistHandler(w http.ResponseWriter, r *http.Request) {
filterName := r.URL.Query().Get("filter")
if filterName == "" {
http.Redirect(w, r, "/filters", http.StatusSeeOther)
return
}
filter := getFilter(filterName)
if filter == nil {
http.Redirect(w, r, "/filters", http.StatusSeeOther)
return
}
// Read current blocklist domains
configMu.RLock()
domainListFolder := config.DomainListFolder
configMu.RUnlock()
blocklistPath := domainListFolder + filter.BlocklistFile
domains, _ := readLines(blocklistPath)
pageData := map[string]interface{}{
"Filter": filter,
"FilterName": filterName,
"URLs": filter.BlocklistURLs,
"Domains": domains,
"Notification": "",
"NotificationType": "",
}
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest)
return
}
action := r.FormValue("action")
switch action {
case "add_domain":
domain := strings.TrimSpace(r.FormValue("domain"))
if domain == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Domain is required"})
return
}
// Add domain to file
domains = append(domains, domain)
writeLines(blocklistPath, domains)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
return
case "remove_domain":
domain := strings.TrimSpace(r.FormValue("domain"))
// Remove domain from slice
for i, d := range domains {
if d == domain {
domains = append(domains[:i], domains[i+1:]...)
break
}
}
writeLines(blocklistPath, domains)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
return
case "add_url":
name := strings.TrimSpace(r.FormValue("name"))
url := strings.TrimSpace(r.FormValue("url"))
if name == "" || url == "" {
pageData["Notification"] = "Name and URL are required."
pageData["NotificationType"] = "error"
} else {
newURL := URLListItem{Name: name, URL: url, Enabled: true, Group: "Custom"}
filter.BlocklistURLs = append(filter.BlocklistURLs, newURL)
updateFilterBlocklistURLs(filterName, filter.BlocklistURLs)
pageData["Notification"] = "URL added successfully."
pageData["NotificationType"] = "success"
pageData["URLs"] = filter.BlocklistURLs
}
case "toggle_url":
idx, _ := strconv.Atoi(r.FormValue("index"))
if idx >= 0 && idx < len(filter.BlocklistURLs) {
filter.BlocklistURLs[idx].Enabled = !filter.BlocklistURLs[idx].Enabled
updateFilterBlocklistURLs(filterName, filter.BlocklistURLs)
pageData["Notification"] = "URL toggled successfully."
pageData["NotificationType"] = "success"
pageData["URLs"] = filter.BlocklistURLs
}
case "remove_url":
idx, _ := strconv.Atoi(r.FormValue("index"))
if idx >= 0 && idx < len(filter.BlocklistURLs) {
filter.BlocklistURLs = append(filter.BlocklistURLs[:idx], filter.BlocklistURLs[idx+1:]...)
updateFilterBlocklistURLs(filterName, filter.BlocklistURLs)
pageData["Notification"] = "URL removed successfully."
pageData["NotificationType"] = "success"
pageData["URLs"] = filter.BlocklistURLs
}
}
}
renderTemplate(w, r, "filter-blocklist.html", pageData)
}
func filterWhitelistHandler(w http.ResponseWriter, r *http.Request) {
filterName := r.URL.Query().Get("filter")
if filterName == "" {
http.Redirect(w, r, "/filters", http.StatusSeeOther)
return
}
filter := getFilter(filterName)
if filter == nil {
http.Redirect(w, r, "/filters", http.StatusSeeOther)
return
}
// Read current whitelist domains
configMu.RLock()
domainListFolder := config.DomainListFolder
configMu.RUnlock()
whitelistPath := domainListFolder + filter.WhitelistFile
domains, _ := readLines(whitelistPath)
pageData := map[string]interface{}{
"Filter": filter,
"FilterName": filterName,
"URLs": filter.WhitelistURLs,
"Domains": domains,
"Notification": "",
"NotificationType": "",
}
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest)
return
}
action := r.FormValue("action")
switch action {
case "add_domain":
domain := strings.TrimSpace(r.FormValue("domain"))
if domain == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Domain is required"})
return
}
// Add domain to file
domains = append(domains, domain)
writeLines(whitelistPath, domains)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
return
case "remove_domain":
domain := strings.TrimSpace(r.FormValue("domain"))
// Remove domain from slice
for i, d := range domains {
if d == domain {
domains = append(domains[:i], domains[i+1:]...)
break
}
}
writeLines(whitelistPath, domains)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
return
case "add_url":
name := strings.TrimSpace(r.FormValue("name"))
url := strings.TrimSpace(r.FormValue("url"))
if name == "" || url == "" {
pageData["Notification"] = "Name and URL are required."
pageData["NotificationType"] = "error"
} else {
newURL := URLListItem{Name: name, URL: url, Enabled: true, Group: "Custom"}
filter.WhitelistURLs = append(filter.WhitelistURLs, newURL)
updateFilterWhitelistURLs(filterName, filter.WhitelistURLs)
pageData["Notification"] = "URL added successfully."
pageData["NotificationType"] = "success"
pageData["URLs"] = filter.WhitelistURLs
}
case "toggle_url":
idx, _ := strconv.Atoi(r.FormValue("index"))
if idx >= 0 && idx < len(filter.WhitelistURLs) {
filter.WhitelistURLs[idx].Enabled = !filter.WhitelistURLs[idx].Enabled
updateFilterWhitelistURLs(filterName, filter.WhitelistURLs)
pageData["Notification"] = "URL toggled successfully."
pageData["NotificationType"] = "success"
pageData["URLs"] = filter.WhitelistURLs
}
case "remove_url":
idx, _ := strconv.Atoi(r.FormValue("index"))
if idx >= 0 && idx < len(filter.WhitelistURLs) {
filter.WhitelistURLs = append(filter.WhitelistURLs[:idx], filter.WhitelistURLs[idx+1:]...)
updateFilterWhitelistURLs(filterName, filter.WhitelistURLs)
pageData["Notification"] = "URL removed successfully."
pageData["NotificationType"] = "success"
pageData["URLs"] = filter.WhitelistURLs
}
}
}
renderTemplate(w, r, "filter-whitelist.html", pageData)
}

View File

@@ -8,4 +8,9 @@ tailwindcss -o static/tailwind.css --minify
tailwindcss -o static/tailwind.css --minify --watch
git add . && git commit -m "update" && git push
git add . && git commit -m "update" && git push
`````````
sudo grep -Rl "domainlist_0.list" / 2>/dev/null

View File

@@ -67,7 +67,16 @@ func main() {
r.HandleFunc("/api/mfa-setup", authMiddleware(mfaSetupHandler)).Methods("POST")
r.HandleFunc("/api/search-domains", authMiddleware(searchDomainsHandler)).Methods("POST")
r.HandleFunc("/api/search-whitelist", authMiddleware(searchWhitelistHandler)).Methods("POST")
r.HandleFunc("/api/filter-search", authMiddleware(filterSearchHandler)).Methods("POST")
r.HandleFunc("/", authMiddleware(dashboardHandler))
r.HandleFunc("/filters", authMiddleware(filtersHandler))
r.HandleFunc("/filters/create", authMiddleware(createFilterHandler)).Methods("POST")
r.HandleFunc("/filters/edit", authMiddleware(editFilterHandler)).Methods("GET", "POST")
r.HandleFunc("/filter", authMiddleware(filterDetailHandler))
r.HandleFunc("/filter/blocklist", authMiddleware(filterBlocklistHandler)).Methods("GET", "POST")
r.HandleFunc("/filter/blocklist/urls", authMiddleware(filterBlocklistHandler)).Methods("GET", "POST")
r.HandleFunc("/filter/whitelist", authMiddleware(filterWhitelistHandler)).Methods("GET", "POST")
r.HandleFunc("/filter/whitelist/urls", authMiddleware(filterWhitelistHandler)).Methods("GET", "POST")
r.HandleFunc("/urllists", authMiddleware(urlListsHandler)).Methods("GET", "POST")
r.HandleFunc("/domains", authMiddleware(domainsHandler)).Methods("GET", "POST")
r.HandleFunc("/whitelist", authMiddleware(whitelistHandler)).Methods("GET", "POST")

File diff suppressed because one or more lines are too long

View File

@@ -6,29 +6,72 @@
<title>{{ .AppName }}</title>
<link rel="icon" href="/static/favicon.svg">
<link href="/static/tailwind.css" rel="stylesheet">
<style>
.sidebar {
transition: transform 0.3s ease;
}
.sidebar.hidden {
transform: translateX(-100%);
}
main {
transition: margin-left 0.3s ease;
}
main.sidebar-open {
margin-left: 240px;
}
</style>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex flex-col">
{{ template "notifications" . }}
<header class="bg-gray-800 p-4 fixed top-0 w-full z-10">
<nav class="flex justify-between">
<a href="/" class="text-xl font-bold">{{ .AppName }}</a>
<header class="bg-gray-800 p-4 flex items-center justify-between relative z-20">
<div class="flex items-center gap-4">
{{ if .Authenticated }}
<ul class="flex space-x-4">
<li><a href="/urllists">URL Lists</a></li>
<li><a href="/domains">Blocked Domains</a></li>
<li><a href="/whitelist">Whitelisted Domains</a></li>
<li><a href="/logs">Logs</a></li>
<li><a href="/profile">Profile</a></li>
<li><a href="/logout">Logout</a></li>
</ul>
<button id="sidebarToggle" class="text-2xl" onclick="toggleSidebar()"></button>
{{ end }}
</nav>
<h1 class="text-xl font-bold">{{ .AppName }}</h1>
</div>
{{ if .Authenticated }}
<div class="flex gap-4">
<a href="/profile" class="hover:text-blue-400">Profile</a>
<a href="/logout" class="hover:text-blue-400">Logout</a>
</div>
{{ end }}
</header>
<main class="flex-grow container mx-auto p-4 pt-20">
{{ if .Authenticated }}
<div class="flex">
<!-- Sidebar -->
<aside id="sidebar" class="sidebar fixed left-0 top-16 w-60 h-full bg-gray-800 border-r border-gray-700 overflow-y-auto hidden">
<nav class="p-4 space-y-2">
<a href="/filters" class="block px-4 py-2 rounded hover:bg-gray-700">Filters</a>
<a href="/urllists" class="block px-4 py-2 rounded hover:bg-gray-700">Global URL Lists</a>
<a href="/logs" class="block px-4 py-2 rounded hover:bg-gray-700">Logs</a>
</nav>
</aside>
<!-- Main Content -->
<main id="mainContent" class="flex-grow container mx-auto p-4 pt-4">
{{ block "content" . }}{{ end }}
</main>
</div>
{{ else }}
<main class="flex-grow container mx-auto p-4 pt-4">
{{ block "content" . }}{{ end }}
</main>
<footer class="bg-gray-800 p-4 text-center">
{{ end }}
<footer class="bg-gray-800 p-4 text-center mt-auto">
&copy; 2025 {{ .AppName }}
</footer>
<script>
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const mainContent = document.getElementById('mainContent');
sidebar.classList.toggle('hidden');
mainContent.classList.toggle('sidebar-open');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,41 @@
{{ define "content" }}
{{ $filter := index .PageData "Filter" }}
<div class="max-w-4xl">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Edit Filter</h1>
<a href="/filters" class="text-blue-400 hover:text-blue-300">← Back to Filters</a>
</div>
<div class="bg-gray-800 p-6 rounded border border-gray-700">
<form method="POST" action="/filters/edit?filter={{ $filter.Name }}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div>
<label class="block text-white font-semibold mb-2">Filter Name</label>
<input type="text" name="name" value="{{ $filter.Name }}" placeholder="e.g., Ads Blocklist" class="w-full p-3 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
</div>
<div>
<label class="block text-white font-semibold mb-2">Description</label>
<input type="text" name="description" value="{{ $filter.Description }}" placeholder="e.g., Blocks advertising domains" class="w-full p-3 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-white font-semibold mb-2">Blocklist Filename</label>
<input type="text" name="blocklist_file" value="{{ $filter.BlocklistFile }}" placeholder="e.g., blocklist.txt" class="w-full p-3 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
</div>
<div>
<label class="block text-white font-semibold mb-2">Whitelist Filename</label>
<input type="text" name="whitelist_file" value="{{ $filter.WhitelistFile }}" placeholder="e.g., whitelist.txt" class="w-full p-3 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
</div>
</div>
<div class="flex gap-2">
<button type="submit" class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold px-6 py-3 rounded transition">Save Changes</button>
<a href="/filters" class="bg-gray-700 hover:bg-gray-600 text-white font-bold px-6 py-3 rounded transition text-center">Cancel</a>
</div>
</form>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,287 @@
{{ define "content" }}
{{ $filter := index .PageData "Filter" }}
{{ $urls := index .PageData "URLs" }}
{{ $domains := index .PageData "Domains" }}
<div class="max-w-6xl">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">{{ $filter.Name }} - Blocklist</h1>
<a href="/filter?filter={{ $filter.Name }}" class="text-blue-400 hover:text-blue-300">← Back to Filter</a>
</div>
<!-- Two column layout: Domains and URLs -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Left: Manual Domains -->
<div>
<h2 class="text-2xl font-bold mb-4">Manual Blocked Domains</h2>
<!-- Add Domain Form -->
<div class="bg-gray-800 p-6 rounded border border-gray-700 mb-4">
<h3 class="text-lg font-semibold mb-3">Add Blocked Domain</h3>
<form id="addDomainForm" onsubmit="handleAddDomain(event, 'blocklist')" class="space-y-3">
<input type="hidden" name="action" value="add_domain">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="text" id="addDomainInput" name="domain" placeholder="e.g., ads.example.com" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold px-4 py-2 rounded transition">Add Domain</button>
</form>
</div>
<!-- Search/Lookup Domains -->
<div class="bg-gray-800 p-6 rounded border border-gray-700">
<h3 class="text-lg font-semibold mb-3">Search Blocked Domains</h3>
<div class="space-y-3">
<input type="text" id="blocklistDomainSearch" placeholder="Search for a blocked domain (use * as wildcard, e.g. *google.com)" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" onkeyup="searchBlocklistDomains()">
<div id="blocklistSearchResults" class="bg-gray-750 rounded border border-gray-600 max-h-96 overflow-y-auto">
<p class="text-gray-400 p-4 text-center text-sm">Enter a domain name to search</p>
</div>
</div>
</div>
</div>
<!-- Right: Blocklist URLs -->
<div>
<h2 class="text-2xl font-bold mb-4">Public Blocklist URLs</h2>
<!-- Add URL Form -->
<div class="bg-gray-800 p-6 rounded border border-gray-700 mb-4">
<h3 class="text-lg font-semibold mb-3">Add Blocklist URL</h3>
<form method="POST" action="/filter/blocklist/urls?filter={{ $filter.Name }}" class="space-y-3">
<input type="hidden" name="action" value="add_url">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="text" name="name" placeholder="Name/Description" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
<input type="url" name="url" placeholder="https://example.com/blocklist.txt" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold px-4 py-2 rounded transition">Add URL</button>
</form>
</div>
<!-- URLs Table -->
{{ if $urls }}
<div class="bg-gray-800 rounded border border-gray-700 overflow-hidden">
<div class="bg-gray-900 px-4 py-2 font-semibold text-white">Blocklist URL Sources</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-750">
<tr>
<th class="px-4 py-2 text-left">Name</th>
<th class="px-4 py-2 text-left">URL</th>
<th class="px-4 py-2 text-center">Status</th>
<th class="px-4 py-2 text-center">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
{{ range $index, $url := $urls }}
<tr class="hover:bg-gray-750">
<td class="px-4 py-2 text-gray-300">{{ $url.Name }}</td>
<td class="px-4 py-2 text-gray-400 text-xs break-all">{{ $url.URL }}</td>
<td class="px-4 py-2 text-center">
{{ if $url.Enabled }}
<span class="text-green-400 text-xs font-semibold">✓ Enabled</span>
{{ else }}
<span class="text-red-400 text-xs font-semibold">✗ Disabled</span>
{{ end }}
</td>
<td class="px-4 py-2 text-center space-x-2">
<form method="POST" action="/filter/blocklist/urls?filter={{ $filter.Name }}" class="inline">
<input type="hidden" name="action" value="toggle_url">
<input type="hidden" name="index" value="{{ $index }}">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<button type="submit" class="text-yellow-400 hover:text-yellow-300 text-xs font-semibold">Toggle</button>
</form>
<form method="POST" action="/filter/blocklist/urls?filter={{ $filter.Name }}" class="inline">
<input type="hidden" name="action" value="remove_url">
<input type="hidden" name="index" value="{{ $index }}">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<button type="submit" class="text-red-400 hover:text-red-300 text-xs font-semibold">Remove</button>
</form>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{ else }}
<p class="text-gray-400 text-center py-4">No URLs added yet</p>
{{ end }}
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirmModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6 max-w-sm w-full mx-4">
<h2 class="text-xl font-bold text-white mb-4">Confirm Removal</h2>
<p class="text-gray-300 mb-6"><span id="modalDomainText"></span></p>
<div class="flex gap-3 justify-end">
<button onclick="closeConfirmModal()" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded transition">Cancel</button>
<button onclick="confirmRemovalAction()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded transition font-semibold">Remove</button>
</div>
</div>
</div>
<!-- Hidden data attributes for JavaScript -->
<div id="appData" data-filter-name="{{ $filter.Name }}" data-csrf-token="{{ .CSRFToken }}" style="display:none;"></div>
<!-- Store domains as JSON -->
<script id="domainsData" type="application/json">
{{ if $domains }}[{{ range $index, $domain := $domains }}{{ if gt $index 0 }},{{ end }}"{{ . }}"{{ end }}]{{ else }}[]{{ end }}
</script>
<script>
const appData = document.getElementById('appData');
const filterName = appData.getAttribute('data-filter-name');
const csrfToken = appData.getAttribute('data-csrf-token');
const domainsElement = document.getElementById('domainsData');
let blocklistDomainsData = domainsElement ? JSON.parse(domainsElement.innerText) : [];
// Modal state
let pendingDomain = null;
let pendingType = null;
function wildcardToRegex(pattern) {
// Escape special regex characters except *
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
// Replace * with .* for wildcard matching
const regex = escaped.replace(/\*/g, '.*');
return new RegExp('^' + regex + '$', 'i');
}
function handleAddDomain(event, type) {
event.preventDefault();
const form = event.target;
const domainInput = document.getElementById('addDomainInput');
const domain = domainInput.value.trim();
if (!domain) {
showNotification('Domain is required', 'error');
return;
}
const url = type === 'blocklist' ? '/filter/blocklist' : '/filter/whitelist';
const formData = new FormData();
formData.append('action', 'add_domain');
formData.append('domain', domain);
formData.append('csrf_token', csrfToken);
const fullUrl = url + '?filter=' + encodeURIComponent(filterName);
fetch(fullUrl, {
method: 'POST',
body: formData
})
.then(response => {
if (response.status === 200) {
// Add to local array
blocklistDomainsData.push(domain);
// Clear input
domainInput.value = '';
// Re-search to update results
searchBlocklistDomains();
// Show success notification
showNotification(`Domain "${domain}" added successfully`, 'success');
} else {
showNotification('Error adding domain', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error adding domain: ' + error.message, 'error');
});
}
function searchBlocklistDomains() {
const query = document.getElementById('blocklistDomainSearch').value.trim();
const resultsDiv = document.getElementById('blocklistSearchResults');
if (!query) {
resultsDiv.innerHTML = '<p class="text-gray-400 p-4 text-center text-sm">Enter a domain name to search (use * as wildcard)</p>';
return;
}
const regex = wildcardToRegex(query);
const matches = blocklistDomainsData.filter(domain => regex.test(domain));
if (matches.length === 0) {
resultsDiv.innerHTML = '<p class="text-gray-400 p-4 text-center text-sm">No domains found</p>';
return;
}
let html = '<ul class="divide-y divide-gray-600">';
matches.forEach(domain => {
html += `
<li class="px-4 py-3 hover:bg-gray-700 flex justify-between items-center">
<span class="text-gray-300 break-all">${domain}</span>
<button onclick="showConfirmModal('${domain.replace(/'/g, "\\'")}', 'blocklist')" class="text-red-400 hover:text-red-300 text-sm font-semibold">Remove</button>
</li>
`;
});
html += '</ul>';
resultsDiv.innerHTML = html;
}
function showConfirmModal(domain, type) {
pendingDomain = domain;
pendingType = type;
const typeName = type === 'blocklist' ? 'blocked' : 'whitelisted';
document.getElementById('modalDomainText').textContent = `Are you sure you want to remove "${domain}" from ${typeName} domains?`;
document.getElementById('confirmModal').classList.remove('hidden');
}
function closeConfirmModal() {
pendingDomain = null;
pendingType = null;
document.getElementById('confirmModal').classList.add('hidden');
}
function confirmRemovalAction() {
if (pendingDomain && pendingType) {
removeDomainAjax(pendingDomain, pendingType);
closeConfirmModal();
}
}
function removeDomainAjax(domain, type) {
const url = type === 'blocklist' ? '/filter/blocklist' : '/filter/whitelist';
const formData = new FormData();
formData.append('action', 'remove_domain');
formData.append('domain', domain);
formData.append('csrf_token', csrfToken);
const fullUrl = url + '?filter=' + encodeURIComponent(filterName);
console.log('Sending request to:', fullUrl);
fetch(fullUrl, {
method: 'POST',
body: formData
})
.then(response => {
console.log('Response status:', response.status);
if (response.status === 200) {
return response.json().then(() => {
// Remove from local array
blocklistDomainsData = blocklistDomainsData.filter(d => d !== domain);
// Re-search to update results
searchBlocklistDomains();
// Show success notification
showNotification(`Domain "${domain}" removed successfully`, 'success');
});
} else {
return response.text().then(text => {
console.error('Response text:', text);
showNotification('Error removing domain: ' + response.status, 'error');
});
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error removing domain: ' + error.message, 'error');
});
}
// Close modal when clicking outside
document.getElementById('confirmModal').addEventListener('click', function(e) {
if (e.target === this) {
closeConfirmModal();
}
});
</script>
{{ end }}

View File

@@ -0,0 +1,123 @@
{{ define "content" }}
{{ $filter := index .PageData "Filter" }}
<div class="max-w-6xl">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">{{ $filter.Name }}</h1>
<a href="/filters" class="text-blue-400 hover:text-blue-300">← Back to Filters</a>
</div>
<p class="text-gray-400 mb-6">{{ $filter.Description }}</p>
<!-- Tabs -->
<div class="flex border-b border-gray-700 mb-6 gap-0">
<button onclick="showTab('search')" id="tab-search" class="px-6 py-3 border-b-2 border-blue-600 font-bold text-white">Search</button>
<button onclick="showTab('blocklist')" id="tab-blocklist" class="px-6 py-3 border-b-2 border-transparent hover:border-gray-600 text-gray-400">Blocklist</button>
<button onclick="showTab('whitelist')" id="tab-whitelist" class="px-6 py-3 border-b-2 border-transparent hover:border-gray-600 text-gray-400">Whitelist</button>
</div>
<!-- Search Tab -->
<div id="tab-content-search" class="tab-content">
<div class="bg-gray-800 p-4 rounded mb-6">
<div class="flex gap-2 mb-4">
<input type="text" id="searchQuery" placeholder="Search domains (use * for wildcard)" onkeydown="if(event.key === 'Enter') filterSearch()" class="flex-1 p-2 bg-gray-700 rounded text-white placeholder-gray-400">
<button type="button" onclick="filterSearch()" class="bg-blue-600 hover:bg-blue-700 text-white font-bold px-4 py-2 rounded transition">Search</button>
</div>
</div>
<div id="searchResults"></div>
</div>
<!-- Blocklist Tab -->
<div id="tab-content-blocklist" class="tab-content hidden">
<div class="grid grid-cols-2 gap-6">
<!-- Add Domain -->
<div>
<h3 class="text-xl font-bold mb-4">Add Domain</h3>
<form method="POST" action="/filter/blocklist?filter={{ $filter.Name }}" class="bg-gray-800 p-4 rounded space-y-4">
<input type="hidden" name="action" value="add_domain">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="text" name="domain" placeholder="Enter domain" class="w-full p-2 bg-gray-700 rounded text-white" required>
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold px-4 py-2 rounded transition">Add</button>
</form>
</div>
<!-- Manage URLs -->
<div>
<h3 class="text-xl font-bold mb-4">Import URLs</h3>
<a href="/filter/blocklist/urls?filter={{ $filter.Name }}" class="block bg-blue-600 hover:bg-blue-700 text-white font-bold px-4 py-2 rounded text-center transition">Manage URLs</a>
</div>
</div>
</div>
<!-- Whitelist Tab -->
<div id="tab-content-whitelist" class="tab-content hidden">
<div class="grid grid-cols-2 gap-6">
<!-- Add Domain -->
<div>
<h3 class="text-xl font-bold mb-4">Add Domain</h3>
<form method="POST" action="/filter/whitelist?filter={{ $filter.Name }}" class="bg-gray-800 p-4 rounded space-y-4">
<input type="hidden" name="action" value="add_domain">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="text" name="domain" placeholder="Enter domain" class="w-full p-2 bg-gray-700 rounded text-white" required>
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold px-4 py-2 rounded transition">Add</button>
</form>
</div>
<!-- Manage URLs -->
<div>
<h3 class="text-xl font-bold mb-4">Import URLs</h3>
<a href="/filter/whitelist/urls?filter={{ $filter.Name }}" class="block bg-blue-600 hover:bg-blue-700 text-white font-bold px-4 py-2 rounded text-center transition">Manage URLs</a>
</div>
</div>
</div>
</div>
<script>
function showTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('#tab-search, #tab-blocklist, #tab-whitelist').forEach(btn => btn.classList.remove('border-blue-600', 'text-white'));
document.querySelectorAll('#tab-search, #tab-blocklist, #tab-whitelist').forEach(btn => btn.classList.add('border-transparent', 'text-gray-400'));
// Show selected tab
document.getElementById('tab-content-' + tabName).classList.remove('hidden');
document.getElementById('tab-' + tabName).classList.add('border-blue-600', 'text-white');
document.getElementById('tab-' + tabName).classList.remove('border-transparent', 'text-gray-400');
}
function filterSearch() {
const query = document.getElementById('searchQuery').value.trim();
if (!query) {
document.getElementById('searchResults').innerHTML = '<p class="text-gray-300">Please enter a search query.</p>';
return;
}
document.getElementById('searchResults').innerHTML = '<p class="text-gray-300">Searching...</p>';
fetch('/api/filter-search', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': '{{ .CSRFToken }}'
},
body: 'filter=' + encodeURIComponent('{{ $filter.Name }}') + '&query=' + encodeURIComponent(query)
})
.then(response => response.json())
.then(data => {
let html = '';
if (data.results && data.results.length > 0) {
html = '<h3 class="text-xl font-bold mb-4">Results (' + data.results.length + ' found)</h3>';
html += '<ul class="space-y-2 max-h-96 overflow-y-auto">';
data.results.forEach(r => {
html += '<li class="bg-gray-800 p-3 rounded flex"><div class="flex-1 text-sm text-gray-300 break-all">' + r.domain + '</div><div class="w-24 text-sm text-blue-400 text-right select-none">' + r.source + '</div></li>';
});
html += '</ul>';
} else {
html = '<p class="text-gray-300">No results found for \'' + query + '\'</p>';
}
document.getElementById('searchResults').innerHTML = html;
})
.catch(error => {
document.getElementById('searchResults').innerHTML = '<p class="text-red-400">Error searching.</p>';
});
}
</script>
{{ end }}

View File

@@ -0,0 +1,288 @@
{{ define "content" }}
{{ $filter := index .PageData "Filter" }}
{{ $urls := index .PageData "URLs" }}
{{ $domains := index .PageData "Domains" }}
<div class="max-w-6xl">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">{{ $filter.Name }} - Whitelist</h1>
<a href="/filter?filter={{ $filter.Name }}" class="text-blue-400 hover:text-blue-300">← Back to Filter</a>
</div>
<!-- Two column layout: Domains and URLs -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Left: Manual Domains -->
<div>
<h2 class="text-2xl font-bold mb-4">Manual Domains</h2>
<!-- Add Domain Form -->
<div class="bg-gray-800 p-6 rounded border border-gray-700 mb-4">
<h3 class="text-lg font-semibold mb-3">Add Domain</h3>
<form id="addDomainForm" onsubmit="handleAddDomain(event, 'whitelist')" class="space-y-3">
<input type="hidden" name="action" value="add_domain">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="text" id="addDomainInput" name="domain" placeholder="e.g., example.com or sub.example.com" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold px-4 py-2 rounded transition">Add Domain</button>
</form>
</div>
<!-- Search/Lookup Domains -->
<div class="bg-gray-800 p-6 rounded border border-gray-700">
<h3 class="text-lg font-semibold mb-3">Search Domains</h3>
<div class="space-y-3">
<input type="text" id="whitelistDomainSearch" placeholder="Search for a domain (use * as wildcard, e.g. *google.com)" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" onkeyup="searchWhitelistDomains()">
<div id="whitelistSearchResults" class="bg-gray-750 rounded border border-gray-600 max-h-96 overflow-y-auto">
<p class="text-gray-400 p-4 text-center text-sm">Enter a domain name to search</p>
</div>
</div>
</div>
</div>
<!-- Right: Whitelist URLs -->
<div>
<h2 class="text-2xl font-bold mb-4">Public Whitelist URLs</h2>
<!-- Add URL Form -->
<div class="bg-gray-800 p-6 rounded border border-gray-700 mb-4">
<h3 class="text-lg font-semibold mb-3">Add Whitelist URL</h3>
<form method="POST" action="/filter/whitelist/urls?filter={{ $filter.Name }}" class="space-y-3">
<input type="hidden" name="action" value="add_url">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="text" name="name" placeholder="Name/Description" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
<input type="url" name="url" placeholder="https://example.com/whitelist.txt" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
<button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white font-bold px-4 py-2 rounded transition">Add URL</button>
</form>
</div>
<!-- URLs Table -->
{{ if $urls }}
<div class="bg-gray-800 rounded border border-gray-700 overflow-hidden">
<div class="bg-gray-900 px-4 py-2 font-semibold text-white">Whitelist URL Sources</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-750">
<tr>
<th class="px-4 py-2 text-left">Name</th>
<th class="px-4 py-2 text-left">URL</th>
<th class="px-4 py-2 text-center">Status</th>
<th class="px-4 py-2 text-center">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
{{ range $index, $url := $urls }}
<tr class="hover:bg-gray-750">
<td class="px-4 py-2 text-gray-300">{{ $url.Name }}</td>
<td class="px-4 py-2 text-gray-400 text-xs break-all">{{ $url.URL }}</td>
<td class="px-4 py-2 text-center">
{{ if $url.Enabled }}
<span class="text-green-400 text-xs font-semibold">✓ Enabled</span>
{{ else }}
<span class="text-red-400 text-xs font-semibold">✗ Disabled</span>
{{ end }}
</td>
<td class="px-4 py-2 text-center space-x-2">
<form method="POST" action="/filter/whitelist/urls?filter={{ $filter.Name }}" class="inline">
<input type="hidden" name="action" value="toggle_url">
<input type="hidden" name="index" value="{{ $index }}">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<button type="submit" class="text-yellow-400 hover:text-yellow-300 text-xs font-semibold">Toggle</button>
</form>
<form method="POST" action="/filter/whitelist/urls?filter={{ $filter.Name }}" class="inline">
<input type="hidden" name="action" value="remove_url">
<input type="hidden" name="index" value="{{ $index }}">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<button type="submit" class="text-red-400 hover:text-red-300 text-xs font-semibold">Remove</button>
</form>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{ else }}
<p class="text-gray-400 text-center py-4">No URLs added yet</p>
{{ end }}
</div>
</div>
</div>
<!-- Confirmation Modal -->
<div id="confirmModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6 max-w-sm w-full mx-4">
<h2 class="text-xl font-bold text-white mb-4">Confirm Removal</h2>
<p class="text-gray-300 mb-6"><span id="modalDomainText"></span></p>
<div class="flex gap-3 justify-end">
<button onclick="closeConfirmModal()" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded transition">Cancel</button>
<button onclick="confirmRemovalAction()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded transition font-semibold">Remove</button>
</div>
</div>
</div>
<!-- Hidden data attributes for JavaScript -->
<div id="appData" data-filter-name="{{ $filter.Name }}" data-csrf-token="{{ .CSRFToken }}" style="display:none;"></div>
<!-- Store domains as JSON -->
<script id="domainsData" type="application/json">
{{ if $domains }}[{{ range $index, $domain := $domains }}{{ if gt $index 0 }},{{ end }}"{{ . }}"{{ end }}]{{ else }}[]{{ end }}
</script>
<script>
const appData = document.getElementById('appData');
const filterName = appData.getAttribute('data-filter-name');
const csrfToken = appData.getAttribute('data-csrf-token');
const domainsElement = document.getElementById('domainsData');
let whitelistDomainsData = domainsElement ? JSON.parse(domainsElement.innerText) : [];
// Modal state
let pendingDomain = null;
let pendingType = null;
function wildcardToRegex(pattern) {
// Escape special regex characters except *
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
// Replace * with .* for wildcard matching
const regex = escaped.replace(/\*/g, '.*');
return new RegExp('^' + regex + '$', 'i');
}
function handleAddDomain(event, type) {
event.preventDefault();
const form = event.target;
const domainInput = document.getElementById('addDomainInput');
const domain = domainInput.value.trim();
if (!domain) {
showNotification('Domain is required', 'error');
return;
}
const url = type === 'blocklist' ? '/filter/blocklist' : '/filter/whitelist';
const formData = new FormData();
formData.append('action', 'add_domain');
formData.append('domain', domain);
formData.append('csrf_token', csrfToken);
const fullUrl = url + '?filter=' + encodeURIComponent(filterName);
fetch(fullUrl, {
method: 'POST',
body: formData
})
.then(response => {
if (response.status === 200) {
// Add to local array
whitelistDomainsData.push(domain);
// Clear input
domainInput.value = '';
// Re-search to update results
searchWhitelistDomains();
// Show success notification
showNotification(`Domain "${domain}" added successfully`, 'success');
} else {
showNotification('Error adding domain', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error adding domain: ' + error.message, 'error');
});
}
function searchWhitelistDomains() {
const query = document.getElementById('whitelistDomainSearch').value.trim();
const resultsDiv = document.getElementById('whitelistSearchResults');
if (!query) {
resultsDiv.innerHTML = '<p class="text-gray-400 p-4 text-center text-sm">Enter a domain name to search (use * as wildcard)</p>';
return;
}
const regex = wildcardToRegex(query);
const matches = whitelistDomainsData.filter(domain => regex.test(domain));
if (matches.length === 0) {
resultsDiv.innerHTML = '<p class="text-gray-400 p-4 text-center text-sm">No domains found</p>';
return;
}
let html = '<ul class="divide-y divide-gray-600">';
matches.forEach(domain => {
html += `
<li class="px-4 py-3 hover:bg-gray-700 flex justify-between items-center">
<span class="text-gray-300 break-all">${domain}</span>
<button onclick="showConfirmModal('${domain.replace(/'/g, "\\'")}', 'whitelist')" class="text-red-400 hover:text-red-300 text-sm font-semibold">Remove</button>
</li>
`;
});
html += '</ul>';
resultsDiv.innerHTML = html;
}
function showConfirmModal(domain, type) {
pendingDomain = domain;
pendingType = type;
const typeName = type === 'blocklist' ? 'blocked' : 'whitelisted';
document.getElementById('modalDomainText').textContent = `Are you sure you want to remove "${domain}" from ${typeName} domains?`;
document.getElementById('confirmModal').classList.remove('hidden');
}
function closeConfirmModal() {
pendingDomain = null;
pendingType = null;
document.getElementById('confirmModal').classList.add('hidden');
}
function confirmRemovalAction() {
if (pendingDomain && pendingType) {
removeDomainAjax(pendingDomain, pendingType);
closeConfirmModal();
}
}
function removeDomainAjax(domain, type) {
const url = type === 'blocklist' ? '/filter/blocklist' : '/filter/whitelist';
const formData = new FormData();
formData.append('action', 'remove_domain');
formData.append('domain', domain);
formData.append('csrf_token', csrfToken);
const fullUrl = url + '?filter=' + encodeURIComponent(filterName);
console.log('Sending request to:', fullUrl);
fetch(fullUrl, {
method: 'POST',
body: formData
})
.then(response => {
console.log('Response status:', response.status);
if (response.status === 200) {
return response.json().then(() => {
// Remove from local array
whitelistDomainsData = whitelistDomainsData.filter(d => d !== domain);
// Re-search to update results
searchWhitelistDomains();
// Show success notification
showNotification(`Domain "${domain}" removed successfully`, 'success');
});
} else {
return response.text().then(text => {
console.error('Response text:', text);
showNotification('Error removing domain: ' + response.status, 'error');
});
}
})
.catch(error => {
console.error('Error:', error);
showNotification('Error removing domain: ' + error.message, 'error');
});
}
// Close modal when clicking outside
document.getElementById('confirmModal').addEventListener('click', function(e) {
if (e.target === this) {
closeConfirmModal();
}
});
</script>
{{ end }}

96
templates/filters.html Normal file
View File

@@ -0,0 +1,96 @@
{{ define "content" }}
<div class="max-w-7xl">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Filters</h1>
<button onclick="showNewFilterForm()" class="bg-green-600 hover:bg-green-700 text-white font-bold px-6 py-2 rounded transition">+ New Filter</button>
</div>
<!-- New Filter Form (Hidden by default) -->
<div id="newFilterForm" class="hidden bg-gray-800 p-6 rounded border border-gray-700 mb-6">
<h2 class="text-2xl font-bold mb-4">Create New Filter</h2>
<form method="POST" action="/filters/create" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div>
<label class="block text-white font-semibold mb-2">Filter Name</label>
<input type="text" name="name" placeholder="e.g., Ads Blocklist" class="w-full p-3 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
</div>
<div>
<label class="block text-white font-semibold mb-2">Description</label>
<input type="text" name="description" placeholder="e.g., Blocks advertising domains" class="w-full p-3 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-white font-semibold mb-2">Blocklist Filename</label>
<input type="text" name="blocklist_file" placeholder="e.g., domainlist_<X>.list" class="w-full p-3 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
</div>
<div>
<label class="block text-white font-semibold mb-2">Whitelist Filename</label>
<input type="text" name="whitelist_file" placeholder="e.g., domainlist_<X+1>.list" class="w-full p-3 bg-gray-700 rounded text-white placeholder-gray-400 border border-gray-600" required>
</div>
</div>
<div class="flex gap-2">
<button type="submit" class="bg-green-600 hover:bg-green-700 text-white font-bold px-6 py-3 rounded transition">Create Filter</button>
<button type="button" onclick="hideNewFilterForm()" class="bg-gray-700 hover:bg-gray-600 text-white font-bold px-6 py-3 rounded transition">Cancel</button>
</div>
</form>
</div>
<!-- Filters Table -->
{{ if .PageData }}
{{ $filters := index .PageData "Filters" }}
{{ if $filters }}
<div class="overflow-x-auto">
<table class="w-full bg-gray-800 rounded border border-gray-700">
<thead class="bg-gray-900">
<tr>
<th class="px-6 py-3 text-left font-semibold text-white">Name</th>
<th class="px-6 py-3 text-left font-semibold text-white">Description</th>
<th class="px-6 py-3 text-center font-semibold text-white">Blocklist File</th>
<th class="px-6 py-3 text-center font-semibold text-white">Whitelist File</th>
<th class="px-6 py-3 text-center font-semibold text-white">Actions</th>
</tr>
</thead>
<tbody>
{{ range $filters }}
<tr class="border-t border-gray-700 hover:bg-gray-750 transition">
<td class="px-6 py-4 text-white font-semibold">{{ .Name }}</td>
<td class="px-6 py-4 text-gray-300">{{ .Description }}</td>
<td class="px-6 py-4 text-gray-400 text-sm text-center">{{ .BlocklistFile }}</td>
<td class="px-6 py-4 text-gray-400 text-sm text-center">{{ .WhitelistFile }}</td>
<td class="px-6 py-4 text-center">
<div class="flex gap-2 justify-center">
<a href="/filter?filter={{ .Name }}" class="bg-blue-600 hover:bg-blue-700 text-white font-bold px-3 py-1 rounded text-sm transition" title="View/Search">View</a>
<a href="/filters/edit?filter={{ .Name }}" class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold px-3 py-1 rounded text-sm transition" title="Edit filter">Edit</a>
<a href="/filter/whitelist/urls?filter={{ .Name }}" class="bg-purple-600 hover:bg-purple-700 text-white font-bold px-3 py-1 rounded text-sm transition" title="Edit whitelist">Whitelist</a>
<a href="/filter/blocklist/urls?filter={{ .Name }}" class="bg-red-600 hover:bg-red-700 text-white font-bold px-3 py-1 rounded text-sm transition" title="Edit blocklist">Blacklist</a>
</div>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ else }}
<p class="text-gray-300">No filters configured.</p>
{{ end }}
{{ else }}
<p class="text-gray-300">No filters configured.</p>
{{ end }}
</div>
<script>
function showNewFilterForm() {
document.getElementById('newFilterForm').classList.remove('hidden');
}
function hideNewFilterForm() {
document.getElementById('newFilterForm').classList.add('hidden');
// Reset form
document.querySelector('#newFilterForm form').reset();
}
</script>
{{ end }}

View File

@@ -1,36 +1,4 @@
{{ define "notifications" }}
{{ if .Notification }}
<div id="notification-container" class="fixed top-4 right-4 max-w-md z-50">
<div id="notification-toast" class="rounded-lg shadow-lg p-4 flex items-start gap-3 {{ if eq .NotificationType "error" }}bg-red-900 border-l-4 border-red-500{{ else if eq .NotificationType "success" }}bg-green-900 border-l-4 border-green-500{{ else }}bg-blue-900 border-l-4 border-blue-500{{ end }}" onmouseenter="pauseNotification()" onmouseleave="resumeNotification()">
<div class="flex-shrink-0">
{{ if eq .NotificationType "error" }}
<svg class="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
{{ else if eq .NotificationType "success" }}
<svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
{{ else }}
<svg class="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
{{ end }}
</div>
<div class="flex-1">
<p class="font-medium {{ if eq .NotificationType "error" }}text-red-200{{ else if eq .NotificationType "success" }}text-green-200{{ else }}text-blue-200{{ end }}">
{{ .Notification }}
</p>
</div>
<button onclick="closeNotification()" class="flex-shrink-0 text-gray-400 hover:text-white transition">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div id="notification-progress" class="mt-2 h-1 bg-gray-700 rounded" style="animation: shrink 5s linear forwards;"></div>
</div>
<style>
@keyframes shrink {
from {
@@ -81,7 +49,94 @@ function resumeNotification() {
}
}
// Auto-close after 5 seconds
// Show notification dynamically
function showNotification(message, type = 'success') {
// Close existing notification if any
var existing = document.getElementById('notification-container');
if (existing) {
existing.remove();
}
var container = document.createElement('div');
container.id = 'notification-container';
container.className = 'fixed top-4 right-4 max-w-md z-50';
var bgClass = type === 'error' ? 'bg-red-900 border-l-4 border-red-500' :
type === 'success' ? 'bg-green-900 border-l-4 border-green-500' :
'bg-blue-900 border-l-4 border-blue-500';
var iconColor = type === 'error' ? 'text-red-400' :
type === 'success' ? 'text-green-400' :
'text-blue-400';
var textColor = type === 'error' ? 'text-red-200' :
type === 'success' ? 'text-green-200' :
'text-blue-200';
var icon = type === 'error' ? '<svg class="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" /></svg>' :
type === 'success' ? '<svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /></svg>' :
'<svg class="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" /></svg>';
container.innerHTML = `
<div id="notification-toast" class="rounded-lg shadow-lg p-4 flex items-start gap-3 ${bgClass}" onmouseenter="pauseNotification()" onmouseleave="resumeNotification()">
<div class="flex-shrink-0">
${icon}
</div>
<div class="flex-1">
<p class="font-medium ${textColor}">
${message}
</p>
</div>
<button onclick="closeNotification()" class="flex-shrink-0 text-gray-400 hover:text-white transition">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div id="notification-progress" class="mt-2 h-1 bg-gray-700 rounded" style="animation: shrink 5s linear forwards;"></div>
`;
document.body.appendChild(container);
// Auto-close after 5 seconds
notificationTimeout = setTimeout(closeNotification, 5000);
}
</script>
{{ if .Notification }}
<div id="notification-container" class="fixed top-4 right-4 max-w-md z-50">
<div id="notification-toast" class="rounded-lg shadow-lg p-4 flex items-start gap-3 {{ if eq .NotificationType "error" }}bg-red-900 border-l-4 border-red-500{{ else if eq .NotificationType "success" }}bg-green-900 border-l-4 border-green-500{{ else }}bg-blue-900 border-l-4 border-blue-500{{ end }}" onmouseenter="pauseNotification()" onmouseleave="resumeNotification()">
<div class="flex-shrink-0">
{{ if eq .NotificationType "error" }}
<svg class="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
{{ else if eq .NotificationType "success" }}
<svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
{{ else }}
<svg class="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
{{ end }}
</div>
<div class="flex-1">
<p class="font-medium {{ if eq .NotificationType "error" }}text-red-200{{ else if eq .NotificationType "success" }}text-green-200{{ else }}text-blue-200{{ end }}">
{{ .Notification }}
</p>
</div>
<button onclick="closeNotification()" class="flex-shrink-0 text-gray-400 hover:text-white transition">
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div id="notification-progress" class="mt-2 h-1 bg-gray-700 rounded" style="animation: shrink 5s linear forwards;"></div>
</div>
<script>
// Auto-close after 5 seconds (for page-loaded notifications)
notificationTimeout = setTimeout(closeNotification, 5000);
</script>
{{ end }}