adding multi filter support
This commit is contained in:
43
blocklist.go
43
blocklist.go
@@ -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
179
config.go
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
523
handlers.go
523
handlers.go
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
7
info.txt
7
info.txt
@@ -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
|
||||
9
main.go
9
main.go
@@ -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
@@ -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">
|
||||
© 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>
|
||||
41
templates/edit-filter.html
Normal file
41
templates/edit-filter.html
Normal 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 }}
|
||||
287
templates/filter-blocklist.html
Normal file
287
templates/filter-blocklist.html
Normal 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 }}
|
||||
123
templates/filter-detail.html
Normal file
123
templates/filter-detail.html
Normal 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 }}
|
||||
288
templates/filter-whitelist.html
Normal file
288
templates/filter-whitelist.html
Normal 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
96
templates/filters.html
Normal 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 }}
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user