mirror of
https://github.com/danielmiessler/fabric.git
synced 2026-09-10 07:36:44 -04:00
fix: secure storage paths and authenticate REST and Ollama servers
- Reject unsafe cross-platform storage names and traversal attempts. - Confine symlink targets within configured filesystem storage directories. - Require API keys for every non-loopback server binding. - Authenticate Ollama routes and securely forward configured credentials. - Validate chat pattern, context, and session names early. - Sanitize client errors to hide internal filesystem details. - Default REST server binding to loopback port 8080. - Add regression coverage for traversal, symlinks, and authentication.
This commit is contained in:
parent
cc2a9d39d6
commit
f37c71f9f7
7
cmd/generate_changelog/incoming/2206.txt
Normal file
7
cmd/generate_changelog/incoming/2206.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
### PR [#2206](https://github.com/danielmiessler/Fabric/pull/2206) by [ksylvan](https://github.com/ksylvan): fix: confine storage names and authenticate Ollama serve
|
||||||
|
|
||||||
|
- Reject unsafe cross-platform storage names and confine symlink targets to configured storage directories.
|
||||||
|
- Require API keys for non-loopback server bindings, authenticate Ollama routes, and securely forward configured credentials.
|
||||||
|
- Sanitize client errors to prevent exposure of internal filesystem paths.
|
||||||
|
- Validate chat pattern, context, and session names early.
|
||||||
|
- Default the REST API to loopback-only port 8080 and add regression coverage for traversal, symlink, and authentication vulnerabilities.
|
||||||
13
docs/docs.go
13
docs/docs.go
|
|
@ -139,6 +139,15 @@ const docTemplate = `{
|
||||||
"$ref": "#/definitions/fsdb.Pattern"
|
"$ref": "#/definitions/fsdb.Pattern"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error",
|
"description": "Internal Server Error",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|
@ -346,7 +355,6 @@ const docTemplate = `{
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"description": "Add Language field to bind from request",
|
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"maxTokens": {
|
"maxTokens": {
|
||||||
|
|
@ -412,9 +420,6 @@ const docTemplate = `{
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"format": "float64"
|
"format": "float64"
|
||||||
},
|
},
|
||||||
"updateChan": {
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"voice": {
|
"voice": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,15 @@
|
||||||
"$ref": "#/definitions/fsdb.Pattern"
|
"$ref": "#/definitions/fsdb.Pattern"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Internal Server Error",
|
"description": "Internal Server Error",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|
@ -340,7 +349,6 @@
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"description": "Add Language field to bind from request",
|
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"maxTokens": {
|
"maxTokens": {
|
||||||
|
|
@ -406,9 +414,6 @@
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"format": "float64"
|
"format": "float64"
|
||||||
},
|
},
|
||||||
"updateChan": {
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"voice": {
|
"voice": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ definitions:
|
||||||
imageSize:
|
imageSize:
|
||||||
type: string
|
type: string
|
||||||
language:
|
language:
|
||||||
description: Add Language field to bind from request
|
|
||||||
type: string
|
type: string
|
||||||
maxTokens:
|
maxTokens:
|
||||||
type: integer
|
type: integer
|
||||||
|
|
@ -95,8 +94,6 @@ definitions:
|
||||||
topP:
|
topP:
|
||||||
format: float64
|
format: float64
|
||||||
type: number
|
type: number
|
||||||
updateChan:
|
|
||||||
type: object
|
|
||||||
voice:
|
voice:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -265,6 +262,12 @@ paths:
|
||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/fsdb.Pattern'
|
$ref: '#/definitions/fsdb.Pattern'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
"500":
|
"500":
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
schema:
|
schema:
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,8 @@ type Flags struct {
|
||||||
DryRun bool `long:"dry-run" description:"Show what would be sent to the model without actually sending it"`
|
DryRun bool `long:"dry-run" description:"Show what would be sent to the model without actually sending it"`
|
||||||
Serve bool `long:"serve" description:"Serve the Fabric Rest API"`
|
Serve bool `long:"serve" description:"Serve the Fabric Rest API"`
|
||||||
ServeOllama bool `long:"serveOllama" description:"Serve the Fabric Rest API with ollama endpoints"`
|
ServeOllama bool `long:"serveOllama" description:"Serve the Fabric Rest API with ollama endpoints"`
|
||||||
ServeAddress string `long:"address" description:"The address to bind the REST API" default:":8080"`
|
ServeAddress string `long:"address" description:"The address to bind the REST API" default:"127.0.0.1:8080"`
|
||||||
ServeAPIKey string `long:"api-key" description:"API key used to secure server routes" default:""`
|
ServeAPIKey string `long:"api-key" env:"FABRIC_API_KEY" description:"API key used to secure server routes" default:""`
|
||||||
Config string `long:"config" description:"Path to YAML config file"`
|
Config string `long:"config" description:"Path to YAML config file"`
|
||||||
Version bool `long:"version" description:"Print current version"`
|
Version bool `long:"version" description:"Print current version"`
|
||||||
ListExtensions bool `long:"listextensions" description:"List all registered extensions"`
|
ListExtensions bool `long:"listextensions" description:"List all registered extensions"`
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import (
|
||||||
restapi "github.com/danielmiessler/fabric/internal/server"
|
restapi "github.com/danielmiessler/fabric/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// serveOllama is a seam for tests, because the real entry point blocks
|
||||||
|
// on a listening socket.
|
||||||
|
var serveOllama = restapi.ServeOllama
|
||||||
|
|
||||||
// handleSetupAndServerCommands handles setup and server-related commands
|
// handleSetupAndServerCommands handles setup and server-related commands
|
||||||
// Returns (handled, error) where handled indicates if a command was processed and should exit
|
// Returns (handled, error) where handled indicates if a command was processed and should exit
|
||||||
func handleSetupAndServerCommands(currentFlags *Flags, registry *core.PluginRegistry, version string) (handled bool, err error) {
|
func handleSetupAndServerCommands(currentFlags *Flags, registry *core.PluginRegistry, version string) (handled bool, err error) {
|
||||||
|
|
@ -22,7 +26,7 @@ func handleSetupAndServerCommands(currentFlags *Flags, registry *core.PluginRegi
|
||||||
|
|
||||||
if currentFlags.ServeOllama {
|
if currentFlags.ServeOllama {
|
||||||
registry.ConfigureVendors()
|
registry.ConfigureVendors()
|
||||||
err = restapi.ServeOllama(registry, currentFlags.ServeAddress, version)
|
err = serveOllama(registry, currentFlags.ServeAddress, version, currentFlags.ServeAPIKey)
|
||||||
return true, err
|
return true, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
37
internal/cli/setup_server_test.go
Normal file
37
internal/cli/setup_server_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/danielmiessler/fabric/internal/core"
|
||||||
|
"github.com/danielmiessler/fabric/internal/plugins/ai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The --serveOllama path must pass the address, version, and API key
|
||||||
|
// flags through to ServeOllama unchanged.
|
||||||
|
func TestHandleSetupAndServerCommands_ServeOllamaWiring(t *testing.T) {
|
||||||
|
var gotAddress, gotVersion, gotKey string
|
||||||
|
prev := serveOllama
|
||||||
|
serveOllama = func(_ *core.PluginRegistry, address, version, apiKey string) error {
|
||||||
|
gotAddress, gotVersion, gotKey = address, version, apiKey
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer func() { serveOllama = prev }()
|
||||||
|
|
||||||
|
registry := &core.PluginRegistry{
|
||||||
|
VendorManager: ai.NewVendorsManager(),
|
||||||
|
VendorsAll: ai.NewVendorsManager(),
|
||||||
|
}
|
||||||
|
flags := &Flags{ServeOllama: true, ServeAddress: "127.0.0.1:9999", ServeAPIKey: "secret"}
|
||||||
|
handled, err := handleSetupAndServerCommands(flags, registry, "v-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handleSetupAndServerCommands() error = %v", err)
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("handleSetupAndServerCommands() handled = false, want true")
|
||||||
|
}
|
||||||
|
if gotAddress != "127.0.0.1:9999" || gotVersion != "v-test" || gotKey != "secret" {
|
||||||
|
t.Fatalf("ServeOllama got (%q, %q, %q), want (127.0.0.1:9999, v-test, secret)",
|
||||||
|
gotAddress, gotVersion, gotKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "ungültiges Data-URL-Format",
|
"ollama_invalid_data_url_format": "ungültiges Data-URL-Format",
|
||||||
"ollama_invalid_http_timeout_using_default": "ungültiges HTTP-Zeitlimit '%s': %v, verwende Standardwert",
|
"ollama_invalid_http_timeout_using_default": "ungültiges HTTP-Zeitlimit '%s': %v, verwende Standardwert",
|
||||||
"ollama_invalid_num_ctx_in_request": "Ungültiger num_ctx in Anfrage: %v",
|
"ollama_invalid_num_ctx_in_request": "Ungültiger num_ctx in Anfrage: %v",
|
||||||
|
"ollama_invalid_request_body": "ungültiger Anfragetext",
|
||||||
"ollama_no_content_from_upstream": "Kein Inhalt vom Upstream Fabric Server erhalten",
|
"ollama_no_content_from_upstream": "Kein Inhalt vom Upstream Fabric Server erhalten",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx überschreitet den maximal zulässigen Wert von %d",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx überschreitet den maximal zulässigen Wert von %d",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx muss eine Zahl sein, ungültiger Typ erhalten",
|
"ollama_num_ctx_invalid_type": "num_ctx muss eine Zahl sein, ungültiger Typ erhalten",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "SSE Zeile überschreitet 1MB Puffer-Limit - Datenzeile zu groß",
|
"ollama_sse_buffer_limit": "SSE Zeile überschreitet 1MB Puffer-Limit - Datenzeile zu groß",
|
||||||
"ollama_upstream_non_2xx": "Upstream Fabric Server hat nicht-2xx Status %d zurückgegeben: %s",
|
"ollama_upstream_non_2xx": "Upstream Fabric Server hat nicht-2xx Status %d zurückgegeben: %s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "Upstream Fabric Server hat nicht-2xx Status %d zurückgegeben und Body konnte nicht gelesen werden: %v",
|
"ollama_upstream_non_2xx_body_unreadable": "Upstream Fabric Server hat nicht-2xx Status %d zurückgegeben und Body konnte nicht gelesen werden: %v",
|
||||||
|
"ollama_upstream_request_failed": "Upstream-Fabric-Server nicht erreichbar",
|
||||||
"ollama_upstream_returned_status": "Upstream Fabric Server hat Status %d zurückgegeben",
|
"ollama_upstream_returned_status": "Upstream Fabric Server hat Status %d zurückgegeben",
|
||||||
"ollama_warning_no_content": "Warnung: Kein Inhalt vom Upstream Fabric Server erhalten",
|
"ollama_warning_no_content": "Warnung: Kein Inhalt vom Upstream Fabric Server erhalten",
|
||||||
"ollama_warning_parse_variables": "Warnung: Fehler beim Parsen von options.variables als JSON: %v",
|
"ollama_warning_parse_variables": "Warnung: Fehler beim Parsen von options.variables als JSON: %v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "Desktop-Benachrichtigung senden, wenn Befehl abgeschlossen ist",
|
"send_desktop_notification": "Desktop-Benachrichtigung senden, wenn Befehl abgeschlossen ist",
|
||||||
"serve_fabric_api_ollama_endpoints": "Fabric REST API mit ollama-Endpunkten bereitstellen",
|
"serve_fabric_api_ollama_endpoints": "Fabric REST API mit ollama-Endpunkten bereitstellen",
|
||||||
"serve_fabric_rest_api": "Fabric REST API bereitstellen",
|
"serve_fabric_rest_api": "Fabric REST API bereitstellen",
|
||||||
|
"server_api_key_required": "Server-Start auf Nicht-Loopback-Adresse %s ohne API-Schlüssel verweigert: Setzen Sie --api-key oder FABRIC_API_KEY, oder binden Sie eine Loopback-Adresse wie 127.0.0.1:8080",
|
||||||
"server_chat_error": "Fehler: %v",
|
"server_chat_error": "Fehler: %v",
|
||||||
"server_error_marshaling_response": "Fehler beim Serialisieren der Antwort: %v",
|
"server_error_marshaling_response": "Fehler beim Serialisieren der Antwort: %v",
|
||||||
"server_error_writing_response": "Fehler beim Schreiben der Antwort: %v",
|
"server_error_writing_response": "Fehler beim Schreiben der Antwort: %v",
|
||||||
"server_invalid_request_format": "ungültiges Anfrageformat: %v",
|
"server_invalid_request_format": "ungültiges Anfrageformat: %v",
|
||||||
|
"server_no_api_key_warning": "REST-API-Server wird ohne API-Schlüssel-Authentifizierung gestartet. Dies kann Sicherheitsrisiken bergen.",
|
||||||
"sessions_creating_new": "Erstelle neue Sitzung: %s\n",
|
"sessions_creating_new": "Erstelle neue Sitzung: %s\n",
|
||||||
"set_debug_level": "Debug-Level festlegen (0=aus, 1=grundlegend, 2=detailliert, 3=Trace, 4=wire)",
|
"set_debug_level": "Debug-Level festlegen (0=aus, 1=grundlegend, 2=detailliert, 3=Trace, 4=wire)",
|
||||||
"set_frequency_penalty": "Häufigkeitsstrafe festlegen",
|
"set_frequency_penalty": "Häufigkeitsstrafe festlegen",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "%s konnte nicht gespeichert werden: %v",
|
"storage_error_save": "%s konnte nicht gespeichert werden: %v",
|
||||||
"storage_error_stat_entry": "Eintrag %s konnte nicht abgefragt werden: %v",
|
"storage_error_stat_entry": "Eintrag %s konnte nicht abgefragt werden: %v",
|
||||||
"storage_error_unmarshal": "%s konnte nicht deserialisiert werden: %s",
|
"storage_error_unmarshal": "%s konnte nicht deserialisiert werden: %s",
|
||||||
|
"storage_invalid_name": "Ungültiger Name: %q",
|
||||||
"strategies_available_header": "Verfügbare Strategien:",
|
"strategies_available_header": "Verfügbare Strategien:",
|
||||||
"strategies_cloning_repository": "Repository %s wird geklont (Pfad: %s)...\\n",
|
"strategies_cloning_repository": "Repository %s wird geklont (Pfad: %s)...\\n",
|
||||||
"strategies_download_success": "✅ Strategien erfolgreich nach %s heruntergeladen und installiert\\n",
|
"strategies_download_success": "✅ Strategien erfolgreich nach %s heruntergeladen und installiert\\n",
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "invalid data URL format",
|
"ollama_invalid_data_url_format": "invalid data URL format",
|
||||||
"ollama_invalid_http_timeout_using_default": "invalid HTTP timeout '%s': %v, using default",
|
"ollama_invalid_http_timeout_using_default": "invalid HTTP timeout '%s': %v, using default",
|
||||||
"ollama_invalid_num_ctx_in_request": "invalid num_ctx in request: %v",
|
"ollama_invalid_num_ctx_in_request": "invalid num_ctx in request: %v",
|
||||||
|
"ollama_invalid_request_body": "invalid request body",
|
||||||
"ollama_no_content_from_upstream": "no content received from upstream Fabric server",
|
"ollama_no_content_from_upstream": "no content received from upstream Fabric server",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx exceeds maximum allowed value of %d",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx exceeds maximum allowed value of %d",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx must be a number, got invalid type",
|
"ollama_num_ctx_invalid_type": "num_ctx must be a number, got invalid type",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "SSE line exceeds 1MB buffer limit - data line too large",
|
"ollama_sse_buffer_limit": "SSE line exceeds 1MB buffer limit - data line too large",
|
||||||
"ollama_upstream_non_2xx": "Upstream Fabric server returned non-2xx status %d: %s",
|
"ollama_upstream_non_2xx": "Upstream Fabric server returned non-2xx status %d: %s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "Upstream Fabric server returned non-2xx status %d and body could not be read: %v",
|
"ollama_upstream_non_2xx_body_unreadable": "Upstream Fabric server returned non-2xx status %d and body could not be read: %v",
|
||||||
|
"ollama_upstream_request_failed": "failed to reach upstream Fabric server",
|
||||||
"ollama_upstream_returned_status": "upstream Fabric server returned status %d",
|
"ollama_upstream_returned_status": "upstream Fabric server returned status %d",
|
||||||
"ollama_warning_no_content": "Warning: no content received from upstream Fabric server",
|
"ollama_warning_no_content": "Warning: no content received from upstream Fabric server",
|
||||||
"ollama_warning_parse_variables": "Warning: failed to parse options.variables as JSON: %v",
|
"ollama_warning_parse_variables": "Warning: failed to parse options.variables as JSON: %v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "Send desktop notification when command completes",
|
"send_desktop_notification": "Send desktop notification when command completes",
|
||||||
"serve_fabric_api_ollama_endpoints": "Serve the Fabric Rest API with ollama endpoints",
|
"serve_fabric_api_ollama_endpoints": "Serve the Fabric Rest API with ollama endpoints",
|
||||||
"serve_fabric_rest_api": "Serve the Fabric Rest API",
|
"serve_fabric_rest_api": "Serve the Fabric Rest API",
|
||||||
|
"server_api_key_required": "refusing to serve on non-loopback address %s without an API key: set --api-key or FABRIC_API_KEY, or bind a loopback address such as 127.0.0.1:8080",
|
||||||
"server_chat_error": "Error: %v",
|
"server_chat_error": "Error: %v",
|
||||||
"server_error_marshaling_response": "error marshaling response: %v",
|
"server_error_marshaling_response": "error marshaling response: %v",
|
||||||
"server_error_writing_response": "error writing response: %v",
|
"server_error_writing_response": "error writing response: %v",
|
||||||
"server_invalid_request_format": "invalid request format: %v",
|
"server_invalid_request_format": "invalid request format: %v",
|
||||||
|
"server_no_api_key_warning": "Starting REST API server without API key authentication. This may pose security risks.",
|
||||||
"sessions_creating_new": "Creating new session: %s\n",
|
"sessions_creating_new": "Creating new session: %s\n",
|
||||||
"set_debug_level": "Set debug level (0=off, 1=basic, 2=detailed, 3=trace, 4=wire)",
|
"set_debug_level": "Set debug level (0=off, 1=basic, 2=detailed, 3=trace, 4=wire)",
|
||||||
"set_frequency_penalty": "Set frequency penalty",
|
"set_frequency_penalty": "Set frequency penalty",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "could not save %s: %v",
|
"storage_error_save": "could not save %s: %v",
|
||||||
"storage_error_stat_entry": "could not stat entry %s: %v",
|
"storage_error_stat_entry": "could not stat entry %s: %v",
|
||||||
"storage_error_unmarshal": "could not unmarshal %s: %s",
|
"storage_error_unmarshal": "could not unmarshal %s: %s",
|
||||||
|
"storage_invalid_name": "invalid name: %q",
|
||||||
"strategies_available_header": "Available Strategies:",
|
"strategies_available_header": "Available Strategies:",
|
||||||
"strategies_cloning_repository": "Cloning repository %s (path: %s)...\n",
|
"strategies_cloning_repository": "Cloning repository %s (path: %s)...\n",
|
||||||
"strategies_download_success": "✅ Successfully downloaded and installed strategies to %s\n",
|
"strategies_download_success": "✅ Successfully downloaded and installed strategies to %s\n",
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "formato de URL de datos inválido",
|
"ollama_invalid_data_url_format": "formato de URL de datos inválido",
|
||||||
"ollama_invalid_http_timeout_using_default": "Tiempo de espera HTTP inválido '%s': %v, usando el valor predeterminado",
|
"ollama_invalid_http_timeout_using_default": "Tiempo de espera HTTP inválido '%s': %v, usando el valor predeterminado",
|
||||||
"ollama_invalid_num_ctx_in_request": "num_ctx inválido en la solicitud: %v",
|
"ollama_invalid_num_ctx_in_request": "num_ctx inválido en la solicitud: %v",
|
||||||
|
"ollama_invalid_request_body": "cuerpo de solicitud no válido",
|
||||||
"ollama_no_content_from_upstream": "no se recibió contenido del servidor Fabric upstream",
|
"ollama_no_content_from_upstream": "no se recibió contenido del servidor Fabric upstream",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx excede el valor máximo permitido de %d",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx excede el valor máximo permitido de %d",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx debe ser un número, se obtuvo tipo inválido",
|
"ollama_num_ctx_invalid_type": "num_ctx debe ser un número, se obtuvo tipo inválido",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "Línea SSE excede el límite de búfer de 1MB - línea de datos demasiado grande",
|
"ollama_sse_buffer_limit": "Línea SSE excede el límite de búfer de 1MB - línea de datos demasiado grande",
|
||||||
"ollama_upstream_non_2xx": "El servidor Fabric upstream devolvió estado no-2xx %d: %s",
|
"ollama_upstream_non_2xx": "El servidor Fabric upstream devolvió estado no-2xx %d: %s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "El servidor Fabric upstream devolvió estado no-2xx %d y el cuerpo no se pudo leer: %v",
|
"ollama_upstream_non_2xx_body_unreadable": "El servidor Fabric upstream devolvió estado no-2xx %d y el cuerpo no se pudo leer: %v",
|
||||||
|
"ollama_upstream_request_failed": "no se pudo conectar con el servidor Fabric ascendente",
|
||||||
"ollama_upstream_returned_status": "el servidor Fabric upstream devolvió estado %d",
|
"ollama_upstream_returned_status": "el servidor Fabric upstream devolvió estado %d",
|
||||||
"ollama_warning_no_content": "Advertencia: no se recibió contenido del servidor Fabric upstream",
|
"ollama_warning_no_content": "Advertencia: no se recibió contenido del servidor Fabric upstream",
|
||||||
"ollama_warning_parse_variables": "Advertencia: error al analizar options.variables como JSON: %v",
|
"ollama_warning_parse_variables": "Advertencia: error al analizar options.variables como JSON: %v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "Enviar notificación de escritorio cuando se complete el comando",
|
"send_desktop_notification": "Enviar notificación de escritorio cuando se complete el comando",
|
||||||
"serve_fabric_api_ollama_endpoints": "Servir la API REST de Fabric con endpoints de ollama",
|
"serve_fabric_api_ollama_endpoints": "Servir la API REST de Fabric con endpoints de ollama",
|
||||||
"serve_fabric_rest_api": "Servir la API REST de Fabric",
|
"serve_fabric_rest_api": "Servir la API REST de Fabric",
|
||||||
|
"server_api_key_required": "se rechaza servir en la dirección no loopback %s sin clave de API: configure --api-key o FABRIC_API_KEY, o use una dirección loopback como 127.0.0.1:8080",
|
||||||
"server_chat_error": "Error: %v",
|
"server_chat_error": "Error: %v",
|
||||||
"server_error_marshaling_response": "error al serializar la respuesta: %v",
|
"server_error_marshaling_response": "error al serializar la respuesta: %v",
|
||||||
"server_error_writing_response": "error al escribir la respuesta: %v",
|
"server_error_writing_response": "error al escribir la respuesta: %v",
|
||||||
"server_invalid_request_format": "formato de solicitud no válido: %v",
|
"server_invalid_request_format": "formato de solicitud no válido: %v",
|
||||||
|
"server_no_api_key_warning": "Iniciando el servidor de API REST sin autenticación por clave de API. Esto puede suponer riesgos de seguridad.",
|
||||||
"sessions_creating_new": "Creando nueva sesión: %s\n",
|
"sessions_creating_new": "Creando nueva sesión: %s\n",
|
||||||
"set_debug_level": "Establecer nivel de depuración (0=apagado, 1=básico, 2=detallado, 3=rastreo, 4=wire)",
|
"set_debug_level": "Establecer nivel de depuración (0=apagado, 1=básico, 2=detallado, 3=rastreo, 4=wire)",
|
||||||
"set_frequency_penalty": "Establecer penalización de frecuencia",
|
"set_frequency_penalty": "Establecer penalización de frecuencia",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "No se pudo guardar %s: %v",
|
"storage_error_save": "No se pudo guardar %s: %v",
|
||||||
"storage_error_stat_entry": "No se pudo obtener información de la entrada %s: %v",
|
"storage_error_stat_entry": "No se pudo obtener información de la entrada %s: %v",
|
||||||
"storage_error_unmarshal": "No se pudo deserializar %s: %s",
|
"storage_error_unmarshal": "No se pudo deserializar %s: %s",
|
||||||
|
"storage_invalid_name": "nombre inválido: %q",
|
||||||
"strategies_available_header": "Estrategias disponibles:",
|
"strategies_available_header": "Estrategias disponibles:",
|
||||||
"strategies_cloning_repository": "Clonando el repositorio %s (ruta: %s)...\\n",
|
"strategies_cloning_repository": "Clonando el repositorio %s (ruta: %s)...\\n",
|
||||||
"strategies_download_success": "✅ Estrategias descargadas e instaladas correctamente en %s\\n",
|
"strategies_download_success": "✅ Estrategias descargadas e instaladas correctamente en %s\\n",
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "فرمت URL داده نامعتبر",
|
"ollama_invalid_data_url_format": "فرمت URL داده نامعتبر",
|
||||||
"ollama_invalid_http_timeout_using_default": "زمان انتظار HTTP نامعتبر '%s': %v، استفاده از مقدار پیشفرض",
|
"ollama_invalid_http_timeout_using_default": "زمان انتظار HTTP نامعتبر '%s': %v، استفاده از مقدار پیشفرض",
|
||||||
"ollama_invalid_num_ctx_in_request": "num_ctx نامعتبر در درخواست: %v",
|
"ollama_invalid_num_ctx_in_request": "num_ctx نامعتبر در درخواست: %v",
|
||||||
|
"ollama_invalid_request_body": "بدنه درخواست نامعتبر",
|
||||||
"ollama_no_content_from_upstream": "هیچ محتوایی از سرور Fabric بالادستی دریافت نشد",
|
"ollama_no_content_from_upstream": "هیچ محتوایی از سرور Fabric بالادستی دریافت نشد",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx از حداکثر مقدار مجاز %d فراتر رفته است",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx از حداکثر مقدار مجاز %d فراتر رفته است",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx باید یک عدد باشد، نوع نامعتبر دریافت شد",
|
"ollama_num_ctx_invalid_type": "num_ctx باید یک عدد باشد، نوع نامعتبر دریافت شد",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "خط SSE از حد بافر 1MB فراتر رفت - خط داده بیش از حد بزرگ است",
|
"ollama_sse_buffer_limit": "خط SSE از حد بافر 1MB فراتر رفت - خط داده بیش از حد بزرگ است",
|
||||||
"ollama_upstream_non_2xx": "سرور Fabric بالادستی وضعیت غیر-2xx %d را برگرداند: %s",
|
"ollama_upstream_non_2xx": "سرور Fabric بالادستی وضعیت غیر-2xx %d را برگرداند: %s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "سرور Fabric بالادستی وضعیت غیر-2xx %d را برگرداند و بدنه قابل خواندن نبود: %v",
|
"ollama_upstream_non_2xx_body_unreadable": "سرور Fabric بالادستی وضعیت غیر-2xx %d را برگرداند و بدنه قابل خواندن نبود: %v",
|
||||||
|
"ollama_upstream_request_failed": "دسترسی به سرور بالادستی Fabric ممکن نیست",
|
||||||
"ollama_upstream_returned_status": "سرور Fabric بالادستی وضعیت %d را برگرداند",
|
"ollama_upstream_returned_status": "سرور Fabric بالادستی وضعیت %d را برگرداند",
|
||||||
"ollama_warning_no_content": "هشدار: هیچ محتوایی از سرور Fabric بالادستی دریافت نشد",
|
"ollama_warning_no_content": "هشدار: هیچ محتوایی از سرور Fabric بالادستی دریافت نشد",
|
||||||
"ollama_warning_parse_variables": "هشدار: شکست در تجزیه options.variables به عنوان JSON: %v",
|
"ollama_warning_parse_variables": "هشدار: شکست در تجزیه options.variables به عنوان JSON: %v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "ارسال اعلان دسکتاپ هنگام تکمیل دستور",
|
"send_desktop_notification": "ارسال اعلان دسکتاپ هنگام تکمیل دستور",
|
||||||
"serve_fabric_api_ollama_endpoints": "سرویس API REST Fabric با نقاط پایانی ollama",
|
"serve_fabric_api_ollama_endpoints": "سرویس API REST Fabric با نقاط پایانی ollama",
|
||||||
"serve_fabric_rest_api": "سرویس API REST Fabric",
|
"serve_fabric_rest_api": "سرویس API REST Fabric",
|
||||||
|
"server_api_key_required": "سرویسدهی روی آدرس غیر loopback %s بدون کلید API رد شد: --api-key یا FABRIC_API_KEY را تنظیم کنید، یا به یک آدرس loopback مانند 127.0.0.1:8080 متصل شوید",
|
||||||
"server_chat_error": "خطا: %v",
|
"server_chat_error": "خطا: %v",
|
||||||
"server_error_marshaling_response": "خطا در سریالسازی پاسخ: %v",
|
"server_error_marshaling_response": "خطا در سریالسازی پاسخ: %v",
|
||||||
"server_error_writing_response": "خطا در نوشتن پاسخ: %v",
|
"server_error_writing_response": "خطا در نوشتن پاسخ: %v",
|
||||||
"server_invalid_request_format": "فرمت درخواست نامعتبر: %v",
|
"server_invalid_request_format": "فرمت درخواست نامعتبر: %v",
|
||||||
|
"server_no_api_key_warning": "سرور REST API بدون احراز هویت کلید API راهاندازی میشود. این ممکن است خطرات امنیتی ایجاد کند.",
|
||||||
"sessions_creating_new": "ایجاد نشست جدید: %s\n",
|
"sessions_creating_new": "ایجاد نشست جدید: %s\n",
|
||||||
"set_debug_level": "تنظیم سطح اشکالزدایی (0=خاموش، 1=پایه، 2=تفصیلی، 3=ردیابی، 4=wire)",
|
"set_debug_level": "تنظیم سطح اشکالزدایی (0=خاموش، 1=پایه، 2=تفصیلی، 3=ردیابی، 4=wire)",
|
||||||
"set_frequency_penalty": "تنظیم جریمه فرکانس",
|
"set_frequency_penalty": "تنظیم جریمه فرکانس",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "ذخیره %s ناموفق بود: %v",
|
"storage_error_save": "ذخیره %s ناموفق بود: %v",
|
||||||
"storage_error_stat_entry": "دریافت اطلاعات ورودی %s ناموفق بود: %v",
|
"storage_error_stat_entry": "دریافت اطلاعات ورودی %s ناموفق بود: %v",
|
||||||
"storage_error_unmarshal": "بازسریالسازی %s ناموفق بود: %s",
|
"storage_error_unmarshal": "بازسریالسازی %s ناموفق بود: %s",
|
||||||
|
"storage_invalid_name": "نام نامعتبر: %q",
|
||||||
"strategies_available_header": "راهبردهای موجود:",
|
"strategies_available_header": "راهبردهای موجود:",
|
||||||
"strategies_cloning_repository": "در حال کلون کردن مخزن %s (مسیر: %s)...\\n",
|
"strategies_cloning_repository": "در حال کلون کردن مخزن %s (مسیر: %s)...\\n",
|
||||||
"strategies_download_success": "✅ راهبردها با موفقیت در %s دانلود و نصب شدند\\n",
|
"strategies_download_success": "✅ راهبردها با موفقیت در %s دانلود و نصب شدند\\n",
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "format d'URL de données invalide",
|
"ollama_invalid_data_url_format": "format d'URL de données invalide",
|
||||||
"ollama_invalid_http_timeout_using_default": "Délai d'expiration HTTP invalide '%s' : %v, utilisation de la valeur par défaut",
|
"ollama_invalid_http_timeout_using_default": "Délai d'expiration HTTP invalide '%s' : %v, utilisation de la valeur par défaut",
|
||||||
"ollama_invalid_num_ctx_in_request": "num_ctx invalide dans la requête : %v",
|
"ollama_invalid_num_ctx_in_request": "num_ctx invalide dans la requête : %v",
|
||||||
|
"ollama_invalid_request_body": "corps de requête invalide",
|
||||||
"ollama_no_content_from_upstream": "aucun contenu reçu du serveur Fabric en amont",
|
"ollama_no_content_from_upstream": "aucun contenu reçu du serveur Fabric en amont",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx dépasse la valeur maximale autorisée de %d",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx dépasse la valeur maximale autorisée de %d",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx doit être un nombre, type invalide reçu",
|
"ollama_num_ctx_invalid_type": "num_ctx doit être un nombre, type invalide reçu",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "La ligne SSE dépasse la limite de tampon de 1 Mo - ligne de données trop grande",
|
"ollama_sse_buffer_limit": "La ligne SSE dépasse la limite de tampon de 1 Mo - ligne de données trop grande",
|
||||||
"ollama_upstream_non_2xx": "Le serveur Fabric en amont a renvoyé un statut non-2xx %d : %s",
|
"ollama_upstream_non_2xx": "Le serveur Fabric en amont a renvoyé un statut non-2xx %d : %s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "Le serveur Fabric en amont a renvoyé un statut non-2xx %d et le corps n'a pas pu être lu : %v",
|
"ollama_upstream_non_2xx_body_unreadable": "Le serveur Fabric en amont a renvoyé un statut non-2xx %d et le corps n'a pas pu être lu : %v",
|
||||||
|
"ollama_upstream_request_failed": "impossible de joindre le serveur Fabric en amont",
|
||||||
"ollama_upstream_returned_status": "le serveur Fabric en amont a renvoyé le statut %d",
|
"ollama_upstream_returned_status": "le serveur Fabric en amont a renvoyé le statut %d",
|
||||||
"ollama_warning_no_content": "Attention : aucun contenu reçu du serveur Fabric en amont",
|
"ollama_warning_no_content": "Attention : aucun contenu reçu du serveur Fabric en amont",
|
||||||
"ollama_warning_parse_variables": "Attention : échec de l'analyse de options.variables en JSON : %v",
|
"ollama_warning_parse_variables": "Attention : échec de l'analyse de options.variables en JSON : %v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "Envoyer une notification de bureau quand la commande se termine",
|
"send_desktop_notification": "Envoyer une notification de bureau quand la commande se termine",
|
||||||
"serve_fabric_api_ollama_endpoints": "Servir l'API REST Fabric avec les endpoints ollama",
|
"serve_fabric_api_ollama_endpoints": "Servir l'API REST Fabric avec les endpoints ollama",
|
||||||
"serve_fabric_rest_api": "Servir l'API REST Fabric",
|
"serve_fabric_rest_api": "Servir l'API REST Fabric",
|
||||||
|
"server_api_key_required": "refus de servir sur l'adresse non loopback %s sans clé API : définissez --api-key ou FABRIC_API_KEY, ou liez une adresse loopback comme 127.0.0.1:8080",
|
||||||
"server_chat_error": "Erreur : %v",
|
"server_chat_error": "Erreur : %v",
|
||||||
"server_error_marshaling_response": "erreur de sérialisation de la réponse : %v",
|
"server_error_marshaling_response": "erreur de sérialisation de la réponse : %v",
|
||||||
"server_error_writing_response": "erreur d'écriture de la réponse : %v",
|
"server_error_writing_response": "erreur d'écriture de la réponse : %v",
|
||||||
"server_invalid_request_format": "format de requête invalide : %v",
|
"server_invalid_request_format": "format de requête invalide : %v",
|
||||||
|
"server_no_api_key_warning": "Démarrage du serveur API REST sans authentification par clé API. Cela peut présenter des risques de sécurité.",
|
||||||
"sessions_creating_new": "Création d'une nouvelle session : %s\n",
|
"sessions_creating_new": "Création d'une nouvelle session : %s\n",
|
||||||
"set_debug_level": "Définir le niveau de débogage (0=désactivé, 1=basique, 2=détaillé, 3=trace, 4=wire)",
|
"set_debug_level": "Définir le niveau de débogage (0=désactivé, 1=basique, 2=détaillé, 3=trace, 4=wire)",
|
||||||
"set_frequency_penalty": "Définir la pénalité de fréquence",
|
"set_frequency_penalty": "Définir la pénalité de fréquence",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "Impossible de sauvegarder %s : %v",
|
"storage_error_save": "Impossible de sauvegarder %s : %v",
|
||||||
"storage_error_stat_entry": "Impossible d'obtenir les informations de l'entrée %s : %v",
|
"storage_error_stat_entry": "Impossible d'obtenir les informations de l'entrée %s : %v",
|
||||||
"storage_error_unmarshal": "Impossible de désérialiser %s : %s",
|
"storage_error_unmarshal": "Impossible de désérialiser %s : %s",
|
||||||
|
"storage_invalid_name": "nom invalide : %q",
|
||||||
"strategies_available_header": "Stratégies disponibles :",
|
"strategies_available_header": "Stratégies disponibles :",
|
||||||
"strategies_cloning_repository": "Clonage du dépôt %s (chemin : %s)...\\n",
|
"strategies_cloning_repository": "Clonage du dépôt %s (chemin : %s)...\\n",
|
||||||
"strategies_download_success": "✅ Stratégies téléchargées et installées avec succès dans %s\\n",
|
"strategies_download_success": "✅ Stratégies téléchargées et installées avec succès dans %s\\n",
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "formato URL dati non valido",
|
"ollama_invalid_data_url_format": "formato URL dati non valido",
|
||||||
"ollama_invalid_http_timeout_using_default": "Timeout HTTP non valido '%s': %v, utilizzo del valore predefinito",
|
"ollama_invalid_http_timeout_using_default": "Timeout HTTP non valido '%s': %v, utilizzo del valore predefinito",
|
||||||
"ollama_invalid_num_ctx_in_request": "num_ctx non valido nella richiesta: %v",
|
"ollama_invalid_num_ctx_in_request": "num_ctx non valido nella richiesta: %v",
|
||||||
|
"ollama_invalid_request_body": "corpo della richiesta non valido",
|
||||||
"ollama_no_content_from_upstream": "nessun contenuto ricevuto dal server Fabric upstream",
|
"ollama_no_content_from_upstream": "nessun contenuto ricevuto dal server Fabric upstream",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx supera il valore massimo consentito di %d",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx supera il valore massimo consentito di %d",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx deve essere un numero, ricevuto tipo non valido",
|
"ollama_num_ctx_invalid_type": "num_ctx deve essere un numero, ricevuto tipo non valido",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "La riga SSE supera il limite del buffer di 1MB - riga di dati troppo grande",
|
"ollama_sse_buffer_limit": "La riga SSE supera il limite del buffer di 1MB - riga di dati troppo grande",
|
||||||
"ollama_upstream_non_2xx": "Il server Fabric upstream ha restituito stato non-2xx %d: %s",
|
"ollama_upstream_non_2xx": "Il server Fabric upstream ha restituito stato non-2xx %d: %s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "Il server Fabric upstream ha restituito stato non-2xx %d e il corpo non è stato leggibile: %v",
|
"ollama_upstream_non_2xx_body_unreadable": "Il server Fabric upstream ha restituito stato non-2xx %d e il corpo non è stato leggibile: %v",
|
||||||
|
"ollama_upstream_request_failed": "impossibile raggiungere il server Fabric a monte",
|
||||||
"ollama_upstream_returned_status": "il server Fabric upstream ha restituito stato %d",
|
"ollama_upstream_returned_status": "il server Fabric upstream ha restituito stato %d",
|
||||||
"ollama_warning_no_content": "Avviso: nessun contenuto ricevuto dal server Fabric upstream",
|
"ollama_warning_no_content": "Avviso: nessun contenuto ricevuto dal server Fabric upstream",
|
||||||
"ollama_warning_parse_variables": "Avviso: impossibile analizzare options.variables come JSON: %v",
|
"ollama_warning_parse_variables": "Avviso: impossibile analizzare options.variables come JSON: %v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "Invia notifica desktop quando il comando è completato",
|
"send_desktop_notification": "Invia notifica desktop quando il comando è completato",
|
||||||
"serve_fabric_api_ollama_endpoints": "Servi l'API REST di Fabric con endpoint ollama",
|
"serve_fabric_api_ollama_endpoints": "Servi l'API REST di Fabric con endpoint ollama",
|
||||||
"serve_fabric_rest_api": "Servi l'API REST di Fabric",
|
"serve_fabric_rest_api": "Servi l'API REST di Fabric",
|
||||||
|
"server_api_key_required": "rifiuto di servire sull'indirizzo non loopback %s senza chiave API: impostare --api-key o FABRIC_API_KEY, oppure associare un indirizzo loopback come 127.0.0.1:8080",
|
||||||
"server_chat_error": "Errore: %v",
|
"server_chat_error": "Errore: %v",
|
||||||
"server_error_marshaling_response": "errore nella serializzazione della risposta: %v",
|
"server_error_marshaling_response": "errore nella serializzazione della risposta: %v",
|
||||||
"server_error_writing_response": "errore nella scrittura della risposta: %v",
|
"server_error_writing_response": "errore nella scrittura della risposta: %v",
|
||||||
"server_invalid_request_format": "formato della richiesta non valido: %v",
|
"server_invalid_request_format": "formato della richiesta non valido: %v",
|
||||||
|
"server_no_api_key_warning": "Avvio del server API REST senza autenticazione con chiave API. Ciò può comportare rischi per la sicurezza.",
|
||||||
"sessions_creating_new": "Creazione nuova sessione: %s\n",
|
"sessions_creating_new": "Creazione nuova sessione: %s\n",
|
||||||
"set_debug_level": "Imposta livello di debug (0=spento, 1=base, 2=dettagliato, 3=traccia, 4=wire)",
|
"set_debug_level": "Imposta livello di debug (0=spento, 1=base, 2=dettagliato, 3=traccia, 4=wire)",
|
||||||
"set_frequency_penalty": "Imposta penalità di frequenza",
|
"set_frequency_penalty": "Imposta penalità di frequenza",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "Impossibile salvare %s: %v",
|
"storage_error_save": "Impossibile salvare %s: %v",
|
||||||
"storage_error_stat_entry": "Impossibile ottenere informazioni sulla voce %s: %v",
|
"storage_error_stat_entry": "Impossibile ottenere informazioni sulla voce %s: %v",
|
||||||
"storage_error_unmarshal": "Impossibile deserializzare %s: %s",
|
"storage_error_unmarshal": "Impossibile deserializzare %s: %s",
|
||||||
|
"storage_invalid_name": "nome non valido: %q",
|
||||||
"strategies_available_header": "Strategie disponibili:",
|
"strategies_available_header": "Strategie disponibili:",
|
||||||
"strategies_cloning_repository": "Clonazione del repository %s (percorso: %s)...\\n",
|
"strategies_cloning_repository": "Clonazione del repository %s (percorso: %s)...\\n",
|
||||||
"strategies_download_success": "✅ Strategie scaricate e installate correttamente in %s\\n",
|
"strategies_download_success": "✅ Strategie scaricate e installate correttamente in %s\\n",
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "無効なデータURLフォーマット",
|
"ollama_invalid_data_url_format": "無効なデータURLフォーマット",
|
||||||
"ollama_invalid_http_timeout_using_default": "無効なHTTPタイムアウト '%s': %v、デフォルトを使用します",
|
"ollama_invalid_http_timeout_using_default": "無効なHTTPタイムアウト '%s': %v、デフォルトを使用します",
|
||||||
"ollama_invalid_num_ctx_in_request": "リクエストに無効な num_ctx があります: %v",
|
"ollama_invalid_num_ctx_in_request": "リクエストに無効な num_ctx があります: %v",
|
||||||
|
"ollama_invalid_request_body": "無効なリクエスト本文",
|
||||||
"ollama_no_content_from_upstream": "アップストリームの Fabric サーバーからコンテンツを受信しませんでした",
|
"ollama_no_content_from_upstream": "アップストリームの Fabric サーバーからコンテンツを受信しませんでした",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx が許可される最大値 %d を超えています",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx が許可される最大値 %d を超えています",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx は数値である必要があります。無効な型を受け取りました",
|
"ollama_num_ctx_invalid_type": "num_ctx は数値である必要があります。無効な型を受け取りました",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "SSE 行が 1MB バッファー制限を超えています - データ行が大きすぎます",
|
"ollama_sse_buffer_limit": "SSE 行が 1MB バッファー制限を超えています - データ行が大きすぎます",
|
||||||
"ollama_upstream_non_2xx": "アップストリームの Fabric サーバーが非 2xx ステータス %d を返しました: %s",
|
"ollama_upstream_non_2xx": "アップストリームの Fabric サーバーが非 2xx ステータス %d を返しました: %s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "アップストリームの Fabric サーバーが非 2xx ステータス %d を返し、ボディを読み取れませんでした: %v",
|
"ollama_upstream_non_2xx_body_unreadable": "アップストリームの Fabric サーバーが非 2xx ステータス %d を返し、ボディを読み取れませんでした: %v",
|
||||||
|
"ollama_upstream_request_failed": "上流のFabricサーバーに到達できませんでした",
|
||||||
"ollama_upstream_returned_status": "アップストリームの Fabric サーバーがステータス %d を返しました",
|
"ollama_upstream_returned_status": "アップストリームの Fabric サーバーがステータス %d を返しました",
|
||||||
"ollama_warning_no_content": "警告: アップストリームの Fabric サーバーからコンテンツを受信しませんでした",
|
"ollama_warning_no_content": "警告: アップストリームの Fabric サーバーからコンテンツを受信しませんでした",
|
||||||
"ollama_warning_parse_variables": "警告: options.variables を JSON として解析できませんでした: %v",
|
"ollama_warning_parse_variables": "警告: options.variables を JSON として解析できませんでした: %v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "コマンド完了時にデスクトップ通知を送信",
|
"send_desktop_notification": "コマンド完了時にデスクトップ通知を送信",
|
||||||
"serve_fabric_api_ollama_endpoints": "ollamaエンドポイント付きのFabric REST APIを提供",
|
"serve_fabric_api_ollama_endpoints": "ollamaエンドポイント付きのFabric REST APIを提供",
|
||||||
"serve_fabric_rest_api": "Fabric REST APIを提供",
|
"serve_fabric_rest_api": "Fabric REST APIを提供",
|
||||||
|
"server_api_key_required": "APIキーなしで非ループバックアドレス %s での提供を拒否しました。--api-key または FABRIC_API_KEY を設定するか、127.0.0.1:8080 のようなループバックアドレスにバインドしてください",
|
||||||
"server_chat_error": "エラー: %v",
|
"server_chat_error": "エラー: %v",
|
||||||
"server_error_marshaling_response": "レスポンスのシリアライズエラー: %v",
|
"server_error_marshaling_response": "レスポンスのシリアライズエラー: %v",
|
||||||
"server_error_writing_response": "レスポンスの書き込みエラー: %v",
|
"server_error_writing_response": "レスポンスの書き込みエラー: %v",
|
||||||
"server_invalid_request_format": "無効なリクエスト形式: %v",
|
"server_invalid_request_format": "無効なリクエスト形式: %v",
|
||||||
|
"server_no_api_key_warning": "APIキー認証なしでREST APIサーバーを起動しています。セキュリティ上のリスクが生じる可能性があります。",
|
||||||
"sessions_creating_new": "新しいセッションを作成中: %s\n",
|
"sessions_creating_new": "新しいセッションを作成中: %s\n",
|
||||||
"set_debug_level": "デバッグレベルを設定(0=オフ、1=基本、2=詳細、3=トレース、4=wire)",
|
"set_debug_level": "デバッグレベルを設定(0=オフ、1=基本、2=詳細、3=トレース、4=wire)",
|
||||||
"set_frequency_penalty": "頻度ペナルティを設定",
|
"set_frequency_penalty": "頻度ペナルティを設定",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "%sを保存できませんでした: %v",
|
"storage_error_save": "%sを保存できませんでした: %v",
|
||||||
"storage_error_stat_entry": "エントリ%sの情報を取得できませんでした: %v",
|
"storage_error_stat_entry": "エントリ%sの情報を取得できませんでした: %v",
|
||||||
"storage_error_unmarshal": "%sをデシリアライズできませんでした: %s",
|
"storage_error_unmarshal": "%sをデシリアライズできませんでした: %s",
|
||||||
|
"storage_invalid_name": "無効な名前: %q",
|
||||||
"strategies_available_header": "利用可能な戦略:",
|
"strategies_available_header": "利用可能な戦略:",
|
||||||
"strategies_cloning_repository": "リポジトリ %s をクローン中 (パス: %s)...\\n",
|
"strategies_cloning_repository": "リポジトリ %s をクローン中 (パス: %s)...\\n",
|
||||||
"strategies_download_success": "✅ 戦略を %s に正常にダウンロードしてインストールしました\\n",
|
"strategies_download_success": "✅ 戦略を %s に正常にダウンロードしてインストールしました\\n",
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "nieprawidłowy format data URL",
|
"ollama_invalid_data_url_format": "nieprawidłowy format data URL",
|
||||||
"ollama_invalid_http_timeout_using_default": "nieprawidłowy limit czasu HTTP '%s': %v, używam domyślnego",
|
"ollama_invalid_http_timeout_using_default": "nieprawidłowy limit czasu HTTP '%s': %v, używam domyślnego",
|
||||||
"ollama_invalid_num_ctx_in_request": "nieprawidłowa wartość num_ctx w żądaniu: %v",
|
"ollama_invalid_num_ctx_in_request": "nieprawidłowa wartość num_ctx w żądaniu: %v",
|
||||||
|
"ollama_invalid_request_body": "nieprawidłowa treść żądania",
|
||||||
"ollama_no_content_from_upstream": "nie odebrano zawartości z upstream serwera fabric",
|
"ollama_no_content_from_upstream": "nie odebrano zawartości z upstream serwera fabric",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx przekracza maksymalną dozwoloną wartość %d",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx przekracza maksymalną dozwoloną wartość %d",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx musi być liczbą, podano nieprawidłowy typ",
|
"ollama_num_ctx_invalid_type": "num_ctx musi być liczbą, podano nieprawidłowy typ",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "Linia SSE przekracza limit bufora 1MB - linia danych zbyt duża",
|
"ollama_sse_buffer_limit": "Linia SSE przekracza limit bufora 1MB - linia danych zbyt duża",
|
||||||
"ollama_upstream_non_2xx": "Upstream serwer fabric zwrócił status inny niż 2xx %d: %s",
|
"ollama_upstream_non_2xx": "Upstream serwer fabric zwrócił status inny niż 2xx %d: %s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "Upstream serwer fabric zwrócił status inny niż 2xx %d i nie można odczytać treści: %v",
|
"ollama_upstream_non_2xx_body_unreadable": "Upstream serwer fabric zwrócił status inny niż 2xx %d i nie można odczytać treści: %v",
|
||||||
|
"ollama_upstream_request_failed": "nie można połączyć się z serwerem nadrzędnym Fabric",
|
||||||
"ollama_upstream_returned_status": "upstream serwer fabric zwrócił status %d",
|
"ollama_upstream_returned_status": "upstream serwer fabric zwrócił status %d",
|
||||||
"ollama_warning_no_content": "Ostrzeżenie: nie odebrano zawartości z upstream serwera fabric",
|
"ollama_warning_no_content": "Ostrzeżenie: nie odebrano zawartości z upstream serwera fabric",
|
||||||
"ollama_warning_parse_variables": "Ostrzeżenie: nie udało się przetworzyć options.variables jako JSON: %v",
|
"ollama_warning_parse_variables": "Ostrzeżenie: nie udało się przetworzyć options.variables jako JSON: %v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "Wyślij powiadomienie pulpitu po zakończeniu polecenia",
|
"send_desktop_notification": "Wyślij powiadomienie pulpitu po zakończeniu polecenia",
|
||||||
"serve_fabric_api_ollama_endpoints": "Uruchom fabric Rest API z endpointami ollama",
|
"serve_fabric_api_ollama_endpoints": "Uruchom fabric Rest API z endpointami ollama",
|
||||||
"serve_fabric_rest_api": "Uruchom fabric Rest API",
|
"serve_fabric_rest_api": "Uruchom fabric Rest API",
|
||||||
|
"server_api_key_required": "odmowa udostępniania na adresie %s spoza pętli zwrotnej bez klucza API: ustaw --api-key lub FABRIC_API_KEY, albo powiąż adres pętli zwrotnej, np. 127.0.0.1:8080",
|
||||||
"server_chat_error": "Błąd: %v",
|
"server_chat_error": "Błąd: %v",
|
||||||
"server_error_marshaling_response": "błąd podczas serializacji odpowiedzi: %v",
|
"server_error_marshaling_response": "błąd podczas serializacji odpowiedzi: %v",
|
||||||
"server_error_writing_response": "błąd podczas zapisywania odpowiedzi: %v",
|
"server_error_writing_response": "błąd podczas zapisywania odpowiedzi: %v",
|
||||||
"server_invalid_request_format": "nieprawidłowy format żądania: %v",
|
"server_invalid_request_format": "nieprawidłowy format żądania: %v",
|
||||||
|
"server_no_api_key_warning": "Uruchamianie serwera REST API bez uwierzytelniania kluczem API. Może to stwarzać zagrożenia bezpieczeństwa.",
|
||||||
"sessions_creating_new": "Tworzenie nowej sesji: %s\n",
|
"sessions_creating_new": "Tworzenie nowej sesji: %s\n",
|
||||||
"set_debug_level": "Ustaw poziom debugowania (0=wyłączone, 1=podstawowe, 2=szczegółowe, 3=śledzenie, 4=surowe)",
|
"set_debug_level": "Ustaw poziom debugowania (0=wyłączone, 1=podstawowe, 2=szczegółowe, 3=śledzenie, 4=surowe)",
|
||||||
"set_frequency_penalty": "Ustaw karę częstotliwości",
|
"set_frequency_penalty": "Ustaw karę częstotliwości",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "nie można zapisać %s: %v",
|
"storage_error_save": "nie można zapisać %s: %v",
|
||||||
"storage_error_stat_entry": "nie można pobrać informacji o wpisie %s: %v",
|
"storage_error_stat_entry": "nie można pobrać informacji o wpisie %s: %v",
|
||||||
"storage_error_unmarshal": "nie można deserializować %s: %s",
|
"storage_error_unmarshal": "nie można deserializować %s: %s",
|
||||||
|
"storage_invalid_name": "nieprawidłowa nazwa: %q",
|
||||||
"strategies_available_header": "Dostępne strategie:",
|
"strategies_available_header": "Dostępne strategie:",
|
||||||
"strategies_cloning_repository": "Klonowanie repozytorium %s (ścieżka: %s)...\n",
|
"strategies_cloning_repository": "Klonowanie repozytorium %s (ścieżka: %s)...\n",
|
||||||
"strategies_download_success": "✅ Pomyślnie pobrano i zainstalowano strategie w %s\n",
|
"strategies_download_success": "✅ Pomyślnie pobrano i zainstalowano strategie w %s\n",
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "formato de URL de dados inválido",
|
"ollama_invalid_data_url_format": "formato de URL de dados inválido",
|
||||||
"ollama_invalid_http_timeout_using_default": "Tempo limite HTTP inválido '%s': %v, usando o padrão",
|
"ollama_invalid_http_timeout_using_default": "Tempo limite HTTP inválido '%s': %v, usando o padrão",
|
||||||
"ollama_invalid_num_ctx_in_request": "num_ctx inválido na requisição: %v",
|
"ollama_invalid_num_ctx_in_request": "num_ctx inválido na requisição: %v",
|
||||||
|
"ollama_invalid_request_body": "corpo de solicitação inválido",
|
||||||
"ollama_no_content_from_upstream": "nenhum conteúdo recebido do servidor Fabric upstream",
|
"ollama_no_content_from_upstream": "nenhum conteúdo recebido do servidor Fabric upstream",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx excede o valor máximo permitido de %d",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx excede o valor máximo permitido de %d",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx deve ser um número, recebeu tipo inválido",
|
"ollama_num_ctx_invalid_type": "num_ctx deve ser um número, recebeu tipo inválido",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "Linha SSE excede limite de buffer de 1MB - linha de dados muito grande",
|
"ollama_sse_buffer_limit": "Linha SSE excede limite de buffer de 1MB - linha de dados muito grande",
|
||||||
"ollama_upstream_non_2xx": "Servidor Fabric upstream retornou status não-2xx %d: %s",
|
"ollama_upstream_non_2xx": "Servidor Fabric upstream retornou status não-2xx %d: %s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "Servidor Fabric upstream retornou status não-2xx %d e o corpo não pôde ser lido: %v",
|
"ollama_upstream_non_2xx_body_unreadable": "Servidor Fabric upstream retornou status não-2xx %d e o corpo não pôde ser lido: %v",
|
||||||
|
"ollama_upstream_request_failed": "falha ao conectar ao servidor Fabric upstream",
|
||||||
"ollama_upstream_returned_status": "servidor Fabric upstream retornou status %d",
|
"ollama_upstream_returned_status": "servidor Fabric upstream retornou status %d",
|
||||||
"ollama_warning_no_content": "Aviso: nenhum conteúdo recebido do servidor Fabric upstream",
|
"ollama_warning_no_content": "Aviso: nenhum conteúdo recebido do servidor Fabric upstream",
|
||||||
"ollama_warning_parse_variables": "Aviso: falha ao analisar options.variables como JSON: %v",
|
"ollama_warning_parse_variables": "Aviso: falha ao analisar options.variables como JSON: %v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "Enviar notificação desktop quando o comando for concluído",
|
"send_desktop_notification": "Enviar notificação desktop quando o comando for concluído",
|
||||||
"serve_fabric_api_ollama_endpoints": "Servir a API REST do Fabric com endpoints ollama",
|
"serve_fabric_api_ollama_endpoints": "Servir a API REST do Fabric com endpoints ollama",
|
||||||
"serve_fabric_rest_api": "Servir a API REST do Fabric",
|
"serve_fabric_rest_api": "Servir a API REST do Fabric",
|
||||||
|
"server_api_key_required": "recusando servir no endereço não loopback %s sem chave de API: defina --api-key ou FABRIC_API_KEY, ou vincule um endereço loopback como 127.0.0.1:8080",
|
||||||
"server_chat_error": "Erro: %v",
|
"server_chat_error": "Erro: %v",
|
||||||
"server_error_marshaling_response": "erro ao serializar resposta: %v",
|
"server_error_marshaling_response": "erro ao serializar resposta: %v",
|
||||||
"server_error_writing_response": "erro ao escrever resposta: %v",
|
"server_error_writing_response": "erro ao escrever resposta: %v",
|
||||||
"server_invalid_request_format": "formato de solicitação inválido: %v",
|
"server_invalid_request_format": "formato de solicitação inválido: %v",
|
||||||
|
"server_no_api_key_warning": "Iniciando o servidor da API REST sem autenticação por chave de API. Isso pode representar riscos de segurança.",
|
||||||
"sessions_creating_new": "Criando nova sessão: %s\n",
|
"sessions_creating_new": "Criando nova sessão: %s\n",
|
||||||
"set_debug_level": "Definir nível de debug (0=desligado, 1=básico, 2=detalhado, 3=rastreamento, 4=wire)",
|
"set_debug_level": "Definir nível de debug (0=desligado, 1=básico, 2=detalhado, 3=rastreamento, 4=wire)",
|
||||||
"set_frequency_penalty": "Definir penalidade de frequência",
|
"set_frequency_penalty": "Definir penalidade de frequência",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "Não foi possível salvar %s: %v",
|
"storage_error_save": "Não foi possível salvar %s: %v",
|
||||||
"storage_error_stat_entry": "Não foi possível obter informações da entrada %s: %v",
|
"storage_error_stat_entry": "Não foi possível obter informações da entrada %s: %v",
|
||||||
"storage_error_unmarshal": "Não foi possível desserializar %s: %s",
|
"storage_error_unmarshal": "Não foi possível desserializar %s: %s",
|
||||||
|
"storage_invalid_name": "nome inválido: %q",
|
||||||
"strategies_available_header": "Estratégias disponíveis:",
|
"strategies_available_header": "Estratégias disponíveis:",
|
||||||
"strategies_cloning_repository": "Clonando repositório %s (caminho: %s)...\\n",
|
"strategies_cloning_repository": "Clonando repositório %s (caminho: %s)...\\n",
|
||||||
"strategies_download_success": "✅ Estratégias baixadas e instaladas com sucesso em %s\\n",
|
"strategies_download_success": "✅ Estratégias baixadas e instaladas com sucesso em %s\\n",
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "formato de URL de dados inválido",
|
"ollama_invalid_data_url_format": "formato de URL de dados inválido",
|
||||||
"ollama_invalid_http_timeout_using_default": "Tempo limite HTTP inválido '%s': %v, a utilizar o padrão",
|
"ollama_invalid_http_timeout_using_default": "Tempo limite HTTP inválido '%s': %v, a utilizar o padrão",
|
||||||
"ollama_invalid_num_ctx_in_request": "num_ctx inválido no pedido: %v",
|
"ollama_invalid_num_ctx_in_request": "num_ctx inválido no pedido: %v",
|
||||||
|
"ollama_invalid_request_body": "corpo de pedido inválido",
|
||||||
"ollama_no_content_from_upstream": "nenhum conteúdo recebido do servidor Fabric upstream",
|
"ollama_no_content_from_upstream": "nenhum conteúdo recebido do servidor Fabric upstream",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx excede o valor máximo permitido de %d",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx excede o valor máximo permitido de %d",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx deve ser um número, recebeu tipo inválido",
|
"ollama_num_ctx_invalid_type": "num_ctx deve ser um número, recebeu tipo inválido",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "Linha SSE excede limite de buffer de 1MB - linha de dados demasiado grande",
|
"ollama_sse_buffer_limit": "Linha SSE excede limite de buffer de 1MB - linha de dados demasiado grande",
|
||||||
"ollama_upstream_non_2xx": "Servidor Fabric upstream retornou estado não-2xx %d: %s",
|
"ollama_upstream_non_2xx": "Servidor Fabric upstream retornou estado não-2xx %d: %s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "Servidor Fabric upstream retornou estado não-2xx %d e o corpo não pôde ser lido: %v",
|
"ollama_upstream_non_2xx_body_unreadable": "Servidor Fabric upstream retornou estado não-2xx %d e o corpo não pôde ser lido: %v",
|
||||||
|
"ollama_upstream_request_failed": "falha ao contactar o servidor Fabric a montante",
|
||||||
"ollama_upstream_returned_status": "servidor Fabric upstream retornou estado %d",
|
"ollama_upstream_returned_status": "servidor Fabric upstream retornou estado %d",
|
||||||
"ollama_warning_no_content": "Aviso: nenhum conteúdo recebido do servidor Fabric upstream",
|
"ollama_warning_no_content": "Aviso: nenhum conteúdo recebido do servidor Fabric upstream",
|
||||||
"ollama_warning_parse_variables": "Aviso: falha ao analisar options.variables como JSON: %v",
|
"ollama_warning_parse_variables": "Aviso: falha ao analisar options.variables como JSON: %v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "Enviar notificação no ambiente de trabalho quando o comando for concluído",
|
"send_desktop_notification": "Enviar notificação no ambiente de trabalho quando o comando for concluído",
|
||||||
"serve_fabric_api_ollama_endpoints": "Servir a API REST do Fabric com endpoints ollama",
|
"serve_fabric_api_ollama_endpoints": "Servir a API REST do Fabric com endpoints ollama",
|
||||||
"serve_fabric_rest_api": "Servir a API REST do Fabric",
|
"serve_fabric_rest_api": "Servir a API REST do Fabric",
|
||||||
|
"server_api_key_required": "recusa de servir no endereço não loopback %s sem chave de API: defina --api-key ou FABRIC_API_KEY, ou vincule um endereço loopback como 127.0.0.1:8080",
|
||||||
"server_chat_error": "Erro: %v",
|
"server_chat_error": "Erro: %v",
|
||||||
"server_error_marshaling_response": "erro ao serializar resposta: %v",
|
"server_error_marshaling_response": "erro ao serializar resposta: %v",
|
||||||
"server_error_writing_response": "erro ao escrever resposta: %v",
|
"server_error_writing_response": "erro ao escrever resposta: %v",
|
||||||
"server_invalid_request_format": "formato de pedido inválido: %v",
|
"server_invalid_request_format": "formato de pedido inválido: %v",
|
||||||
|
"server_no_api_key_warning": "A iniciar o servidor da API REST sem autenticação por chave de API. Isto pode representar riscos de segurança.",
|
||||||
"sessions_creating_new": "A criar nova sessão: %s\n",
|
"sessions_creating_new": "A criar nova sessão: %s\n",
|
||||||
"set_debug_level": "Definir nível de debug (0=desligado, 1=básico, 2=detalhado, 3=rastreio, 4=wire)",
|
"set_debug_level": "Definir nível de debug (0=desligado, 1=básico, 2=detalhado, 3=rastreio, 4=wire)",
|
||||||
"set_frequency_penalty": "Definir penalidade de frequência",
|
"set_frequency_penalty": "Definir penalidade de frequência",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "Não foi possível guardar %s: %v",
|
"storage_error_save": "Não foi possível guardar %s: %v",
|
||||||
"storage_error_stat_entry": "Não foi possível obter informações da entrada %s: %v",
|
"storage_error_stat_entry": "Não foi possível obter informações da entrada %s: %v",
|
||||||
"storage_error_unmarshal": "Não foi possível desserializar %s: %s",
|
"storage_error_unmarshal": "Não foi possível desserializar %s: %s",
|
||||||
|
"storage_invalid_name": "nome inválido: %q",
|
||||||
"strategies_available_header": "Estratégias disponíveis:",
|
"strategies_available_header": "Estratégias disponíveis:",
|
||||||
"strategies_cloning_repository": "A clonar repositório %s (caminho: %s)...\\n",
|
"strategies_cloning_repository": "A clonar repositório %s (caminho: %s)...\\n",
|
||||||
"strategies_download_success": "✅ Estratégias transferidas e instaladas com sucesso em %s\\n",
|
"strategies_download_success": "✅ Estratégias transferidas e instaladas com sucesso em %s\\n",
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@
|
||||||
"ollama_invalid_data_url_format": "无效的数据 URL 格式",
|
"ollama_invalid_data_url_format": "无效的数据 URL 格式",
|
||||||
"ollama_invalid_http_timeout_using_default": "无效的 HTTP 超时时间 '%s':%v,使用默认值",
|
"ollama_invalid_http_timeout_using_default": "无效的 HTTP 超时时间 '%s':%v,使用默认值",
|
||||||
"ollama_invalid_num_ctx_in_request": "请求中的 num_ctx 无效:%v",
|
"ollama_invalid_num_ctx_in_request": "请求中的 num_ctx 无效:%v",
|
||||||
|
"ollama_invalid_request_body": "无效的请求正文",
|
||||||
"ollama_no_content_from_upstream": "未从上游 Fabric 服务器收到内容",
|
"ollama_no_content_from_upstream": "未从上游 Fabric 服务器收到内容",
|
||||||
"ollama_num_ctx_exceeds_maximum": "num_ctx 超过允许的最大值 %d",
|
"ollama_num_ctx_exceeds_maximum": "num_ctx 超过允许的最大值 %d",
|
||||||
"ollama_num_ctx_invalid_type": "num_ctx 必须是数字,收到无效类型",
|
"ollama_num_ctx_invalid_type": "num_ctx 必须是数字,收到无效类型",
|
||||||
|
|
@ -388,6 +389,7 @@
|
||||||
"ollama_sse_buffer_limit": "SSE 行超过 1MB 缓冲区限制 - 数据行过大",
|
"ollama_sse_buffer_limit": "SSE 行超过 1MB 缓冲区限制 - 数据行过大",
|
||||||
"ollama_upstream_non_2xx": "上游 Fabric 服务器返回非 2xx 状态 %d:%s",
|
"ollama_upstream_non_2xx": "上游 Fabric 服务器返回非 2xx 状态 %d:%s",
|
||||||
"ollama_upstream_non_2xx_body_unreadable": "上游 Fabric 服务器返回非 2xx 状态 %d 且无法读取正文:%v",
|
"ollama_upstream_non_2xx_body_unreadable": "上游 Fabric 服务器返回非 2xx 状态 %d 且无法读取正文:%v",
|
||||||
|
"ollama_upstream_request_failed": "无法连接上游 Fabric 服务器",
|
||||||
"ollama_upstream_returned_status": "上游 Fabric 服务器返回状态 %d",
|
"ollama_upstream_returned_status": "上游 Fabric 服务器返回状态 %d",
|
||||||
"ollama_warning_no_content": "警告:未从上游 Fabric 服务器收到内容",
|
"ollama_warning_no_content": "警告:未从上游 Fabric 服务器收到内容",
|
||||||
"ollama_warning_parse_variables": "警告:无法将 options.variables 解析为 JSON:%v",
|
"ollama_warning_parse_variables": "警告:无法将 options.variables 解析为 JSON:%v",
|
||||||
|
|
@ -513,10 +515,12 @@
|
||||||
"send_desktop_notification": "命令完成时发送桌面通知",
|
"send_desktop_notification": "命令完成时发送桌面通知",
|
||||||
"serve_fabric_api_ollama_endpoints": "提供带有 ollama 端点的 Fabric REST API 服务",
|
"serve_fabric_api_ollama_endpoints": "提供带有 ollama 端点的 Fabric REST API 服务",
|
||||||
"serve_fabric_rest_api": "提供 Fabric REST API 服务",
|
"serve_fabric_rest_api": "提供 Fabric REST API 服务",
|
||||||
|
"server_api_key_required": "拒绝在非回环地址 %s 上提供服务(未配置 API 密钥):请设置 --api-key 或 FABRIC_API_KEY,或绑定回环地址(如 127.0.0.1:8080)",
|
||||||
"server_chat_error": "错误:%v",
|
"server_chat_error": "错误:%v",
|
||||||
"server_error_marshaling_response": "序列化响应错误:%v",
|
"server_error_marshaling_response": "序列化响应错误:%v",
|
||||||
"server_error_writing_response": "写入响应错误:%v",
|
"server_error_writing_response": "写入响应错误:%v",
|
||||||
"server_invalid_request_format": "无效的请求格式:%v",
|
"server_invalid_request_format": "无效的请求格式:%v",
|
||||||
|
"server_no_api_key_warning": "正在启动 REST API 服务器,未启用 API 密钥身份验证。这可能带来安全风险。",
|
||||||
"sessions_creating_new": "正在创建新会话:%s\n",
|
"sessions_creating_new": "正在创建新会话:%s\n",
|
||||||
"set_debug_level": "设置调试级别(0=关闭,1=基本,2=详细,3=跟踪,4=wire)",
|
"set_debug_level": "设置调试级别(0=关闭,1=基本,2=详细,3=跟踪,4=wire)",
|
||||||
"set_frequency_penalty": "设置频率惩罚",
|
"set_frequency_penalty": "设置频率惩罚",
|
||||||
|
|
@ -620,6 +624,7 @@
|
||||||
"storage_error_save": "无法保存 %s:%v",
|
"storage_error_save": "无法保存 %s:%v",
|
||||||
"storage_error_stat_entry": "无法获取条目 %s 的信息:%v",
|
"storage_error_stat_entry": "无法获取条目 %s 的信息:%v",
|
||||||
"storage_error_unmarshal": "无法反序列化 %s:%s",
|
"storage_error_unmarshal": "无法反序列化 %s:%s",
|
||||||
|
"storage_invalid_name": "无效的名称:%q",
|
||||||
"strategies_available_header": "可用的策略:",
|
"strategies_available_header": "可用的策略:",
|
||||||
"strategies_cloning_repository": "正在克隆仓库 %s(至路径:%s)...\\n",
|
"strategies_cloning_repository": "正在克隆仓库 %s(至路径:%s)...\\n",
|
||||||
"strategies_download_success": "✅ 已成功下载并安装策略到 %s\\n",
|
"strategies_download_success": "✅ 已成功下载并安装策略到 %s\\n",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
package db
|
package db
|
||||||
|
|
||||||
|
// Storage is the contract for a named-entity store. Each implementation
|
||||||
|
// specifies the names that are valid. It rejects an invalid name with a
|
||||||
|
// typed error. Callers can map this error to a client error.
|
||||||
type Storage[T any] interface {
|
type Storage[T any] interface {
|
||||||
Configure() (err error)
|
Configure() (err error)
|
||||||
Get(name string) (ret *T, err error)
|
Get(name string) (ret *T, err error)
|
||||||
GetNames() (ret []string, err error)
|
GetNames() (ret []string, err error)
|
||||||
Delete(name string) (err error)
|
Delete(name string) (err error)
|
||||||
|
// Exists reports false for an invalid name. It cannot show the
|
||||||
|
// difference between a rejected name and an absent entry.
|
||||||
Exists(name string) (ret bool)
|
Exists(name string) (ret bool)
|
||||||
Rename(oldName, newName string) (err error)
|
Rename(oldName, newName string) (err error)
|
||||||
Save(name string, content []byte) (err error)
|
Save(name string, content []byte) (err error)
|
||||||
|
|
|
||||||
|
|
@ -55,14 +55,18 @@ func (o *PatternsEntity) GetRaw(name string) (*Pattern, error) {
|
||||||
return o.getFromDB(name)
|
return o.getFromDB(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *PatternsEntity) loadPattern(source string) (pattern *Pattern, err error) {
|
// LooksLikePatternFilePath reports whether loadPattern uses source as a
|
||||||
// Determine if this is a file path
|
// filesystem path. HTTP handlers must reject these names to keep the
|
||||||
isFilePath := strings.HasPrefix(source, "\\") ||
|
// CLI file-path feature out of the REST API.
|
||||||
|
func LooksLikePatternFilePath(source string) bool {
|
||||||
|
return strings.HasPrefix(source, "\\") ||
|
||||||
strings.HasPrefix(source, "/") ||
|
strings.HasPrefix(source, "/") ||
|
||||||
strings.HasPrefix(source, "~") ||
|
strings.HasPrefix(source, "~") ||
|
||||||
strings.HasPrefix(source, ".")
|
strings.HasPrefix(source, ".")
|
||||||
|
}
|
||||||
|
|
||||||
if isFilePath {
|
func (o *PatternsEntity) loadPattern(source string) (pattern *Pattern, err error) {
|
||||||
|
if LooksLikePatternFilePath(source) {
|
||||||
// Resolve the file path using GetAbsolutePath
|
// Resolve the file path using GetAbsolutePath
|
||||||
var absPath string
|
var absPath string
|
||||||
if absPath, err = util.GetAbsolutePath(source); err != nil {
|
if absPath, err = util.GetAbsolutePath(source); err != nil {
|
||||||
|
|
@ -119,8 +123,13 @@ func (o *PatternsEntity) applyVariables(
|
||||||
|
|
||||||
// retrieves a pattern from the database by name
|
// retrieves a pattern from the database by name
|
||||||
func (o *PatternsEntity) getFromDB(name string) (ret *Pattern, err error) {
|
func (o *PatternsEntity) getFromDB(name string) (ret *Pattern, err error) {
|
||||||
if strings.Contains(name, "..") {
|
if ValidateStorageName(name) != nil {
|
||||||
return nil, fmt.Errorf(i18n.T("pattern_invalid_name"), name)
|
// The typed error lets an HTTP route without a pre-validation
|
||||||
|
// guard map this rejection to 400, not 500.
|
||||||
|
return nil, &InvalidStorageNameError{
|
||||||
|
Name: name,
|
||||||
|
Message: fmt.Sprintf(i18n.T("pattern_invalid_name"), name),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// First check custom patterns directory if it exists
|
// First check custom patterns directory if it exists
|
||||||
|
|
@ -283,13 +292,45 @@ func (o *PatternsEntity) Get(name string) (*Pattern, error) {
|
||||||
return o.GetApplyVariables(name, nil, "")
|
return o.GetApplyVariables(name, nil, "")
|
||||||
}
|
}
|
||||||
func (o *PatternsEntity) Save(name string, content []byte) (err error) {
|
func (o *PatternsEntity) Save(name string, content []byte) (err error) {
|
||||||
patternDir := filepath.Join(o.Dir, name)
|
// Do not store a name that loadPattern uses as a file path, for
|
||||||
|
// example ".foo" or "~bar". For such a name, GetApplyVariables reads
|
||||||
|
// from the filesystem, not from the database.
|
||||||
|
if LooksLikePatternFilePath(name) {
|
||||||
|
return &InvalidStorageNameError{
|
||||||
|
Name: name,
|
||||||
|
Message: fmt.Sprintf(i18n.T("pattern_invalid_name"), name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var patternDir string
|
||||||
|
if patternDir, err = o.resolvedPath(name); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err = os.MkdirAll(patternDir, os.ModePerm); err != nil {
|
if err = os.MkdirAll(patternDir, os.ModePerm); err != nil {
|
||||||
return fmt.Errorf(i18n.T("patterns_error_create_directory"), err)
|
return fmt.Errorf(i18n.T("patterns_error_create_directory"), err)
|
||||||
}
|
}
|
||||||
patternPath := filepath.Join(patternDir, o.SystemPatternFile)
|
patternPath := filepath.Join(patternDir, o.SystemPatternFile)
|
||||||
|
// The pattern file can be a symlink that already exists. Do not
|
||||||
|
// write through a symlink that goes out of the pattern directory.
|
||||||
|
if err = symlinkContained(patternDir, patternPath, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err = os.WriteFile(patternPath, content, 0644); err != nil {
|
if err = os.WriteFile(patternPath, content, 0644); err != nil {
|
||||||
return fmt.Errorf(i18n.T("patterns_error_save_pattern"), err)
|
return fmt.Errorf(i18n.T("patterns_error_save_pattern"), err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rename applies the file-path guard from Save to the destination name.
|
||||||
|
// Without the guard, the inherited StorageEntity.Rename accepts ".foo"
|
||||||
|
// or "~foo", and loadPattern then reads these names from the
|
||||||
|
// filesystem. A path-like source stays permitted, which lets you rename
|
||||||
|
// a legacy entry to a valid name.
|
||||||
|
func (o *PatternsEntity) Rename(oldName, newName string) error {
|
||||||
|
if LooksLikePatternFilePath(newName) {
|
||||||
|
return &InvalidStorageNameError{
|
||||||
|
Name: newName,
|
||||||
|
Message: fmt.Sprintf(i18n.T("pattern_invalid_name"), newName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o.StorageEntity.Rename(oldName, newName)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package fsdb
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/danielmiessler/fabric/internal/i18n"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -179,6 +181,88 @@ func TestPatternsEntity_Save(t *testing.T) {
|
||||||
assert.Equal(t, content, data)
|
assert.Equal(t, content, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save must reject a name that loadPattern uses as a filesystem path,
|
||||||
|
// and that includes a traversal name. For such a name,
|
||||||
|
// GetApplyVariables reads from the disk, not from the database.
|
||||||
|
func TestPatternsEntity_SaveRejectsFilePathNames(t *testing.T) {
|
||||||
|
entity, cleanup := setupTestPatternsEntity(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
for _, name := range []string{"..", ".foo", "..bar", "~bar", "/abs/path", `\win\path`} {
|
||||||
|
err := entity.Save(name, []byte("pwned"))
|
||||||
|
assert.Error(t, err, "expected error for file-path name: %q", name)
|
||||||
|
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
|
||||||
|
// If Save accepts an absolute name, it does not write in
|
||||||
|
// entity.Dir, and the join below points to the incorrect
|
||||||
|
// location. For these two names, only the error assertion
|
||||||
|
// gives protection.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// For ".." this path is the system.md of the parent directory,
|
||||||
|
// which Save makes if it accepts a traversal name.
|
||||||
|
_, statErr := os.Stat(filepath.Join(entity.Dir, name, entity.SystemPatternFile))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "wrote a pattern file for: %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename must reject a file-path-like destination, the same as Save.
|
||||||
|
// The inherited StorageEntity.Rename accepts such a destination.
|
||||||
|
func TestPatternsEntity_RenameRejectsFilePathDestination(t *testing.T) {
|
||||||
|
entity, cleanup := setupTestPatternsEntity(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
createTestPattern(t, entity, "good-name", "content")
|
||||||
|
for _, newName := range []string{".foo", "~bar"} {
|
||||||
|
err := entity.Rename("good-name", newName)
|
||||||
|
var invalidName *InvalidStorageNameError
|
||||||
|
require.ErrorAs(t, err, &invalidName, "expected rejection for destination: %q", newName)
|
||||||
|
_, statErr := os.Stat(filepath.Join(entity.Dir, newName))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "renamed to: %q", newName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid destination still works.
|
||||||
|
require.NoError(t, entity.Rename("good-name", "better-name"))
|
||||||
|
_, err := os.Stat(filepath.Join(entity.Dir, "better-name"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save must not write through a symlinked pattern directory or a
|
||||||
|
// symlinked pattern file that points outside the storage tree.
|
||||||
|
func TestPatternsEntity_SaveRejectsSymlinkEscape(t *testing.T) {
|
||||||
|
entity, cleanup := setupTestPatternsEntity(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
outsideDir := t.TempDir()
|
||||||
|
mustSymlink(t, outsideDir, filepath.Join(entity.Dir, "linked-dir"))
|
||||||
|
err := entity.Save("linked-dir", []byte("pwned"))
|
||||||
|
var invalidName *InvalidStorageNameError
|
||||||
|
require.ErrorAs(t, err, &invalidName)
|
||||||
|
_, statErr := os.Stat(filepath.Join(outsideDir, entity.SystemPatternFile))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "wrote through the symlinked pattern dir")
|
||||||
|
|
||||||
|
outsideFile := filepath.Join(outsideDir, "target.md")
|
||||||
|
require.NoError(t, os.WriteFile(outsideFile, []byte("keep"), 0o644))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(entity.Dir, "real-pattern"), 0o755))
|
||||||
|
mustSymlink(t, outsideFile, filepath.Join(entity.Dir, "real-pattern", entity.SystemPatternFile))
|
||||||
|
err = entity.Save("real-pattern", []byte("pwned"))
|
||||||
|
require.ErrorAs(t, err, &invalidName)
|
||||||
|
got, readErr := os.ReadFile(outsideFile)
|
||||||
|
require.NoError(t, readErr)
|
||||||
|
assert.Equal(t, "keep", string(got), "outside pattern file was overwritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetApplyVariables_FromFile(t *testing.T) {
|
||||||
|
entity, cleanup := setupTestPatternsEntity(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "fromfile.md")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("Hello {{input}}"), 0o644))
|
||||||
|
|
||||||
|
result, err := entity.GetApplyVariables(path, nil, "world")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Hello world", result.Pattern)
|
||||||
|
}
|
||||||
|
|
||||||
func TestPatternsEntity_CustomPatterns(t *testing.T) {
|
func TestPatternsEntity_CustomPatterns(t *testing.T) {
|
||||||
// Create main patterns directory
|
// Create main patterns directory
|
||||||
mainDir, err := os.MkdirTemp("", "test-main-patterns-*")
|
mainDir, err := os.MkdirTemp("", "test-main-patterns-*")
|
||||||
|
|
@ -333,20 +417,43 @@ func TestPrintPattern(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetFromDB_PathTraversal(t *testing.T) {
|
func TestGetFromDB_PathTraversal(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
entity, cleanup := setupTestPatternsEntity(t)
|
entity, cleanup := setupTestPatternsEntity(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
traversalNames := []string{
|
for _, name := range invalidStorageNames {
|
||||||
"../etc/passwd",
|
t.Run(name, func(t *testing.T) {
|
||||||
"../../secret",
|
_, err := entity.GetRaw(name)
|
||||||
"foo/../bar",
|
require.Error(t, err, "expected error for traversal name: %q", name)
|
||||||
"..",
|
assert.Contains(t, err.Error(), "invalid pattern name", "wrong error for: %q", name)
|
||||||
"valid/../../../etc/shadow",
|
var invalidName *InvalidStorageNameError
|
||||||
|
assert.ErrorAs(t, err, &invalidName, "want typed rejection for: %q", name)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
for _, name := range traversalNames {
|
}
|
||||||
_, err := entity.GetRaw(name)
|
|
||||||
assert.Error(t, err, "expected error for traversal name: %q", name)
|
// A ".." in a name without separators is one safe path element.
|
||||||
assert.Contains(t, err.Error(), "invalid pattern name", "wrong error for: %q", name)
|
// ValidateStorageName accepts it, and getFromDB also accepts it.
|
||||||
|
func TestGetFromDB_AllowsDotsWithinName(t *testing.T) {
|
||||||
|
entity, cleanup := setupTestPatternsEntity(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
createTestPattern(t, entity, "foo..bar", "dotty {{input}}")
|
||||||
|
|
||||||
|
pattern, err := entity.GetRaw("foo..bar")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "dotty {{input}}", pattern.Pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLooksLikePatternFilePath(t *testing.T) {
|
||||||
|
for _, source := range []string{"/x", `~\x`, `\x`, `.\x`, "~", ".", ".."} {
|
||||||
|
assert.True(t, LooksLikePatternFilePath(source), "expected file-path detection for: %q", source)
|
||||||
|
}
|
||||||
|
for _, source := range []string{"", "pattern", "foo..bar", "a/b", "x~y", "x.y"} {
|
||||||
|
assert.False(t, LooksLikePatternFilePath(source), "unexpected file-path detection for: %q", source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ type SessionsEntity struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *SessionsEntity) Get(name string) (session *Session, err error) {
|
func (o *SessionsEntity) Get(name string) (session *Session, err error) {
|
||||||
|
// Reject invalid names here. Exists reports false for them, and the
|
||||||
|
// missing-session branch then answers with a new empty session and
|
||||||
|
// no error.
|
||||||
|
if err = ValidateStorageName(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
session = &Session{Name: name}
|
session = &Session{Name: name}
|
||||||
|
|
||||||
if o.Exists(name) {
|
if o.Exists(name) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,20 @@ func TestSessions_GetOrCreateSession(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get must reject an invalid name and must not answer with a new empty
|
||||||
|
// session. GET /sessions/<bad-name> is then a 400, the same as for the
|
||||||
|
// other entities.
|
||||||
|
func TestSessions_GetRejectsInvalidNames(t *testing.T) {
|
||||||
|
sessions := &SessionsEntity{
|
||||||
|
StorageEntity: &StorageEntity{Dir: t.TempDir(), FileExtension: ".json"},
|
||||||
|
}
|
||||||
|
for _, name := range invalidStorageNames {
|
||||||
|
if _, err := sessions.Get(name); err == nil {
|
||||||
|
t.Errorf("Get(%q) succeeded, want error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessions_SaveSession(t *testing.T) {
|
func TestSessions_SaveSession(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
sessions := &SessionsEntity{
|
sessions := &SessionsEntity{
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import (
|
||||||
"github.com/danielmiessler/fabric/internal/util"
|
"github.com/danielmiessler/fabric/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StorageEntity is the filesystem-backed db.Storage implementation.
|
||||||
|
// Each method that gets a name requires one that obeys
|
||||||
|
// ValidateStorageName. It rejects other names with
|
||||||
|
// *InvalidStorageNameError, which HTTP handlers map to 400.
|
||||||
type StorageEntity struct {
|
type StorageEntity struct {
|
||||||
Label string
|
Label string
|
||||||
Dir string
|
Dir string
|
||||||
|
|
@ -68,34 +72,57 @@ func (o *StorageEntity) GetNames() (ret []string, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *StorageEntity) Delete(name string) (err error) {
|
func (o *StorageEntity) Delete(name string) (err error) {
|
||||||
if err = os.RemoveAll(o.BuildFilePathByName(name)); err != nil {
|
var path string
|
||||||
|
if path, err = o.resolvedPath(name); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = os.RemoveAll(path); err != nil {
|
||||||
err = fmt.Errorf(i18n.T("storage_error_delete"), name, err)
|
err = fmt.Errorf(i18n.T("storage_error_delete"), name, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *StorageEntity) Exists(name string) (ret bool) {
|
func (o *StorageEntity) Exists(name string) (ret bool) {
|
||||||
_, err := os.Stat(o.BuildFilePathByName(name))
|
path, err := o.resolvedPath(name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err = os.Stat(path)
|
||||||
ret = !os.IsNotExist(err)
|
ret = !os.IsNotExist(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *StorageEntity) Rename(oldName, newName string) (err error) {
|
func (o *StorageEntity) Rename(oldName, newName string) (err error) {
|
||||||
if err = os.Rename(o.BuildFilePathByName(oldName), o.BuildFilePathByName(newName)); err != nil {
|
var oldPath, newPath string
|
||||||
|
if oldPath, err = o.resolvedPath(oldName); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newPath, err = o.resolvedPath(newName); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = os.Rename(oldPath, newPath); err != nil {
|
||||||
err = fmt.Errorf(i18n.T("storage_error_rename"), oldName, newName, err)
|
err = fmt.Errorf(i18n.T("storage_error_rename"), oldName, newName, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *StorageEntity) Save(name string, content []byte) (err error) {
|
func (o *StorageEntity) Save(name string, content []byte) (err error) {
|
||||||
if err = os.WriteFile(o.BuildFilePathByName(name), content, 0644); err != nil {
|
var path string
|
||||||
|
if path, err = o.resolvedPath(name); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = os.WriteFile(path, content, 0644); err != nil {
|
||||||
err = fmt.Errorf(i18n.T("storage_error_save"), name, err)
|
err = fmt.Errorf(i18n.T("storage_error_save"), name, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *StorageEntity) Load(name string) (ret []byte, err error) {
|
func (o *StorageEntity) Load(name string) (ret []byte, err error) {
|
||||||
if ret, err = os.ReadFile(o.BuildFilePathByName(name)); err != nil {
|
var path string
|
||||||
|
if path, err = o.resolvedPath(name); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ret, err = os.ReadFile(path); err != nil {
|
||||||
err = fmt.Errorf(i18n.T("storage_error_load"), name, err)
|
err = fmt.Errorf(i18n.T("storage_error_load"), name, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -120,11 +147,6 @@ func (o *StorageEntity) ListNames(shellCompleteList bool) (err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *StorageEntity) BuildFilePathByName(name string) (ret string) {
|
|
||||||
ret = o.BuildFilePath(o.buildFileName(name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *StorageEntity) BuildFilePath(fileName string) (ret string) {
|
func (o *StorageEntity) BuildFilePath(fileName string) (ret string) {
|
||||||
ret = filepath.Join(o.Dir, fileName)
|
ret = filepath.Join(o.Dir, fileName)
|
||||||
return
|
return
|
||||||
|
|
@ -134,6 +156,122 @@ func (o *StorageEntity) buildFileName(name string) string {
|
||||||
return fmt.Sprintf("%s%v", name, o.FileExtension)
|
return fmt.Sprintf("%s%v", name, o.FileExtension)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidStorageNameError reports a name that storage-name validation
|
||||||
|
// rejected. HTTP handlers map it to 400 Bad Request. All other storage
|
||||||
|
// errors stay 500 errors.
|
||||||
|
type InvalidStorageNameError struct {
|
||||||
|
Name string
|
||||||
|
Message string // optional: the default is the storage_invalid_name translation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *InvalidStorageNameError) Error() string {
|
||||||
|
if e.Message != "" {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(i18n.T("storage_invalid_name"), e.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowsReservedNames are DOS device names. On Windows, these names
|
||||||
|
// identify devices, not files. The match ignores case and all text after
|
||||||
|
// the first dot, because Windows maps "CON.tar.gz" to the CON device.
|
||||||
|
var windowsReservedNames = map[string]bool{
|
||||||
|
"CON": true, "PRN": true, "AUX": true, "NUL": true,
|
||||||
|
"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
|
||||||
|
"COM6": true, "COM7": true, "COM8": true, "COM9": true,
|
||||||
|
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
|
||||||
|
"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateStorageName rejects an empty name, ".", "..", and each name
|
||||||
|
// that is not a single path element. It also rejects names that are
|
||||||
|
// dangerous only on Windows: names with ":" (an NTFS alternate data
|
||||||
|
// stream suffix), names with a dot or space at the end, and reserved
|
||||||
|
// DOS device names. Windows removes a dot or space at the end, and the
|
||||||
|
// shortened name then collides with an existing entry. The policy is
|
||||||
|
// the same on each platform, and an entry made on one system stays
|
||||||
|
// valid on the other systems. A Unix
|
||||||
|
// entry that already has a name against these rules shows in GetNames
|
||||||
|
// but is not accessible. To repair it, rename its file or directory on
|
||||||
|
// disk. Call this function before you join a name to a storage
|
||||||
|
// directory.
|
||||||
|
func ValidateStorageName(name string) error {
|
||||||
|
if name == "" || name == "." || name == ".." {
|
||||||
|
return &InvalidStorageNameError{Name: name}
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(name, `/\:`) {
|
||||||
|
return &InvalidStorageNameError{Name: name}
|
||||||
|
}
|
||||||
|
if name != strings.TrimRight(name, ". ") {
|
||||||
|
return &InvalidStorageNameError{Name: name}
|
||||||
|
}
|
||||||
|
base := strings.ToUpper(name)
|
||||||
|
if i := strings.IndexByte(base, '.'); i >= 0 {
|
||||||
|
base = base[:i]
|
||||||
|
}
|
||||||
|
if windowsReservedNames[base] {
|
||||||
|
return &InvalidStorageNameError{Name: name}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// symlinkContained rejects an entry at path if the entry resolves,
|
||||||
|
// through symlinks, to a target outside absDir. A missing entry passes,
|
||||||
|
// because the lexical check in resolvedPath already keeps the path that
|
||||||
|
// a write will make in the directory. The two inputs must be absolute
|
||||||
|
// paths. If they are not, you cannot compare the resolved forms. The
|
||||||
|
// check does not fully prevent local races. If a hostile local writer
|
||||||
|
// enters the threat model, move to os.Root.
|
||||||
|
func symlinkContained(absDir, path, name string) error {
|
||||||
|
target, err := filepath.EvalSymlinks(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if _, lerr := os.Lstat(path); os.IsNotExist(lerr) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// This is a dangling symlink. A write through it makes the
|
||||||
|
// outside target.
|
||||||
|
return &InvalidStorageNameError{Name: name}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolvedDir, err := filepath.EvalSymlinks(absDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(resolvedDir, target)
|
||||||
|
if err != nil || !filepath.IsLocal(rel) {
|
||||||
|
return &InvalidStorageNameError{Name: name}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvedPath keeps name in the entity directory. It validates the
|
||||||
|
// name, checks containment again after absolute resolution, and
|
||||||
|
// rejects a symlinked entry that resolves out of the directory.
|
||||||
|
// Symlinks that stay in the directory are permitted. A storage
|
||||||
|
// directory that is a symlink is also permitted.
|
||||||
|
func (o *StorageEntity) resolvedPath(name string) (string, error) {
|
||||||
|
if err := ValidateStorageName(name); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
absDir, err := filepath.Abs(o.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
absFull, err := filepath.Abs(filepath.Join(o.Dir, o.buildFileName(name)))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(absDir, absFull)
|
||||||
|
if err != nil || !filepath.IsLocal(rel) {
|
||||||
|
return "", &InvalidStorageNameError{Name: name}
|
||||||
|
}
|
||||||
|
if err := symlinkContained(absDir, absFull, name); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return absFull, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (o *StorageEntity) SaveAsJson(name string, item any) (err error) {
|
func (o *StorageEntity) SaveAsJson(name string, item any) (err error) {
|
||||||
var jsonString []byte
|
var jsonString []byte
|
||||||
if jsonString, err = json.Marshal(item); err == nil {
|
if jsonString, err = json.Marshal(item); err == nil {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package fsdb
|
package fsdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/danielmiessler/fabric/internal/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStorage_SaveAndLoad(t *testing.T) {
|
func TestStorage_SaveAndLoad(t *testing.T) {
|
||||||
|
|
@ -50,3 +54,197 @@ func TestStorage_Delete(t *testing.T) {
|
||||||
t.Errorf("expected file to be deleted")
|
t.Errorf("expected file to be deleted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// invalidStorageNames are names that ValidateStorageName must reject on
|
||||||
|
// each platform. The storage tests and the pattern traversal tests
|
||||||
|
// share this list, and one new attack name gets a test at each
|
||||||
|
// location. The backslash cases guard the `\` half of the separator
|
||||||
|
// check. That half is the Windows-only escape guard that a "simplify
|
||||||
|
// to filepath.Base" refactor removes without a test failure. The colon,
|
||||||
|
// reserved-name, and trailing dot and space cases guard the Windows
|
||||||
|
// protections: NTFS alternate data streams, DOS device names, and name
|
||||||
|
// suffixes that Windows removes.
|
||||||
|
var invalidStorageNames = []string{
|
||||||
|
"..", "../keep.txt", "/etc/passwd", "foo/../../keep.txt", ".", "",
|
||||||
|
`foo\bar`, `..\x`,
|
||||||
|
"foo:bar", "NUL", "con.txt", "CON.tar.gz", "foo.", "foo ",
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTraversalFixture returns a storage entity in a temporary root and
|
||||||
|
// a marker file out of the entity directory. It also returns a check
|
||||||
|
// that fails the test if the marker or the entity directory is gone.
|
||||||
|
func newTraversalFixture(t *testing.T) (storage *StorageEntity, checkSurvived func()) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "contexts")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
marker := filepath.Join(root, "keep.txt")
|
||||||
|
if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
storage = &StorageEntity{Dir: dir, Label: "Contexts"}
|
||||||
|
checkSurvived = func() {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := os.Stat(marker); err != nil {
|
||||||
|
t.Fatalf("parent marker was removed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
t.Fatalf("storage dir was removed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_RejectsPathTraversal(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage, checkSurvived := newTraversalFixture(t)
|
||||||
|
for _, name := range invalidStorageNames {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if err := storage.Delete(name); err == nil {
|
||||||
|
t.Fatalf("Delete(%q) succeeded, want error", name)
|
||||||
|
}
|
||||||
|
if err := storage.Save(name, []byte("pwned")); err == nil {
|
||||||
|
t.Fatalf("Save(%q) succeeded, want error", name)
|
||||||
|
}
|
||||||
|
if _, err := storage.Load(name); err == nil {
|
||||||
|
t.Fatalf("Load(%q) succeeded, want error", name)
|
||||||
|
}
|
||||||
|
if storage.Exists(name) {
|
||||||
|
t.Fatalf("Exists(%q) is true, want false", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSurvived()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidStorageNameError_DefaultMessage(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
err := &InvalidStorageNameError{Name: "bad:name"}
|
||||||
|
if got, want := err.Error(), `invalid name: "bad:name"`; got != want {
|
||||||
|
t.Fatalf("Error() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_Rename(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
storage := &StorageEntity{Dir: dir, Label: "Contexts"}
|
||||||
|
if err := storage.Save("old", []byte("content")); err != nil {
|
||||||
|
t.Fatalf("failed to save content: %v", err)
|
||||||
|
}
|
||||||
|
if err := storage.Rename("old", "new"); err != nil {
|
||||||
|
t.Fatalf("failed to rename: %v", err)
|
||||||
|
}
|
||||||
|
if storage.Exists("old") {
|
||||||
|
t.Errorf("expected old name to be gone")
|
||||||
|
}
|
||||||
|
loaded, err := storage.Load("new")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load renamed content: %v", err)
|
||||||
|
}
|
||||||
|
if string(loaded) != "content" {
|
||||||
|
t.Errorf("expected %q, got %q", "content", string(loaded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_RenameRejectsPathTraversal(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage, checkSurvived := newTraversalFixture(t)
|
||||||
|
if err := storage.Save("ok", []byte("content")); err != nil {
|
||||||
|
t.Fatalf("failed to save content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range invalidStorageNames {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if err := storage.Rename("ok", name); err == nil {
|
||||||
|
t.Fatalf("Rename(%q, %q) succeeded, want error", "ok", name)
|
||||||
|
}
|
||||||
|
if err := storage.Rename(name, "ok"); err == nil {
|
||||||
|
t.Fatalf("Rename(%q, %q) succeeded, want error", name, "ok")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSurvived()
|
||||||
|
if !storage.Exists("ok") {
|
||||||
|
t.Fatalf("legitimate entry was moved or deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustSymlink makes a symlink. If symlinks are not available, for
|
||||||
|
// example on Windows without the privilege, it skips the test.
|
||||||
|
func mustSymlink(t *testing.T, target, link string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Skipf("cannot create symlink: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorage_RejectsSymlinkEscape(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "store")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outside := filepath.Join(root, "outside.txt")
|
||||||
|
if err := os.WriteFile(outside, []byte("keep"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustSymlink(t, outside, filepath.Join(dir, "escape"))
|
||||||
|
mustSymlink(t, filepath.Join(root, "missing.txt"), filepath.Join(dir, "dangling"))
|
||||||
|
|
||||||
|
storage := &StorageEntity{Dir: dir}
|
||||||
|
for _, name := range []string{"escape", "dangling"} {
|
||||||
|
if _, err := storage.Load(name); err == nil {
|
||||||
|
t.Fatalf("Load(%q) through an outside symlink did not fail", name)
|
||||||
|
}
|
||||||
|
if err := storage.Save(name, []byte("pwned")); err == nil {
|
||||||
|
t.Fatalf("Save(%q) through an outside symlink did not fail", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(outside); string(got) != "keep" {
|
||||||
|
t.Fatalf("outside file was overwritten: %q", got)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "missing.txt")); err == nil {
|
||||||
|
t.Fatal("dangling symlink target was created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and Save operate through a symlink that stays in the storage
|
||||||
|
// directory, and through a storage directory that is a symlink.
|
||||||
|
func TestStorage_AllowsInternalAndDirSymlinks(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
realDir := filepath.Join(root, "real")
|
||||||
|
if err := os.MkdirAll(realDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage := &StorageEntity{Dir: realDir}
|
||||||
|
if err := storage.Save("target", []byte("content")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustSymlink(t, filepath.Join(realDir, "target"), filepath.Join(realDir, "alias"))
|
||||||
|
if got, err := storage.Load("alias"); err != nil || string(got) != "content" {
|
||||||
|
t.Fatalf("Load through an internal symlink: got %q, err %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
linkDir := filepath.Join(root, "link")
|
||||||
|
mustSymlink(t, realDir, linkDir)
|
||||||
|
linked := &StorageEntity{Dir: linkDir}
|
||||||
|
if got, err := linked.Load("target"); err != nil || string(got) != "content" {
|
||||||
|
t.Fatalf("Load via a symlinked storage dir: got %q, err %v", got, err)
|
||||||
|
}
|
||||||
|
if err := linked.Save("new", []byte("x")); err != nil {
|
||||||
|
t.Fatalf("Save via a symlinked storage dir: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,47 @@
|
||||||
package restapi
|
package restapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/danielmiessler/fabric/internal/i18n"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const APIKeyHeader = "X-API-Key"
|
const APIKeyHeader = "X-API-Key"
|
||||||
|
|
||||||
|
// requireAPIKeyForBind rejects a non-loopback bind address that has no
|
||||||
|
// API key. An empty or unspecified host binds each interface, and that
|
||||||
|
// counts as non-loopback.
|
||||||
|
func requireAPIKeyForBind(address, apiKey string) error {
|
||||||
|
if apiKey != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
host := address
|
||||||
|
if h, _, err := net.SplitHostPort(address); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
if host == "localhost" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf(i18n.T("server_api_key_required"), address)
|
||||||
|
}
|
||||||
|
|
||||||
// APIKeyMiddleware validates API key for protected endpoints.
|
// APIKeyMiddleware validates API key for protected endpoints.
|
||||||
// Swagger documentation endpoints (/swagger/*) are exempt from authentication
|
// Swagger documentation endpoints (/swagger/*) are exempt from authentication
|
||||||
// to allow users to browse and test the API documentation freely.
|
// to allow users to browse and test the API documentation freely.
|
||||||
func APIKeyMiddleware(apiKey string) gin.HandlerFunc {
|
func APIKeyMiddleware(apiKey string) gin.HandlerFunc {
|
||||||
|
// Compare digests, not the raw values. ConstantTimeCompare returns
|
||||||
|
// early when the lengths are different, and that shows the length of
|
||||||
|
// the configured key.
|
||||||
|
expectedKey := sha256.Sum256([]byte(apiKey))
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Skip authentication for Swagger documentation endpoints
|
// Skip authentication for Swagger documentation endpoints
|
||||||
// This allows public access to API docs even when authentication is enabled
|
// This allows public access to API docs even when authentication is enabled
|
||||||
|
|
@ -28,7 +57,8 @@ func APIKeyMiddleware(apiKey string) gin.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if headerApiKey != apiKey {
|
headerKey := sha256.Sum256([]byte(headerApiKey))
|
||||||
|
if subtle.ConstantTimeCompare(headerKey[:], expectedKey[:]) != 1 {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Wrong API Key"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Wrong API Key"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,11 +73,23 @@ func (h *ChatHandler) HandleChat(c *gin.Context) {
|
||||||
|
|
||||||
if err := c.BindJSON(&request); err != nil {
|
if err := c.BindJSON(&request); err != nil {
|
||||||
log.Printf("Error binding JSON: %v", err)
|
log.Printf("Error binding JSON: %v", err)
|
||||||
c.Writer.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
setHSTS(c)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf(i18n.T("server_invalid_request_format"), err)})
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf(i18n.T("server_invalid_request_format"), err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, prompt := range request.Prompts {
|
||||||
|
if rejectUnsafePatternName(c, prompt.PatternName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rejectInvalidStorageName(c, prompt.ContextName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rejectInvalidStorageName(c, prompt.SessionName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add log to check received language field
|
// Add log to check received language field
|
||||||
log.Printf("Received chat request - Language: '%s', Prompts: %d", request.Language, len(request.Prompts))
|
log.Printf("Received chat request - Language: '%s', Prompts: %d", request.Language, len(request.Prompts))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -45,6 +46,7 @@ type APIConvert struct {
|
||||||
registry *core.PluginRegistry
|
registry *core.PluginRegistry
|
||||||
r *gin.Engine
|
r *gin.Engine
|
||||||
addr *string
|
addr *string
|
||||||
|
apiKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type OllamaRequestBody struct {
|
type OllamaRequestBody struct {
|
||||||
|
|
@ -188,12 +190,49 @@ func parseOllamaNumCtx(options map[string]any) (int, error) {
|
||||||
return contextLength, nil
|
return contextLength, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServeOllama(registry *core.PluginRegistry, address string, version string) (err error) {
|
// fabricChatClient sends the /api/chat self-forward, which can contain
|
||||||
|
// the configured API key. It does not use a proxy, because the default
|
||||||
|
// transport obeys HTTP_PROXY and can send the key to the proxy. It does
|
||||||
|
// not obey redirects, and cannot send the key again to a location that
|
||||||
|
// the operator did not configure.
|
||||||
|
var fabricChatClient = newFabricChatClient()
|
||||||
|
|
||||||
|
func newFabricChatClient() *http.Client {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.Proxy = nil
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeOllama operates the Ollama-compatible API server on address. An
|
||||||
|
// empty apiKey sets authentication to off. This is permitted only for
|
||||||
|
// loopback binds.
|
||||||
|
func ServeOllama(registry *core.PluginRegistry, address string, version string, apiKey string) error {
|
||||||
|
if err := requireAPIKeyForBind(address, apiKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return newOllamaEngine(registry, address, version, apiKey).Run(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOllamaEngine makes the engine but does not start it, which lets
|
||||||
|
// tests operate the routes. The address parameter is the /api/chat
|
||||||
|
// forward target, not the listen address that Run gets. In production
|
||||||
|
// the two are the same value.
|
||||||
|
func newOllamaEngine(registry *core.PluginRegistry, address string, version string, apiKey string) *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
r.Use(gin.Logger())
|
r.Use(gin.Logger())
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
|
if apiKey != "" {
|
||||||
|
r.Use(APIKeyMiddleware(apiKey))
|
||||||
|
} else {
|
||||||
|
slog.Warn("Starting Ollama-compatible API server without API key authentication. This may pose security risks.")
|
||||||
|
}
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
fabricDb := registry.Db
|
fabricDb := registry.Db
|
||||||
|
|
@ -208,6 +247,7 @@ func ServeOllama(registry *core.PluginRegistry, address string, version string)
|
||||||
registry: registry,
|
registry: registry,
|
||||||
r: r,
|
r: r,
|
||||||
addr: &address,
|
addr: &address,
|
||||||
|
apiKey: apiKey,
|
||||||
}
|
}
|
||||||
// Ollama Endpoints
|
// Ollama Endpoints
|
||||||
r.GET("/api/tags", typeConversion.ollamaTags)
|
r.GET("/api/tags", typeConversion.ollamaTags)
|
||||||
|
|
@ -216,13 +256,7 @@ func ServeOllama(registry *core.PluginRegistry, address string, version string)
|
||||||
})
|
})
|
||||||
r.POST("/api/chat", typeConversion.ollamaChat)
|
r.POST("/api/chat", typeConversion.ollamaChat)
|
||||||
|
|
||||||
// Start server
|
return r
|
||||||
err = r.Run(address)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f APIConvert) ollamaTags(c *gin.Context) {
|
func (f APIConvert) ollamaTags(c *gin.Context) {
|
||||||
|
|
@ -267,7 +301,7 @@ func (f APIConvert) ollamaChat(c *gin.Context) {
|
||||||
err = json.Unmarshal(body, &prompt)
|
err = json.Unmarshal(body, &prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf(i18n.T("ollama_error_unmarshalling_body"), err)
|
log.Printf(i18n.T("ollama_error_unmarshalling_body"), err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_error_endpoint")})
|
c.JSON(http.StatusBadRequest, gin.H{"error": i18n.T("ollama_invalid_request_body")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,14 +369,14 @@ func (f APIConvert) ollamaChat(c *gin.Context) {
|
||||||
fabricChatReq, err := json.Marshal(chat)
|
fabricChatReq, err := json.Marshal(chat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf(i18n.T("ollama_error_marshalling_body"), err)
|
log.Printf(i18n.T("ollama_error_marshalling_body"), err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_failed_create_request")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
baseURL, err := buildFabricChatURL(*f.addr)
|
baseURL, err := buildFabricChatURL(*f.addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf(i18n.T("ollama_error_building_chat_url"), err)
|
log.Printf(i18n.T("ollama_error_building_chat_url"), err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_failed_create_request")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req, err = http.NewRequest("POST", fmt.Sprintf("%s/chat", baseURL), bytes.NewBuffer(fabricChatReq))
|
req, err = http.NewRequest("POST", fmt.Sprintf("%s/chat", baseURL), bytes.NewBuffer(fabricChatReq))
|
||||||
|
|
@ -351,13 +385,16 @@ func (f APIConvert) ollamaChat(c *gin.Context) {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_failed_create_request")})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_failed_create_request")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if f.apiKey != "" {
|
||||||
|
req.Header.Set(APIKeyHeader, f.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
req = req.WithContext(c.Request.Context())
|
req = req.WithContext(c.Request.Context())
|
||||||
|
|
||||||
fabricRes, err := http.DefaultClient.Do(req)
|
fabricRes, err := fabricChatClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf(i18n.T("ollama_error_getting_chat_body"), err)
|
log.Printf(i18n.T("ollama_error_getting_chat_body"), err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.T("ollama_upstream_request_failed")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer fabricRes.Body.Close()
|
defer fabricRes.Body.Close()
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,16 @@ package restapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/danielmiessler/fabric/internal/core"
|
||||||
|
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildFabricChatURL(t *testing.T) {
|
func TestBuildFabricChatURL(t *testing.T) {
|
||||||
|
|
@ -361,3 +368,193 @@ func TestParseOllamaNumCtx(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewOllamaEngine_APIKeyWiring(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
registry := &core.PluginRegistry{Db: fsdb.NewDb(t.TempDir())}
|
||||||
|
|
||||||
|
getVersion := func(r *gin.Engine, key string) int {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||||
|
if key != "" {
|
||||||
|
req.Header.Set(APIKeyHeader, key)
|
||||||
|
}
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
withKey := newOllamaEngine(registry, ":0", "test-version", "secret")
|
||||||
|
if code := getVersion(withKey, ""); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("no key presented: got %d, want 401", code)
|
||||||
|
}
|
||||||
|
if code := getVersion(withKey, "secret"); code != http.StatusOK {
|
||||||
|
t.Fatalf("valid key presented: got %d, want 200", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutKey := newOllamaEngine(registry, ":0", "test-version", "")
|
||||||
|
if code := getVersion(withoutKey, ""); code != http.StatusOK {
|
||||||
|
t.Fatalf("no key configured: got %d, want 200", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOllamaChat_ForwardsAPIKeyToChat(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Make the loopback /chat route with the middleware installed, the
|
||||||
|
// same as newOllamaEngine makes it when --api-key is set.
|
||||||
|
upstream := gin.New()
|
||||||
|
upstream.Use(APIKeyMiddleware("secret"))
|
||||||
|
upstream.POST("/chat", func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
fmt.Fprint(c.Writer, "data: {\"type\":\"content\",\"format\":\"markdown\",\"content\":\"hi\"}\n\n")
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(upstream)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
chatRequest := func(key string) int {
|
||||||
|
r := gin.New()
|
||||||
|
conv := APIConvert{addr: &server.URL, apiKey: key}
|
||||||
|
r.POST("/api/chat", conv.ollamaChat)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
body := `{"model":"test:latest","messages":[{"role":"user","content":"hi"}]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := chatRequest("secret"); code != http.StatusOK {
|
||||||
|
t.Fatalf("matching key: got %d, want 200", code)
|
||||||
|
}
|
||||||
|
if code := chatRequest("wrong"); code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("wrong key: got %d, want 401", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The self-forward client must not use a proxy. A proxy gets the
|
||||||
|
// configured API key, and the operator did not configure that host.
|
||||||
|
// The redirect test below covers the no-redirect property.
|
||||||
|
func TestFabricChatClient_NoProxy(t *testing.T) {
|
||||||
|
transport, ok := fabricChatClient.Transport.(*http.Transport)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport is %T, want *http.Transport", fabricChatClient.Transport)
|
||||||
|
}
|
||||||
|
if transport.Proxy != nil {
|
||||||
|
t.Fatal("self-forward transport has a proxy configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An upstream redirect must show as an upstream error. The client must
|
||||||
|
// not go to the redirect target with the API key.
|
||||||
|
func TestOllamaChat_DoesNotFollowUpstreamRedirect(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
redirectTargetHit := false
|
||||||
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
redirectTargetHit = true
|
||||||
|
}))
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, target.URL+"/chat", http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
conv := APIConvert{addr: &upstream.URL, apiKey: "secret"}
|
||||||
|
r.POST("/api/chat", conv.ollamaChat)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
body := `{"model":"test:latest","messages":[{"role":"user","content":"hi"}]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if redirectTargetHit {
|
||||||
|
t.Fatal("the redirect target was contacted")
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusFound {
|
||||||
|
t.Fatalf("got %d, want the upstream 302 surfaced as an error", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed client JSON is a client error. The answer is a 400 with a
|
||||||
|
// stable generic message, not a 500.
|
||||||
|
func TestOllamaChat_MalformedJSONIs400(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
addr := ":0"
|
||||||
|
r := gin.New()
|
||||||
|
conv := APIConvert{addr: &addr}
|
||||||
|
r.POST("/api/chat", conv.ollamaChat)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader("{not json"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("got %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An upstream that is not available is a 500. The body must not contain
|
||||||
|
// the raw transport error, because that error shows the internal
|
||||||
|
// upstream URL.
|
||||||
|
func TestOllamaChat_UpstreamFailureHidesDetails(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
url := server.URL
|
||||||
|
server.Close() // nothing listens on url anymore
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
conv := APIConvert{addr: &url}
|
||||||
|
r.POST("/api/chat", conv.ollamaChat)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
body := `{"model":"test:latest","messages":[{"role":"user","content":"hi"}]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("got %d, want 500", w.Code)
|
||||||
|
}
|
||||||
|
got := w.Body.String()
|
||||||
|
if strings.Contains(got, "dial tcp") || strings.Contains(got, strings.TrimPrefix(url, "http://")) {
|
||||||
|
t.Fatalf("500 body leaks transport details: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no configured key, the forwarded request must not contain the header.
|
||||||
|
func TestOllamaChat_NoKeyOmitsHeader(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
var gotHeader string
|
||||||
|
upstream := gin.New()
|
||||||
|
upstream.POST("/chat", func(c *gin.Context) {
|
||||||
|
gotHeader = c.GetHeader(APIKeyHeader)
|
||||||
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
fmt.Fprint(c.Writer, "data: {\"type\":\"content\",\"format\":\"markdown\",\"content\":\"hi\"}\n\n")
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(upstream)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
conv := APIConvert{addr: &server.URL, apiKey: ""}
|
||||||
|
r.POST("/api/chat", conv.ollamaChat)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
body := `{"model":"test:latest","messages":[{"role":"user","content":"hi"}]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if gotHeader != "" {
|
||||||
|
t.Fatalf("X-API-Key was forwarded with no key configured: %q", gotHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
330
internal/server/path_traversal_test.go
Normal file
330
internal/server/path_traversal_test.go
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
package restapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/danielmiessler/fabric/internal/i18n"
|
||||||
|
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each storage route that gets a name must reject a traversal name, not
|
||||||
|
// only DELETE. Most routes share storageError through the fsdb layer.
|
||||||
|
// The exists route validates in the handler, because its storage
|
||||||
|
// contract returns only a bool.
|
||||||
|
func TestStorageHandler_RejectsTraversalOnAllRoutes(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
contextsDir := filepath.Join(root, "contexts")
|
||||||
|
if err := os.MkdirAll(contextsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
marker := filepath.Join(root, "keep.txt")
|
||||||
|
if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
NewContextsHandler(r, &fsdb.ContextsEntity{
|
||||||
|
StorageEntity: &fsdb.StorageEntity{Label: "Contexts", Dir: contextsDir},
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct{ method, path string }{
|
||||||
|
{http.MethodGet, "/contexts/%2e%2e"},
|
||||||
|
{http.MethodDelete, "/contexts/%2e%2e"},
|
||||||
|
{http.MethodDelete, "/contexts/.."},
|
||||||
|
{http.MethodPost, "/contexts/%2e%2e"},
|
||||||
|
{http.MethodPut, "/contexts/rename/%2e%2e/ok"},
|
||||||
|
{http.MethodPut, "/contexts/rename/ok/%2e%2e"},
|
||||||
|
{http.MethodGet, "/contexts/exists/%2e%2e"},
|
||||||
|
} {
|
||||||
|
var body io.Reader
|
||||||
|
if tc.method == http.MethodPost {
|
||||||
|
body = strings.NewReader("x")
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(tc.method, tc.path, body)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s %s: got %d, want 400", tc.method, tc.path, w.Code)
|
||||||
|
}
|
||||||
|
if w.Header().Get("Strict-Transport-Security") == "" {
|
||||||
|
t.Fatalf("%s %s: validation 400 lacks the HSTS header", tc.method, tc.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(marker); err != nil {
|
||||||
|
t.Fatalf("parent marker was deleted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(contextsDir); err != nil {
|
||||||
|
t.Fatalf("contexts dir was deleted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-validation failure stays a 500. Its body must not show the
|
||||||
|
// filesystem path that is in the wrapped *os.PathError.
|
||||||
|
func TestStorageHandler_GenericErrorHidesPaths(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
r := gin.New()
|
||||||
|
NewContextsHandler(r, &fsdb.ContextsEntity{
|
||||||
|
StorageEntity: &fsdb.StorageEntity{Label: "Contexts", Dir: dir},
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/contexts/missing", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("got %d, want 500", w.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), dir) {
|
||||||
|
t.Fatalf("500 body leaks the storage path: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "internal error") {
|
||||||
|
t.Fatalf("500 body is not the generic message: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pattern backend failure must use the shared storageError mapping.
|
||||||
|
// That is a JSON envelope with the generic message, never the wrapped
|
||||||
|
// os.PathError.
|
||||||
|
func TestPatternsHandler_BackendErrorHidesPaths(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
r := gin.New()
|
||||||
|
NewPatternsHandler(r, &fsdb.PatternsEntity{
|
||||||
|
StorageEntity: &fsdb.StorageEntity{Label: "Patterns", Dir: dir, ItemIsDir: true},
|
||||||
|
SystemPatternFile: "system.md",
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, req := range []*http.Request{
|
||||||
|
httptest.NewRequest(http.MethodGet, "/patterns/missing", nil),
|
||||||
|
httptest.NewRequest(http.MethodPost, "/patterns/missing/apply", strings.NewReader(`{"input":"x"}`)),
|
||||||
|
} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("%s %s: got %d, want 500", req.Method, req.URL.Path, w.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), dir) {
|
||||||
|
t.Fatalf("%s %s: 500 body leaks the storage path: %s", req.Method, req.URL.Path, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "internal error") {
|
||||||
|
t.Fatalf("%s %s: 500 body is not the generic envelope: %s", req.Method, req.URL.Path, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPatternsHandler_RejectsPathTraversalSave(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
patternsDir := filepath.Join(root, "patterns")
|
||||||
|
if err := os.MkdirAll(patternsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
NewPatternsHandler(r, &fsdb.PatternsEntity{
|
||||||
|
StorageEntity: &fsdb.StorageEntity{Label: "Patterns", Dir: patternsDir, ItemIsDir: true},
|
||||||
|
SystemPatternFile: "system.md",
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/patterns/%2e%2e", strings.NewReader("pwned"))
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("POST /patterns/%%2e%%2e: got %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "system.md")); err == nil {
|
||||||
|
t.Fatal("wrote system.md in the parent directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatHandler_RejectsUnsafeNames(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// The zero-value handler is an intentional seam. A request that goes
|
||||||
|
// through pre-validation causes a nil panic in HandleChat. These
|
||||||
|
// tests cannot pass on a request that got no validation.
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/chat", (&ChatHandler{}).HandleChat)
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
// Every prefix class loadPattern treats as a filesystem path
|
||||||
|
"absolute pattern name": `{"prompts":[{"patternName":"/etc/hosts","userInput":"x"}]}`,
|
||||||
|
"home pattern name": `{"prompts":[{"patternName":"~/secret.md","userInput":"x"}]}`,
|
||||||
|
"relative pattern name": `{"prompts":[{"patternName":"./secret.md","userInput":"x"}]}`,
|
||||||
|
"backslash pattern name": `{"prompts":[{"patternName":"\\secret.md","userInput":"x"}]}`,
|
||||||
|
// Context and session names get the same pre-validation
|
||||||
|
"traversal context name": `{"prompts":[{"userInput":"x","contextName":"../keep.txt"}]}`,
|
||||||
|
"traversal session name": `{"prompts":[{"userInput":"x","sessionName":".."}]}`,
|
||||||
|
// The rejection loop stops at the first bad name, at each depth
|
||||||
|
"second prompt path-like": `{"prompts":[{"userInput":"x"},{"patternName":"/etc/hosts","userInput":"y"}]}`,
|
||||||
|
}
|
||||||
|
for name, body := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("got status %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPatternsHandler_RejectsUnsafeNamesOnReadRoutes(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
NewPatternsHandler(r, &fsdb.PatternsEntity{
|
||||||
|
StorageEntity: &fsdb.StorageEntity{Label: "Patterns", Dir: t.TempDir(), ItemIsDir: true},
|
||||||
|
SystemPatternFile: "system.md",
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, req := range []*http.Request{
|
||||||
|
httptest.NewRequest(http.MethodGet, "/patterns/%2e%2e", nil),
|
||||||
|
httptest.NewRequest(http.MethodPost, "/patterns/%2e%2e/apply", strings.NewReader(`{"input":"x"}`)),
|
||||||
|
// Names that fail only ValidateStorageName, not the file-path check
|
||||||
|
httptest.NewRequest(http.MethodGet, "/patterns/foo:bar", nil),
|
||||||
|
httptest.NewRequest(http.MethodGet, "/patterns/NUL", nil),
|
||||||
|
httptest.NewRequest(http.MethodPost, "/patterns/foo:bar/apply", strings.NewReader(`{"input":"x"}`)),
|
||||||
|
} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s %s: got %d, want 400", req.Method, req.URL.Path, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A safe request must go through pre-validation. With the zero-value
|
||||||
|
// handler seam, a request that goes through causes a nil panic, and
|
||||||
|
// Recovery changes that into a 500. A 400 shows that validation
|
||||||
|
// rejected valid names.
|
||||||
|
func TestChatHandler_AcceptsBenignNames(t *testing.T) {
|
||||||
|
if _, err := i18n.Init("en"); err != nil {
|
||||||
|
t.Fatalf("i18n.Init() error = %v", err)
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
r.POST("/chat", (&ChatHandler{}).HandleChat)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
body := `{"prompts":[{"userInput":"x","patternName":"summarize","contextName":"myctx","sessionName":"mysession"}]}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code == http.StatusBadRequest {
|
||||||
|
t.Fatalf("benign request was rejected with 400: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-loopback bind without an API key must fail closed before the
|
||||||
|
// server starts. A loopback bind operates without a key.
|
||||||
|
func TestRequireAPIKeyForBind(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
address string
|
||||||
|
apiKey string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"127.0.0.1:8080", "", false},
|
||||||
|
{"localhost:8080", "", false},
|
||||||
|
{"localhost", "", false},
|
||||||
|
{"[::1]:8080", "", false},
|
||||||
|
{":8080", "", true}, // wildcard bind exposes every interface
|
||||||
|
{"0.0.0.0:8080", "", true},
|
||||||
|
{"[::]:8080", "", true},
|
||||||
|
{"192.168.1.50:8080", "", true},
|
||||||
|
{"example.com:8080", "", true},
|
||||||
|
{":8080", "secret", false},
|
||||||
|
{"0.0.0.0:8080", "secret", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.address, func(t *testing.T) {
|
||||||
|
err := requireAPIKeyForBind(tt.address, tt.apiKey)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("requireAPIKeyForBind(%q, %q) error = %v, wantErr %v", tt.address, tt.apiKey, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve and ServeOllama must return the fail-closed error and must not
|
||||||
|
// start an unauthenticated server on a non-loopback bind. The registry
|
||||||
|
// is nil, and a check that does not occur first causes a panic.
|
||||||
|
func TestServeFailsClosedOnNonLoopbackBind(t *testing.T) {
|
||||||
|
if err := Serve(nil, ":0", ""); err == nil {
|
||||||
|
t.Fatal("Serve on a wildcard bind without a key did not fail")
|
||||||
|
}
|
||||||
|
if err := ServeOllama(nil, ":0", "v", ""); err == nil {
|
||||||
|
t.Fatal("ServeOllama on a wildcard bind without a key did not fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyMiddleware(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(APIKeyMiddleware("secret"))
|
||||||
|
r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||||
|
|
||||||
|
t.Run("missing key", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("got %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong key", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||||
|
req.Header.Set(APIKeyHeader, "wrong")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("got %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid key", func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||||
|
req.Header.Set(APIKeyHeader, "secret")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("got %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package restapi
|
package restapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/danielmiessler/fabric/internal/i18n"
|
||||||
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
|
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -14,6 +16,22 @@ type PatternsHandler struct {
|
||||||
patterns *fsdb.PatternsEntity
|
patterns *fsdb.PatternsEntity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rejectUnsafePatternName answers a 400 when name is a file-path-like
|
||||||
|
// pattern name or does not obey storage-name validation. An empty name
|
||||||
|
// passes, because the chat handler guards prompt.PatternName, which is
|
||||||
|
// optional.
|
||||||
|
func rejectUnsafePatternName(c *gin.Context, name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fsdb.LooksLikePatternFilePath(name) || fsdb.ValidateStorageName(name) != nil {
|
||||||
|
setHSTS(c)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf(i18n.T("pattern_invalid_name"), name)})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// NewPatternsHandler creates a new PatternsHandler
|
// NewPatternsHandler creates a new PatternsHandler
|
||||||
func NewPatternsHandler(r *gin.Engine, patterns *fsdb.PatternsEntity) (ret *PatternsHandler) {
|
func NewPatternsHandler(r *gin.Engine, patterns *fsdb.PatternsEntity) (ret *PatternsHandler) {
|
||||||
// Create a storage handler but don't register any routes yet
|
// Create a storage handler but don't register any routes yet
|
||||||
|
|
@ -40,15 +58,19 @@ func NewPatternsHandler(r *gin.Engine, patterns *fsdb.PatternsEntity) (ret *Patt
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param name path string true "Pattern name"
|
// @Param name path string true "Pattern name"
|
||||||
// @Success 200 {object} fsdb.Pattern
|
// @Success 200 {object} fsdb.Pattern
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Router /patterns/{name} [get]
|
// @Router /patterns/{name} [get]
|
||||||
func (h *PatternsHandler) Get(c *gin.Context) {
|
func (h *PatternsHandler) Get(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
|
if rejectUnsafePatternName(c, name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
pattern, err := h.patterns.GetRaw(name)
|
pattern, err := h.patterns.GetRaw(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, err.Error())
|
storageError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, pattern)
|
c.JSON(http.StatusOK, pattern)
|
||||||
|
|
@ -75,6 +97,9 @@ type PatternApplyRequest struct {
|
||||||
// @Router /patterns/{name}/apply [post]
|
// @Router /patterns/{name}/apply [post]
|
||||||
func (h *PatternsHandler) ApplyPattern(c *gin.Context) {
|
func (h *PatternsHandler) ApplyPattern(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
|
if rejectUnsafePatternName(c, name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var request PatternApplyRequest
|
var request PatternApplyRequest
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
|
@ -93,7 +118,7 @@ func (h *PatternsHandler) ApplyPattern(c *gin.Context) {
|
||||||
|
|
||||||
pattern, err := h.patterns.GetApplyVariables(name, variables, request.Input)
|
pattern, err := h.patterns.GetApplyVariables(name, variables, request.Input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, err.Error())
|
storageError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, pattern)
|
c.JSON(http.StatusOK, pattern)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/danielmiessler/fabric/internal/core"
|
"github.com/danielmiessler/fabric/internal/core"
|
||||||
|
"github.com/danielmiessler/fabric/internal/i18n"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
swaggerFiles "github.com/swaggo/files"
|
swaggerFiles "github.com/swaggo/files"
|
||||||
ginSwagger "github.com/swaggo/gin-swagger"
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
|
|
@ -27,6 +28,10 @@ import (
|
||||||
// @in header
|
// @in header
|
||||||
// @name X-API-Key
|
// @name X-API-Key
|
||||||
func Serve(registry *core.PluginRegistry, address string, apiKey string) (err error) {
|
func Serve(registry *core.PluginRegistry, address string, apiKey string) (err error) {
|
||||||
|
if err = requireAPIKeyForBind(address, apiKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
|
|
@ -36,7 +41,7 @@ func Serve(registry *core.PluginRegistry, address string, apiKey string) (err er
|
||||||
if apiKey != "" {
|
if apiKey != "" {
|
||||||
r.Use(APIKeyMiddleware(apiKey))
|
r.Use(APIKeyMiddleware(apiKey))
|
||||||
} else {
|
} else {
|
||||||
slog.Warn("Starting REST API server without API key authentication. This may pose security risks.")
|
slog.Warn(i18n.T("server_no_api_key_warning"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swagger UI and documentation endpoint with custom YAML handler
|
// Swagger UI and documentation endpoint with custom YAML handler
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package restapi
|
package restapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/danielmiessler/fabric/internal/plugins/db"
|
"github.com/danielmiessler/fabric/internal/plugins/db"
|
||||||
|
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,6 +17,42 @@ type StorageHandler[T any] struct {
|
||||||
storage db.Storage[T]
|
storage db.Storage[T]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setHSTS sets the Strict-Transport-Security header. Each validation
|
||||||
|
// 400 sends it, the same as the chat BindJSON 400 path.
|
||||||
|
func setHSTS(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageError answers err. A name-validation rejection becomes a 400,
|
||||||
|
// and its body contains only the rejected name. All other errors stay
|
||||||
|
// 500 errors with a generic body, because fsdb wraps *os.PathError
|
||||||
|
// values and err.Error() then sends absolute filesystem paths to the
|
||||||
|
// client. The full error goes to the log.
|
||||||
|
func storageError(c *gin.Context, err error) {
|
||||||
|
if _, ok := errors.AsType[*fsdb.InvalidStorageNameError](err); ok {
|
||||||
|
setHSTS(c)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("storage operation failed", "error", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectInvalidStorageName answers a 400 when name does not obey
|
||||||
|
// storage-name validation. An empty name passes, because the fields
|
||||||
|
// that this guards are optional.
|
||||||
|
func rejectInvalidStorageName(c *gin.Context, name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := fsdb.ValidateStorageName(name); err != nil {
|
||||||
|
setHSTS(c)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// NewStorageHandler creates a new StorageHandler
|
// NewStorageHandler creates a new StorageHandler
|
||||||
func NewStorageHandler[T any](r *gin.Engine, entityType string, storage db.Storage[T]) (ret *StorageHandler[T]) {
|
func NewStorageHandler[T any](r *gin.Engine, entityType string, storage db.Storage[T]) (ret *StorageHandler[T]) {
|
||||||
ret = &StorageHandler[T]{storage: storage}
|
ret = &StorageHandler[T]{storage: storage}
|
||||||
|
|
@ -31,7 +70,7 @@ func (h *StorageHandler[T]) Get(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
item, err := h.storage.Get(name)
|
item, err := h.storage.Get(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, err.Error())
|
storageError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, item)
|
c.JSON(http.StatusOK, item)
|
||||||
|
|
@ -41,7 +80,7 @@ func (h *StorageHandler[T]) Get(c *gin.Context) {
|
||||||
func (h *StorageHandler[T]) GetNames(c *gin.Context) {
|
func (h *StorageHandler[T]) GetNames(c *gin.Context) {
|
||||||
names, err := h.storage.GetNames()
|
names, err := h.storage.GetNames()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, err.Error())
|
storageError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, names)
|
c.JSON(http.StatusOK, names)
|
||||||
|
|
@ -52,15 +91,20 @@ func (h *StorageHandler[T]) Delete(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
err := h.storage.Delete(name)
|
err := h.storage.Delete(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, err.Error())
|
storageError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exists handles the GET /storage/exists/:name route
|
// Exists handles the GET /storage/exists/:name route. The storage
|
||||||
|
// Exists contract cannot report an invalid name, and the handler must
|
||||||
|
// validate the name itself. An invalid name is a 400, not a "false".
|
||||||
func (h *StorageHandler[T]) Exists(c *gin.Context) {
|
func (h *StorageHandler[T]) Exists(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
|
if rejectInvalidStorageName(c, name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
exists := h.storage.Exists(name)
|
exists := h.storage.Exists(name)
|
||||||
c.JSON(http.StatusOK, exists)
|
c.JSON(http.StatusOK, exists)
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +115,7 @@ func (h *StorageHandler[T]) Rename(c *gin.Context) {
|
||||||
newName := c.Param("newName")
|
newName := c.Param("newName")
|
||||||
err := h.storage.Rename(oldName, newName)
|
err := h.storage.Rename(oldName, newName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, err.Error())
|
storageError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
|
|
@ -87,14 +131,14 @@ func (h *StorageHandler[T]) Save(c *gin.Context) {
|
||||||
|
|
||||||
content, err := io.ReadAll(body)
|
content, err := io.ReadAll(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, err.Error())
|
storageError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the content to storage
|
// Save the content to storage
|
||||||
err = h.storage.Save(name, content)
|
err = h.storage.Save(name, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, err.Error())
|
storageError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,17 @@ docker run --rm -it -v $PWD/.env:/root/.config/fabric/.env fabric -p your-patter
|
||||||
|
|
||||||
## Running the server
|
## Running the server
|
||||||
|
|
||||||
Expose port 8080 to use Fabric's REST API:
|
Expose port 8080 to use Fabric's REST API. In a container, bind all
|
||||||
|
interfaces with `--address :8080` so the mapped port can reach the
|
||||||
|
server, and set an API key, which is mandatory for non-loopback binds:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -it -p 8080:8080 -v $HOME/.fabric-config:/root/.config/fabric fabric --serve
|
docker run --rm -it -p 8080:8080 -v $HOME/.fabric-config:/root/.config/fabric \
|
||||||
|
-e FABRIC_API_KEY=your-secret-key fabric --serve --address :8080
|
||||||
```
|
```
|
||||||
|
|
||||||
The API will be available at `http://localhost:8080`.
|
The API will be available at `http://localhost:8080`. Requests must send
|
||||||
|
the key in the `X-API-Key` header.
|
||||||
|
|
||||||
## Multi-arch builds and GHCR packages
|
## Multi-arch builds and GHCR packages
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue