Update On Sat Dec 6 19:36:47 CET 2025

This commit is contained in:
github-action[bot]
2025-12-06 19:36:48 +01:00
parent 9c03728c5d
commit 643032245f
100 changed files with 977 additions and 248 deletions

1
.github/update.log vendored
View File

@@ -1203,3 +1203,4 @@ Update On Tue Dec 2 19:43:40 CET 2025
Update On Wed Dec 3 19:42:45 CET 2025 Update On Wed Dec 3 19:42:45 CET 2025
Update On Thu Dec 4 19:44:01 CET 2025 Update On Thu Dec 4 19:44:01 CET 2025
Update On Fri Dec 5 19:41:34 CET 2025 Update On Fri Dec 5 19:41:34 CET 2025
Update On Sat Dec 6 19:36:39 CET 2025

View File

@@ -197,6 +197,10 @@ func newSearcher(major int) *searcher {
case 12: case 12:
fallthrough fallthrough
case 13: case 13:
fallthrough
case 14:
fallthrough
case 15:
s = &searcher{ s = &searcher{
headSize: 64, headSize: 64,
tcpItemSize: 744, tcpItemSize: 744,

View File

@@ -2,10 +2,10 @@
"manifest_version": 1, "manifest_version": 1,
"latest": { "latest": {
"mihomo": "v1.19.17", "mihomo": "v1.19.17",
"mihomo_alpha": "alpha-6539b50", "mihomo_alpha": "alpha-f44aa22",
"clash_rs": "v0.9.2", "clash_rs": "v0.9.2",
"clash_premium": "2023-09-05-gdcc8d87", "clash_premium": "2023-09-05-gdcc8d87",
"clash_rs_alpha": "0.9.2-alpha+sha.e1f8fbb" "clash_rs_alpha": "0.9.2-alpha+sha.81f5ac5"
}, },
"arch_template": { "arch_template": {
"mihomo": { "mihomo": {
@@ -69,5 +69,5 @@
"linux-armv7hf": "clash-armv7-unknown-linux-gnueabihf" "linux-armv7hf": "clash-armv7-unknown-linux-gnueabihf"
} }
}, },
"updated_at": "2025-12-04T22:21:10.965Z" "updated_at": "2025-12-05T22:21:23.531Z"
} }

View File

@@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
## [2.51.0](https://github.com/filebrowser/filebrowser/compare/v2.50.0...v2.51.0) (2025-12-06)
### Features
* update translations ([2d88c06](https://github.com/filebrowser/filebrowser/commit/2d88c067611e936056dbbf04247f1c1c709b2a09))
### Bug Fixes
* added column separator select (comma, semicolon and both) in CSV viewer ([#5604](https://github.com/filebrowser/filebrowser/issues/5604)) ([204a3f0](https://github.com/filebrowser/filebrowser/commit/204a3f0eeaa0c68781b60651bf27c4b27eac44e6))
### Refactorings
* cleanup package names ([#5605](https://github.com/filebrowser/filebrowser/issues/5605)) ([f029c30](https://github.com/filebrowser/filebrowser/commit/f029c3005e450cfbebb074c42dbdf65db9c8d56a))
## [2.50.0](https://github.com/filebrowser/filebrowser/compare/v2.49.0...v2.50.0) (2025-11-30) ## [2.50.0](https://github.com/filebrowser/filebrowser/compare/v2.49.0...v2.50.0) (2025-11-30)

View File

@@ -1,5 +1,5 @@
## Multistage build: First stage fetches dependencies ## Multistage build: First stage fetches dependencies
FROM alpine:3.22 AS fetcher FROM alpine:3.23 AS fetcher
# install and copy ca-certificates, mailcap, and tini-static; download JSON.sh # install and copy ca-certificates, mailcap, and tini-static; download JSON.sh
RUN apk update && \ RUN apk update && \

View File

@@ -11,7 +11,7 @@ import (
"slices" "slices"
"strings" "strings"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
@@ -146,7 +146,7 @@ func (a *HookAuth) GetValues(s string) {
// SaveUser updates the existing user or creates a new one when not found // SaveUser updates the existing user or creates a new one when not found
func (a *HookAuth) SaveUser() (*users.User, error) { func (a *HookAuth) SaveUser() (*users.User, error) {
u, err := a.Users.Get(a.Server.Root, a.Cred.Username) u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
if err != nil && !errors.Is(err, fbErrors.ErrNotExist) { if err != nil && !errors.Is(err, fberrors.ErrNotExist) {
return nil, err return nil, err
} }

View File

@@ -4,7 +4,7 @@ import (
"errors" "errors"
"net/http" "net/http"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
) )
@@ -21,7 +21,7 @@ type ProxyAuth struct {
func (a ProxyAuth) Auth(r *http.Request, usr users.Store, setting *settings.Settings, srv *settings.Server) (*users.User, error) { func (a ProxyAuth) Auth(r *http.Request, usr users.Store, setting *settings.Settings, srv *settings.Server) (*users.User, error) {
username := r.Header.Get(a.Header) username := r.Header.Get(a.Header)
user, err := usr.Get(srv.Root, username) user, err := usr.Get(srv.Root, username)
if errors.Is(err, fbErrors.ErrNotExist) { if errors.Is(err, fberrors.ErrNotExist) {
return a.createUser(usr, setting, srv, username) return a.createUser(usr, setting, srv, username)
} }
return user, err return user, err

View File

@@ -2,7 +2,7 @@ package cmd
import ( import (
"encoding/json" "encoding/json"
nerrors "errors" "errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -12,7 +12,7 @@ import (
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/filebrowser/filebrowser/v2/auth" "github.com/filebrowser/filebrowser/v2/auth"
"github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
) )
@@ -104,7 +104,7 @@ func getProxyAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (a
} }
if header == "" { if header == "" {
return nil, nerrors.New("you must set the flag 'auth.header' for method 'proxy'") return nil, errors.New("you must set the flag 'auth.header' for method 'proxy'")
} }
return &auth.ProxyAuth{Header: header}, nil return &auth.ProxyAuth{Header: header}, nil
@@ -163,7 +163,7 @@ func getHookAuth(flags *pflag.FlagSet, defaultAuther map[string]interface{}) (au
} }
if command == "" { if command == "" {
return nil, nerrors.New("you must set the flag 'auth.command' for method 'hook'") return nil, errors.New("you must set the flag 'auth.command' for method 'hook'")
} }
return &auth.HookAuth{Command: command}, nil return &auth.HookAuth{Command: command}, nil
@@ -186,7 +186,7 @@ func getAuthentication(flags *pflag.FlagSet, defaults ...interface{}) (settings.
case auth.MethodHookAuth: case auth.MethodHookAuth:
auther, err = getHookAuth(flags, defaultAuther) auther, err = getHookAuth(flags, defaultAuther)
default: default:
return "", nil, errors.ErrInvalidAuthMethod return "", nil, fberrors.ErrInvalidAuthMethod
} }
if err != nil { if err != nil {
@@ -361,7 +361,7 @@ func getSettings(flags *pflag.FlagSet, set *settings.Settings, ser *settings.Ser
flags.Visit(visit) flags.Visit(visit)
} }
err := nerrors.Join(errs...) err := errors.Join(errs...)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -1,4 +1,4 @@
package errors package fberrors
import ( import (
"errors" "errors"

View File

@@ -23,7 +23,7 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/rules" "github.com/filebrowser/filebrowser/v2/rules"
) )
@@ -168,7 +168,7 @@ func stat(opts *FileOptions) (*FileInfo, error) {
// algorithm. The checksums data is saved on File object. // algorithm. The checksums data is saved on File object.
func (i *FileInfo) Checksum(algo string) error { func (i *FileInfo) Checksum(algo string) error {
if i.IsDir { if i.IsDir {
return fbErrors.ErrIsDirectory return fberrors.ErrIsDirectory
} }
if i.Checksums == nil { if i.Checksums == nil {
@@ -193,7 +193,7 @@ func (i *FileInfo) Checksum(algo string) error {
case "sha512": case "sha512":
h = sha512.New() h = sha512.New()
default: default:
return fbErrors.ErrInvalidOption return fberrors.ErrInvalidOption
} }
_, err = io.Copy(h, reader) _, err = io.Copy(h, reader)

View File

@@ -25,9 +25,29 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div v-if="data.rows.length > 100" class="csv-info"> <div class="csv-footer">
<i class="material-icons">info</i> <div class="csv-info" v-if="data.rows.length > 100">
<span>Showing {{ data.rows.length }} rows</span> <i class="material-icons">info</i>
<span>Showing {{ data.rows.length }} rows</span>
</div>
<div class="column-separator">
<label for="columnSeparator">Column Separator</label>
<select
id="columnSeparator"
class="input input--block"
v-model="columnSeparator"
>
<option :value="[',']">
{{ $t("available_csv_separators.comma") }}
</option>
<option :value="[';']">
{{ $t("available_csv_separators.semicolon") }}
</option>
<option :value="[',', ';']">
{{ $t("available_csv_separators.both") }}
</option>
</select>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -35,7 +55,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { parseCSV, type CsvData } from "@/utils/csv"; import { parseCSV, type CsvData } from "@/utils/csv";
import { computed } from "vue"; import { computed, ref } from "vue";
interface Props { interface Props {
content: string; content: string;
@@ -46,9 +66,11 @@ const props = withDefaults(defineProps<Props>(), {
error: "", error: "",
}); });
const columnSeparator = ref([","]);
const data = computed<CsvData>(() => { const data = computed<CsvData>(() => {
try { try {
return parseCSV(props.content); return parseCSV(props.content, columnSeparator.value);
} catch (e) { } catch (e) {
console.error("Failed to parse CSV:", e); console.error("Failed to parse CSV:", e);
return { headers: [], rows: [] }; return { headers: [], rows: [] };
@@ -181,6 +203,18 @@ const displayError = computed(() => {
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
} }
.csv-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.5rem;
}
.csv-footer > :only-child {
margin-left: auto;
}
.csv-info { .csv-info {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -194,6 +228,21 @@ const displayError = computed(() => {
font-size: 0.875rem; font-size: 0.875rem;
} }
.column-separator {
display: flex;
align-items: center;
gap: 0.5rem;
}
.column-separator > label {
font-size: small;
text-align: end;
}
.column-separator > select {
margin-bottom: 0;
}
.csv-info i { .csv-info i {
font-size: 1.2rem; font-size: 1.2rem;
color: var(--blue); color: var(--blue);

View File

@@ -272,5 +272,10 @@
"minutes": "دقائق", "minutes": "دقائق",
"seconds": "ثواني", "seconds": "ثواني",
"unit": "وحدة الوقت" "unit": "وحدة الوقت"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Минути", "minutes": "Минути",
"seconds": "Секунди", "seconds": "Секунди",
"unit": "Единица за време" "unit": "Единица за време"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minuts", "minutes": "Minuts",
"seconds": "Segons", "seconds": "Segons",
"unit": "Unitat" "unit": "Unitat"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minuty", "minutes": "Minuty",
"seconds": "Sekundy", "seconds": "Sekundy",
"unit": "Časová jednotka" "unit": "Časová jednotka"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -43,8 +43,8 @@
"upload": "Upload", "upload": "Upload",
"openFile": "Datei öffnen", "openFile": "Datei öffnen",
"discardChanges": "Verwerfen", "discardChanges": "Verwerfen",
"saveChanges": "Save changes", "saveChanges": "Änderungen speichern",
"editAsText": "Edit as Text" "editAsText": "Als Text bearbeiten"
}, },
"download": { "download": {
"downloadFile": "Download Datei", "downloadFile": "Download Datei",
@@ -77,8 +77,8 @@
"sortByName": "Nach Namen sortieren", "sortByName": "Nach Namen sortieren",
"sortBySize": "Nach Größe sortieren", "sortBySize": "Nach Größe sortieren",
"noPreview": "Für diese Datei ist keine Vorschau verfügbar.", "noPreview": "Für diese Datei ist keine Vorschau verfügbar.",
"csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.", "csvTooLarge": "Die CSV-Datei ist zu groß für die Vorschau (>5 MB). Bitte herunterladen, um sie anzuzeigen.",
"csvLoadFailed": "Failed to load CSV file." "csvLoadFailed": "Fehler beim Laden der CSV-Datei."
}, },
"help": { "help": {
"click": "Wähle Datei oder Ordner", "click": "Wähle Datei oder Ordner",
@@ -105,9 +105,9 @@
"username": "Benutzername", "username": "Benutzername",
"usernameTaken": "Benutzername ist bereits vergeben", "usernameTaken": "Benutzername ist bereits vergeben",
"wrongCredentials": "Falsche Zugangsdaten", "wrongCredentials": "Falsche Zugangsdaten",
"passwordTooShort": "Password must be at least {min} characters", "passwordTooShort": "Passwort muss mindestens {min} Zeichen lang sein",
"logout_reasons": { "logout_reasons": {
"inactivity": "You have been logged out due to inactivity." "inactivity": "Du wurdest aufgrund von Inaktivität abgemeldet."
} }
}, },
"permanent": "Permanent", "permanent": "Permanent",
@@ -162,7 +162,7 @@
"video": "Video" "video": "Video"
}, },
"settings": { "settings": {
"aceEditorTheme": "Ace editor theme", "aceEditorTheme": "Ace Editor Theme",
"admin": "Admin", "admin": "Admin",
"administrator": "Administrator", "administrator": "Administrator",
"allowCommands": "Befehle ausführen", "allowCommands": "Befehle ausführen",
@@ -170,7 +170,7 @@
"allowNew": "Erstellen neuer Dateien und Ordner", "allowNew": "Erstellen neuer Dateien und Ordner",
"allowPublish": "Veröffentlichen von neuen Beiträgen und Seiten", "allowPublish": "Veröffentlichen von neuen Beiträgen und Seiten",
"allowSignup": "Erlaube Benutzern sich zu registrieren", "allowSignup": "Erlaube Benutzern sich zu registrieren",
"hideLoginButton": "Hide the login button from public pages", "hideLoginButton": "Den Login-Button auf öffentlichen Seiten ausblenden",
"avoidChanges": "(leer lassen, um Änderungen zu vermeiden)", "avoidChanges": "(leer lassen, um Änderungen zu vermeiden)",
"branding": "Design", "branding": "Design",
"brandingDirectoryPath": "Designverzeichnispfad", "brandingDirectoryPath": "Designverzeichnispfad",
@@ -180,7 +180,7 @@
"commandRunnerHelp": "Hier könne Sie Befehle eintragen, welche bei den benannten Aktionen ausgeführt werden. Sie müssen pro Zeile jeweils einen Befehl eingeben. Die Umgebungsvariable {0} und {1} sind verfügbar, wobei {0} relative zu {1} ist. Für mehr Informationen über diese Funktion und die verfügbaren Umgebungsvariablen lesen Sie bitte die {2}.", "commandRunnerHelp": "Hier könne Sie Befehle eintragen, welche bei den benannten Aktionen ausgeführt werden. Sie müssen pro Zeile jeweils einen Befehl eingeben. Die Umgebungsvariable {0} und {1} sind verfügbar, wobei {0} relative zu {1} ist. Für mehr Informationen über diese Funktion und die verfügbaren Umgebungsvariablen lesen Sie bitte die {2}.",
"commandsUpdated": "Befehle aktualisiert!", "commandsUpdated": "Befehle aktualisiert!",
"createUserDir": "Automatisches Erstellen des Home-Verzeichnisses beim Anlegen neuer Benutzer", "createUserDir": "Automatisches Erstellen des Home-Verzeichnisses beim Anlegen neuer Benutzer",
"minimumPasswordLength": "Minimum password length", "minimumPasswordLength": "Mindestlänge für Passwörter",
"tusUploads": "Gestückelter Upload", "tusUploads": "Gestückelter Upload",
"tusUploadsHelp": "File Browser unterstützt das Hochladen von gestückelten Dateien und ermöglicht so einen effizienten, zuverlässigen, fortsetzbaren und gestückelten Datei-Upload auch in unzuverlässigen Netzwerken.", "tusUploadsHelp": "File Browser unterstützt das Hochladen von gestückelten Dateien und ermöglicht so einen effizienten, zuverlässigen, fortsetzbaren und gestückelten Datei-Upload auch in unzuverlässigen Netzwerken.",
"tusUploadsChunkSize": "Gibt die maximale Größe pro Anfrage an (direkte Uploads werden für kleinere Uploads verwendet). Bitte geben Sie eine Byte-Angabe oder eine Zeichenfolge wie 10 MB, 1 GB usw. an", "tusUploadsChunkSize": "Gibt die maximale Größe pro Anfrage an (direkte Uploads werden für kleinere Uploads verwendet). Bitte geben Sie eine Byte-Angabe oder eine Zeichenfolge wie 10 MB, 1 GB usw. an",
@@ -272,5 +272,10 @@
"minutes": "Minuten", "minutes": "Minuten",
"seconds": "Sekunden", "seconds": "Sekunden",
"unit": "Zeiteinheit" "unit": "Zeiteinheit"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Λεπτά", "minutes": "Λεπτά",
"seconds": "Δευτερόλεπτα", "seconds": "Δευτερόλεπτα",
"unit": "Μονάδα χρόνου" "unit": "Μονάδα χρόνου"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minutes", "minutes": "Minutes",
"seconds": "Seconds", "seconds": "Seconds",
"unit": "Time Unit" "unit": "Time Unit"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minutos", "minutes": "Minutos",
"seconds": "Segundos", "seconds": "Segundos",
"unit": "Unidad" "unit": "Unidad"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "دقیقه", "minutes": "دقیقه",
"seconds": "ثانیه", "seconds": "ثانیه",
"unit": "واحد زمان" "unit": "واحد زمان"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minutes", "minutes": "Minutes",
"seconds": "Secondes", "seconds": "Secondes",
"unit": "Unité de temps" "unit": "Unité de temps"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "דקות", "minutes": "דקות",
"seconds": "שניות", "seconds": "שניות",
"unit": "יחידת זמן" "unit": "יחידת זמן"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minute", "minutes": "Minute",
"seconds": "Sekunde", "seconds": "Sekunde",
"unit": "Jedinica vremena" "unit": "Jedinica vremena"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Perc", "minutes": "Perc",
"seconds": "Másodperc", "seconds": "Másodperc",
"unit": "Időegység" "unit": "Időegység"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Mínútur", "minutes": "Mínútur",
"seconds": "Sekúndur", "seconds": "Sekúndur",
"unit": "Tímastilling" "unit": "Tímastilling"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minuti", "minutes": "Minuti",
"seconds": "Secondi", "seconds": "Secondi",
"unit": "Unità di tempo" "unit": "Unità di tempo"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "分", "minutes": "分",
"seconds": "秒", "seconds": "秒",
"unit": "時間の単位" "unit": "時間の単位"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "분", "minutes": "분",
"seconds": "초", "seconds": "초",
"unit": "Time Unit" "unit": "Time Unit"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minuten", "minutes": "Minuten",
"seconds": "Seconden", "seconds": "Seconden",
"unit": "Tijdseenheid" "unit": "Tijdseenheid"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minutt", "minutes": "Minutt",
"seconds": "Sekunder", "seconds": "Sekunder",
"unit": "Time format" "unit": "Time format"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minuty", "minutes": "Minuty",
"seconds": "Sekundy", "seconds": "Sekundy",
"unit": "Jednostka czasu" "unit": "Jednostka czasu"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minutos", "minutes": "Minutos",
"seconds": "Segundos", "seconds": "Segundos",
"unit": "Unidades de Tempo" "unit": "Unidades de Tempo"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minutos", "minutes": "Minutos",
"seconds": "Segundos", "seconds": "Segundos",
"unit": "Unidades de tempo" "unit": "Unidades de tempo"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minute", "minutes": "Minute",
"seconds": "Secunde", "seconds": "Secunde",
"unit": "Unitate de timp" "unit": "Unitate de timp"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Минуты", "minutes": "Минуты",
"seconds": "Секунды", "seconds": "Секунды",
"unit": "Единица времени" "unit": "Единица времени"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minúty", "minutes": "Minúty",
"seconds": "Sekundy", "seconds": "Sekundy",
"unit": "Jednotka času" "unit": "Jednotka času"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Minuter", "minutes": "Minuter",
"seconds": "Sekunder", "seconds": "Sekunder",
"unit": "Tidsenhet" "unit": "Tidsenhet"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Dakika", "minutes": "Dakika",
"seconds": "Saniye", "seconds": "Saniye",
"unit": "Zaman birimi" "unit": "Zaman birimi"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Хвилини", "minutes": "Хвилини",
"seconds": "Секунди", "seconds": "Секунди",
"unit": "Одиниця часу" "unit": "Одиниця часу"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "Phút", "minutes": "Phút",
"seconds": "Giây", "seconds": "Giây",
"unit": "Đơn vị" "unit": "Đơn vị"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "分钟", "minutes": "分钟",
"seconds": "秒", "seconds": "秒",
"unit": "时间单位" "unit": "时间单位"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -272,5 +272,10 @@
"minutes": "分鐘", "minutes": "分鐘",
"seconds": "秒", "seconds": "秒",
"unit": "時間單位" "unit": "時間單位"
},
"available_csv_separators": {
"comma": "Comma (,)",
"semicolon": "Semicolon (;)",
"both": "Both (,) and (;)"
} }
} }

View File

@@ -7,7 +7,10 @@ export interface CsvData {
* Parse CSV content into headers and rows * Parse CSV content into headers and rows
* Supports quoted fields and handles commas within quotes * Supports quoted fields and handles commas within quotes
*/ */
export function parseCSV(content: string): CsvData { export function parseCSV(
content: string,
columnSeparator: Array<string>
): CsvData {
if (!content || content.trim().length === 0) { if (!content || content.trim().length === 0) {
return { headers: [], rows: [] }; return { headers: [], rows: [] };
} }
@@ -35,7 +38,7 @@ export function parseCSV(content: string): CsvData {
// Toggle quote state // Toggle quote state
inQuotes = !inQuotes; inQuotes = !inQuotes;
} }
} else if (char === "," && !inQuotes) { } else if (columnSeparator.includes(char) && !inQuotes) {
// Field separator // Field separator
row.push(currentField); row.push(currentField);
currentField = ""; currentField = "";

View File

@@ -19,7 +19,7 @@ require (
github.com/samber/lo v1.52.0 github.com/samber/lo v1.52.0
github.com/shirou/gopsutil/v4 v4.25.11 github.com/shirou/gopsutil/v4 v4.25.11
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1

View File

@@ -214,8 +214,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"encoding/json" "encoding/json"
@@ -13,7 +13,7 @@ import (
"github.com/golang-jwt/jwt/v5/request" "github.com/golang-jwt/jwt/v5/request"
fbAuth "github.com/filebrowser/filebrowser/v2/auth" fbAuth "github.com/filebrowser/filebrowser/v2/auth"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
) )
@@ -185,7 +185,7 @@ var signupHandler = func(_ http.ResponseWriter, r *http.Request, d *data) (int,
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome) log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
err = d.store.Users.Save(user) err = d.store.Users.Save(user)
if errors.Is(err, fbErrors.ErrExist) { if errors.Is(err, fberrors.ErrExist) {
return http.StatusConflict, err return http.StatusConflict, err
} else if err != nil { } else if err != nil {
return http.StatusInternalServerError, err return http.StatusInternalServerError, err

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"bufio" "bufio"

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"log" "log"

View File

@@ -1,6 +1,6 @@
//go:build !dev //go:build !dev
package http package fbhttp
// global headers to append to every response // global headers to append to every response
var globalHeaders = map[string]string{ var globalHeaders = map[string]string{

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"io/fs" "io/fs"

View File

@@ -1,5 +1,5 @@
//go:generate go-enum --sql --marshal --names --file $GOFILE //go:generate go-enum --sql --marshal --names --file $GOFILE
package http package fbhttp
import ( import (
"bytes" "bytes"

View File

@@ -1,7 +1,7 @@
// Code generated by go-enum // Code generated by go-enum
// DO NOT EDIT! // DO NOT EDIT!
package http package fbhttp
import ( import (
"database/sql/driver" "database/sql/driver"

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"context" "context"
@@ -17,7 +17,7 @@ import (
"github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/disk"
"github.com/spf13/afero" "github.com/spf13/afero"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/fileutils" "github.com/filebrowser/filebrowser/v2/fileutils"
) )
@@ -44,7 +44,7 @@ var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d
if checksum := r.URL.Query().Get("checksum"); checksum != "" { if checksum := r.URL.Query().Get("checksum"); checksum != "" {
err := file.Checksum(checksum) err := file.Checksum(checksum)
if errors.Is(err, fbErrors.ErrInvalidOption) { if errors.Is(err, fberrors.ErrInvalidOption) {
return http.StatusBadRequest, nil return http.StatusBadRequest, nil
} else if err != nil { } else if err != nil {
return http.StatusInternalServerError, err return http.StatusInternalServerError, err
@@ -238,7 +238,7 @@ func checkParent(src, dst string) error {
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
if !strings.HasPrefix(rel, "../") && rel != ".." && rel != "." { if !strings.HasPrefix(rel, "../") && rel != ".." && rel != "." {
return fbErrors.ErrSourceIsParent return fberrors.ErrSourceIsParent
} }
return nil return nil
@@ -304,13 +304,13 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach
switch action { switch action {
case "copy": case "copy":
if !d.user.Perm.Create { if !d.user.Perm.Create {
return fbErrors.ErrPermissionDenied return fberrors.ErrPermissionDenied
} }
return fileutils.Copy(d.user.Fs, src, dst, d.settings.FileMode, d.settings.DirMode) return fileutils.Copy(d.user.Fs, src, dst, d.settings.FileMode, d.settings.DirMode)
case "rename": case "rename":
if !d.user.Perm.Rename { if !d.user.Perm.Rename {
return fbErrors.ErrPermissionDenied return fberrors.ErrPermissionDenied
} }
src = path.Clean("/" + src) src = path.Clean("/" + src)
dst = path.Clean("/" + dst) dst = path.Clean("/" + dst)
@@ -335,7 +335,7 @@ func patchAction(ctx context.Context, action, src, dst string, d *data, fileCach
return fileutils.MoveFile(d.user.Fs, src, dst, d.settings.FileMode, d.settings.DirMode) return fileutils.MoveFile(d.user.Fs, src, dst, d.settings.FileMode, d.settings.DirMode)
default: default:
return fmt.Errorf("unsupported action %s: %w", action, fbErrors.ErrInvalidRequestParams) return fmt.Errorf("unsupported action %s: %w", action, fberrors.ErrInvalidRequestParams)
} }
} }

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"net/http" "net/http"

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"encoding/json" "encoding/json"

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"crypto/rand" "crypto/rand"
@@ -14,7 +14,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/share" "github.com/filebrowser/filebrowser/v2/share"
) )
@@ -38,7 +38,7 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
} else { } else {
s, err = d.store.Share.FindByUserID(d.user.ID) s, err = d.store.Share.FindByUserID(d.user.ID)
} }
if errors.Is(err, fbErrors.ErrNotExist) { if errors.Is(err, fberrors.ErrNotExist) {
return renderJSON(w, r, []*share.Link{}) return renderJSON(w, r, []*share.Link{})
} }
@@ -58,7 +58,7 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
s, err := d.store.Share.Gets(r.URL.Path, d.user.ID) s, err := d.store.Share.Gets(r.URL.Path, d.user.ID)
if errors.Is(err, fbErrors.ErrNotExist) { if errors.Is(err, fberrors.ErrNotExist) {
return renderJSON(w, r, []*share.Link{}) return renderJSON(w, r, []*share.Link{})
} }

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"encoding/json" "encoding/json"

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"bytes" "bytes"

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"context" "context"

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"encoding/json" "encoding/json"
@@ -12,7 +12,7 @@ import (
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
) )
@@ -36,7 +36,7 @@ func getUserID(r *http.Request) (uint, error) {
func getUser(_ http.ResponseWriter, r *http.Request) (*modifyUserRequest, error) { func getUser(_ http.ResponseWriter, r *http.Request) (*modifyUserRequest, error) {
if r.Body == nil { if r.Body == nil {
return nil, fbErrors.ErrEmptyRequest return nil, fberrors.ErrEmptyRequest
} }
req := &modifyUserRequest{} req := &modifyUserRequest{}
@@ -46,7 +46,7 @@ func getUser(_ http.ResponseWriter, r *http.Request) (*modifyUserRequest, error)
} }
if req.What != "user" { if req.What != "user" {
return nil, fbErrors.ErrInvalidDataType return nil, fberrors.ErrInvalidDataType
} }
return req, nil return req, nil
@@ -87,7 +87,7 @@ var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *
var userGetHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { var userGetHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
u, err := d.store.Users.Get(d.server.Root, d.raw.(uint)) u, err := d.store.Users.Get(d.server.Root, d.raw.(uint))
if errors.Is(err, fbErrors.ErrNotExist) { if errors.Is(err, fberrors.ErrNotExist) {
return http.StatusNotFound, err return http.StatusNotFound, err
} }
@@ -122,7 +122,7 @@ var userPostHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *
} }
if req.Data.Password == "" { if req.Data.Password == "" {
return http.StatusBadRequest, fbErrors.ErrEmptyPassword return http.StatusBadRequest, fberrors.ErrEmptyPassword
} }
req.Data.Password, err = users.ValidateAndHashPwd(req.Data.Password, d.settings.MinimumPasswordLength) req.Data.Password, err = users.ValidateAndHashPwd(req.Data.Password, d.settings.MinimumPasswordLength)

View File

@@ -1,4 +1,4 @@
package http package fbhttp
import ( import (
"encoding/json" "encoding/json"

View File

@@ -1,7 +1,7 @@
package settings package settings
import ( import (
"github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/rules" "github.com/filebrowser/filebrowser/v2/rules"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
) )
@@ -72,7 +72,7 @@ var defaultEvents = []string{
// Save saves the settings for the current instance. // Save saves the settings for the current instance.
func (s *Storage) Save(set *Settings) error { func (s *Storage) Save(set *Settings) error {
if len(set.Key) == 0 { if len(set.Key) == 0 {
return errors.ErrEmptyKey return fberrors.ErrEmptyKey
} }
if set.Defaults.Locale == "" { if set.Defaults.Locale == "" {

View File

@@ -3,7 +3,7 @@ package share
import ( import (
"time" "time"
"github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
) )
// StorageBackend is the interface to implement for a share storage. // StorageBackend is the interface to implement for a share storage.
@@ -79,7 +79,7 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
if err := s.Delete(link.Hash); err != nil { if err := s.Delete(link.Hash); err != nil {
return nil, err return nil, err
} }
return nil, errors.ErrNotExist return nil, fberrors.ErrNotExist
} }
return link, nil return link, nil

View File

@@ -4,7 +4,7 @@ import (
"github.com/asdine/storm/v3" "github.com/asdine/storm/v3"
"github.com/filebrowser/filebrowser/v2/auth" "github.com/filebrowser/filebrowser/v2/auth"
"github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/settings" "github.com/filebrowser/filebrowser/v2/settings"
) )
@@ -25,7 +25,7 @@ func (s authBackend) Get(t settings.AuthMethod) (auth.Auther, error) {
case auth.MethodNoAuth: case auth.MethodNoAuth:
auther = &auth.NoAuth{} auther = &auth.NoAuth{}
default: default:
return nil, errors.ErrInvalidAuthMethod return nil, fberrors.ErrInvalidAuthMethod
} }
return auther, get(s.db, "auther", auther) return auther, get(s.db, "auther", auther)

View File

@@ -6,7 +6,7 @@ import (
"github.com/asdine/storm/v3" "github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q" "github.com/asdine/storm/v3/q"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/share" "github.com/filebrowser/filebrowser/v2/share"
) )
@@ -18,7 +18,7 @@ func (s shareBackend) All() ([]*share.Link, error) {
var v []*share.Link var v []*share.Link
err := s.db.All(&v) err := s.db.All(&v)
if errors.Is(err, storm.ErrNotFound) { if errors.Is(err, storm.ErrNotFound) {
return v, fbErrors.ErrNotExist return v, fberrors.ErrNotExist
} }
return v, err return v, err
@@ -28,7 +28,7 @@ func (s shareBackend) FindByUserID(id uint) ([]*share.Link, error) {
var v []*share.Link var v []*share.Link
err := s.db.Select(q.Eq("UserID", id)).Find(&v) err := s.db.Select(q.Eq("UserID", id)).Find(&v)
if errors.Is(err, storm.ErrNotFound) { if errors.Is(err, storm.ErrNotFound) {
return v, fbErrors.ErrNotExist return v, fberrors.ErrNotExist
} }
return v, err return v, err
@@ -38,7 +38,7 @@ func (s shareBackend) GetByHash(hash string) (*share.Link, error) {
var v share.Link var v share.Link
err := s.db.One("Hash", hash, &v) err := s.db.One("Hash", hash, &v)
if errors.Is(err, storm.ErrNotFound) { if errors.Is(err, storm.ErrNotFound) {
return nil, fbErrors.ErrNotExist return nil, fberrors.ErrNotExist
} }
return &v, err return &v, err
@@ -48,7 +48,7 @@ func (s shareBackend) GetPermanent(path string, id uint) (*share.Link, error) {
var v share.Link var v share.Link
err := s.db.Select(q.Eq("Path", path), q.Eq("Expire", 0), q.Eq("UserID", id)).First(&v) err := s.db.Select(q.Eq("Path", path), q.Eq("Expire", 0), q.Eq("UserID", id)).First(&v)
if errors.Is(err, storm.ErrNotFound) { if errors.Is(err, storm.ErrNotFound) {
return nil, fbErrors.ErrNotExist return nil, fberrors.ErrNotExist
} }
return &v, err return &v, err
@@ -58,7 +58,7 @@ func (s shareBackend) Gets(path string, id uint) ([]*share.Link, error) {
var v []*share.Link var v []*share.Link
err := s.db.Select(q.Eq("Path", path), q.Eq("UserID", id)).Find(&v) err := s.db.Select(q.Eq("Path", path), q.Eq("UserID", id)).Find(&v)
if errors.Is(err, storm.ErrNotFound) { if errors.Is(err, storm.ErrNotFound) {
return v, fbErrors.ErrNotExist return v, fberrors.ErrNotExist
} }
return v, err return v, err

View File

@@ -7,7 +7,7 @@ import (
"github.com/asdine/storm/v3" "github.com/asdine/storm/v3"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/users" "github.com/filebrowser/filebrowser/v2/users"
) )
@@ -25,14 +25,14 @@ func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
case string: case string:
arg = "Username" arg = "Username"
default: default:
return nil, fbErrors.ErrInvalidDataType return nil, fberrors.ErrInvalidDataType
} }
err = st.db.One(arg, i, user) err = st.db.One(arg, i, user)
if err != nil { if err != nil {
if errors.Is(err, storm.ErrNotFound) { if errors.Is(err, storm.ErrNotFound) {
return nil, fbErrors.ErrNotExist return nil, fberrors.ErrNotExist
} }
return nil, err return nil, err
} }
@@ -44,7 +44,7 @@ func (st usersBackend) Gets() ([]*users.User, error) {
var allUsers []*users.User var allUsers []*users.User
err := st.db.All(&allUsers) err := st.db.All(&allUsers)
if errors.Is(err, storm.ErrNotFound) { if errors.Is(err, storm.ErrNotFound) {
return nil, fbErrors.ErrNotExist return nil, fberrors.ErrNotExist
} }
if err != nil { if err != nil {
@@ -76,7 +76,7 @@ func (st usersBackend) Update(user *users.User, fields ...string) error {
func (st usersBackend) Save(user *users.User) error { func (st usersBackend) Save(user *users.User) error {
err := st.db.Save(user) err := st.db.Save(user)
if errors.Is(err, storm.ErrAlreadyExists) { if errors.Is(err, storm.ErrAlreadyExists) {
return fbErrors.ErrExist return fberrors.ErrExist
} }
return err return err
} }

View File

@@ -5,13 +5,13 @@ import (
"github.com/asdine/storm/v3" "github.com/asdine/storm/v3"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
) )
func get(db *storm.DB, name string, to interface{}) error { func get(db *storm.DB, name string, to interface{}) error {
err := db.Get("config", name, to) err := db.Get("config", name, to)
if errors.Is(err, storm.ErrNotFound) { if errors.Is(err, storm.ErrNotFound) {
return fbErrors.ErrNotExist return fberrors.ErrNotExist
} }
return err return err

View File

@@ -6,17 +6,17 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
fbErrors "github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
) )
// ValidateAndHashPwd validates and hashes a password. // ValidateAndHashPwd validates and hashes a password.
func ValidateAndHashPwd(password string, minimumLength uint) (string, error) { func ValidateAndHashPwd(password string, minimumLength uint) (string, error) {
if uint(len(password)) < minimumLength { if uint(len(password)) < minimumLength {
return "", fbErrors.ErrShortPassword{MinimumLength: minimumLength} return "", fberrors.ErrShortPassword{MinimumLength: minimumLength}
} }
if _, ok := commonPasswords[password]; ok { if _, ok := commonPasswords[password]; ok {
return "", fbErrors.ErrEasyPassword return "", fberrors.ErrEasyPassword
} }
return HashPwd(password) return HashPwd(password)

View File

@@ -4,7 +4,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
) )
// StorageBackend is the interface to implement for a users storage. // StorageBackend is the interface to implement for a users storage.
@@ -109,16 +109,16 @@ func (s *Storage) Delete(id interface{}) error {
return err return err
} }
if user.ID == 1 { if user.ID == 1 {
return errors.ErrRootUserDeletion return fberrors.ErrRootUserDeletion
} }
return s.back.DeleteByUsername(id) return s.back.DeleteByUsername(id)
case uint: case uint:
if id == 1 { if id == 1 {
return errors.ErrRootUserDeletion return fberrors.ErrRootUserDeletion
} }
return s.back.DeleteByID(id) return s.back.DeleteByID(id)
default: default:
return errors.ErrInvalidDataType return fberrors.ErrInvalidDataType
} }
} }

View File

@@ -5,7 +5,7 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/errors" fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/files" "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/rules" "github.com/filebrowser/filebrowser/v2/rules"
) )
@@ -64,11 +64,11 @@ func (u *User) Clean(baseScope string, fields ...string) error {
switch field { switch field {
case "Username": case "Username":
if u.Username == "" { if u.Username == "" {
return errors.ErrEmptyUsername return fberrors.ErrEmptyUsername
} }
case "Password": case "Password":
if u.Password == "" { if u.Password == "" {
return errors.ErrEmptyPassword return fberrors.ErrEmptyPassword
} }
case "ViewMode": case "ViewMode":
if u.ViewMode == "" { if u.ViewMode == "" {

View File

@@ -197,6 +197,10 @@ func newSearcher(major int) *searcher {
case 12: case 12:
fallthrough fallthrough
case 13: case 13:
fallthrough
case 14:
fallthrough
case 15:
s = &searcher{ s = &searcher{
headSize: 64, headSize: 64,
tcpItemSize: 744, tcpItemSize: 744,

View File

@@ -5,12 +5,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=filebrowser PKG_NAME:=filebrowser
PKG_VERSION:=2.50.0 PKG_VERSION:=2.51.0
PKG_RELEASE:=1 PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/filebrowser/filebrowser/tar.gz/v${PKG_VERSION}? PKG_SOURCE_URL:=https://codeload.github.com/filebrowser/filebrowser/tar.gz/v${PKG_VERSION}?
PKG_HASH:=5947c8a8c7c8df2b2646953cfa1fdee9efac8b4415a368074acab94eacc56fd7 PKG_HASH:=83807c5330343a4f1201e0e061725769628d87b9a5bdd9486967a8f880ce6571
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILES:=LICENSE PKG_LICENSE_FILES:=LICENSE

View File

@@ -13,7 +13,7 @@ s = m:section(NamedSection, arg[1], "nodes", "")
s.addremove = false s.addremove = false
s.dynamic = false s.dynamic = false
o = s:option(DummyValue, "passwall", " ") o = s:option(DummyValue, "passwall", " ")
o.rawhtml = true o.rawhtml = true
o.template = "passwall/node_list/link_share_man" o.template = "passwall/node_list/link_share_man"
o.value = arg[1] o.value = arg[1]
@@ -61,7 +61,7 @@ if api.is_finded("ipt2socks") then
s.fields["type"]:value("Socks", translate("Socks")) s.fields["type"]:value("Socks", translate("Socks"))
o = s:option(ListValue, _n("del_protocol")) --始终隐藏,用于删除 protocol o = s:option(ListValue, _n("del_protocol"), " ") --始终隐藏,用于删除 protocol
o:depends({ [_n("__hide")] = "1" }) o:depends({ [_n("__hide")] = "1" })
o.rewrite_option = "protocol" o.rewrite_option = "protocol"

View File

@@ -64,11 +64,10 @@ if api.is_js_luci() then
uci:commit(appname) uci:commit(appname)
api.showMsg_Redirect() api.showMsg_Redirect()
end end
end m.render = function(self, ...)
Map.render(self, ...)
m.render = function(self, ...) api.optimize_cbi_ui()
Map.render(self, ...) end
api.optimize_cbi_ui()
end end
-- [[ Subscribe Settings ]]-- -- [[ Subscribe Settings ]]--

View File

@@ -20,11 +20,10 @@ if api.is_js_luci() then
uci:commit(appname) uci:commit(appname)
api.showMsg_Redirect(self.redirect, 3000) api.showMsg_Redirect(self.redirect, 3000)
end end
end m.render = function(self, ...)
Map.render(self, ...)
m.render = function(self, ...) api.optimize_cbi_ui()
Map.render(self, ...) end
api.optimize_cbi_ui()
end end
local has_ss = api.is_finded("ss-redir") local has_ss = api.is_finded("ss-redir")

View File

@@ -91,25 +91,38 @@ o.datatype = "min(1)"
o.default = 1 o.default = 1
o:depends("enable_autoswitch", true) o:depends("enable_autoswitch", true)
autoswitch_backup_node = s:option(DynamicList, "autoswitch_backup_node", translate("List of backup nodes")) o = s:option(MultiValue, "autoswitch_backup_node", translate("List of backup nodes"))
autoswitch_backup_node:depends("enable_autoswitch", true) o:depends("enable_autoswitch", true)
function o.write(self, section, value) o.widget = "checkbox"
local t = {} o.template = appname .. "/cbi/nodes_multiselect"
local t2 = {} local keylist = {}
if type(value) == "table" then local vallist = {}
local x local grouplist = {}
for _, x in ipairs(value) do for i, v in pairs(nodes_table) do
if x and #x > 0 then keylist[i] = v.id
if not t2[x] then vallist[i] = v.remark
t2[x] = x grouplist[i] = v.group or ""
t[#t+1] = x socks_node:value(v.id, v["remark"])
end end
end o.keylist = keylist
end o.vallist = vallist
o.group = grouplist
-- 读取旧 DynamicList
function o.cfgvalue(self, section)
local val = m.uci:get_list(appname, section, "autoswitch_backup_node")
if val then
return val
else else
t = { value } return {}
end end
return DynamicList.write(self, section, t) end
-- 写入保持 DynamicList
function o.write(self, section, value)
local result = {}
for v in value:gmatch("%S+") do
result[#result + 1] = v
end
m.uci:set_list(appname, section, "autoswitch_backup_node", result)
end end
o = s:option(Flag, "autoswitch_restore_switch", translate("Restore Switch"), translate("When detects main node is available, switch back to the main node.")) o = s:option(Flag, "autoswitch_restore_switch", translate("Restore Switch"), translate("When detects main node is available, switch back to the main node."))
@@ -125,12 +138,7 @@ o:value("https://connect.rom.miui.com/generate_204", "MIUI (CN)")
o:value("https://connectivitycheck.platform.hicloud.com/generate_204", "HiCloud (CN)") o:value("https://connectivitycheck.platform.hicloud.com/generate_204", "HiCloud (CN)")
o:depends("enable_autoswitch", true) o:depends("enable_autoswitch", true)
for k, v in pairs(nodes_table) do o = s:option(DummyValue, "btn", " ")
autoswitch_backup_node:value(v.id, v["remark"])
socks_node:value(v.id, v["remark"])
end
o = s:option(DummyValue, "btn", " ")
o.template = appname .. "/socks_auto_switch/btn" o.template = appname .. "/socks_auto_switch/btn"
o:depends("enable_autoswitch", true) o:depends("enable_autoswitch", true)

View File

@@ -61,7 +61,8 @@ for k, e in ipairs(api.get_valid_nodes()) do
id = e[".name"], id = e[".name"],
remark = e["remark"], remark = e["remark"],
type = e["type"], type = e["type"],
chain_proxy = e["chain_proxy"] chain_proxy = e["chain_proxy"],
group = e["group"]
} }
end end
if e.protocol == "_balancing" then if e.protocol == "_balancing" then
@@ -98,26 +99,35 @@ m.uci:foreach(appname, "socks", function(s)
end) end)
-- 负载均衡列表 -- 负载均衡列表
o = s:option(DynamicList, _n("balancing_node"), translate("Load balancing node list"), translate("Load balancing node list, <a target='_blank' href='https://xtls.github.io/config/routing.html#balancerobject'>document</a>")) o = s:option(MultiValue, _n("balancing_node"), translate("Load balancing node list"), translate("Load balancing node list, <a target='_blank' href='https://xtls.github.io/config/routing.html#balancerobject'>document</a>"))
o:depends({ [_n("protocol")] = "_balancing" }) o:depends({ [_n("protocol")] = "_balancing" })
local valid_ids = {} o.widget = "checkbox"
for k, v in pairs(nodes_table) do o.template = appname .. "/cbi/nodes_multiselect"
o:value(v.id, v.remark) local keylist = {}
valid_ids[v.id] = true local vallist = {}
local grouplist = {}
for i, v in ipairs(nodes_table) do
keylist[i] = v.id
vallist[i] = v.remark
grouplist[i] = v.group or ""
end end
-- 去重并禁止自定义非法输入 o.keylist = keylist
o.vallist = vallist
o.group = grouplist
-- 读取旧 DynamicList
function o.cfgvalue(self, section)
local val = m.uci:get_list(appname, section, "balancing_node")
if val then
return val
else
return {}
end
end
-- 写入保持 DynamicList
function o.custom_write(self, section, value) function o.custom_write(self, section, value)
local result = {} local result = {}
if type(value) == "table" then for v in value:gmatch("%S+") do
local seen = {} result[#result + 1] = v
for _, v in ipairs(value) do
if v and not seen[v] and valid_ids[v] then
table.insert(result, v)
seen[v] = true
end
end
else
result = { value }
end end
m.uci:set_list(appname, section, "balancing_node", result) m.uci:set_list(appname, section, "balancing_node", result)
end end

View File

@@ -77,7 +77,8 @@ for k, e in ipairs(api.get_valid_nodes()) do
id = e[".name"], id = e[".name"],
remark = e["remark"], remark = e["remark"],
type = e["type"], type = e["type"],
chain_proxy = e["chain_proxy"] chain_proxy = e["chain_proxy"],
group = e["group"]
} }
end end
if e.protocol == "_iface" then if e.protocol == "_iface" then
@@ -105,26 +106,35 @@ m.uci:foreach(appname, "socks", function(s)
end) end)
--[[ URLTest ]] --[[ URLTest ]]
o = s:option(DynamicList, _n("urltest_node"), translate("URLTest node list"), translate("List of nodes to test, <a target='_blank' href='https://sing-box.sagernet.org/configuration/outbound/urltest'>document</a>")) o = s:option(MultiValue, _n("urltest_node"), translate("URLTest node list"), translate("List of nodes to test, <a target='_blank' href='https://sing-box.sagernet.org/configuration/outbound/urltest'>document</a>"))
o:depends({ [_n("protocol")] = "_urltest" }) o:depends({ [_n("protocol")] = "_urltest" })
local valid_ids = {} o.widget = "checkbox"
for k, v in pairs(nodes_table) do o.template = appname .. "/cbi/nodes_multiselect"
o:value(v.id, v.remark) local keylist = {}
valid_ids[v.id] = true local vallist = {}
local grouplist = {}
for i, v in ipairs(nodes_table) do
keylist[i] = v.id
vallist[i] = v.remark
grouplist[i] = v.group or ""
end end
-- 去重并禁止自定义非法输入 o.keylist = keylist
o.vallist = vallist
o.group = grouplist
-- 读取旧 DynamicList
function o.cfgvalue(self, section)
local val = m.uci:get_list(appname, section, "urltest_node")
if val then
return val
else
return {}
end
end
-- 写入保持 DynamicList
function o.custom_write(self, section, value) function o.custom_write(self, section, value)
local result = {} local result = {}
if type(value) == "table" then for v in value:gmatch("%S+") do
local seen = {} result[#result + 1] = v
for _, v in ipairs(value) do
if v and not seen[v] and valid_ids[v] then
table.insert(result, v)
seen[v] = true
end
end
else
result = { value }
end end
m.uci:set_list(appname, section, "urltest_node", result) m.uci:set_list(appname, section, "urltest_node", result)
end end

View File

@@ -47,10 +47,10 @@ function set_apply_on_parse(map)
map.on_after_apply = function(self) map.on_after_apply = function(self)
showMsg_Redirect(self.redirect, 3000) showMsg_Redirect(self.redirect, 3000)
end end
end map.render = function(self, ...)
map.render = function(self, ...) getmetatable(self).__index.render(self, ...) -- 保持原渲染流程
getmetatable(self).__index.render(self, ...) -- 保持原渲染流程 optimize_cbi_ui()
optimize_cbi_ui() end
end end
end end

View File

@@ -0,0 +1,260 @@
<%+cbi/valueheader%>
<%
local api = require "luci.passwall.api"
local cbid = "cbid." .. self.config .. "." .. section .. "." .. self.option
-- 读取 MultiValue
local values = {}
for i, key in pairs(self.keylist) do
values[#values + 1] = {
key = key,
label = self.vallist[i] or key,
group = self.group and self.group[i] or nil
}
end
-- 获取选中值
local selected = {}
local cval = self:cfgvalue(section)
if type(cval) == "table" then
for _, v in pairs(cval) do
selected[v] = true
end
elseif type(cval) == "string" then
for v in cval:gmatch("%S+") do
selected[v] = true
end
end
-- 按原顺序分组
local groups = {}
local group_order = {}
for _, item in ipairs(values) do
local g = item.group
if not g or g == "" then
g = api.i18n.translate("default")
end
if not groups[g] then
groups[g] = {}
table.insert(group_order, g)
end
table.insert(groups[g], item)
end
%>
<div id="<%=cbid%>" style="display: inline-block;">
<!-- 搜索 -->
<input type="text"
id="<%=cbid%>.search"
class="node-search-input cbi-input-text"
placeholder="<%:Search nodes...%>"
oninput="filterGroups_<%=self.option%>(this.value)"
style="width:100%;padding:6px;margin-bottom:8px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box;" />
<!-- 主容器 -->
<div style="max-height:300px; overflow:auto; margin-bottom:8px; white-space:nowrap;">
<ul class="cbi-multi" id="<%=cbid%>.node_list" style="padding:0 !important;margin:0 !important;width:100%;box-sizing:border-box;">
<% for _, gname in ipairs(group_order) do %>
<% local items = groups[gname] %>
<li class="group-block" data-group="<%=gname%>" style="list-style:none; padding:0; margin:0 0 8px 0;">
<!-- 组标题 -->
<div class="group-title"
onclick="toggleGroup_<%=self.option%>('<%=gname%>')"
style="cursor:pointer;padding:6px;background:#f0f0f0;border-radius:4px;margin-bottom:4px;display:flex;align-items:center;white-space:nowrap;">
<span id="arrow-<%=self.option%>-<%=gname%>" style="width:16px;"></span>
<b><%=gname%></b>
<span id="group-count-<%=self.option%>-<%=gname%>" style="margin-left:8px;color:blue;">
(0/<%=#items%>)
</span>
</div>
<!-- 组内容(可折叠)-->
<ul id="group-<%=self.option%>-<%=gname%>" style="margin:0 0 8px 16px; padding:0; list-style:none;">
<% for _, item in ipairs(items) do %>
<li data-node-name="<%=pcdata(item.label):lower()%>" style="list-style:none; padding:0; margin:0; white-space:nowrap;">
<input
type="checkbox"
class="cbi-input-checkbox"
style="vertical-align: middle; margin-right:6px;"
<%= attr("id", cbid .. "." .. item.key) ..
attr("name", cbid) ..
attr("value", item.key) ..
ifattr(selected[item.key], "checked", "checked")
%> />
<label for="<%=cbid .. "." .. item.key%>"><%=pcdata(item.label)%></label>
</li>
<% end %>
</ul>
</li>
<% end %>
</ul>
</div>
<!-- 控制栏 -->
<div style="margin-top:4px;display:flex;gap:4px;align-items:center;">
<input class="btn cbi-button cbi-button-edit" type="button" onclick="selectAll_<%=self.option%>(true)" value="<%:Select all%>">
<input class="btn cbi-button cbi-button-edit" type="button" onclick="selectAll_<%=self.option%>(false)" value="<%:DeSelect all%>">
<span id="count-<%=self.option%>" style="color:#666;"></span>
</div>
</div>
<%+cbi/valuefooter%>
<script type="text/javascript">
//<![CDATA[
(function(){
const cbid = "<%=cbid%>";
const opt = "<%=self.option%>";
const listId = cbid + ".node_list";
// 折叠组
window["toggleGroup_" + opt] = function(g){
const ul = document.getElementById("group-" + opt + "-" + g);
const arrow = document.getElementById("arrow-" + opt + "-" + g);
if (!ul) return;
// 判断是否在搜索状态
const keyword = document.getElementById(cbid + ".search").value.trim().toLowerCase();
const isSearching = keyword.length > 0;
// 搜索状态下,仅切换当前组,不处理其他组
if (isSearching) {
if (ul.style.display === "none") {
ul.style.display = "";
if (arrow) arrow.textContent = "▼";
} else {
ul.style.display = "none";
if (arrow) arrow.textContent = "►";
}
return;
}
// 非搜索模式:先折叠其他组
const groups = document.querySelectorAll("[id='" + listId + "'] .group-block");
groups.forEach(group=>{
const gname = group.getAttribute("data-group");
const gul = document.getElementById("group-" + opt + "-" + gname);
const garrow = document.getElementById("arrow-" + opt + "-" + gname);
if (gname !== g) {
if (gul) gul.style.display = "none";
if (garrow) garrow.textContent = "►";
}
});
document.getElementById(listId).parentNode.scrollTop = 0;
// 切换当前组
if (ul.style.display === "none") {
ul.style.display = "";
if (arrow) arrow.textContent = "▼";
} else {
ul.style.display = "none";
if (arrow) arrow.textContent = "►";
}
};
// 搜索
window["filterGroups_" + opt] = function(keyword){
keyword = keyword.toLowerCase().trim();
const groups = document.querySelectorAll("[id='" + listId + "'] .group-block");
groups.forEach(group=>{
const items = group.querySelectorAll("li[data-node-name]");
let matchCount = 0;
items.forEach(li=>{
const name = li.getAttribute("data-node-name");
if (!keyword || name.indexOf(keyword) !== -1) {
li.style.display = "";
matchCount++;
} else {
li.style.display = "none";
}
});
// 搜索时自动展开所有组
const gname = group.getAttribute("data-group");
const ul = document.getElementById("group-" + opt + "-" + gname);
const arrow = document.getElementById("arrow-" + opt + "-" + gname);
if (matchCount === 0 && keyword !== "") {
group.style.display = "none";
} else {
group.style.display = "";
if (keyword) {
ul.style.display = "";
arrow.textContent = "▼";
}
}
});
updateCount();
// 清空搜索后恢复全部折叠
if (!keyword) {
const groups = document.querySelectorAll("[id='" + listId + "'] .group-block");
groups.forEach(group=>{
const gname = group.getAttribute("data-group");
const ul = document.getElementById("group-" + opt + "-" + gname);
const arrow = document.getElementById("arrow-" + opt + "-" + gname);
if (ul) ul.style.display = "none";
if (arrow) arrow.textContent = "►";
});
}
};
// 全选 / 全不选
window["selectAll_" + opt] = function(flag){
const cbs = document.querySelectorAll("[id='" + listId + "'] input[type=checkbox]");
cbs.forEach(cb=>{
if (cb.offsetParent !== null) cb.checked = flag;
});
updateCount();
};
// 计数
function updateCount(){
const cbs = document.querySelectorAll("[id='" + listId + "'] input[type=checkbox]");
let checked = 0;
cbs.forEach(cb=>{ if (cb.checked) checked++; });
// 更新总计
document.getElementById("count-" + opt).innerHTML =
"<%:Selected:%> <span style='color:red;'>" + checked + " / " + cbs.length + "</span>";
// 更新每个组
const groups = document.querySelectorAll("[id='" + listId + "'] .group-block");
groups.forEach(group=>{
const gname = group.getAttribute("data-group");
const groupCbs = group.querySelectorAll("li[data-node-name] input[type=checkbox]");
let groupChecked = 0;
groupCbs.forEach(cb=>{ if(cb.checked) groupChecked++; });
const span = document.getElementById("group-count-" + opt + "-" + gname);
if(span) span.textContent = "(" + groupChecked + "/" + groupCbs.length + ")";
});
}
document.getElementById(listId)?.addEventListener("change", updateCount);
// 初始化折叠所有组和计数
const initObserver = new MutationObserver(() => {
const list = document.getElementById(listId);
if (!list) return;
if (list.offsetParent === null) return;
if (list.dataset.initDone === "1") return;
list.dataset.initDone = "1";
const groups = document.querySelectorAll("[id='" + listId + "'] .group-block");
groups.forEach(group => {
const gname = group.getAttribute("data-group");
const ul = document.getElementById("group-" + opt + "-" + gname);
const arrow = document.getElementById("arrow-" + opt + "-" + gname);
if (ul) ul.style.display = "none";
if (arrow) arrow.textContent = "►";
});
updateCount();
});
initObserver.observe(document.body, {
attributes: true,
subtree: true,
attributeFilter: ["style", "class"]
});
})();
//]]>
</script>

View File

@@ -388,6 +388,9 @@ msgstr "置顶"
msgid "Select" msgid "Select"
msgstr "选择" msgstr "选择"
msgid "Selected:"
msgstr "已选:"
msgid "DeSelect" msgid "DeSelect"
msgstr "反选" msgstr "反选"
@@ -2016,3 +2019,6 @@ msgstr "调整节点分组"
msgid "Currently using %s node" msgid "Currently using %s node"
msgstr "当前使用的 %s 节点" msgstr "当前使用的 %s 节点"
msgid "Search nodes..."
msgstr "搜索节点…"

View File

@@ -143,9 +143,18 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial
} else { } else {
dialer.Timeout = C.TCPConnectTimeout dialer.Timeout = C.TCPConnectTimeout
} }
// TODO: Add an option to customize the keep alive period if options.TCPKeepAlive >= 0 {
dialer.KeepAlive = C.TCPKeepAliveInitial keepIdle := time.Duration(options.TCPKeepAlive)
dialer.Control = control.Append(dialer.Control, control.SetKeepAlivePeriod(C.TCPKeepAliveInitial, C.TCPKeepAliveInterval)) if keepIdle == 0 {
keepIdle = C.TCPKeepAliveInitial
}
keepInterval := time.Duration(options.TCPKeepAliveInterval)
if keepInterval == 0 {
keepInterval = C.TCPKeepAliveInterval
}
dialer.KeepAlive = keepIdle
dialer.Control = control.Append(dialer.Control, control.SetKeepAlivePeriod(keepIdle, keepInterval))
}
var udpFragment bool var udpFragment bool
if options.UDPFragment != nil { if options.UDPFragment != nil {
udpFragment = *options.UDPFragment udpFragment = *options.UDPFragment

View File

@@ -3,7 +3,7 @@ package constant
import "time" import "time"
const ( const (
TCPKeepAliveInitial = 10 * time.Minute TCPKeepAliveInitial = 5 * time.Minute
TCPKeepAliveInterval = 75 * time.Second TCPKeepAliveInterval = 75 * time.Second
TCPConnectTimeout = 5 * time.Second TCPConnectTimeout = 5 * time.Second
TCPTimeout = 15 * time.Second TCPTimeout = 15 * time.Second

View File

@@ -5,13 +5,29 @@ icon: material/alert-decagram
#### 1.13.0-alpha.28 #### 1.13.0-alpha.28
* Update quic-go to v0.57.1 * Update quic-go to v0.57.1
* Add `tcp_keep_alive` and `tcp_keep_alive_interval` options for dial fields **1**
* Update default TCP keep-alive initial period from 10 minutes to 5 minutes
* Fixes and improvements * Fixes and improvements
Unfortunately, for non-technical reasons, we are currently unable to notarize a standalone version of the macOS client: **1**:
because system extensions require signatures to function, we have had to temporarily halt its release.
We plan to fix the App Store release issue and launch a new standalone desktop client, but until then, See [Dial Fields](/configuration/shared/dial/#tcp_keep_alive).
only clients on TestFlight will be available (unless you have an Apple Developer Program and compile from source code).
__Unfortunately, for non-technical reasons, we are currently unable to notarize the standalone version of the macOS client:
because system extensions require signatures to function, we have had to temporarily halt its release.__
__We plan to fix the App Store release issue and launch a new standalone desktop client, but until then,
only clients on TestFlight will be available (unless you have an Apple Developer Program and compile from source code).__
#### 1.12.13
* Fixes and improvements
__Unfortunately, for non-technical reasons, we are currently unable to notarize the standalone version of the macOS client:
because system extensions require signatures to function, we have had to temporarily halt its release.__
__We plan to fix the App Store release issue and launch a new standalone desktop client, but until then,
only clients on TestFlight will be available (unless you have an Apple Developer Program and compile from source code).__
#### 1.12.12 #### 1.12.12

View File

@@ -2,6 +2,11 @@
icon: material/new-box icon: material/new-box
--- ---
!!! quote "Changes in sing-box 1.13.0"
:material-plus: [tcp_keep_alive](#tcp_keep_alive)
:material-plus: [tcp_keep_alive_interval](#tcp_keep_alive_interval)
!!! quote "Changes in sing-box 1.12.0" !!! quote "Changes in sing-box 1.12.0"
:material-plus: [domain_resolver](#domain_resolver) :material-plus: [domain_resolver](#domain_resolver)
@@ -29,8 +34,10 @@ icon: material/new-box
"connect_timeout": "", "connect_timeout": "",
"tcp_fast_open": false, "tcp_fast_open": false,
"tcp_multi_path": false, "tcp_multi_path": false,
"tcp_keep_alive": "",
"tcp_keep_alive_interval": "",
"udp_fragment": false, "udp_fragment": false,
"domain_resolver": "", // or {} "domain_resolver": "", // or {}
"network_strategy": "", "network_strategy": "",
"network_type": [], "network_type": [],
@@ -112,6 +119,24 @@ Enable TCP Fast Open.
Enable TCP Multi Path. Enable TCP Multi Path.
#### tcp_keep_alive
!!! question "Since sing-box 1.13.0"
Default value changed from `10m` to `5m`.
TCP keep-alive initial period.
`5m` will be used by default.
#### tcp_keep_alive_interval
!!! question "Since sing-box 1.13.0"
TCP keep-alive interval.
`75s` will be used by default.
#### udp_fragment #### udp_fragment
Enable UDP fragmentation. Enable UDP fragmentation.

View File

@@ -2,6 +2,11 @@
icon: material/new-box icon: material/new-box
--- ---
!!! quote "sing-box 1.13.0 中的更改"
:material-plus: [tcp_keep_alive](#tcp_keep_alive)
:material-plus: [tcp_keep_alive_interval](#tcp_keep_alive_interval)
!!! quote "sing-box 1.12.0 中的更改" !!! quote "sing-box 1.12.0 中的更改"
:material-plus: [domain_resolver](#domain_resolver) :material-plus: [domain_resolver](#domain_resolver)
@@ -29,7 +34,10 @@ icon: material/new-box
"connect_timeout": "", "connect_timeout": "",
"tcp_fast_open": false, "tcp_fast_open": false,
"tcp_multi_path": false, "tcp_multi_path": false,
"tcp_keep_alive": "",
"tcp_keep_alive_interval": "",
"udp_fragment": false, "udp_fragment": false,
"domain_resolver": "", // 或 {} "domain_resolver": "", // 或 {}
"network_strategy": "", "network_strategy": "",
"network_type": [], "network_type": [],
@@ -109,6 +117,24 @@ icon: material/new-box
启用 TCP Multi Path。 启用 TCP Multi Path。
#### tcp_keep_alive
!!! question "自 sing-box 1.13.0 起"
默认值从 `10m` 更改为 `5m`
TCP keep-alive 初始周期。
默认使用 `5m`
#### tcp_keep_alive_interval
!!! question "自 sing-box 1.13.0 起"
TCP keep-alive 间隔。
默认使用 `75s`
#### udp_fragment #### udp_fragment
启用 UDP 分段。 启用 UDP 分段。

View File

@@ -2,6 +2,10 @@
icon: material/new-box icon: material/new-box
--- ---
!!! quote "Changes in sing-box 1.13.0"
:material-alert: [tcp_keep_alive](#tcp_keep_alive)
!!! quote "Changes in sing-box 1.12.0" !!! quote "Changes in sing-box 1.12.0"
:material-plus: [netns](#netns) :material-plus: [netns](#netns)
@@ -29,6 +33,8 @@ icon: material/new-box
"netns": "", "netns": "",
"tcp_fast_open": false, "tcp_fast_open": false,
"tcp_multi_path": false, "tcp_multi_path": false,
"tcp_keep_alive": "",
"tcp_keep_alive_interval": "",
"udp_fragment": false, "udp_fragment": false,
"udp_timeout": "", "udp_timeout": "",
"detour": "", "detour": "",
@@ -101,6 +107,22 @@ Enable TCP Fast Open.
Enable TCP Multi Path. Enable TCP Multi Path.
#### tcp_keep_alive
!!! question "Since sing-box 1.13.0"
Default value changed from `10m` to `5m`.
TCP keep-alive initial period.
`5m` will be used by default.
#### tcp_keep_alive_interval
TCP keep-alive interval.
`75s` will be used by default.
#### udp_fragment #### udp_fragment
Enable UDP fragmentation. Enable UDP fragmentation.

View File

@@ -2,6 +2,10 @@
icon: material/new-box icon: material/new-box
--- ---
!!! quote "sing-box 1.13.0 中的更改"
:material-alert: [tcp_keep_alive](#tcp_keep_alive)
!!! quote "Changes in sing-box 1.12.0" !!! quote "Changes in sing-box 1.12.0"
:material-plus: [netns](#netns) :material-plus: [netns](#netns)
@@ -29,6 +33,8 @@ icon: material/new-box
"netns": "", "netns": "",
"tcp_fast_open": false, "tcp_fast_open": false,
"tcp_multi_path": false, "tcp_multi_path": false,
"tcp_keep_alive": "",
"tcp_keep_alive_interval": "",
"udp_fragment": false, "udp_fragment": false,
"udp_timeout": "", "udp_timeout": "",
"detour": "", "detour": "",
@@ -101,6 +107,22 @@ icon: material/new-box
启用 TCP Multi Path。 启用 TCP Multi Path。
#### tcp_keep_alive
!!! question "自 sing-box 1.13.0 起"
默认值从 `10m` 更改为 `5m`
TCP keep-alive 初始周期。
默认使用 `5m`
#### tcp_keep_alive_interval
TCP keep-alive 间隔。
默认使用 `75s`
#### udp_fragment #### udp_fragment
启用 UDP 分段。 启用 UDP 分段。

View File

@@ -65,24 +65,26 @@ type DialerOptionsWrapper interface {
} }
type DialerOptions struct { type DialerOptions struct {
Detour string `json:"detour,omitempty"` Detour string `json:"detour,omitempty"`
BindInterface string `json:"bind_interface,omitempty"` BindInterface string `json:"bind_interface,omitempty"`
Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"`
Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"`
ProtectPath string `json:"protect_path,omitempty"` ProtectPath string `json:"protect_path,omitempty"`
RoutingMark FwMark `json:"routing_mark,omitempty"` RoutingMark FwMark `json:"routing_mark,omitempty"`
ReuseAddr bool `json:"reuse_addr,omitempty"` ReuseAddr bool `json:"reuse_addr,omitempty"`
NetNs string `json:"netns,omitempty"` NetNs string `json:"netns,omitempty"`
ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"`
TCPFastOpen bool `json:"tcp_fast_open,omitempty"` TCPFastOpen bool `json:"tcp_fast_open,omitempty"`
TCPMultiPath bool `json:"tcp_multi_path,omitempty"` TCPMultiPath bool `json:"tcp_multi_path,omitempty"`
UDPFragment *bool `json:"udp_fragment,omitempty"` TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"`
UDPFragmentDefault bool `json:"-"` TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"`
DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` UDPFragment *bool `json:"udp_fragment,omitempty"`
NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` UDPFragmentDefault bool `json:"-"`
NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"`
FallbackNetworkType badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"` NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"`
FallbackDelay badoption.Duration `json:"fallback_delay,omitempty"` NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"`
FallbackNetworkType badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"`
FallbackDelay badoption.Duration `json:"fallback_delay,omitempty"`
// Deprecated: migrated to domain resolver // Deprecated: migrated to domain resolver
DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"`

View File

@@ -85,6 +85,7 @@ function index()
entry({"admin", "services", appname, "reassign_group"}, call("reassign_group")).leaf = true entry({"admin", "services", appname, "reassign_group"}, call("reassign_group")).leaf = true
entry({"admin", "services", appname, "get_node"}, call("get_node")).leaf = true entry({"admin", "services", appname, "get_node"}, call("get_node")).leaf = true
entry({"admin", "services", appname, "save_node_order"}, call("save_node_order")).leaf = true entry({"admin", "services", appname, "save_node_order"}, call("save_node_order")).leaf = true
entry({"admin", "services", appname, "save_node_list_opt"}, call("save_node_list_opt")).leaf = true
entry({"admin", "services", appname, "update_rules"}, call("update_rules")).leaf = true entry({"admin", "services", appname, "update_rules"}, call("update_rules")).leaf = true
entry({"admin", "services", appname, "subscribe_del_node"}, call("subscribe_del_node")).leaf = true entry({"admin", "services", appname, "subscribe_del_node"}, call("subscribe_del_node")).leaf = true
entry({"admin", "services", appname, "subscribe_del_all"}, call("subscribe_del_all")).leaf = true entry({"admin", "services", appname, "subscribe_del_all"}, call("subscribe_del_all")).leaf = true
@@ -655,6 +656,15 @@ function reassign_group()
http_write_json({ status = "ok" }) http_write_json({ status = "ok" })
end end
function save_node_list_opt()
local option = http.formvalue("option") or ""
local value = http.formvalue("value") or ""
if option ~= "" then
api.sh_uci_set(appname, "@global_other[0]", option, value, true)
end
http_write_json({ status = "ok" })
end
function update_rules() function update_rules()
local update = http.formvalue("update") local update = http.formvalue("update")
luci.sys.call("lua /usr/share/passwall/rule_update.lua log '" .. update .. "' > /dev/null 2>&1 &") luci.sys.call("lua /usr/share/passwall/rule_update.lua log '" .. update .. "' > /dev/null 2>&1 &")

View File

@@ -160,6 +160,11 @@ table td, .table .td {
background-color: rgba(131, 191, 255, 0.7) !important; background-color: rgba(131, 191, 255, 0.7) !important;
box-shadow: 0 4px 6px rgba(0,0,0,0.1); box-shadow: 0 4px 6px rgba(0,0,0,0.1);
} }
/* hide save button */
.cbi-page-actions {
display: none !important;
}
</style> </style>
<% if api.is_js_luci() then -%> <% if api.is_js_luci() then -%>
@@ -215,7 +220,7 @@ table td, .table .td {
//<![CDATA[ //<![CDATA[
let auto_detection_time = "<%=api.uci_get_type("global_other", "auto_detection_time", "0")%>" let auto_detection_time = "<%=api.uci_get_type("global_other", "auto_detection_time", "0")%>"
let show_node_info = "<%=api.uci_get_type("global_other", "show_node_info", "0")%>" let show_node_info = "<%=api.uci_get_type("global_other", "show_node_info", "0")%>"
var node_list = []; var node_list = [];
var ajax = { var ajax = {
@@ -552,7 +557,7 @@ table td, .table .td {
); );
} }
} }
function ping_node(cbi_id, dom, type) { function ping_node(cbi_id, dom, type) {
var full = get_address_full(cbi_id); var full = get_address_full(cbi_id);
if ((type == "icmp" && full.address != "" ) || (type == "tcping" && full.address != "" && full.port != "")) { if ((type == "icmp" && full.address != "" ) || (type == "tcping" && full.address != "" && full.port != "")) {
@@ -862,10 +867,10 @@ table td, .table .td {
return str; return str;
} }
XHR.get('<%=api.url("get_node")%>', null, function loadNodeList() {
function(x, result) { XHR.get('<%=api.url("get_node")%>', null, function(x, result) {
var node_list = result var node_list = result
var group_nodes = {} var group_nodes = {}
for (let i = 0; i < node_list.length; i++) { for (let i = 0; i < node_list.length; i++) {
let _node = node_list[i] let _node = node_list[i]
@@ -877,7 +882,7 @@ table td, .table .td {
} }
group_nodes[_node.group].push(_node) group_nodes[_node.group].push(_node)
} }
var tab_ul_html = '<ul class="cbi-tabmenu">' var tab_ul_html = '<ul class="cbi-tabmenu">'
var tab_ul_li_html = '' var tab_ul_li_html = ''
var tab_content_html = '<fieldset class="cbi-section-node cbi-section-node-tabbed" id="cbi-passwall-nodes">' var tab_content_html = '<fieldset class="cbi-section-node cbi-section-node-tabbed" id="cbi-passwall-nodes">'
@@ -926,7 +931,7 @@ table td, .table .td {
innerHTML = innerHTML.split("{{remarks_val}}").join(o["remarks"]); innerHTML = innerHTML.split("{{remarks_val}}").join(o["remarks"]);
innerHTML = innerHTML.split("{{address_val}}").join(o["address"] || ""); innerHTML = innerHTML.split("{{address_val}}").join(o["address"] || "");
innerHTML = innerHTML.split("{{port_val}}").join(o["port"] || ""); innerHTML = innerHTML.split("{{port_val}}").join(o["port"] || "");
node_tr_html += innerHTML node_tr_html += innerHTML
} }
_html = _html.split("{{node-tr}}").join(node_tr_html); _html = _html.split("{{node-tr}}").join(node_tr_html);
@@ -937,7 +942,7 @@ table td, .table .td {
if (group === "default") { if (group === "default") {
group_name = "<%:default%>" group_name = "<%:default%>"
} }
tab_ul_li_html += tab_ul_li_html +=
'<li group_name="' + group + '" id="tab.passwall.nodes.' + group + '" class="cbi-tab">' + '<li group_name="' + group + '" id="tab.passwall.nodes.' + group + '" class="cbi-tab">' +
'<a onclick="this.blur(); return cbi_t_switch(\'passwall.nodes\', \'' + group + '\')" href="<%=REQUEST_URI%>?tab.passwall.nodes=' + group + '">' + group_name + " | " + "<font style='color: red'>" + group_nodes[group].length + '</font></a>' + '<a onclick="this.blur(); return cbi_t_switch(\'passwall.nodes\', \'' + group + '\')" href="<%=REQUEST_URI%>?tab.passwall.nodes=' + group + '">' + group_name + " | " + "<font style='color: red'>" + group_nodes[group].length + '</font></a>' +
@@ -947,17 +952,17 @@ table td, .table .td {
'' + table_html + '' + table_html +
'</div>' '</div>'
} }
tab_ul_html += tab_ul_li_html + '</ul>' tab_ul_html += tab_ul_li_html + '</ul>'
tab_content_html += '</fieldset>' tab_content_html += '</fieldset>'
var tab_html = tab_ul_html + tab_content_html var tab_html = tab_ul_html + tab_content_html
document.getElementById("node_list").innerHTML = tab_html document.getElementById("node_list").innerHTML = tab_html
for (let group in group_nodes) { for (let group in group_nodes) {
cbi_t_add("passwall.nodes", group) cbi_t_add("passwall.nodes", group)
} }
if (default_group) { if (default_group) {
cbi_t_switch("passwall.nodes", default_group) cbi_t_switch("passwall.nodes", default_group)
} }
@@ -983,12 +988,60 @@ table td, .table .td {
} }
} }
} }
get_now_use_node(); get_now_use_node();
pingAllNodes(); pingAllNodes();
});
}
loadNodeList();
//Node list option saving logic
document.addEventListener("DOMContentLoaded", function () {
function waitForElement(selector, callback) {
const el = document.querySelector(selector);
if (el) return callback(el);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
observer.disconnect();
callback(el);
}
});
observer.observe(document.body, { childList: true, subtree: true });
} }
);
function onChange(option, value) {
XHR.get('<%=api.url("save_node_list_opt")%>', {
option: option,
value: value
}, function(x) {
if (x && x.status == 200) {
document.getElementById("node_list").innerHTML = "";
loadNodeList();
} else {
alert("<%:Error%>");
}
});
}
waitForElement('input[type="checkbox"][name*="passwall"][name*="show_node_info"]', function(el) {
el.addEventListener("change", () => {
el.blur();
show_node_info = el.checked ? "1" : "0";
onChange("show_node_info", show_node_info);
});
});
waitForElement('select[name*="passwall"][name*="auto_detection_time"]', function(el) {
el.addEventListener("change", () => {
el.blur();
auto_detection_time = el.value;
onChange("auto_detection_time", auto_detection_time);
});
});
});
//]]> //]]>
</script> </script>

View File

@@ -21,13 +21,13 @@ define Download/geoip
HASH:=6878dbacfb1fcb1ee022f63ed6934bcefc95a3c4ba10c88f1131fb88dbf7c337 HASH:=6878dbacfb1fcb1ee022f63ed6934bcefc95a3c4ba10c88f1131fb88dbf7c337
endef endef
GEOSITE_VER:=20251205081953 GEOSITE_VER:=20251206075552
GEOSITE_FILE:=dlc.dat.$(GEOSITE_VER) GEOSITE_FILE:=dlc.dat.$(GEOSITE_VER)
define Download/geosite define Download/geosite
URL:=https://github.com/v2fly/domain-list-community/releases/download/$(GEOSITE_VER)/ URL:=https://github.com/v2fly/domain-list-community/releases/download/$(GEOSITE_VER)/
URL_FILE:=dlc.dat URL_FILE:=dlc.dat
FILE:=$(GEOSITE_FILE) FILE:=$(GEOSITE_FILE)
HASH:=c99135dc54376b37185fd27a814435ab923f37ca6e6a784169cf743347c94beb HASH:=f1276502c556709de5cc6c7581cb2e9369721c37dc6142aacf5771f7c60106f6
endef endef
GEOSITE_IRAN_VER:=202512010051 GEOSITE_IRAN_VER:=202512010051

View File

@@ -3,7 +3,7 @@
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core) A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
[![API](https://img.shields.io/badge/API-21%2B-yellow.svg?style=flat)](https://developer.android.com/about/versions/lollipop) [![API](https://img.shields.io/badge/API-21%2B-yellow.svg?style=flat)](https://developer.android.com/about/versions/lollipop)
[![Kotlin Version](https://img.shields.io/badge/Kotlin-2.2.0-blue.svg)](https://kotlinlang.org) [![Kotlin Version](https://img.shields.io/badge/Kotlin-2.2.21-blue.svg)](https://kotlinlang.org)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/2dust/v2rayNG)](https://github.com/2dust/v2rayNG/commits/master) [![GitHub commit activity](https://img.shields.io/github/commit-activity/m/2dust/v2rayNG)](https://github.com/2dust/v2rayNG/commits/master)
[![CodeFactor](https://www.codefactor.io/repository/github/2dust/v2rayng/badge)](https://www.codefactor.io/repository/github/2dust/v2rayng) [![CodeFactor](https://www.codefactor.io/repository/github/2dust/v2rayng/badge)](https://www.codefactor.io/repository/github/2dust/v2rayng)
[![GitHub Releases](https://img.shields.io/github/downloads/2dust/v2rayNG/latest/total?logo=github)](https://github.com/2dust/v2rayNG/releases) [![GitHub Releases](https://img.shields.io/github/downloads/2dust/v2rayNG/latest/total?logo=github)](https://github.com/2dust/v2rayNG/releases)

View File

@@ -112,7 +112,8 @@ object AppConfig {
const val TG_CHANNEL_URL = "https://t.me/github_2dust" const val TG_CHANNEL_URL = "https://t.me/github_2dust"
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204" const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204" const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
const val IP_API_URL = "https://speed.cloudflare.com/meta" // const val IP_API_URL = "https://speed.cloudflare.com/meta"
const val IP_API_URL = "https://api.ip.sb/geoip"
/** DNS server addresses. */ /** DNS server addresses. */
const val DNS_PROXY = "1.1.1.1" const val DNS_PROXY = "1.1.1.1"

View File

@@ -1,8 +1,8 @@
[versions] [versions]
agp = "8.12.3" agp = "8.13.1"
desugarJdkLibs = "2.1.5" desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.8" gradleLicensePlugin = "0.9.8"
kotlin = "2.2.20" kotlin = "2.2.21"
coreKtx = "1.16.0" coreKtx = "1.16.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"

View File

@@ -1,6 +1,6 @@
#Thu Nov 14 12:42:51 BDT 2024 #Thu Nov 14 12:42:51 BDT 2024
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@@ -8,12 +8,10 @@ from ..utils import (
ExtractorError, ExtractorError,
determine_ext, determine_ext,
filter_dict, filter_dict,
get_first,
int_or_none, int_or_none,
parse_iso8601, parse_iso8601,
update_url, update_url,
url_or_none, url_or_none,
variadic,
) )
from ..utils.traversal import traverse_obj from ..utils.traversal import traverse_obj
@@ -51,7 +49,7 @@ class LoomIE(InfoExtractor):
}, { }, {
# m3u8 raw-url, mp4 transcoded-url, cdn url == raw-url, vtt sub and json subs # m3u8 raw-url, mp4 transcoded-url, cdn url == raw-url, vtt sub and json subs
'url': 'https://www.loom.com/share/9458bcbf79784162aa62ffb8dd66201b', 'url': 'https://www.loom.com/share/9458bcbf79784162aa62ffb8dd66201b',
'md5': '51737ec002969dd28344db4d60b9cbbb', 'md5': '7b6bfdef8181c4ffc376e18919a4dcc2',
'info_dict': { 'info_dict': {
'id': '9458bcbf79784162aa62ffb8dd66201b', 'id': '9458bcbf79784162aa62ffb8dd66201b',
'ext': 'mp4', 'ext': 'mp4',
@@ -71,12 +69,13 @@ class LoomIE(InfoExtractor):
'ext': 'webm', 'ext': 'webm',
'title': 'OMFG clown', 'title': 'OMFG clown',
'description': 'md5:285c5ee9d62aa087b7e3271b08796815', 'description': 'md5:285c5ee9d62aa087b7e3271b08796815',
'uploader': 'MrPumkin B', 'uploader': 'Brailey Bragg',
'upload_date': '20210924', 'upload_date': '20210924',
'timestamp': 1632519618, 'timestamp': 1632519618,
'duration': 210, 'duration': 210,
}, },
'params': {'skip_download': 'dash'}, 'params': {'skip_download': 'dash'},
'expected_warnings': ['Failed to parse JSON'], # transcoded-url no longer available
}, { }, {
# password-protected # password-protected
'url': 'https://www.loom.com/share/50e26e8aeb7940189dff5630f95ce1f4', 'url': 'https://www.loom.com/share/50e26e8aeb7940189dff5630f95ce1f4',
@@ -91,10 +90,11 @@ class LoomIE(InfoExtractor):
'duration': 35, 'duration': 35,
}, },
'params': {'videopassword': 'seniorinfants2'}, 'params': {'videopassword': 'seniorinfants2'},
'expected_warnings': ['Failed to parse JSON'], # transcoded-url no longer available
}, { }, {
# embed, transcoded-url endpoint sends empty JSON response, split video and audio HLS formats # embed, transcoded-url endpoint sends empty JSON response, split video and audio HLS formats
'url': 'https://www.loom.com/embed/ddcf1c1ad21f451ea7468b1e33917e4e', 'url': 'https://www.loom.com/embed/ddcf1c1ad21f451ea7468b1e33917e4e',
'md5': 'b321d261656848c184a94e3b93eae28d', 'md5': 'f983a0f02f24331738b2f43aecb05256',
'info_dict': { 'info_dict': {
'id': 'ddcf1c1ad21f451ea7468b1e33917e4e', 'id': 'ddcf1c1ad21f451ea7468b1e33917e4e',
'ext': 'mp4', 'ext': 'mp4',
@@ -119,11 +119,12 @@ class LoomIE(InfoExtractor):
'duration': 247, 'duration': 247,
'timestamp': 1676274030, 'timestamp': 1676274030,
}, },
'skip': '404 Not Found',
}] }]
_GRAPHQL_VARIABLES = { _GRAPHQL_VARIABLES = {
'GetVideoSource': { 'GetVideoSource': {
'acceptableMimes': ['DASH', 'M3U8', 'MP4'], 'acceptableMimes': ['DASH', 'M3U8', 'MP4', 'WEBM'],
}, },
} }
_GRAPHQL_QUERIES = { _GRAPHQL_QUERIES = {
@@ -192,6 +193,12 @@ class LoomIE(InfoExtractor):
id id
nullableRawCdnUrl(acceptableMimes: $acceptableMimes, password: $password) { nullableRawCdnUrl(acceptableMimes: $acceptableMimes, password: $password) {
url url
credentials {
Policy
Signature
KeyPairId
__typename
}
__typename __typename
} }
__typename __typename
@@ -240,9 +247,9 @@ class LoomIE(InfoExtractor):
} }
}\n'''), }\n'''),
} }
_APOLLO_GRAPHQL_VERSION = '0a1856c' _APOLLO_GRAPHQL_VERSION = '45a5bd4'
def _call_graphql_api(self, operations, video_id, note=None, errnote=None): def _call_graphql_api(self, operation_name, video_id, note=None, errnote=None, fatal=True):
password = self.get_param('videopassword') password = self.get_param('videopassword')
return self._download_json( return self._download_json(
'https://www.loom.com/graphql', video_id, note or 'Downloading GraphQL JSON', 'https://www.loom.com/graphql', video_id, note or 'Downloading GraphQL JSON',
@@ -252,7 +259,9 @@ class LoomIE(InfoExtractor):
'x-loom-request-source': f'loom_web_{self._APOLLO_GRAPHQL_VERSION}', 'x-loom-request-source': f'loom_web_{self._APOLLO_GRAPHQL_VERSION}',
'apollographql-client-name': 'web', 'apollographql-client-name': 'web',
'apollographql-client-version': self._APOLLO_GRAPHQL_VERSION, 'apollographql-client-version': self._APOLLO_GRAPHQL_VERSION,
}, data=json.dumps([{ 'graphql-operation-name': operation_name,
'Origin': 'https://www.loom.com',
}, data=json.dumps({
'operationName': operation_name, 'operationName': operation_name,
'variables': { 'variables': {
'videoId': video_id, 'videoId': video_id,
@@ -260,7 +269,7 @@ class LoomIE(InfoExtractor):
**self._GRAPHQL_VARIABLES.get(operation_name, {}), **self._GRAPHQL_VARIABLES.get(operation_name, {}),
}, },
'query': self._GRAPHQL_QUERIES[operation_name], 'query': self._GRAPHQL_QUERIES[operation_name],
} for operation_name in variadic(operations)], separators=(',', ':')).encode()) }, separators=(',', ':')).encode(), fatal=fatal)
def _call_url_api(self, endpoint, video_id): def _call_url_api(self, endpoint, video_id):
response = self._download_json( response = self._download_json(
@@ -275,7 +284,7 @@ class LoomIE(InfoExtractor):
}, separators=(',', ':')).encode()) }, separators=(',', ':')).encode())
return traverse_obj(response, ('url', {url_or_none})) return traverse_obj(response, ('url', {url_or_none}))
def _extract_formats(self, video_id, metadata, gql_data): def _extract_formats(self, video_id, metadata, video_data):
formats = [] formats = []
video_properties = traverse_obj(metadata, ('video_properties', { video_properties = traverse_obj(metadata, ('video_properties', {
'width': ('width', {int_or_none}), 'width': ('width', {int_or_none}),
@@ -330,7 +339,7 @@ class LoomIE(InfoExtractor):
transcoded_url = self._call_url_api('transcoded-url', video_id) transcoded_url = self._call_url_api('transcoded-url', video_id)
formats.extend(get_formats(transcoded_url, 'transcoded', quality=-1)) # transcoded quality formats.extend(get_formats(transcoded_url, 'transcoded', quality=-1)) # transcoded quality
cdn_url = get_first(gql_data, ('data', 'getVideo', 'nullableRawCdnUrl', 'url', {url_or_none})) cdn_url = traverse_obj(video_data, ('data', 'getVideo', 'nullableRawCdnUrl', 'url', {url_or_none}))
# cdn_url is usually a dupe, but the raw-url/transcoded-url endpoints could return errors # cdn_url is usually a dupe, but the raw-url/transcoded-url endpoints could return errors
valid_urls = [update_url(url, query=None) for url in (raw_url, transcoded_url) if url] valid_urls = [update_url(url, query=None) for url in (raw_url, transcoded_url) if url]
if cdn_url and update_url(cdn_url, query=None) not in valid_urls: if cdn_url and update_url(cdn_url, query=None) not in valid_urls:
@@ -338,10 +347,21 @@ class LoomIE(InfoExtractor):
return formats return formats
def _get_subtitles(self, video_id):
subs_data = self._call_graphql_api(
'FetchVideoTranscript', video_id, 'Downloading GraphQL subtitles JSON', fatal=False)
return filter_dict({
'en': traverse_obj(subs_data, (
'data', 'fetchVideoTranscript',
('source_url', 'captions_source_url'), {
'url': {url_or_none},
})) or None,
})
def _real_extract(self, url): def _real_extract(self, url):
video_id = self._match_id(url) video_id = self._match_id(url)
metadata = get_first( metadata = traverse_obj(
self._call_graphql_api('GetVideoSSR', video_id, 'Downloading GraphQL metadata JSON'), self._call_graphql_api('GetVideoSSR', video_id, 'Downloading GraphQL metadata JSON', fatal=False),
('data', 'getVideo', {dict})) or {} ('data', 'getVideo', {dict})) or {}
if metadata.get('__typename') == 'VideoPasswordMissingOrIncorrect': if metadata.get('__typename') == 'VideoPasswordMissingOrIncorrect':
@@ -350,22 +370,19 @@ class LoomIE(InfoExtractor):
'This video is password-protected, use the --video-password option', expected=True) 'This video is password-protected, use the --video-password option', expected=True)
raise ExtractorError('Invalid video password', expected=True) raise ExtractorError('Invalid video password', expected=True)
gql_data = self._call_graphql_api(['FetchChapters', 'FetchVideoTranscript', 'GetVideoSource'], video_id) video_data = self._call_graphql_api(
'GetVideoSource', video_id, 'Downloading GraphQL video JSON')
chapter_data = self._call_graphql_api(
'FetchChapters', video_id, 'Downloading GraphQL chapters JSON', fatal=False)
duration = traverse_obj(metadata, ('video_properties', 'duration', {int_or_none})) duration = traverse_obj(metadata, ('video_properties', 'duration', {int_or_none}))
return { return {
'id': video_id, 'id': video_id,
'duration': duration, 'duration': duration,
'chapters': self._extract_chapters_from_description( 'chapters': self._extract_chapters_from_description(
get_first(gql_data, ('data', 'fetchVideoChapters', 'content', {str})), duration) or None, traverse_obj(chapter_data, ('data', 'fetchVideoChapters', 'content', {str})), duration) or None,
'formats': self._extract_formats(video_id, metadata, gql_data), 'formats': self._extract_formats(video_id, metadata, video_data),
'subtitles': filter_dict({ 'subtitles': self.extract_subtitles(video_id),
'en': traverse_obj(gql_data, (
..., 'data', 'fetchVideoTranscript',
('source_url', 'captions_source_url'), {
'url': {url_or_none},
})) or None,
}),
**traverse_obj(metadata, { **traverse_obj(metadata, {
'title': ('name', {str}), 'title': ('name', {str}),
'description': ('description', {str}), 'description': ('description', {str}),
@@ -376,6 +393,7 @@ class LoomIE(InfoExtractor):
class LoomFolderIE(InfoExtractor): class LoomFolderIE(InfoExtractor):
_WORKING = False
IE_NAME = 'loom:folder' IE_NAME = 'loom:folder'
_VALID_URL = r'https?://(?:www\.)?loom\.com/share/folder/(?P<id>[\da-f]{32})' _VALID_URL = r'https?://(?:www\.)?loom\.com/share/folder/(?P<id>[\da-f]{32})'
_TESTS = [{ _TESTS = [{