update url list format

This commit is contained in:
2025-12-06 09:06:39 +00:00
parent 9961077353
commit 8dd7c87d63
9 changed files with 357 additions and 109 deletions

View File

@@ -83,7 +83,7 @@ func createDefaultURLList() {
_, err := os.Stat(urlFileList)
if os.IsNotExist(err) {
log.Printf("%s: Using Default url list: %s", time.Now(), urlFileList)
writeLines(urlFileList, defaultURLs)
writeURLListCSV(urlFileList, defaultURLs)
}
}
@@ -100,13 +100,14 @@ func fetchAndMergeSources() {
var builder strings.Builder
builder.Write(blockData)
urls, _ := readLines(urlFileList)
for _, url := range urls {
if strings.HasPrefix(url, "#") {
items, _ := readURLListCSV(urlFileList)
for _, item := range items {
if !item.Enabled {
continue
}
resp, err := http.Get(url)
resp, err := http.Get(item.URL)
if err != nil {
log.Printf("%s: Failed to fetch %s: %v", time.Now(), item.Name, err)
continue
}
body, _ := io.ReadAll(resp.Body)

View File

@@ -40,7 +40,7 @@ type Config struct {
MergedListTmp string `json:"merged_list_tmp"`
// Default blocklist URLs
DefaultURLs []string `json:"default_urls"`
DefaultURLs []URLListItem `json:"default_urls"`
}
var (
@@ -64,25 +64,25 @@ func getDefaultConfig() Config {
ProcessName: "coredns",
TmpFile: "/sdcard1/combined-blocklist.txt",
LastUpdateFile: "/sdcard1/last_update.txt",
URLFileList: "/sdcard1/urllist.txt",
URLFileList: "/sdcard1/urllist.csv",
BlocklistFile: "/run/utm/domain_list/domainlist_0.list",
RemoveFile: "/run/utm/domain_list/domainlist_1.list",
MergedListTmp: "/tmp/mergedlist.txt",
DefaultURLs: []string{
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_27.txt",
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_49.txt",
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt",
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt",
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_18.txt",
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_23.txt",
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_11.txt",
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt",
"https://adguardteam.github.io/HostlistsRegistry/assets/filter_50.txt",
"https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt",
"https://osint.digitalside.it/Threat-Intel/lists/latestdomains.txt",
"https://v.firebog.net/hosts/Prigent-Crypto.txt",
"https://phishing.army/download/phishing_army_blocklist_extended.txt",
"https://v.firebog.net/hosts/static/w3kbl.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"},
},
}
}

View File

@@ -26,52 +26,87 @@ func dashboardHandler(w http.ResponseWriter, r *http.Request) {
}
func urlListsHandler(w http.ResponseWriter, r *http.Request) {
configMu.RLock()
urlFileList := config.URLFileList
defaultURLs := config.DefaultURLs
configMu.RUnlock()
if r.Method == "POST" {
action := r.FormValue("action")
url := sanitizeInput(r.FormValue("url"))
indexStr := r.FormValue("index")
configMu.RLock()
urlFileList := config.URLFileList
defaultURLs := config.DefaultURLs
configMu.RUnlock()
urls, err := readLines(urlFileList)
items, err := readURLListCSV(urlFileList)
if err != nil {
renderTemplate(w, r, "urllists.html", struct {
URLs []string
Items []URLListItem
Notification string
NotificationType string
}{
URLs: urls,
Items: items,
Notification: "Error reading URL list",
NotificationType: "error",
})
return
}
errorMsg := ""
var errorMsg string
switch action {
case "add":
if !isValidURL(url) {
name := sanitizeInput(r.FormValue("name"))
urlStr := sanitizeInput(r.FormValue("url"))
group := sanitizeInput(r.FormValue("group"))
note := sanitizeInput(r.FormValue("note"))
if name == "" {
errorMsg = "Name is required"
} else if !isValidURL(urlStr) {
errorMsg = "Invalid URL format. Must start with http:// or https://"
} else {
urls = append(urls, url)
items = append(items, URLListItem{
Name: name,
Enabled: true,
Group: group,
URL: urlStr,
Note: note,
})
}
case "remove":
indexStr := r.FormValue("index")
index, _ := strconv.Atoi(indexStr)
if index >= 0 && index < len(urls) {
urls = append(urls[:index], urls[index+1:]...)
if index >= 0 && index < len(items) {
items = append(items[:index], items[index+1:]...)
} else {
errorMsg = "Invalid index"
}
case "toggle":
indexStr := r.FormValue("index")
index, _ := strconv.Atoi(indexStr)
if index >= 0 && index < len(urls) {
if strings.HasPrefix(urls[index], "#") {
urls[index] = strings.TrimPrefix(urls[index], "#")
if index >= 0 && index < len(items) {
items[index].Enabled = !items[index].Enabled
} else {
errorMsg = "Invalid index"
}
case "edit":
indexStr := r.FormValue("index")
name := sanitizeInput(r.FormValue("name"))
urlStr := sanitizeInput(r.FormValue("url"))
group := sanitizeInput(r.FormValue("group"))
note := sanitizeInput(r.FormValue("note"))
index, _ := strconv.Atoi(indexStr)
if index >= 0 && index < len(items) {
if name == "" {
errorMsg = "Name is required"
} else if !isValidURL(urlStr) {
errorMsg = "Invalid URL format. Must start with http:// or https://"
} else {
urls[index] = "#" + urls[index]
items[index].Name = name
items[index].URL = urlStr
items[index].Group = group
items[index].Note = note
}
} else {
errorMsg = "Invalid index"
@@ -80,54 +115,56 @@ func urlListsHandler(w http.ResponseWriter, r *http.Request) {
if errorMsg != "" {
renderTemplate(w, r, "urllists.html", struct {
URLs []string
Items []URLListItem
Notification string
NotificationType string
}{
URLs: urls,
Items: items,
Notification: errorMsg,
NotificationType: "error",
})
return
}
writeLines(urlFileList, urls)
// Show success notification
if len(urls) == 0 {
writeLines(urlFileList, defaultURLs)
urls = defaultURLs
err = writeURLListCSV(urlFileList, items)
if err != nil {
renderTemplate(w, r, "urllists.html", struct {
Items []URLListItem
Notification string
NotificationType string
}{
Items: items,
Notification: "Error saving URL list",
NotificationType: "error",
})
return
}
renderTemplate(w, r, "urllists.html", struct {
URLs []string
Items []URLListItem
Notification string
NotificationType string
}{
URLs: urls,
Items: items,
Notification: "URL list updated successfully",
NotificationType: "success",
})
return
}
configMu.RLock()
urlFileList := config.URLFileList
defaultURLs := config.DefaultURLs
configMu.RUnlock()
urls, _ := readLines(urlFileList)
if len(urls) == 0 {
writeLines(urlFileList, defaultURLs)
urls = defaultURLs
items, err := readURLListCSV(urlFileList)
if err != nil || len(items) == 0 {
// Use default blocklists
defaultItems := defaultURLs
writeURLListCSV(urlFileList, defaultItems)
items = defaultItems
}
data := struct {
URLs []string
renderTemplate(w, r, "urllists.html", struct {
Items []URLListItem
}{
URLs: urls,
}
renderTemplate(w, r, "urllists.html", data)
Items: items,
})
}
func domainsHandler(w http.ResponseWriter, r *http.Request) {

View File

@@ -6,4 +6,6 @@ go build -o unifi-blocklist-app main.go
tailwindcss -o static/tailwind.css --minify
tailwindcss -o static/tailwind.css --minify --watch
git add . && git commit -m "update" && git push

File diff suppressed because one or more lines are too long

View File

@@ -1,34 +1,155 @@
{{ define "content" }}
<div class="max-w-4xl mx-auto">
<h1 class="text-3xl font-bold mb-6">URL Lists</h1>
<form method="POST" class="mb-6 bg-gray-800 p-4 rounded">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="hidden" name="action" value="add">
<div class="flex gap-2">
<input type="text" name="url" placeholder="Add URL (e.g., https://example.com/blocklist.txt)" class="flex-1 p-2 bg-gray-700 rounded text-white placeholder-gray-400">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white font-bold px-4 py-2 rounded transition">Add</button>
</div>
</form>
<ul class="space-y-2">
{{ range $i, $url := .PageData.URLs }}
<li class="flex justify-between items-center bg-gray-800 p-3 rounded">
<span class="text-sm text-gray-300 break-all">{{ $url }}</span>
<div class="flex gap-2 ml-4">
<form method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="index" value="{{ $i }}">
<button type="submit" class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold px-3 py-1 rounded text-sm transition">Toggle</button>
</form>
<form method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="action" value="remove">
<input type="hidden" name="index" value="{{ $i }}">
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white font-bold px-3 py-1 rounded text-sm transition">Remove</button>
</form>
<div class="max-w-6xl mx-auto">
<h1 class="text-3xl font-bold mb-6">URL Blocklists</h1>
<!-- Add New URL Form -->
<div class="bg-gray-800 p-6 rounded mb-6">
<h2 class="text-xl font-bold mb-4">Add New Blocklist</h2>
<form method="POST" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="hidden" name="action" value="add">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Name *</label>
<input type="text" name="name" placeholder="e.g., AdGuard Default" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Group</label>
<input type="text" name="group" placeholder="e.g., Adblock, Malware" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400">
</div>
</div>
</li>
{{ end }}
</ul>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">URL *</label>
<input type="text" name="url" placeholder="https://example.com/blocklist.txt" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Note</label>
<textarea name="note" placeholder="Optional notes about this blocklist" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400" rows="2"></textarea>
</div>
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white font-bold px-4 py-2 rounded transition">Add Blocklist</button>
</form>
</div>
<!-- Blocklists Table -->
<div class="bg-gray-800 rounded overflow-hidden">
<table class="w-full">
<thead class="bg-gray-700">
<tr>
<th class="px-4 py-3 text-left text-sm font-bold text-gray-300">Status</th>
<th class="px-4 py-3 text-left text-sm font-bold text-gray-300">Name</th>
<th class="px-4 py-3 text-left text-sm font-bold text-gray-300">Group</th>
<th class="px-4 py-3 text-left text-sm font-bold text-gray-300">URL</th>
<th class="px-4 py-3 text-left text-sm font-bold text-gray-300">Note</th>
<th class="px-4 py-3 text-left text-sm font-bold text-gray-300">Actions</th>
</tr>
</thead>
<tbody>
{{ range $i, $item := .PageData.Items }}
<tr class="border-t border-gray-700 hover:bg-gray-750 transition">
<td class="px-4 py-3 text-center">
<form method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="index" value="{{ $i }}">
<button type="submit" class="px-3 py-1 rounded text-sm font-bold transition {{ if $item.Enabled }}bg-green-600 hover:bg-green-700 text-white{{ else }}bg-gray-600 hover:bg-gray-700 text-gray-200{{ end }}">
{{ if $item.Enabled }}Enabled{{ else }}Disabled{{ end }}
</button>
</form>
</td>
<td class="px-4 py-3 text-sm text-gray-300">{{ $item.Name }}</td>
<td class="px-4 py-3 text-sm text-gray-400">{{ if $item.Group }}{{ $item.Group }}{{ else }}<span class="text-gray-600">-</span>{{ end }}</td>
<td class="px-4 py-3 text-sm text-gray-400 max-w-xs truncate" title="{{ $item.URL }}">{{ $item.URL }}</td>
<td class="px-4 py-3 text-sm text-gray-400 max-w-xs truncate" title="{{ $item.Note }}">{{ if $item.Note }}{{ $item.Note }}{{ else }}<span class="text-gray-600">-</span>{{ end }}</td>
<td class="px-4 py-3 text-sm space-x-2">
<button onclick="openEditModal('{{ $i }}', '{{ $item.Name }}', '{{ $item.URL }}', '{{ $item.Group }}', '{{ $item.Note }}')" class="bg-blue-600 hover:bg-blue-700 text-white font-bold px-2 py-1 rounded transition">
Edit
</button>
<form method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="action" value="remove">
<input type="hidden" name="index" value="{{ $i }}">
<button type="submit" onclick="return confirm('Are you sure you want to delete this blocklist?')" class="bg-red-600 hover:bg-red-700 text-white font-bold px-2 py-1 rounded transition">
Delete
</button>
</form>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ if not .PageData.Items }}
<div class="text-center text-gray-400 py-8">
No blocklists configured. Add one to get started.
</div>
{{ end }}
</div>
<!-- Edit Modal -->
<div id="editModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-gray-800 rounded-lg shadow-lg max-w-md w-full mx-4 p-6">
<h2 class="text-xl font-bold mb-4">Edit Blocklist</h2>
<form method="POST" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="index" id="modalIndex" value="">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Name *</label>
<input type="text" name="name" id="modalName" placeholder="Name" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Group</label>
<input type="text" name="group" id="modalGroup" placeholder="Group" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">URL *</label>
<input type="text" name="url" id="modalURL" placeholder="URL" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Note</label>
<textarea name="note" id="modalNote" placeholder="Note" class="w-full p-2 bg-gray-700 rounded text-white placeholder-gray-400" rows="2"></textarea>
</div>
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeEditModal()" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded font-medium transition">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded font-medium transition">
Save Changes
</button>
</div>
</form>
</div>
</div>
<script>
function openEditModal(index, name, url, group, note) {
document.getElementById('modalIndex').value = index;
document.getElementById('modalName').value = name;
document.getElementById('modalURL').value = url;
document.getElementById('modalGroup').value = group;
document.getElementById('modalNote').value = note;
document.getElementById('editModal').classList.remove('hidden');
}
function closeEditModal() {
document.getElementById('editModal').classList.add('hidden');
}
// Close modal when clicking outside
document.getElementById('editModal').addEventListener('click', function(event) {
if (event.target === this) {
closeEditModal();
}
});
</script>
{{ end }}

15
test/urllist.csv Normal file
View File

@@ -0,0 +1,15 @@
Name,Enabled,Group,URL,Note
AdGuard DNS filter,false,Default,https://adguardteam.github.io/HostlistsRegistry/assets/filter_27.txt,
AdGuard Russian filter,true,Default,https://adguardteam.github.io/HostlistsRegistry/assets/filter_49.txt,
AdGuard Base filter,false,Default,https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt,
AdGuard Tracking Protection,true,Default,https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt,
AdGuard Social Media filter,true,Default,https://adguardteam.github.io/HostlistsRegistry/assets/filter_18.txt,
AdGuard Annoyance filter,true,Default,https://adguardteam.github.io/HostlistsRegistry/assets/filter_23.txt,
AdGuard Mobile Ads filter,true,Default,https://adguardteam.github.io/HostlistsRegistry/assets/filter_11.txt,
AdGuard Search Ads filter,false,Default,https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt,
AdGuard Adult filter,true,Default,https://adguardteam.github.io/HostlistsRegistry/assets/filter_50.txt,
AntiMalware Hosts,true,Default,https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt,
DigitalSide Threat Intel,true,Default,https://osint.digitalside.it/Threat-Intel/lists/latestdomains.txt,
Firebog Crypto,true,Default,https://v.firebog.net/hosts/Prigent-Crypto.txt,
Phishing Army,true,Default,https://phishing.army/download/phishing_army_blocklist_extended.txt,
W3kbl,true,Default,https://v.firebog.net/hosts/static/w3kbl.txt,
1 Name Enabled Group URL Note
2 AdGuard DNS filter false Default https://adguardteam.github.io/HostlistsRegistry/assets/filter_27.txt
3 AdGuard Russian filter true Default https://adguardteam.github.io/HostlistsRegistry/assets/filter_49.txt
4 AdGuard Base filter false Default https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
5 AdGuard Tracking Protection true Default https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt
6 AdGuard Social Media filter true Default https://adguardteam.github.io/HostlistsRegistry/assets/filter_18.txt
7 AdGuard Annoyance filter true Default https://adguardteam.github.io/HostlistsRegistry/assets/filter_23.txt
8 AdGuard Mobile Ads filter true Default https://adguardteam.github.io/HostlistsRegistry/assets/filter_11.txt
9 AdGuard Search Ads filter false Default https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt
10 AdGuard Adult filter true Default https://adguardteam.github.io/HostlistsRegistry/assets/filter_50.txt
11 AntiMalware Hosts true Default https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt
12 DigitalSide Threat Intel true Default https://osint.digitalside.it/Threat-Intel/lists/latestdomains.txt
13 Firebog Crypto true Default https://v.firebog.net/hosts/Prigent-Crypto.txt
14 Phishing Army true Default https://phishing.army/download/phishing_army_blocklist_extended.txt
15 W3kbl true Default https://v.firebog.net/hosts/static/w3kbl.txt

View File

@@ -1,13 +0,0 @@
https://adguardteam.github.io/HostlistsRegistry/assets/filter_27.txt
https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt
https://adguardteam.github.io/HostlistsRegistry/assets/filter_18.txt
https://adguardteam.github.io/HostlistsRegistry/assets/filter_23.txt
https://adguardteam.github.io/HostlistsRegistry/assets/filter_11.txt
https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt
https://adguardteam.github.io/HostlistsRegistry/assets/filter_50.txt
https://raw.githubusercontent.com/DandelionSprout/adfilt/master/Alternate%20versions%20Anti-Malware%20List/AntiMalwareHosts.txt
https://osint.digitalside.it/Threat-Intel/lists/latestdomains.txt
https://v.firebog.net/hosts/Prigent-Crypto.txt
https://phishing.army/download/phishing_army_blocklist_extended.txt
https://v.firebog.net/hosts/static/w3kbl.txt

View File

@@ -159,6 +159,91 @@ func isValidDomain(d string) bool {
return re.MatchString(d)
}
// URLListItem represents a single URL entry in the CSV
type URLListItem struct {
Name string
Enabled bool
Group string
URL string
Note string
}
// readURLListCSV reads the URL list from a CSV file
func readURLListCSV(file string) ([]URLListItem, error) {
data, err := os.ReadFile(file)
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
var items []URLListItem
for i, line := range lines {
line = strings.TrimSpace(line)
if line == "" || (i == 0 && strings.HasPrefix(line, "Name,")) {
// Skip empty lines and header
continue
}
parts := strings.SplitN(line, ",", 5)
if len(parts) < 2 {
continue
}
item := URLListItem{
Name: strings.TrimSpace(parts[0]),
Enabled: strings.TrimSpace(parts[1]) == "true",
Group: "",
URL: "",
Note: "",
}
if len(parts) > 2 {
item.Group = strings.TrimSpace(parts[2])
}
if len(parts) > 3 {
item.URL = strings.TrimSpace(parts[3])
}
if len(parts) > 4 {
item.Note = strings.TrimSpace(parts[4])
}
items = append(items, item)
}
return items, nil
}
// writeURLListCSV writes the URL list to a CSV file
func writeURLListCSV(file string, items []URLListItem) error {
var sb strings.Builder
sb.WriteString("Name,Enabled,Group,URL,Note\n")
for _, item := range items {
enabled := "false"
if item.Enabled {
enabled = "true"
}
// Escape quotes and wrap in quotes if needed
name := escapeCSVField(item.Name)
group := escapeCSVField(item.Group)
url := escapeCSVField(item.URL)
note := escapeCSVField(item.Note)
sb.WriteString(name + "," + enabled + "," + group + "," + url + "," + note + "\n")
}
return os.WriteFile(file, []byte(sb.String()), 0644)
}
// escapeCSVField properly escapes a CSV field
func escapeCSVField(field string) string {
if strings.ContainsAny(field, ",\"\n") {
return "\"" + strings.ReplaceAll(field, "\"", "\"\"") + "\""
}
return field
}
func generateSecret() string {
// This function is kept for backward compatibility but is not currently used
// since we're using pquerna/otp for TOTP generation